【仓颉语言入门 · 第26课】并发基础:线程的创建与等待
前面的 25 课里,程序永远是一条道走到黑:main 从第一行执行到最后一行,一件事做完才能做下一件。但真实世界的程序经常要"同时"干几件事——下载文件的同时刷新进度条、算一批数据的同时响应用户点击。本课认识仓颉的线程:用 spawn 一句话分出一个新的执行体,用 Future 和 get() 等它跑完、取回结果。学完你会写第一个"多线程"程序,并且亲眼看到仓颉在编译期就拦下了一类最经典的并发 Bug。
本文所有代码与输出均在仓颉 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 | 线程(本文)、Channel 通道与同步原语、并发实战 |
| 七、项目实战 | 29~30 | 命令行小工具、GeoJSON 数据处理实战 |
一、为什么需要并发
先看一个单线程的尴尬场景:程序要"下载"三个文件(用 sleep 模拟耗时),每个 1 秒:
main(): Int64 {
for (i in 1..=3) {
sleep(Duration.second * 1) // 假装在下载
println("文件 ${i} 下载完成")
}
println("全部完成")
return 0
}
sleep 让当前线程睡一会儿(下一节细讲),Duration.second * 1 就是"1 秒"。运行它,你会看着光标干等 3 秒——三个任务是串行的:文件 2 必须等文件 1 下完才开始。
但这三个下载互相之间毫无依赖,明明可以同时进行。如果能"兵分三路",总耗时应该接近 1 秒而不是 3 秒。
这就是并发要解决的问题:把互不依赖的任务分给多个执行体同时跑。仓颉里这个"执行体"就是线程(thread)。
📌 仓颉线程是"轻量级线程":它由仓颉运行时调度,创建和切换的开销远小于操作系统线程,一次创建成百上千个也不是问题。所以仓颉鼓励"一个任务一个线程"的写法,不用像某些语言那样精打细算地开线程池。
二、spawn:一句话创建线程
创建线程只有一个关键字:spawn。后面跟一对花括号,括号里的代码块就是新线程要干的事:
main(): Int64 {
spawn {
println("子线程在跑")
}
println("主线程继续走")
sleep(Duration.millisecond * 100) // 主线程等一下,原因马上讲
return 0
}
某次运行的输出:
主线程继续走
子线程在跑
两个关键观察:
你可能会问:最后那行 sleep 是干嘛的?试着删掉它再运行——子线程在跑 这行字不见了。原因在第五节讲,先记住结论:主线程跑完 main 程序就结束了,子线程会被直接带走。
三、Future 与 get:等待线程、取回结果
光"分兵"不够,还得能"收兵":等子线程干完活,把结果拿回来。
3.1 spawn 的返回值是 Future
spawn 表达式有返回值,类型是 Future<T>——一个"未来的结果"。花括号里 return 什么类型,T 就是什么类型:
main(): Int64 {
let f: Future<Int64> = spawn {
return 42 // 子线程的计算结果
}
println("主线程继续走")
let v = f.get() // 等子线程跑完,把 42 拿回来
println("拿到结果:${v}")
return 0
}
输出:
主线程继续走
拿到结果:42
get() 的语义就一句话:如果线程还没跑完,当前线程就停下来等它;跑完了就把结果取出来。这就是其他语言里常叫 join 的那个操作。
类型标注 let f: Future<Int64> 不是必须的,编译器推得出来;但写出来能帮你建立"spawn 返回 Future"的心智模型。
3.2 没有返回值的线程
花括号里不写 return,线程类型就是 Future<Unit>,此时 get() 的作用纯粹是"等它干完":
main(): Int64 {
let g = spawn {
println("打杂线程")
}
g.get() // 不要结果,只要它打印完
println("主线程确认打杂完毕")
return 0
}
输出(这次顺序是保证的,因为主线程在 get() 处等了):
打杂线程
主线程确认打杂完毕
3.3 get 可以重复调用
get() 只是"读取结果",读多少次都行,不会消耗掉:
let f = spawn { return 99 }
println("${f.get()}") // 99
println("${f.get()}") // 还是 99
四、sleep 与 Duration:让线程睡一会儿
sleep 是仓颉的内置函数,不需要 import,作用是让当前线程暂停指定时长。参数是一个 Duration(时长),常用写法是"单位 × 数量":
sleep(Duration.second * 1) // 睡 1 秒
sleep(Duration.millisecond * 100) // 睡 100 毫秒
sleep(Duration.minute * 2) // 睡 2 分钟
sleep 睡的是调用它的那个线程:在子线程里 sleep,主线程照常跑;反之亦然。利用这一点可以直观看到"两路并行":
main(): Int64 {
spawn {
sleep(Duration.second * 1)
println("子线程:我睡了 1 秒,刚醒")
}
println("主线程:我可没睡") // 立刻打印,不用等 1 秒
sleep(Duration.second * 2) // 主线程多睡一会儿,等子线程醒
return 0
}
五、主线程与子线程的关系:main 结束,全军收队
第二节留下的悬念:main 最后一行执行完,程序就退出,不管子线程是否跑完。实测:
main(): Int64 {
spawn {
sleep(Duration.second * 1)
println("这行能打印吗?")
}
println("main 结束")
return 0
}
输出只有一行:
main 结束
子线程还在睡觉,进程已经结束了,那行打印永远没机会执行。
⚠️ 经验法则:只要你希望子线程的活被干完,主线程就必须用 get() 等它(或 sleep 足够的时间——但这只是演示手法,正经代码一律用 get())。
子线程的另一个问题是异常。子线程里抛异常会发生什么?实测结果有两条:
main(): Int64 {
let bad = spawn {
throw IllegalArgumentException("子线程炸了")
}
try {
bad.get()
} catch (e: IllegalArgumentException) {
println("main 捕获到:${e.message}")
}
println("main 正常收尾")
return 0
}
终端里 stdout 是干净的(main 捕获到:子线程炸了 / main 正常收尾),但 stderr 里仍能看到 IllegalArgumentException: 子线程炸了 的报告。所以子线程里的代码最好自己处理好异常,别往出抛。
六、spawn 的捕获规则:let 可以,var 直接编译报错
spawn 的花括号是一个闭包(第 13 课),能用外面的变量,但有一条硬规则——只允许捕获不可变的 let,捕获 var 直接编译失败:
main(): Int64 {
let name = "小明"
spawn {
println("${name} 的线程") // ✅ let 可以捕获
}.get()
var count = 0
spawn {
count += 1 // ❌ 编译报错!
}
return 0
}
编译器原话:
error: 'spawn' expressions cannot capture mutable variables; consider using 'let' or boxing
这不是编译器刁难你,而是在编译期消灭数据竞争:多个线程同时读写同一个 var,结果是不可预测的(下一节你会亲眼看到)。仓颉的选择是"宁可不让你写,也不让你踩坑"。
那报错信息里的 boxing 是什么意思?看第七节。
七、数据竞争初体验:boxing 绕过之后的世界
所谓 boxing(装箱),就是把可变状态塞进一个 class 实例里。let box = CounterBox() 本身不可变(引用不换),所以能过编译;但 box 指向的对象内部是可变的——编译器就放行了:
class CounterBox {
var count: Int64 = 0
}
main(): Int64 {
let box = CounterBox()
let futures = ArrayList<Future<Unit>>()
for (_ in 1..=10) {
futures.add(spawn {
for (_ in 1..=10000) {
box.count += 1 // 10 个线程同时改同一个 count
}
})
}
for (f in futures) {
f.get() // 等 10 个线程全部跑完
}
println("期望 100000,实际 ${box.count}")
return 0
}
(ArrayList 记得 import std.collection.ArrayList。)
连跑两次的实测输出:
期望 100000,实际 33017
期望 100000,实际 33874
10 个线程各加 10000 次,结果应该是 100000,实际只有 3 万多,而且每次运行数字都不一样。这就是数据竞争:count += 1 实际是"读出 → 加一 → 写回"三步,两个线程可能读到同一个旧值,各加一次却只写回一个结果,另一次加法凭空丢失。
这节课你只要建立两个认知:
正确的解法(Channel 通道、互斥锁等同步原语)是下一课的内容,这节只需要记住"问题长什么样"。
八、综合实战:多线程分段求和
把本课知识串成一个真正能提速的程序:计算 1 加到 100 万。单线程是一个 for 循环;多线程的思路是切蛋糕——把区间切成 4 段,4 个线程各算一段,最后 get() 汇总:
import std.collection.ArrayList
func rangeSum(start: Int64, end: Int64): Int64 {
var sum: Int64 = 0
for (i in start..=end) {
sum += i
}
return sum
}
main(): Int64 {
let total = 1000000
let threadCount = 4
let step = total / threadCount // 每段 250000 个数
// 单线程基线
let base = rangeSum(1, total)
println("单线程:${base}")
// 多线程:每段一个线程
let futures = ArrayList<Future<Int64>>()
for (k in 0..threadCount) {
let start = k * step + 1 // 每段的起止都是 let,可安全捕获
let end = (k + 1) * step
futures.add(spawn {
return rangeSum(start, end)
})
}
var sum: Int64 = 0
for (f in futures) {
sum += f.get() // 逐个收兵,把 4 段加起来
}
println("多线程:${sum}")
println("结果一致:${sum == base}")
return 0
}
实测输出:
单线程:500000500000
多线程:500000500000
结果一致:true
三个细节值得品味:
- 每个线程拿到自己的 start / end:它们是循环里新算的 let,不存在共享,编译器放行,运行也安全——这就是"切蛋糕"式并发的安全本质:各干各的,只汇合结果;
- spawn 里调用了普通函数 rangeSum——线程体不限于几行 println,任意复杂的逻辑都可以放进去;
- 汇总用 f.get() 逐个等待。第一个 get() 返回时其他线程可能还在跑,没关系,下一轮循环再等就是。
九、CIDE 实操:亲手感受"顺序不保证"
在 CIDE 里 cjpm init –name threaddemo 新建工程,把下面代码贴进 src/main.cj:
main(): Int64 {
for (i in 1..=5) {
spawn {
println("线程 ${i}")
}
}
sleep(Duration.millisecond * 100)
return 0
}
点击运行,然后连按三次。某三次的输出:
线程 5
线程 2
线程 3
线程 4
线程 1
线程 1
线程 2
线程 3
线程 4
线程 5
线程 3
线程 1
线程 2
线程 5
线程 4
三次顺序全不一样——这就是"调度器说了算"的直接证据。以后写并发代码时脑子里要时刻悬着这句话:任何依赖线程执行顺序的逻辑都是 Bug。
再做一个反向实验:删掉最后的 sleep 再运行,5 行输出可能只剩 2~3 行甚至一行没有(主线程先跑完,进程直接收队)。把 sleep 换成对 5 个 Future 逐个 get(),输出立刻恢复完整——这正是第五节的规则在起作用。
十、常用 API 速查
| 创建线程 | spawn { … } | 返回 Future<T>,T 由花括号里的 return 决定 |
| 等待并取结果 | future.get() | 线程没跑完就阻塞等待;可重复调用 |
| 只等待不要结果 | future.get() | Future<Unit> 的 get 返回 Unit,纯等结束 |
| 睡眠 | sleep(Duration.second * 1) | 内置函数,无需 import,睡当前线程 |
| 毫秒级睡眠 | sleep(Duration.millisecond * 100) | Duration 单位:second / millisecond / minute 等 |
| 存多个 Future | ArrayList<Future<Int64>> | import std.collection.ArrayList |
十一、常见问题 FAQ
Q1:spawn 需要 import 什么吗?
不需要。spawn、sleep、Duration、Future 都在编译器默认导入的 std.core 里,直接写。
Q2:为什么我的子线程的打印没出现?
九成是主线程先跑完 main,进程结束了。给子线程的 Future 调 get(),让主线程等它。
Q3:error: 'spawn' expressions cannot capture mutable variables 怎么改?
三条路:① 把要用的值在 spawn 前算成 let 再捕获(最推荐,本课实战就是这么做的);② 确实需要共享可变状态时,用 class 把状态装箱(第七节),但要自己承担数据竞争风险,正确解法见第 27 课;③ 能通过参数/返回值传递的,就别共享。
Q4:get() 会等多久?如果子线程死循环了呢?
一直等下去。get() 没有超时参数,所以线程体里别写死循环;需要"定时检查"的场景等第 27 课学了 Channel 再说。
Q5:子线程里抛异常,程序会崩吗?
不会当场崩。异常会存在 Future 里:你 get() 时它抛给你(可以 try-catch);没人 get(),进程结束时运行时也会把它打到 stderr。建议子线程内部自己消化异常。
Q6:仓颉线程就是操作系统线程吗?
不是一一对应。仓颉线程是运行时调度的轻量执行体,由运行时映射到少量系统线程上执行,所以开几百个也很便宜。日常编码不需要关心这层映射。
十二、课后练习
写一个程序:spawn 一个线程打印 "我是子线程",主线程打印 "我是主线程",最后用 get() 等待子线程结束。连运行三次,观察两行的先后顺序是不是每次都一样(回想第九节的结论)。
写一个函数 func slowAdd(a: Int64, b: Int64): Future<Int64>:内部 spawn 一个线程,先 sleep(Duration.second * 1)(模拟耗时计算),再 return a + b。main 里调用它拿 Future,先打印 "计算已派出,我先干别的",再 get() 拿结果打印。运行后应看到:第一行立刻出现,约 1 秒后结果出现。
起 3 个线程分别计算 1~100、101~200、201~300 的和(提示:照第八节实战的思路,手写三个 spawn 即可,不用循环),用三个 get() 汇总并打印总和。期望输出 45150;多运行几次,确认结果每次都一样(对比练习 1 的顺序不稳定,体会"结果汇合"和"执行顺序"的区别)。
(观察实验)把第七节 CounterBox 的例子抄下来运行三次,记录三次的实际数字;然后把内层循环从 1..=10000 改成 1..=10 再运行三次。回答两个问题:① 小循环次数时结果为什么经常"碰巧"是对的?② 这个程序的错误能在编译期发现吗?为什么?
下节预告
本课结尾的 CounterBox 惨案还悬着:10 个线程加 10 万次,结果只有 3 万多。第 27 课 Channel 通道与同步原语就来收拾这个局面——仓颉推荐的姿势是"不要共享状态,改用通信":线程之间通过 Channel 互相发送消息,数据在哪里、归谁管一目了然;再配上 Mutex 互斥锁、Atomic 原子类型等同步原语,把本课对不上的账一笔一笔算平。
系列说明:本系列基于 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
网硕互联帮助中心






评论前必须登录!
注册