
【倉頡語言入門 · 第26課】并發(fā)基礎(chǔ)線程的創(chuàng)建與等待前面的 25 課里程序永遠(yuǎn)是一條道走到黑main從第一行執(zhí)行到最后一行一件事做完才能做下一件。但真實(shí)世界的程序經(jīng)常要同時干幾件事——下載文件的同時刷新進(jìn)度條、算一批數(shù)據(jù)的同時響應(yīng)用戶點(diǎn)擊。本課認(rèn)識倉頡的線程用spawn一句話分出一個新的執(zhí)行體用Future和get()等它跑完、取回結(jié)果。學(xué)完你會寫第一個多線程程序并且親眼看到倉頡在編譯期就攔下了一類最經(jīng)典的并發(fā) Bug。本文所有代碼與輸出均在倉頡 SDK 1.2.0 下逐行實(shí)測編譯運(yùn)行。目錄系列導(dǎo)航整套路線共7 個模塊、30 課模塊課次內(nèi)容一、環(huán)境與入門0105環(huán)境搭建與 Hello World、變量與基本類型、運(yùn)算符與輸入輸出、分支、循環(huán)二、常用類型與數(shù)據(jù)組織0610字符串、數(shù)組與區(qū)間、ArrayList/HashMap/HashSet、可空類型、錯誤處理三、函數(shù)與函數(shù)式1114函數(shù)、Lambda 與高階函數(shù)、閉包、迭代器與惰性序列四、面向?qū)ο笈c類型系統(tǒng)1520struct/class、構(gòu)造與屬性、接口、枚舉與 match 模式匹配、泛型、擴(kuò)展五、工程化與標(biāo)準(zhǔn)庫2125cjpm 包管理與多文件、文件 IO、JSON 處理、網(wǎng)絡(luò)編程、單元測試六、并發(fā)編程2628線程本文、Channel 通道與同步原語、并發(fā)實(shí)戰(zhàn)七、項(xiàng)目實(shí)戰(zhàn)2930命令行小工具、GeoJSON 數(shù)據(jù)處理實(shí)戰(zhàn)環(huán)境搭建與第一個倉頡程序變量、常量與基本數(shù)據(jù)類型運(yùn)算符與標(biāo)準(zhǔn)輸入輸出分支結(jié)構(gòu)與 match 表達(dá)式循環(huán)結(jié)構(gòu)while / for / Range字符串詳解與字符串插值數(shù)組 Array 與區(qū)間 Range集合框架ArrayList、HashMap、HashSet可空類型?與 Option錯誤處理異常機(jī)制與 Result函數(shù)定義、參數(shù)與返回值Lambda 與高階函數(shù)閉包、作用域與函數(shù)類型迭代器 Iterator 與 Sequence結(jié)構(gòu)體 struct 與類 class構(gòu)造函數(shù)、屬性與方法接口 interface 與實(shí)現(xiàn)枚舉 enum、代數(shù)數(shù)據(jù)類型與 match 模式匹配泛型編程擴(kuò)展、類型別名與可見性控制cjpm 包管理與多文件項(xiàng)目組織文件與目錄 IOJSON 處理網(wǎng)絡(luò)編程入門單元測試并發(fā)基礎(chǔ)線程的創(chuàng)建與等待本文Channel 通道與同步原語并發(fā)實(shí)戰(zhàn)多線程任務(wù)處理實(shí)戰(zhàn)一帶文件持久化的命令行小工具實(shí)戰(zhàn)二GeoJSON 數(shù)據(jù)處理程序一、為什么需要并發(fā)先看一個單線程的尷尬場景程序要下載三個文件用 sleep 模擬耗時每個 1 秒main(): Int64 { for (i in 1..3) { sleep(Duration.second * 1) // 假裝在下載 println(文件 ${i} 下載完成) } println(全部完成) return 0 }sleep讓當(dāng)前線程睡一會兒下一節(jié)細(xì)講Duration.second * 1就是1 秒。運(yùn)行它你會看著光標(biāo)干等 3 秒——三個任務(wù)是串行的文件 2 必須等文件 1 下完才開始。但這三個下載互相之間毫無依賴明明可以同時進(jìn)行。如果能兵分三路總耗時應(yīng)該接近 1 秒而不是 3 秒。這就是并發(fā)要解決的問題把互不依賴的任務(wù)分給多個執(zhí)行體同時跑。倉頡里這個執(zhí)行體就是線程thread。倉頡線程是輕量級線程它由倉頡運(yùn)行時調(diào)度創(chuàng)建和切換的開銷遠(yuǎn)小于操作系統(tǒng)線程一次創(chuàng)建成百上千個也不是問題。所以倉頡鼓勵一個任務(wù)一個線程的寫法不用像某些語言那樣精打細(xì)算地開線程池。二、spawn一句話創(chuàng)建線程創(chuàng)建線程只有一個關(guān)鍵字spawn。后面跟一對花括號括號里的代碼塊就是新線程要干的事main(): Int64 { spawn { println(子線程在跑) } println(主線程繼續(xù)走) sleep(Duration.millisecond * 100) // 主線程等一下原因馬上講 return 0 }某次運(yùn)行的輸出主線程繼續(xù)走 子線程在跑兩個關(guān)鍵觀察主線程繼續(xù)走打印在前。spawn只是分兵主線程自己不會停下來等——它分完兵立刻執(zhí)行下一行。這就是并發(fā)兩個字的直觀含義兩路人馬同時推進(jìn)。多運(yùn)行幾次順序可能反過來。誰先跑是運(yùn)行時調(diào)度器決定的你的代碼不能依賴任何固定順序。你可能會問最后那行sleep是干嘛的試著刪掉它再運(yùn)行——子線程在跑這行字不見了。原因在第五節(jié)講先記住結(jié)論主線程跑完main程序就結(jié)束了子線程會被直接帶走。三、Future 與 get等待線程、取回結(jié)果光分兵不夠還得能收兵等子線程干完活把結(jié)果拿回來。3.1 spawn 的返回值是 Futurespawn表達(dá)式有返回值類型是FutureT——一個未來的結(jié)果?;ɡㄌ柪飏eturn什么類型T就是什么類型main(): Int64 { let f: FutureInt64 spawn { return 42 // 子線程的計(jì)算結(jié)果 } println(主線程繼續(xù)走) let v f.get() // 等子線程跑完把 42 拿回來 println(拿到結(jié)果${v}) return 0 }輸出主線程繼續(xù)走 拿到結(jié)果42get()的語義就一句話如果線程還沒跑完當(dāng)前線程就停下來等它跑完了就把結(jié)果取出來。這就是其他語言里常叫join的那個操作。類型標(biāo)注let f: FutureInt64不是必須的編譯器推得出來但寫出來能幫你建立spawn 返回 Future的心智模型。3.2 沒有返回值的線程花括號里不寫return線程類型就是FutureUnit此時get()的作用純粹是等它干完main(): Int64 { let g spawn { println(打雜線程) } g.get() // 不要結(jié)果只要它打印完 println(主線程確認(rèn)打雜完畢) return 0 }輸出這次順序是保證的因?yàn)橹骶€程在get()處等了打雜線程 主線程確認(rèn)打雜完畢3.3 get 可以重復(fù)調(diào)用get()只是讀取結(jié)果讀多少次都行不會消耗掉let f spawn { return 99 } println(${f.get()}) // 99 println(${f.get()}) // 還是 99四、sleep 與 Duration讓線程睡一會兒sleep是倉頡的內(nèi)置函數(shù)不需要 import作用是讓當(dāng)前線程暫停指定時長。參數(shù)是一個Duration時長常用寫法是單位 × 數(shù)量sleep(Duration.second * 1) // 睡 1 秒 sleep(Duration.millisecond * 100) // 睡 100 毫秒 sleep(Duration.minute * 2) // 睡 2 分鐘sleep睡的是調(diào)用它的那個線程在子線程里 sleep主線程照常跑反之亦然。利用這一點(diǎn)可以直觀看到兩路并行main(): Int64 { spawn { sleep(Duration.second * 1) println(子線程我睡了 1 秒剛醒) } println(主線程我可沒睡) // 立刻打印不用等 1 秒 sleep(Duration.second * 2) // 主線程多睡一會兒等子線程醒 return 0 }五、主線程與子線程的關(guān)系main 結(jié)束全軍收隊(duì)第二節(jié)留下的懸念main最后一行執(zhí)行完程序就退出不管子線程是否跑完。實(shí)測main(): Int64 { spawn { sleep(Duration.second * 1) println(這行能打印嗎) } println(main 結(jié)束) return 0 }輸出只有一行main 結(jié)束子線程還在睡覺進(jìn)程已經(jīng)結(jié)束了那行打印永遠(yuǎn)沒機(jī)會執(zhí)行。??經(jīng)驗(yàn)法則只要你希望子線程的活被干完主線程就必須用get()等它或 sleep 足夠的時間——但這只是演示手法正經(jīng)代碼一律用get()。子線程的另一個問題是異常。子線程里拋異常會發(fā)生什么實(shí)測結(jié)果有兩條異常不會把主線程當(dāng)場打死程序繼續(xù)跑進(jìn)程結(jié)束時運(yùn)行時會把這個異常報(bào)告到錯誤輸出stderr——即使你get()時捕獲了它這份報(bào)告依然會打。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: 子線程炸了的報(bào)告。所以子線程里的代碼最好自己處理好異常別往出拋。六、spawn 的捕獲規(guī)則let 可以var 直接編譯報(bào)錯spawn的花括號是一個閉包第 13 課能用外面的變量但有一條硬規(guī)則——只允許捕獲不可變的let捕獲var直接編譯失敗main(): Int64 { let name 小明 spawn { println(${name} 的線程) // ? let 可以捕獲 }.get() var count 0 spawn { count 1 // ? 編譯報(bào)錯 } return 0 }編譯器原話error: spawn expressions cannot capture mutable variables; consider using let or boxing這不是編譯器刁難你而是在編譯期消滅數(shù)據(jù)競爭多個線程同時讀寫同一個var結(jié)果是不可預(yù)測的下一節(jié)你會親眼看到。倉頡的選擇是寧可不讓你寫也不讓你踩坑。那報(bào)錯信息里的boxing是什么意思看第七節(jié)。七、數(shù)據(jù)競爭初體驗(yàn)boxing 繞過之后的世界所謂 boxing裝箱就是把可變狀態(tài)塞進(jìn)一個class 實(shí)例里。let box CounterBox()本身不可變引用不換所以能過編譯但box指向的對象內(nèi)部是可變的——編譯器就放行了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實(shí)際 ${box.count}) return 0 }ArrayList記得import std.collection.ArrayList。連跑兩次的實(shí)測輸出期望 100000實(shí)際 33017 期望 100000實(shí)際 3387410 個線程各加 10000 次結(jié)果應(yīng)該是 100000實(shí)際只有 3 萬多而且每次運(yùn)行數(shù)字都不一樣。這就是數(shù)據(jù)競爭count 1實(shí)際是讀出 → 加一 → 寫回三步兩個線程可能讀到同一個舊值各加一次卻只寫回一個結(jié)果另一次加法憑空丟失。這節(jié)課你只要建立兩個認(rèn)知倉頡用禁止捕獲 var把絕大多數(shù)共享可變的寫法擋在了編譯期用 class boxing 可以繞過但繞過去的后果自負(fù)——結(jié)果就是上面這種對不上的賬。正確的解法Channel 通道、互斥鎖等同步原語是下一課的內(nèi)容這節(jié)只需要記住問題長什么樣。八、綜合實(shí)戰(zhàn)多線程分段求和把本課知識串成一個真正能提速的程序計(jì)算 1 加到 100 萬。單線程是一個 for 循環(huán)多線程的思路是切蛋糕——把區(qū)間切成 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 個數(shù) // 單線程基線 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(結(jié)果一致${sum base}) return 0 }實(shí)測輸出單線程500000500000 多線程500000500000 結(jié)果一致true三個細(xì)節(jié)值得品味每個線程拿到自己的start/end它們是循環(huán)里新算的let不存在共享編譯器放行運(yùn)行也安全——這就是切蛋糕式并發(fā)的安全本質(zhì)各干各的只匯合結(jié)果spawn里調(diào)用了普通函數(shù)rangeSum——線程體不限于幾行 println任意復(fù)雜的邏輯都可以放進(jìn)去匯總用f.get()逐個等待。第一個get()返回時其他線程可能還在跑沒關(guān)系下一輪循環(huán)再等就是。九、CIDE 實(shí)操親手感受順序不保證在 CIDE 里cjpm init --name threaddemo新建工程把下面代碼貼進(jìn)src/main.cjmain(): Int64 { for (i in 1..5) { spawn { println(線程 ${i}) } } sleep(Duration.millisecond * 100) return 0 }點(diǎn)擊運(yùn)行然后連按三次。某三次的輸出線程 5 線程 2 線程 3 線程 4 線程 1線程 1 線程 2 線程 3 線程 4 線程 5線程 3 線程 1 線程 2 線程 5 線程 4三次順序全不一樣——這就是調(diào)度器說了算的直接證據(jù)。以后寫并發(fā)代碼時腦子里要時刻懸著這句話任何依賴線程執(zhí)行順序的邏輯都是 Bug。再做一個反向?qū)嶒?yàn)刪掉最后的sleep再運(yùn)行5 行輸出可能只剩 23 行甚至一行沒有主線程先跑完進(jìn)程直接收隊(duì)。把sleep換成對 5 個 Future 逐個get()輸出立刻恢復(fù)完整——這正是第五節(jié)的規(guī)則在起作用。十、常用 API 速查目的寫法說明創(chuàng)建線程spawn { ... }返回FutureTT 由花括號里的 return 決定等待并取結(jié)果future.get()線程沒跑完就阻塞等待可重復(fù)調(diào)用只等待不要結(jié)果future.get()FutureUnit的 get 返回 Unit純等結(jié)束睡眠sleep(Duration.second * 1)內(nèi)置函數(shù)無需 import睡當(dāng)前線程毫秒級睡眠sleep(Duration.millisecond * 100)Duration 單位second / millisecond / minute 等存多個 FutureArrayListFutureInt64import std.collection.ArrayList十一、常見問題 FAQQ1spawn 需要 import 什么嗎不需要。spawn、sleep、Duration、Future都在編譯器默認(rèn)導(dǎo)入的std.core里直接寫。Q2為什么我的子線程的打印沒出現(xiàn)九成是主線程先跑完main進(jìn)程結(jié)束了。給子線程的 Future 調(diào)get()讓主線程等它。Q3error: spawn expressions cannot capture mutable variables怎么改三條路① 把要用的值在 spawn 前算成let再捕獲最推薦本課實(shí)戰(zhàn)就是這么做的② 確實(shí)需要共享可變狀態(tài)時用 class 把狀態(tài)裝箱第七節(jié)但要自己承擔(dān)數(shù)據(jù)競爭風(fēng)險正確解法見第 27 課③ 能通過參數(shù)/返回值傳遞的就別共享。Q4get() 會等多久如果子線程死循環(huán)了呢一直等下去。get()沒有超時參數(shù)所以線程體里別寫死循環(huán)需要定時檢查的場景等第 27 課學(xué)了 Channel 再說。Q5子線程里拋異常程序會崩嗎不會當(dāng)場崩。異常會存在 Future 里你get()時它拋給你可以 try-catch沒人get()進(jìn)程結(jié)束時運(yùn)行時也會把它打到 stderr。建議子線程內(nèi)部自己消化異常。Q6倉頡線程就是操作系統(tǒng)線程嗎不是一一對應(yīng)。倉頡線程是運(yùn)行時調(diào)度的輕量執(zhí)行體由運(yùn)行時映射到少量系統(tǒng)線程上執(zhí)行所以開幾百個也很便宜。日常編碼不需要關(guān)心這層映射。十二、課后練習(xí)寫一個程序spawn一個線程打印我是子線程主線程打印我是主線程最后用get()等待子線程結(jié)束。連運(yùn)行三次觀察兩行的先后順序是不是每次都一樣回想第九節(jié)的結(jié)論。寫一個函數(shù)func slowAdd(a: Int64, b: Int64): FutureInt64內(nèi)部spawn一個線程先sleep(Duration.second * 1)模擬耗時計(jì)算再return a b。main 里調(diào)用它拿Future先打印計(jì)算已派出我先干別的再get()拿結(jié)果打印。運(yùn)行后應(yīng)看到第一行立刻出現(xiàn)約 1 秒后結(jié)果出現(xiàn)。起 3 個線程分別計(jì)算1~100、101~200、201~300的和提示照第八節(jié)實(shí)戰(zhàn)的思路手寫三個 spawn 即可不用循環(huán)用三個get()匯總并打印總和。期望輸出45150多運(yùn)行幾次確認(rèn)結(jié)果每次都一樣對比練習(xí) 1 的順序不穩(wěn)定體會結(jié)果匯合和執(zhí)行順序的區(qū)別。觀察實(shí)驗(yàn)把第七節(jié)CounterBox的例子抄下來運(yùn)行三次記錄三次的實(shí)際數(shù)字然后把內(nèi)層循環(huán)從1..10000改成1..10再運(yùn)行三次。回答兩個問題① 小循環(huán)次數(shù)時結(jié)果為什么經(jīng)常碰巧是對的② 這個程序的錯誤能在編譯期發(fā)現(xiàn)嗎為什么下節(jié)預(yù)告本課結(jié)尾的CounterBox慘案還懸著10 個線程加 10 萬次結(jié)果只有 3 萬多。第 27 課Channel 通道與同步原語就來收拾這個局面——倉頡推薦的姿勢是不要共享狀態(tài)改用通信線程之間通過Channel互相發(fā)送消息數(shù)據(jù)在哪里、歸誰管一目了然再配上Mutex互斥鎖、Atomic原子類型等同步原語把本課對不上的賬一筆一筆算平。系列說明本系列基于 Windows 平臺 CIDE 倉頡 SDK1.2.0編寫所有代碼均已實(shí)際編譯運(yùn)行通過。如遇 SDK 版本差異導(dǎo)致的細(xì)節(jié)出入以你本地版本為準(zhǔn)歡迎評論區(qū)交流。 遇到問題掃碼聯(lián)系作者跟著課程練習(xí)時如果在 SDK 安裝、環(huán)境變量配置、編譯報(bào)錯或調(diào)試上卡住歡迎掃碼加作者企業(yè)微信直接咨詢請備注倉頡課程離線環(huán)境下圖片可能加載不出來也可以在 CIDE 菜單Help ? 聯(lián)系作者 / Contact中查看同一張二維碼應(yīng)用內(nèi)置兜底圖無需聯(lián)網(wǎng)。 工具下載本系列全程使用的倉頡 IDE ——CIDE免費(fèi)開源、社區(qū)版GitCode 倉庫 / 安裝包下載https://gitcode.com/wp_upala/cide打開頁面后進(jìn)入發(fā)行版Releases兩種包任選其一安裝版下載CIDE-版本-x64-Setup.exe雙擊安裝適合日常長期使用免安裝版Portable下載CIDE-版本-x64-Portable.zip解壓到任意目錄即用不寫注冊表、不留安裝痕跡拷到 U 盤也能在別的電腦直接運(yùn)行包內(nèi)附《使用說明.txt》。適合先試用、或在受限電腦上學(xué)習(xí)本系列課程。倉頡 SDK 請前往倉頡編程語言官網(wǎng)下載https://cangjie-lang.cn