【仓颉语言入门 · 第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~20struct/class、构造与属性、接口、枚举与 match 模式匹配、泛型、扩展
五、工程化与标准库21~25cjpm 包管理与多文件、文件 IO、JSON 处理、网络编程、单元测试
六、并发编程26~28线程(本文)、Channel 通道与同步原语、并发实战
七、项目实战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. Channel 通道与同步原语
  28. 并发实战:多线程任务处理
  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
}

某次运行的输出:

主线程继续走
子线程在跑

两个关键观察:

  1. "主线程继续走"打印在前。spawn 只是"分兵",主线程自己不会停下来等——它分完兵立刻执行下一行。这就是"并发"两个字的直观含义:两路人马同时推进。
  2. 多运行几次,顺序可能反过来。谁先跑是运行时调度器决定的,你的代码不能依赖任何固定顺序。

你可能会问:最后那行 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())。

子线程的另一个问题是异常。子线程里抛异常会发生什么?实测结果有两条:

  1. 异常不会把主线程当场打死,程序继续跑;
  2. 进程结束时,运行时会把这个异常报告到错误输出(stderr)——即使你 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 实际是"读出 → 加一 → 写回"三步,两个线程可能读到同一个旧值,各加一次却只写回一个结果,另一次加法凭空丢失。

这节课你只要建立两个认知:

  1. 仓颉用"禁止捕获 var"把绝大多数共享可变的写法挡在了编译期;
  2. 用 class boxing 可以绕过,但绕过去的后果自负——结果就是上面这种对不上的账。

正确的解法(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 等
存多个 FutureArrayList<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:仓颉线程就是操作系统线程吗?

不是一一对应。仓颉线程是运行时调度的轻量执行体,由运行时映射到少量系统线程上执行,所以开几百个也很便宜。日常编码不需要关心这层映射。


十二、课后练习

  1. 写一个程序:spawn 一个线程打印 "我是子线程",主线程打印 "我是主线程",最后用 get() 等待子线程结束。连运行三次,观察两行的先后顺序是不是每次都一样(回想第九节的结论)。

  2. 写一个函数 func slowAdd(a: Int64, b: Int64): Future<Int64>:内部 spawn 一个线程,先 sleep(Duration.second * 1)(模拟耗时计算),再 return a + b。main 里调用它拿 Future,先打印 "计算已派出,我先干别的",再 get() 拿结果打印。运行后应看到:第一行立刻出现,约 1 秒后结果出现。

  3. 起 3 个线程分别计算 1~100、101~200、201~300 的和(提示:照第八节实战的思路,手写三个 spawn 即可,不用循环),用三个 get() 汇总并打印总和。期望输出 45150;多运行几次,确认结果每次都一样(对比练习 1 的顺序不稳定,体会"结果汇合"和"执行顺序"的区别)。

  4. (观察实验)把第七节 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
Logo

一站式 AI 云服务平台

更多推荐