ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

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

【仓颉语言入门 · 第26课】 【仓颉语言入门 · 第26课】并发基础线程的创建与等待前面的 25 课里程序永远是一条道走到黑main从第一行执行到最后一行一件事做完才能做下一件。但真实世界的程序经常要同时干几件事——下载文件的同时刷新进度条、算一批数据的同时响应用户点击。本课认识仓颉的线程用spawn一句话分出一个新的执行体用Future和get()等它跑完、取回结果。学完你会写第一个多线程程序并且亲眼看到仓颉在编译期就拦下了一类最经典的并发 Bug。本文所有代码与输出均在仓颉 SDK 1.2.0 下逐行实测编译运行。目录系列导航整套路线共7 个模块、30 课模块课次内容一、环境与入门0105环境搭建与 Hello World、变量与基本类型、运算符与输入输出、分支、循环二、常用类型与数据组织0610字符串、数组与区间、ArrayList/HashMap/HashSet、可空类型、错误处理三、函数与函数式1114函数、Lambda 与高阶函数、闭包、迭代器与惰性序列四、面向对象与类型系统1520struct/class、构造与属性、接口、枚举与 match 模式匹配、泛型、扩展五、工程化与标准库2125cjpm 包管理与多文件、文件 IO、JSON 处理、网络编程、单元测试六、并发编程2628线程本文、Channel 通道与同步原语、并发实战七、项目实战2930命令行小工具、GeoJSON 数据处理实战环境搭建与第一个仓颉程序变量、常量与基本数据类型运算符与标准输入输出分支结构与 match 表达式循环结构while / for / Range字符串详解与字符串插值数组 Array 与区间 Range集合框架ArrayList、HashMap、HashSet可空类型?与 Option错误处理异常机制与 Result函数定义、参数与返回值Lambda 与高阶函数闭包、作用域与函数类型迭代器 Iterator 与 Sequence结构体 struct 与类 class构造函数、属性与方法接口 interface 与实现枚举 enum、代数数据类型与 match 模式匹配泛型编程扩展、类型别名与可见性控制cjpm 包管理与多文件项目组织文件与目录 IOJSON 处理网络编程入门单元测试并发基础线程的创建与等待本文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 的返回值是 Futurespawn表达式有返回值类型是FutureT——一个未来的结果。花括号里return什么类型T就是什么类型main(): Int64 { let f: FutureInt64 spawn { return 42 // 子线程的计算结果 } println(主线程继续走) let v f.get() // 等子线程跑完把 42 拿回来 println(拿到结果${v}) return 0 }输出主线程继续走 拿到结果42get()的语义就一句话如果线程还没跑完当前线程就停下来等它跑完了就把结果取出来。这就是其他语言里常叫join的那个操作。类型标注let f: FutureInt64不是必须的编译器推得出来但写出来能帮你建立spawn 返回 Future的心智模型。3.2 没有返回值的线程花括号里不写return线程类型就是FutureUnit此时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 ArrayListFutureUnit() 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实际 3387410 个线程各加 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 ArrayListFutureInt64() 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.cjmain(): 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 行输出可能只剩 23 行甚至一行没有主线程先跑完进程直接收队。把sleep换成对 5 个 Future 逐个get()输出立刻恢复完整——这正是第五节的规则在起作用。十、常用 API 速查目的写法说明创建线程spawn { ... }返回FutureTT 由花括号里的 return 决定等待并取结果future.get()线程没跑完就阻塞等待可重复调用只等待不要结果future.get()FutureUnit的 get 返回 Unit纯等结束睡眠sleep(Duration.second * 1)内置函数无需 import睡当前线程毫秒级睡眠sleep(Duration.millisecond * 100)Duration 单位second / millisecond / minute 等存多个 FutureArrayListFutureInt64import std.collection.ArrayList十一、常见问题 FAQQ1spawn 需要 import 什么吗不需要。spawn、sleep、Duration、Future都在编译器默认导入的std.core里直接写。Q2为什么我的子线程的打印没出现九成是主线程先跑完main进程结束了。给子线程的 Future 调get()让主线程等它。Q3error: spawn expressions cannot capture mutable variables怎么改三条路① 把要用的值在 spawn 前算成let再捕获最推荐本课实战就是这么做的② 确实需要共享可变状态时用 class 把状态装箱第七节但要自己承担数据竞争风险正确解法见第 27 课③ 能通过参数/返回值传递的就别共享。Q4get() 会等多久如果子线程死循环了呢一直等下去。get()没有超时参数所以线程体里别写死循环需要定时检查的场景等第 27 课学了 Channel 再说。Q5子线程里抛异常程序会崩吗不会当场崩。异常会存在 Future 里你get()时它抛给你可以 try-catch没人get()进程结束时运行时也会把它打到 stderr。建议子线程内部自己消化异常。Q6仓颉线程就是操作系统线程吗不是一一对应。仓颉线程是运行时调度的轻量执行体由运行时映射到少量系统线程上执行所以开几百个也很便宜。日常编码不需要关心这层映射。十二、课后练习写一个程序spawn一个线程打印我是子线程主线程打印我是主线程最后用get()等待子线程结束。连运行三次观察两行的先后顺序是不是每次都一样回想第九节的结论。写一个函数func slowAdd(a: Int64, b: Int64): FutureInt64内部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 仓颉 SDK1.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
返回列表