云计算百科
云计算领域专业知识百科平台

【仓颉语言入门 · 第27课】

第 27 课 线程同步:互斥锁、原子类型与条件变量

上节课结尾的 CounterBox 惨案还悬着:10 个线程各加 10000 次,期望 100000,实际只有 33017、33874……每次跑数字还都不一样。本课就来算账:用 Mutex 互斥锁把"读—加—写"三步合成一步,用 Atomic 原子类型让自增本身不可分割,再用 Condition 条件变量解决"线程之间你等我、我等你"的通信问题。

另外,本课会先实测一个上节课预告里提到、很多资料也在讲的东西——Channel 通道,并如实告诉你仓颉 SDK 1.2.0 的现状,然后用本课学的原语亲手实现一个阻塞队列(通道最核心的部分)。

本文所有代码与输出均在仓颉 SDK 1.2.0 下逐行实测编译运行。


目录(系列导航)

整套路线共 7 个模块、30 课:

模块课次内容
一、环境与入门 01~05 环境搭建与 Hello World、变量与基本类型、运算符与输入输出、分支、循环
二、常用类型与数据组织 06~10 字符串、数组与区间、ArrayList/HashMap/HashSet、可空类型、错误处理
三、函数与函数式 11~14 函数、Lambda 与高阶函数、闭包、迭代器与惰性序列
四、面向对象与类型系统 15~20 struct/class、构造与属性、接口、枚举与 match 模式匹配、泛型、扩展
五、工程化与标准库 21~25 cjpm 包管理与多文件、文件 IO、JSON 处理、网络编程、单元测试
六、并发编程 26~28 线程的创建与等待、线程同步(本文)、并发实战
七、项目实战 29~30 命令行小工具、GeoJSON 数据处理实战
  • 环境搭建与第一个仓颉程序
  • 变量、常量与基本数据类型
  • 运算符与标准输入输出
  • 分支结构与 match 表达式
  • 循环结构:while / for / Range
  • 字符串详解与字符串插值
  • 数组 Array 与区间 Range
  • 集合框架:ArrayList、HashMap、HashSet
  • 可空类型 ? 与 Option
  • 错误处理:异常机制与 Result
  • 函数定义、参数与返回值
  • Lambda 与高阶函数
  • 闭包、作用域与函数类型
  • 迭代器 Iterator 与 Sequence
  • 结构体 struct 与类 class
  • 构造函数、属性与方法
  • 接口 interface 与实现
  • 枚举 enum、代数数据类型与 match 模式匹配
  • 泛型编程
  • 扩展、类型别名与可见性控制
  • cjpm 包管理与多文件项目组织
  • 文件与目录 IO
  • JSON 处理
  • 网络编程入门
  • 单元测试
  • 并发基础:线程的创建与等待
  • 线程同步:互斥锁、原子类型与条件变量(本文)
  • 并发实战:多线程任务处理
  • 实战一:带文件持久化的命令行小工具
  • 实战二:GeoJSON 数据处理程序

  • 一、先破案:100000 是怎么丢的

    先回顾上节课的"罪犯代码":

    class CounterBox {
    var count: Int64 = 0
    }

    // 10 个线程,每个把 box.count += 1 执行 10000 次

    count += 1 看起来是"一句话",但对 CPU 来说是三条指令:

  • 读:把内存里 count 的当前值读到寄存器;
  • 改:寄存器里的值加 1;
  • 写:把新值写回内存。
  • 两个线程可能同时读到同一个旧值:

    线程 A:读 100 ──→ 加到 101 ──→ 写回 101
    线程 B: 读 100 ──→ 加到 101 ──→ 写回 101

    两次加法,结果只涨了 1——另一次凭空丢了。10 个线程抢得越凶,丢得越多。

    破案结论:只要一个操作中间能被别的线程插进来,它就是不安全的。解法也很直接——想办法把这三步"锁"在一起,让同一时刻只有一个线程能动 count。这就是同步原语干的事。

    📌 本课所有类型都在 std.sync 包里(sync = synchronization,同步),用到时文件开头写 import std.sync.*。spawn、Future、get()、sleep、Duration 仍在 std.core,不用 import。


    二、Mutex 互斥锁:lock / unlock 与 synchronized 块

    Mutex(mutual exclusion 的缩写,读作"缪泰克斯")就是一把锁。最朴素的用法是手动 lock() / unlock():

    import std.sync.*

    main(): Int64 {
    let lock = Mutex()
    lock.lock()
    println("我拿到锁了")
    lock.unlock()

    if (lock.tryLock()) {
    println("这次也拿到了")
    lock.unlock()
    } else {
    println("锁被占着,没拿到")
    }
    return 0
    }

    运行结果:

    我拿到锁了
    这次也拿到了

    规则很简单:

    • lock():拿锁。锁如果正被别的线程拿着,我就站在门口等(阻塞),直到拿到为止;
    • unlock():放锁,让给别人;
    • tryLock():试一下,拿到返回 true,拿不到不等、立刻返回 false。适合"拿不到就算了"的场景。

    但手动配对容易出错——加了锁忘了解,其他线程就得永远排队。仓颉提供了更省心的 synchronized 块:

    import std.sync.*

    main(): Int64 {
    let lock = Mutex()
    var count = 0
    synchronized (lock) {
    count += 1 // 这段代码同一时刻只有一个线程能执行
    }
    println("count = ${count}")
    return 0
    }

    count = 1

    synchronized (lock) { … } 的意思是:“执行花括号里的代码前先拿锁,花括号结束时自动放锁”。

    自动到什么程度?里面抛异常,锁也照放不误:

    import std.sync.*

    main(): Int64 {
    let lock = Mutex()
    try {
    synchronized (lock) {
    throw IllegalArgumentException("故意捣乱")
    }
    } catch (e: IllegalArgumentException) {
    println("捕获异常:${e.message}")
    }
    lock.lock()
    println("锁还能拿到:synchronized 已自动释放")
    lock.unlock()
    return 0
    }

    捕获异常:故意捣乱
    锁还能拿到:synchronized 已自动释放

    📌 结论:99% 的场景都用 synchronized,不要手写 lock/unlock。 本课后面只有"创建条件变量"那一处必须手动加锁(第六节会讲原因)。

    🔸 版本小提示:SDK 1.2.0 的 Mutex 本身就是可重入的——同一个线程对同一把锁连续 lock() 两次不会死锁,解两次即可。旧资料里的 ReentrantMutex 已废弃,编译会告警:warning: class 'ReentrantMutex' is deprecated. Use 'public class Mutex' instead.,看到它直接换成 Mutex 即可。


    三、修复 CounterBox(方案一:synchronized)

    把上节课的 CounterBox 改造一下:给它配一把锁,所有对 count 的修改都必须穿过这把锁:

    import std.sync.*
    import std.collection.ArrayList

    class SafeCounter {
    let lock = Mutex()
    var count: Int64 = 0

    func addOne() {
    synchronized (lock) {
    count += 1
    }
    }
    }

    main(): Int64 {
    let box = SafeCounter()
    let futures = ArrayList<Future<Unit>>()
    for (_ in 1..=10) {
    futures.add(spawn {
    for (_ in 1..=10000) {
    box.addOne()
    }
    })
    }
    for (f in futures) {
    f.get()
    }
    println("期望 100000,实际 ${box.count}")
    return 0
    }

    连跑三次:

    期望 100000,实际 100000

    期望 100000,实际 100000

    期望 100000,实际 100000

    三次都是 100000。原来的"读—改—写"被 synchronized 包成了一个不可分割的整体:线程 B 想读,必须等线程 A 写完放锁,读到的永远是最新值。账,算平了。

    注意两个和上节课一脉相承的细节:

  • box 仍然是 let(引用不变),可变的是对象内部,spawn 的捕获规则没变;
  • 最后必须逐个 get() 等齐 10 个线程,否则 main 一结束进程就收队了。

  • 四、Atomic 原子类型

    Mutex 是"谁都别抢,排队来"。还有一种更轻量的思路:让变量本身的读写操作天生不可分割——这就是原子类型(atomic,"原子"取"不可再分"之意)。

    4.1 有哪些原子类型

    std.sync 提供 9 个:

    类型包装的普通类型
    AtomicBool Bool
    AtomicInt8 / AtomicInt16 / AtomicInt32 / AtomicInt64 Int8 / Int16 / Int32 / Int64
    AtomicUInt8 / AtomicUInt16 / AtomicUInt32 / AtomicUInt64 UInt8 / UInt16 / UInt32 / UInt64

    4.2 数值型原子类型的方法

    以 AtomicInt64 为例:

    import std.sync.*

    main(): Int64 {
    let a = AtomicInt64(10)
    println("fetchAdd 旧值:${a.fetchAdd(5)}")
    println("现在:${a.load()}")
    println("swap 旧值:${a.swap(100)}")
    println("现在:${a.load()}")
    println("CAS 成功?${a.compareAndSwap(100, 200)}")
    println("现在:${a.load()}")
    println("CAS 失败?${a.compareAndSwap(100, 999)}")
    println("现在:${a.load()}")
    a.fetchSub(50)
    println("fetchSub 后:${a.load()}")
    return 0
    }

    逐行实测输出:

    fetchAdd 旧值:10
    现在:15
    swap 旧值:15
    现在:100
    CAS 成功?true
    现在:200
    CAS 失败?false
    现在:200
    fetchSub 后:150

    方法清单:

    方法作用返回值
    load() 读当前值 当前值
    store(v) 写入新值 无
    swap(v) 换成新值 旧值
    fetchAdd(v) / fetchSub(v) 加 / 减 旧值
    fetchAnd(v) / fetchOr(v) / fetchXor(v) 位与 / 位或 / 位异或 旧值
    compareAndSwap(expect, new) 当前值等于 expect 才换成 new 是否交换成功(Bool)

    注意两个初学者最容易踩的点:

  • fetchAdd 返回的是旧值,不是加完的值——上面输出里 fetchAdd(5) 打印 10,但接着 load() 是 15;
  • 原子类型没有 .value 字段,读值必须用 load()。直接写 a.value 编译器会报:error: can not access field 'value'。
  • AtomicBool 用得最多的是 compareAndSwap(简称 CAS):

    import std.sync.*

    main(): Int64 {
    let b = AtomicBool(false)
    println("Bool CAS:${b.compareAndSwap(false, true)}")
    println("Bool 值:${b.load()}")
    b.store(false)
    println("store 后:${b.load()}")
    return 0
    }

    Bool CAS:true
    Bool 值:true
    store 后:false


    五、修复 CounterBox(方案二:AtomicInt64)

    计数器只有一个整数在变,用原子类型比加锁更直接:

    import std.sync.*
    import std.collection.ArrayList

    class AtomicCounter {
    let count = AtomicInt64(0)

    func addOne() {
    count.fetchAdd(1)
    }
    }

    main(): Int64 {
    let box = AtomicCounter()
    let futures = ArrayList<Future<Unit>>()
    for (_ in 1..=10) {
    futures.add(spawn {
    for (_ in 1..=10000) {
    box.addOne()
    }
    })
    }
    for (f in futures) {
    f.get()
    }
    println("期望 100000,实际 ${box.count.load()}")
    return 0
    }

    期望 100000,实际 100000

    fetchAdd(1) 这一个调用就完成了"读—加—写",中间不可能插进第二个线程,所以连锁都省了。读结果时记得用 load(),不能直接写 box.count。

    两种方案怎么选?

    • 只保护一个数字(计数器、序号、开关标志)→ 用 Atomic,更轻、更快;
    • 要保护一段逻辑或多个字段(比如"x 和 y 必须一起改"、“先判断再修改”)→ 用 Mutex + synchronized。一把锁里想包几行包几行,原子类型管不了多行逻辑。

    六、线程间的等待与通知:Condition 条件变量

    互斥锁解决了"抢"的问题,但并发里还有一类"等"的问题:消费者线程要等生产者把数据准备好。一直循环问"好了吗?好了吗?"(忙等)既浪费 CPU,又拿着锁不放。

    6.1 一个完整的等待—通知

    仓颉的解法是 Condition 条件变量:等的人调用 wait() 睡过去,准备好的人调用 notify() 把它叫醒。

    import std.sync.*
    import std.collection.ArrayList

    main(): Int64 {
    let lock = Mutex()
    lock.lock()
    let notEmpty = lock.condition() // 条件变量必须由"持锁线程"创建
    lock.unlock()
    let queue = ArrayList<Int64>()

    // 消费者:队列空就等
    let consumer = spawn {
    synchronized (lock) {
    while (queue.size == 0) {
    notEmpty.wait() // 睡:放锁、等人 notify
    }
    println("消费者拿到:${queue[0]}")
    }
    }

    sleep(Duration.millisecond * 200)

    // 生产者:放数据,叫醒消费者
    synchronized (lock) {
    queue.add(42)
    notEmpty.notify()
    println("生产者放入:42")
    }
    consumer.get()
    return 0
    }

    生产者放入:42
    消费者拿到:42

    执行过程是这样的:

  • 消费者先进 synchronized,发现队列是空的,执行 wait()——睡过去,同时把锁交出来(不然生产者永远进不来,就死锁了);
  • 200 毫秒后生产者拿到锁、放入 42、notify() 叫醒消费者;
  • 消费者重新拿到锁,从 wait() 醒来,再看一眼 while 条件——现在不空了,退出循环取数据。
  • 6.2 三个必须记住的规矩

    规矩一:条件变量只能由持锁线程创建。

    lock.condition() 要求调用时本线程已经拿着这把锁,不然后果是一个运行时异常(就是上面 main 里先 lock() 再创建再 unlock() 的原因):

    IllegalSynchronizationStateException: Mutex is not locked by current thread.

    规矩二:wait() 必须放在 while 循环里,不能用 if。

    线程从 wait() 醒来时,条件不一定还成立(可能被别的线程抢先改回去了,也可能存在虚假唤醒)。用 while,醒来后会再检查一次条件,不成立就继续睡;用 if 就直接往下冲了。

    规矩三:wait() / notify() 都要在 synchronized (lock) 块里调用。

    notify() 只叫醒一个等待者;想全叫醒用 notifyAll()。一个生产者配一个消费者时 notify() 就够;多个消费者抢任务时要用 notifyAll()。

    6.3 带超时的等待

    不想无限等?给 wait 传 timeout: 参数,返回 Bool:被通知唤醒返回 true,超时返回 false。

    import std.sync.*

    main(): Int64 {
    let lock = Mutex()
    lock.lock()
    let cond = lock.condition()
    lock.unlock()
    synchronized (lock) {
    let r = cond.wait(timeout: Duration.millisecond * 100)
    println("超时等待返回:${r}")
    }
    return 0
    }

    超时等待返回:false

    注意参数名 timeout: 不能省,漏写会报 error: missing argument prefix 'timeout:' for named parameter。


    七、Channel 之谜:1.2.0 实测没有,那就自己造一个

    7.1 先说结论

    很多并发语言(比如 Go)提倡一种模型:“不要通过共享内存来通信,而要通过通信来共享内存”——线程之间不直接抢变量,而是往一个叫 Channel(通道) 的管子里发消息、收消息,数据同一时刻只在一个线程手里,从根上避免数据竞争。

    上节课的预告说这节课讲 Channel。备课的时候我们做了一件本系列一贯的事:在仓颉 SDK 1.2.0 里亲手试。结果是:

    import std.sync.*

    main(): Int64 {
    let ch = Channel<Int64>()
    ch.send(1)
    return 0
    }

    error: undeclared identifier 'Channel'

    截至仓颉 SDK 1.2.0,标准库里还没有公开的 Channel 类型(std.sync 里有锁、条件变量、原子类型、信号量等,但没有通道)。网上不少文章里的 import concurrency、channel.send() 之类写法在 1.2.0 下都编不过,请大家以本机 SDK 的实测为准。

    那"通道"这个概念就学不了了吗?恰恰相反——理解了锁和条件变量,你会发现通道一点都不神秘:它就是一个加了锁的队列,配上两个条件变量。 我们现在就亲手实现一个。

    7.2 实现一个 BlockingQueue(阻塞队列)

    通道最核心的行为有两个:

    • 发送:队列满了就等(等消费者腾位置);
    • 接收:队列空了就等(等生产者放数据)。

    用"一把锁 + 两个条件变量 + 第 8 课的 ArrayList"实现。注意删除队首元素用的是第 8 课教过的区间写法 buf.remove(0..1):

    import std.sync.*
    import std.collection.ArrayList

    class BlockingQueue<T> {
    let buf = ArrayList<T>()
    let capacity: Int64
    let lock = Mutex()
    let notEmpty: Condition
    let notFull: Condition

    init(capacity: Int64) {
    this.capacity = capacity
    lock.lock()
    this.notEmpty = lock.condition()
    this.notFull = lock.condition()
    lock.unlock()
    }

    func put(item: T) {
    synchronized (lock) {
    while (buf.size >= capacity) {
    notFull.wait() // 队列满:等消费者取走
    }
    buf.add(item)
    notEmpty.notify() // 放入后:叫醒等数据的人
    }
    }

    func take(): T {
    synchronized (lock) {
    while (buf.size == 0) {
    notEmpty.wait() // 队列空:等生产者放入
    }
    let item = buf[0]
    buf.remove(0..1)
    notFull.notify() // 取走后:叫醒等位置的人
    return item
    }
    }
    }

    对照 Channel 的概念看这份实现:

    Channel 概念本实现的对应物
    发送 send / 接收 receive put / take
    带缓冲通道,容量 n BlockingQueue<T>(n)
    发送时缓冲满则阻塞 put 里的 notFull.wait()
    接收时缓冲空则阻塞 take 里的 notEmpty.wait()
    无缓冲通道(发送方和接收方当面交接) 容量传 1 即可近似

    7.3 跑一个生产者—消费者

    一个生产者放 5 个数,一个消费者收 5 个数。为了让输出稳定可对照,只让消费者打印:

    main(): Int64 {
    let q = BlockingQueue<Int64>(2)

    let producer = spawn {
    for (i in 1..=5) {
    q.put(i)
    }
    }
    let consumer = spawn {
    for (_ in 1..=5) {
    let v = q.take()
    println("收到:${v}")
    }
    }
    producer.get()
    consumer.get()
    println("收完了")
    return 0
    }

    连跑三次,输出都一样:

    收到:1
    收到:2
    收到:3
    收到:4
    收到:5
    收完了

    生产者放得快也没用——队列容量只有 2,放满就睡;消费者取走一个才腾出一个位置。虽然两个线程在并发推进,但数据经过队列时严格先进先出,所以消费者收到的顺序恒为 1→5。这就是"用通信来共享内存"的雏形:谁拿到队列里的数据,谁才有权处理它。

    🔸 这个类刻意只保留了通道最本质的骨架,没有做关闭(close)、多消费者唤醒等工程细节。等官方 Channel 在后续 SDK 版本落地后,优先用官方实现;但只要理解了这份实现,以后用谁的通道都一样。


    八、其它同步原语速览

    8.1 Semaphore 信号量:限流

    Semaphore(n) 可以理解为一沓共 n 张的"通行证":acquire() 领一张(发完了就等),release() 还一张。典型用途是限流——比如同时只允许 2 个线程下载:

    import std.sync.*

    main(): Int64 {
    let sem = Semaphore(2)
    sem.acquire()
    sem.acquire()
    println("许可用完时 tryAcquire:${sem.tryAcquire()}")
    sem.release()
    println("释放一个后 tryAcquire:${sem.tryAcquire()}")
    return 0
    }

    许可用完时 tryAcquire:false
    释放一个后 tryAcquire:true

    • acquire():领通行证,没有就等;
    • tryAcquire():试领,没有立刻返回 false;
    • release():还回一张。

    多线程用法就是模板:进临界区前 acquire(),出来 release()(务必成对)。课后练习第 4 题会用它统计"同时在场人数的峰值"。

    8.2 Barrier 栅栏:等人到齐再一起走

    Barrier(n) 是一个集合点:n 个线程都到达(都调用 wait())之前,大家一起等;最后一个到的瞬间,全体同时放行。像旅游团"人齐了再发车":

    import std.sync.*
    import std.collection.ArrayList

    main(): Int64 {
    let barrier = Barrier(3)
    let futures = ArrayList<Future<Unit>>()
    for (i in 1..=3) {
    futures.add(spawn {
    println("线程 ${i} 到达集合点")
    barrier.wait()
    println("线程 ${i} 一起出发")
    })
    }
    for (f in futures) {
    f.get()
    }
    return 0
    }

    一次实测输出:

    线程 3 到达集合点
    线程 1 到达集合点
    线程 2 到达集合点
    线程 3 一起出发
    线程 1 一起出发
    线程 2 一起出发

    线程编号的先后顺序每次可能不同(老规矩:调度器说了算),但有一条规律铁打不变:三行"到达集合点"一定全部出现之后,才会出现任何一行"一起出发"。这正是栅栏保证的。

    8.3 ReadWriteLock 读写锁:读共享、写独占

    ReadWriteLock 里有两把锁:readLock(读锁)和 writeLock(写锁)。规则是:多个线程可以同时持读锁,但写锁同一时刻只能一个线程持有,且持写锁时别人连读都不能读。适合"读多写少"的数据(比如配置表):

    import std.sync.*

    main(): Int64 {
    let rw = ReadWriteLock()
    rw.readLock.lock()
    println("拿到读锁")
    rw.readLock.unlock()
    rw.writeLock.lock()
    println("拿到写锁")
    rw.writeLock.unlock()
    return 0
    }

    拿到读锁
    拿到写锁

    🔸 旧资料里还可能看到 Monitor 类型(wait/notify 写在对象本身上),它在 1.2.0 已废弃,编译告警:warning: class 'Monitor' is deprecated. Use 'public interface Condition' instead.。新代码统一用第六节的 Mutex + Condition 写法。


    九、CIDE 实操:连跑三次,三个 100000

    在 CIDE 里 cjpm init –name syncfix 新建工程,把第三节"方案一"的完整代码贴进 src/main.cj,点运行,然后连按三次运行:

    期望 100000,实际 100000

    期望 100000,实际 100000

    期望 100000,实际 100000

    三次一模一样。再翻出上节课第 26 课第七节那个没加锁的 CounterBox 对照着跑三次——33017、33874 之类的乱数 vs 三个 100000,这就是"有没有同步"最直观的对比。

    再做一个破坏实验(记得改回来):把 addOne() 里的 synchronized (lock) { … } 删掉花括号里的锁、变回裸 count += 1,运行,数字立刻开始乱跳。加锁的代码哪里都没坏,去掉锁才坏——并发 Bug 不会在你写代码时报错,只会在运行结果里悄悄错。


    十、常用 API 速查

    功能写法备注
    导入同步包 import std.sync.* 本课类型全在 std.sync
    互斥锁 Mutex() 1.2.0 可重入;ReentrantMutex 已废弃
    同步块 synchronized (lock) { … } 块结束自动放锁,异常也放
    手动加/放锁 lock.lock() / lock.unlock() 必须成对,优先用 synchronized
    尝试加锁 lock.tryLock() 返回 Bool,拿不到不等
    创建条件变量 lock.condition() 调用时必须已持锁,类型写 Condition
    等待 cond.wait() 必须在 synchronized 里,且放在 while 中
    限时等待 cond.wait(timeout: Duration.second * 1) 通知返回 true,超时返回 false
    通知 cond.notify() / cond.notifyAll() 唤醒一个 / 全部等待者
    原子整数 AtomicInt64(0) 另有 8/16/32 位及无符号版本
    原子布尔 AtomicBool(false) load() / store() / compareAndSwap()
    原子自增 a.fetchAdd(1) 返回旧值;读新值用 load()
    比较交换 a.compareAndSwap(expect, new) 相等才换,返回是否成功
    信号量 Semaphore(n) acquire() / tryAcquire() / release()
    栅栏 Barrier(n) n 个线程都 wait() 后一起放行
    读写锁 ReadWriteLock() .readLock.lock()、.writeLock.lock() 配对 unlock

    十一、常见问题 FAQ

    Q1:为什么编译报 error: undeclared identifier 'Mutex'?

    没写 import std.sync.*。spawn、sleep 这些在 std.core 不用导,但锁、原子类型、信号量都在 std.sync,必须显式导入。

    Q2:synchronized 块和手动 lock/unlock 用哪个?

    一律用 synchronized。它在块结束时(包括异常跳出时)自动放锁,不会忘记;手动 unlock 只在创建条件变量这类特殊场景下用。

    Q3:lock.condition() 为什么报 IllegalSynchronizationStateException?

    条件变量必须由"当前正持锁的线程"创建。照第六节的固定写法:先 lock.lock(),再 let cond = lock.condition(),然后 lock.unlock(),之后把 cond 分享给各线程使用。

    Q4:1.2.0 真的没有 Channel 吗?我看网上文章都在用。

    以本机 SDK 实测为准:写 Channel<Int64>() 会得到 error: undeclared identifier 'Channel'。1.2.0 的 std.sync 只提供锁、条件变量、原子类型、信号量、栅栏、读写锁等。第七节已经用这些原语实现了通道最核心的阻塞队列;官方 Channel 请关注后续 SDK 版本。

    Q5:原子变量为什么不能直接读写 a.value?

    原子类型没有公开的 value 字段(报 error: can not access field 'value')。读用 load()、写用 store(),所有访问都走原子方法,才能保证并发安全。

    Q6:wait() 为什么必须写在 while 里,if 不行吗?

    线程从 wait() 醒来时条件未必仍成立(可能被其他线程抢先改变,或出现虚假唤醒)。while 会在醒来后重新检查一次条件,不满足就继续等;if 只检查一次,醒来就硬着头皮往下走,容易出错。这是条件变量的标准写法,照抄即可。


    十二、课后练习

  • (必做)用锁保护一个"坐标点":x、y 两个字段必须一起移动(这类多字段复合操作原子类型管不了,只能用锁)。把下面程序的 move 方法补完整,然后起 10 个线程各调用 move(1) 1000 次。需要 import std.sync.* 和 import std.collection.ArrayList。期望输出:x = 10000,y = 10000。
  • class Position {
    let lock = Mutex()
    var x: Int64 = 0
    var y: Int64 = 0

    func move(step: Int64) {
    // TODO:用 synchronized (lock) 把 x、y 同时加上 step
    }
    }
    // main:10 个 spawn,每个循环 1000 次调用 p.move(1);
    // 全部 get() 后打印 println("x = ${p.x},y = ${p.y}")

  • (必做)用 compareAndSwap 实现"一次性初始化":5 个线程同时去抢,最多只能有一个成功。函数签名已经给好,main 里起 5 个线程(进入后先 sleep(Duration.millisecond * 100) 让大家尽量同时),把每个 Future<Bool> 的结果在主线程 get() 回来数 true 的个数。期望输出:成功初始化的线程数:1。
  • import std.sync.*
    // func tryInit(flag: AtomicInt64): Bool { return flag.compareAndSwap(0, 1) }
    // 提示:main 里 let flag = AtomicInt64(0),把 flag 传进每个 spawn

  • (必做)“开门令”:工作线程先打印 等待开门,然后在条件变量上等待;主线程睡 200 毫秒后打印 开门 并通知它;工作线程被唤醒后打印 开始干活。请补全下面骨架中的三处 TODO(提示:创建条件变量的固定写法见第六节,等待和通知都要包在 synchronized (lock) 里)。期望输出严格按下面三行顺序出现:
  • 等待开门
    开门
    开始干活

    let lock = Mutex()
    // TODO:lock.lock() → let cond = lock.condition() → lock.unlock()
    let worker = spawn {
    synchronized (lock) {
    println("等待开门")
    // TODO:cond.wait()
    println("开始干活")
    }
    }
    sleep(Duration.millisecond * 200)
    synchronized (lock) {
    println("开门")
    // TODO:cond.notify()
    }
    worker.get()

  • (选做)用 Semaphore(2) 模拟"只有 2 个位的更衣室":6 个人(线程)进场,每人进去后停留 sleep(Duration.millisecond * 100) 再出来。用一个带 Mutex 的装箱类统计"同时在场人数"的峰值(注意:计数变量是 var,不能直接被 spawn 捕获,要像第 26 课那样装进 class)。期望输出:同时在更衣室的峰值人数:2。

  • 下节预告

    本课把"线程之间如何安全地协作"讲完了:锁防抢、原子类型防丢数、条件变量防忙等、阻塞队列负责传话。第 28 课 并发实战:多线程任务处理 把这些家伙拉到真正的业务场景里遛一遛:一批任务怎么派给多个工作线程并发处理、处理结果怎么按顺序回收、生产过快消费过慢时怎么用阻塞队列削峰填谷。三节课的并发知识会在那一课串成一条线。


    系列说明:本系列基于 Windows 平台 + CIDE + 仓颉 SDK(1.2.0)编写,所有代码均已实际编译运行通过。如遇 SDK 版本差异导致的细节出入,以你本地版本为准,欢迎评论区交流。


    💬 遇到问题?扫码联系作者

    跟着课程练习时,如果在 SDK 安装、环境变量配置、编译报错或调试上卡住,欢迎扫码加作者企业微信直接咨询(请备注"仓颉课程"): 在这里插入图片描述

    离线环境下图片可能加载不出来,也可以在 CIDE 菜单 Help ▸ 联系作者 / Contact 中查看同一张二维码(应用内置兜底图,无需联网)。


    📥 工具下载

    本系列全程使用的仓颉 IDE —— CIDE(免费开源、社区版):

    • GitCode 仓库 / 安装包下载:https://gitcode.com/wp_upala/cide
    • 打开页面后进入 发行版(Releases),两种包任选其一:
      • 安装版:下载 CIDE-<版本>-x64-Setup.exe,双击安装,适合日常长期使用;
      • 免安装版(Portable):下载 CIDE-<版本>-x64-Portable.zip,解压到任意目录即用,不写注册表、不留安装痕迹,拷到 U 盘也能在别的电脑直接运行(包内附《使用说明.txt》)。适合先试用、或在受限电脑上学习本系列课程。
    • 仓颉 SDK 请前往仓颉编程语言官网下载:https://cangjie-lang.cn
    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 【仓颉语言入门 · 第27课】
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!