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

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

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

    某次运行的输出:

    主线程继续走
    子线程在跑

    两个关键观察:

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

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

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

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

  • 仓颉用"禁止捕获 var"把绝大多数共享可变的写法挡在了编译期;
  • 用 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 等
    存多个 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
    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 【仓颉语言入门 · 第26课】
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!