【仓颉语言入门 · 第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 数据处理实战
  1. 环境搭建与第一个仓颉程序
  2. 变量、常量与基本数据类型
  3. 运算符与标准输入输出
  4. 分支结构与 match 表达式
  5. 循环结构:while / for / Range
  6. 字符串详解与字符串插值
  7. 数组 Array 与区间 Range
  8. 集合框架:ArrayList、HashMap、HashSet
  9. 可空类型 ? 与 Option
  10. 错误处理:异常机制与 Result
  11. 函数定义、参数与返回值
  12. Lambda 与高阶函数
  13. 闭包、作用域与函数类型
  14. 迭代器 Iterator 与 Sequence
  15. 结构体 struct 与类 class
  16. 构造函数、属性与方法
  17. 接口 interface 与实现
  18. 枚举 enum、代数数据类型与 match 模式匹配
  19. 泛型编程
  20. 扩展、类型别名与可见性控制
  21. cjpm 包管理与多文件项目组织
  22. 文件与目录 IO
  23. JSON 处理
  24. 网络编程入门
  25. 单元测试
  26. 并发基础:线程的创建与等待
  27. 线程同步:互斥锁、原子类型与条件变量(本文)
  28. 并发实战:多线程任务处理
  29. 实战一:带文件持久化的命令行小工具
  30. 实战二:GeoJSON 数据处理程序

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

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

class CounterBox {
    var count: Int64 = 0
}

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

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

  1. 读:把内存里 count 的当前值读到寄存器;
  2. 改:寄存器里的值加 1;
  3. 写:把新值写回内存。

两个线程可能同时读到同一个旧值:

线程 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 写完放锁,读到的永远是最新值。账,算平了。

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

  1. box 仍然是 let(引用不变),可变的是对象内部,spawn 的捕获规则没变;
  2. 最后必须逐个 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)

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

  1. fetchAdd 返回的是旧值,不是加完的值——上面输出里 fetchAdd(5) 打印 10,但接着 load() 是 15;
  2. 原子类型没有 .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

执行过程是这样的:

  1. 消费者先进 synchronized,发现队列是空的,执行 wait()——睡过去,同时把锁交出来(不然生产者永远进不来,就死锁了);
  2. 200 毫秒后生产者拿到锁、放入 42、notify() 叫醒消费者;
  3. 消费者重新拿到锁,从 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。


七、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 只检查一次,醒来就硬着头皮往下走,容易出错。这是条件变量的标准写法,照抄即可。


十二、课后练习

  1. (必做)用锁保护一个"坐标点":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}")
  1. (必做)用 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
  1. (必做)"开门令":工作线程先打印 等待开门,然后在条件变量上等待;主线程睡 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()
  1. (选做)用 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
posted @ 2026-10-06 01:52  upala  阅读(16)  评论(0)    收藏  举报