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