編程核心概念詳解:從進(jìn)程線程到鎖與死鎖)
1. 一切要從“程序卡住”說(shuō)起你在某個(gè)業(yè)務(wù)系統(tǒng)里寫(xiě)過(guò)這樣一個(gè)功能批量拉取幾百家上游接口的數(shù)據(jù)然后逐條清洗入庫(kù)。第一次跑的時(shí)候你發(fā)現(xiàn)整個(gè)過(guò)程要花四十多分鐘而真正在“計(jì)算”的時(shí)間其實(shí)不到一半絕大部分時(shí)間都耗在了等待網(wǎng)絡(luò)響應(yīng)上。于是你自然會(huì)想能不能讓多個(gè)請(qǐng)求同時(shí)發(fā)出去而不是一個(gè)個(gè)排隊(duì)等這個(gè)念頭就是并發(fā)編程最初的火花。我自己第一次感覺(jué)到該學(xué)并發(fā)編程也是在類似的場(chǎng)景。當(dāng)時(shí)接手一個(gè)數(shù)據(jù)同步模塊每天凌晨抓取外部數(shù)據(jù)任務(wù)隊(duì)列一長(zhǎng)運(yùn)行時(shí)間直奔兩個(gè)小時(shí)。一開(kāi)始我以為是網(wǎng)速問(wèn)題后來(lái)加了日志才發(fā)現(xiàn)CPU使用率一直只有5%左右。換句話說(shuō)整個(gè)機(jī)器大部分時(shí)間在“發(fā)呆”程序卻在按部就班地排隊(duì)。那不是機(jī)器慢是程序設(shè)計(jì)導(dǎo)致資源的巨大浪費(fèi)。這篇博文不教某個(gè)語(yǔ)言的API也不帶你速成某個(gè)框架而是把并發(fā)編程的底子梳理一遍進(jìn)程、線程、并發(fā)、并行、臨界區(qū)、鎖、調(diào)度、上下文切換一個(gè)個(gè)講清楚。無(wú)論你用Go、Java、Python還是C這些概念都是通用的是你判斷“并發(fā)方案該怎么設(shè)計(jì)”的地基。適合剛?cè)腴T并發(fā)編程的開(kāi)發(fā)者也適合寫(xiě)過(guò)一些多線程代碼但總覺(jué)得“差點(diǎn)意思”的人。再退一步說(shuō)就算你現(xiàn)在主要寫(xiě)業(yè)務(wù)邏輯理解這些概念也能幫你更早發(fā)現(xiàn)并發(fā)bug而不是等上線后讓它在生產(chǎn)環(huán)境教做人。2. 先分清臺(tái)前演員進(jìn)程、線程與協(xié)程2.1 進(jìn)程資源容器也是隔離邊界進(jìn)程是操作系統(tǒng)進(jìn)行資源分配的最小單位。這句話背下來(lái)很容易真正理解它需要想清楚“資源”包括什么內(nèi)存地址空間、文件描述符、環(huán)境變量、信號(hào)處理、線程列表……基本上一個(gè)正在運(yùn)行的程序所擁有的家當(dāng)都會(huì)被操作系統(tǒng)登記在一個(gè)叫做“進(jìn)程控制塊”的結(jié)構(gòu)里。進(jìn)程之間默認(rèn)是互相隔離的。一個(gè)進(jìn)程崩潰一般不會(huì)直接拖垮另一個(gè)進(jìn)程。A進(jìn)程里把內(nèi)存寫(xiě)爛了錯(cuò)誤也在A自己的地址空間里操作系統(tǒng)順手就把A終結(jié)了其他進(jìn)程照常運(yùn)行。這種隔離既是好處也是代價(jià)。好處是穩(wěn)定安全代價(jià)是進(jìn)程間通信成本極高??邕M(jìn)程傳遞數(shù)據(jù)得走管道、消息隊(duì)列、共享內(nèi)存、套接字這些IPC機(jī)制每一樣都伴隨著數(shù)據(jù)的拷貝或者系統(tǒng)調(diào)用性能遠(yuǎn)比不上同進(jìn)程內(nèi)線程間的數(shù)據(jù)共享。在并發(fā)編程里進(jìn)程通常不是我們“造并發(fā)”的第一選擇但它定義了“隔離”與“穩(wěn)定”的邊界。你在設(shè)計(jì)一個(gè)系統(tǒng)時(shí)如果不同模塊之間需要很強(qiáng)的故障隔離才考慮拆成多進(jìn)程。2.2 線程真正干活的最小單位線程是CPU調(diào)度的最小單位。比進(jìn)程更“輕”因?yàn)橥粋€(gè)進(jìn)程內(nèi)的多個(gè)線程共享進(jìn)程的資源同一塊堆內(nèi)存、同一個(gè)文件描述符表、同一種全局狀態(tài)。線程自己只保留一小塊獨(dú)立的棧空間以及寄存器上下文。正因?yàn)楣蚕韮?nèi)存線程之間的數(shù)據(jù)交換變得特別簡(jiǎn)單。你定義一個(gè)全局變量所有線程都能訪問(wèn)不需要管道也不需要消息隊(duì)列。但也正因?yàn)楣蚕砺闊┦聛?lái)了兩個(gè)線程同時(shí)改同一個(gè)變量結(jié)果可能錯(cuò)亂。第三章和第五章會(huì)重點(diǎn)展開(kāi)這部分。從模型上看一個(gè)進(jìn)程至少有一個(gè)線程也可以有很多線程。線程由操作系統(tǒng)內(nèi)核來(lái)調(diào)度創(chuàng)建和切換都需要陷入內(nèi)核態(tài)這也就決定了線程不可能無(wú)限多——后面講上下文切換的時(shí)候你就能直觀感受到它的成本。2.3 協(xié)程用戶態(tài)里的“輕量級(jí)線程”協(xié)程常被叫做“用戶態(tài)線程”。它和線程最大的區(qū)別是協(xié)程的調(diào)度不依賴操作系統(tǒng)內(nèi)核而是由應(yīng)用程序自己控制。線程是被操作系統(tǒng)搶來(lái)?yè)屓サ卣{(diào)度搶占式協(xié)程則是代碼主動(dòng)讓出執(zhí)行權(quán)協(xié)作式。你用協(xié)程寫(xiě)并發(fā)本質(zhì)上是在一個(gè)線程里交替執(zhí)行多段邏輯。當(dāng)前協(xié)程遇到I/O等待比如網(wǎng)絡(luò)請(qǐng)求發(fā)出去了就主動(dòng)把執(zhí)行權(quán)交給另一個(gè)協(xié)程讓CPU繼續(xù)干別的。因?yàn)榍袚Q發(fā)生在用戶態(tài)不需要陷入內(nèi)核所以協(xié)程切換的開(kāi)銷比線程小一到兩個(gè)數(shù)量級(jí)。這也是“高并發(fā)”服務(wù)端廣泛采用協(xié)程的根本原因。大量請(qǐng)求同時(shí)進(jìn)來(lái)如果為每個(gè)請(qǐng)求建一個(gè)線程線程數(shù)一多光切來(lái)切去系統(tǒng)就累垮了改用協(xié)程一個(gè)線程里可以掛著成千上萬(wàn)個(gè)“輕量任務(wù)”I/O等待時(shí)自動(dòng)讓位性能提升非常明顯。不過(guò)協(xié)程也有挑剔的地方如果代碼里某個(gè)操作是純CPU密集且不讓出那其他協(xié)程就只能干等著。協(xié)作式調(diào)度信任每個(gè)協(xié)程“自覺(jué)”遇到阻塞點(diǎn)要主動(dòng)讓位。這也讓協(xié)程調(diào)試比線程更隱蔽——出問(wèn)題往往不是“數(shù)據(jù)競(jìng)爭(zhēng)”而是“某個(gè)協(xié)程卡住了整個(gè)線程”。2.4 一張表看三者關(guān)系維度進(jìn)程線程協(xié)程資源分配單位是獨(dú)立地址空間否共享進(jìn)程資源否在線程內(nèi)部調(diào)度CPU調(diào)度單位否是否由用戶態(tài)調(diào)度創(chuàng)建/切換成本最高較高極低數(shù)據(jù)隔離強(qiáng)弱共享內(nèi)存同線程內(nèi)共享適用場(chǎng)景強(qiáng)故障隔離、多實(shí)例服務(wù)多核并行計(jì)算、I/O并行海量I/O并發(fā)、請(qǐng)求密集場(chǎng)景選型上我的經(jīng)驗(yàn)是需要強(qiáng)隔離就上進(jìn)程需要壓榨多核CPU就上線程需要扛住海量并發(fā)I/O就上協(xié)程。很多成熟的運(yùn)行時(shí)已經(jīng)把協(xié)程封裝得很好了你甚至不需要關(guān)心底層細(xì)節(jié)但知道這些邊界會(huì)讓你在調(diào)優(yōu)時(shí)少走彎路。3. 并發(fā)與并行很多新手栽在這里的一字之差3.1 并發(fā)邏輯上同時(shí)物理上交替并發(fā)強(qiáng)調(diào)的是“能同時(shí)處理多個(gè)任務(wù)”的能力。哪怕你的機(jī)器只有一個(gè)CPU核也可以并發(fā)操作系統(tǒng)把CPU時(shí)間切成一段段讓任務(wù)A跑一下、任務(wù)B跑一下、任務(wù)C跑一下切換得足夠快你感知上好像它們同時(shí)在跑。用生活里的例子講一個(gè)服務(wù)員要給三桌客人上菜。他不可能同時(shí)出現(xiàn)在三張桌子邊但他可以這一秒給A桌上菜下一秒給B桌上菜三桌人看起來(lái)都得到了服務(wù)。這是并發(fā)——交替處理但同一時(shí)刻手上只有一件事。在并發(fā)模型里這是任務(wù)之間的“交錯(cuò)執(zhí)行”。每個(gè)任務(wù)都被分割成極小的時(shí)間片操作系統(tǒng)負(fù)責(zé)在多個(gè)任務(wù)間快速切換。你寫(xiě)的多線程代碼在單核CPU上運(yùn)行就是典型的并發(fā)而不是并行。3.2 并行物理上真的同時(shí)并行是“同時(shí)執(zhí)行多個(gè)任務(wù)”要求CPU提供多核支持每個(gè)核在同一個(gè)時(shí)刻各干各的。回到服務(wù)員例子餐廳新雇了三個(gè)服務(wù)員一個(gè)盯A桌一個(gè)盯B桌一個(gè)盯C桌三桌同時(shí)被服務(wù)。這是并行。并行天然包含并發(fā)的思想但反過(guò)來(lái)不成立并發(fā)只表明“有同時(shí)處理多任務(wù)的能力”不保證物理層面的同時(shí)執(zhí)行。一個(gè)并發(fā)程序放到單核機(jī)器上照樣能跑但一個(gè)并行程序如果只有單核那它最多只能是并發(fā)。程序員經(jīng)常在這兩個(gè)詞上翻車最典型的是面試和討論時(shí)你說(shuō)“我的程序是并發(fā)的”對(duì)方問(wèn)“那并行了嗎”這倆不是同義詞別混著用。3.3 為什么你寫(xiě)的并發(fā)程序不一定并行很多人寫(xiě)完多線程代碼發(fā)現(xiàn)跑起來(lái)并沒(méi)有更快甚至更慢。原因很扎心你只制造了并發(fā)沒(méi)有制造并行。一個(gè)程序要真正做到并行至少得滿足幾個(gè)條件機(jī)器有多核CPU任務(wù)能被拆分到多個(gè)線程各個(gè)線程之間沒(méi)有強(qiáng)依賴不需要頻繁等待彼此線程切來(lái)切去的額外開(kāi)銷小于并行帶來(lái)的收益。如果你只是把任務(wù)塞給線程池但任務(wù)本身全是共享變量上的串行操作那這些線程要么在搶鎖要么在等鎖真正的“并行執(zhí)行”只是一個(gè)幻覺(jué)。我之前寫(xiě)過(guò)一個(gè)統(tǒng)計(jì)程序數(shù)據(jù)按切片分給8個(gè)線程去處理但主線程要等所有子線程的結(jié)果匯總。子線程之間不爭(zhēng)搶資源時(shí)性能確實(shí)逼近線性加速可一旦我在某個(gè)共享計(jì)數(shù)器上加了鎖8個(gè)線程變成輪流搶一把鎖速度立刻掉到比單線程還難看。那次之后我深刻明白并行的收益取決于你能否減少爭(zhēng)用而不是單純?cè)黾泳€程數(shù)。3.4 常見(jiàn)并發(fā)模型對(duì)照模型核心思想代表場(chǎng)景主要風(fēng)險(xiǎn)多線程共享內(nèi)存線程通過(guò)共享變量通信桌面應(yīng)用、傳統(tǒng)后端服務(wù)數(shù)據(jù)競(jìng)爭(zhēng)、死鎖Actor模型每個(gè)實(shí)體獨(dú)立只靠消息傳遞分布式系統(tǒng)消息設(shè)計(jì)復(fù)雜事件循環(huán)單線程循環(huán)處理事件非阻塞I/O前端JS、高性能網(wǎng)絡(luò)服務(wù)回調(diào)地獄、CPU密集任務(wù)卡循環(huán)數(shù)據(jù)并行同一操作作用于大量數(shù)據(jù)的不同切片圖像處理、數(shù)值計(jì)算負(fù)載均衡難做流水線并行任務(wù)分成多階段階段間接力處理生產(chǎn)消費(fèi)者模型各階段速度不匹配時(shí)瓶頸明顯選哪種模型不是看哪個(gè)“高大上”而是看任務(wù)特性。I/O密集型任務(wù)適合事件循環(huán)和協(xié)程CPU密集型任務(wù)適合多線程加數(shù)據(jù)并行任務(wù)間邊界清晰、需要擴(kuò)展性強(qiáng)時(shí)Actor模型是個(gè)好思路。3.5 并發(fā)能力由什么決定決定一個(gè)系統(tǒng)并發(fā)能力的不只是線程數(shù)。瓶頸往往在三個(gè)地方CPU核心數(shù)決定你真正能做到多少并行。I/O等待時(shí)間決定有多少時(shí)間被白白浪費(fèi)這也是并發(fā)優(yōu)化的主要空間。共享資源爭(zhēng)用鎖、隊(duì)列、數(shù)據(jù)庫(kù)連接等爭(zhēng)用越大并發(fā)收益越小。所以評(píng)估并發(fā)方案時(shí)先問(wèn)自己這個(gè)任務(wù)到底是CPU密集還是I/O密集如果是I/O密集協(xié)程或異步I/O能帶來(lái)巨大收益如果是CPU密集要多核并行才有意義此時(shí)要小心鎖競(jìng)爭(zhēng)和線程數(shù)如果兩者混雜那就要做混合設(shè)計(jì)這也是很多真實(shí)系統(tǒng)最復(fù)雜的地方。4. 底層機(jī)制調(diào)度器、上下文切換與臨界區(qū)4.1 調(diào)度器在忙什么時(shí)間片只是個(gè)幌子操作系統(tǒng)的調(diào)度器負(fù)責(zé)決定“下一個(gè)該誰(shuí)上CPU”。它有很多調(diào)度算法常見(jiàn)的思路包括優(yōu)先級(jí)調(diào)度、時(shí)間片輪轉(zhuǎn)、多級(jí)反饋隊(duì)列。實(shí)際運(yùn)行中每個(gè)線程并不會(huì)老老實(shí)實(shí)跑完整個(gè)時(shí)間片因?yàn)镮/O等待、鎖阻塞、時(shí)鐘中斷都會(huì)打斷它。大多數(shù)通用操作系統(tǒng)采用搶占式調(diào)度內(nèi)核認(rèn)為某個(gè)線程用CPU太久了直接剝奪它的執(zhí)行權(quán)交給另一個(gè)線程。這種設(shè)計(jì)讓系統(tǒng)響應(yīng)更及時(shí)但也意味著你的線程什么時(shí)候被切走是不確定的。這種不確定性是并發(fā)bug最難查的根源之一——你在本地跑一百次都對(duì)到生產(chǎn)環(huán)境偶發(fā)一次錯(cuò)大概率就是調(diào)度時(shí)序變了。調(diào)度還分“內(nèi)核線程”和“用戶線程”。很多語(yǔ)言運(yùn)行時(shí)把用戶線程協(xié)程/綠色線程映射到內(nèi)核線程上例如多對(duì)一模型、一對(duì)一模型、多對(duì)多模型。一對(duì)一模型下每個(gè)用戶線程對(duì)應(yīng)一個(gè)內(nèi)核線程利用多核方便但線程一多內(nèi)核資源就緊張多對(duì)多模型則更靈活由運(yùn)行時(shí)負(fù)責(zé)在有限的內(nèi)核線程上調(diào)度海量用戶任務(wù)。4.2 上下文切換的成本比你想的高得多上下文切換就是暫停當(dāng)前線程、保存它的狀態(tài)寄存器、程序計(jì)數(shù)器、棧指針等然后加載另一個(gè)線程的狀態(tài)繼續(xù)執(zhí)行。聽(tīng)起來(lái)只是“保存和恢復(fù)”但這個(gè)動(dòng)作一點(diǎn)都不廉價(jià)。它包含幾部分開(kāi)銷保存/恢復(fù)CPU寄存器更新內(nèi)核棧和調(diào)度數(shù)據(jù)結(jié)構(gòu)CPU緩存被新線程的狀態(tài)污染后續(xù)訪問(wèn)緩存的命中率下降涉及用戶態(tài)和內(nèi)核態(tài)之間的切換如果切換對(duì)象是不同進(jìn)程的線程還會(huì)涉及地址空間的切換代價(jià)更高。實(shí)測(cè)經(jīng)驗(yàn)一次線程上下文切換通常耗時(shí)在微秒量級(jí)看起來(lái)不多但如果你有1000個(gè)線程在瘋狂爭(zhēng)搶CPU每秒可能出現(xiàn)幾萬(wàn)次切換這些開(kāi)銷會(huì)直接吃掉一整個(gè)核心的計(jì)算能力。這解釋了為什么“線程越多越好”是錯(cuò)的。當(dāng)線程數(shù)超過(guò)CPU核數(shù)太多時(shí)每個(gè)線程分到的時(shí)間片短切換開(kāi)銷占比高整體吞吐量不升反降。我見(jiàn)過(guò)一個(gè)項(xiàng)目為了追求“并發(fā)量”把線程池調(diào)到500個(gè)結(jié)果監(jiān)控面板顯示系統(tǒng)CPU大量消耗在切換上業(yè)務(wù)進(jìn)度反而比50個(gè)線程時(shí)更慢。后來(lái)把線程壓到與核心數(shù)相仿加上I/O等待時(shí)的主動(dòng)讓位性能立刻回升。4.3 競(jìng)態(tài)條件并發(fā)bug的源頭競(jìng)態(tài)條件指的是程序的結(jié)果依賴于多個(gè)線程的執(zhí)行順序而這個(gè)順序又無(wú)法被保證。換句話說(shuō)同樣的輸入每次運(yùn)行的輸出可能不同。一個(gè)最簡(jiǎn)單的例證兩個(gè)線程同時(shí)對(duì)共享變量count執(zhí)行“讀-加-寫(xiě)”操作。假設(shè)初始值是0兩個(gè)線程都讀到了0各自加1后都寫(xiě)回1最終結(jié)果是1雖然邏輯上應(yīng)該是2。問(wèn)題出在“讀-加-寫(xiě)”這三步不是一個(gè)不可分割的原子操作線程A讀到0后可能被切走線程B也讀到0兩者互相覆蓋。這種bug致命的點(diǎn)是它不會(huì)每次都觸發(fā)可能跑幾千次才出現(xiàn)一次。你很難復(fù)現(xiàn)也很難定位。真實(shí)項(xiàng)目里競(jìng)態(tài)往往和某種微妙的調(diào)度條件綁定比如恰好某個(gè)線程先跑了一小段時(shí)間、恰好內(nèi)存屏障沒(méi)生效、恰好兩個(gè)請(qǐng)求同時(shí)到達(dá)。它是并發(fā)編程里最典型的“隱形殺手”。4.4 臨界區(qū)與不可分割操作要解決競(jìng)態(tài)核心是讓“讀-加-寫(xiě)”這種復(fù)合操作變成不可分割的整體。這個(gè)不可分割執(zhí)行的區(qū)域就叫臨界區(qū)。進(jìn)入臨界區(qū)之前線程需要獲取某種機(jī)制保護(hù)自己最常見(jiàn)的是鎖出了臨界區(qū)再釋放鎖確保同一時(shí)刻只有一個(gè)線程能執(zhí)行這段代碼。這樣即使線程在臨界區(qū)內(nèi)被切走其他線程也進(jìn)不來(lái)必須等它把整個(gè)操作做完。這里有個(gè)容易被忽略的點(diǎn)臨界區(qū)必須覆蓋完整的關(guān)鍵操作序列既不能“缺斤少兩”也不應(yīng)隨意擴(kuò)大范圍。臨界區(qū)太小保護(hù)不全競(jìng)態(tài)依然存在臨界區(qū)太大其他線程等著的時(shí)間變長(zhǎng)并發(fā)性能大打折扣。劃定合適的臨界區(qū)邊界是并發(fā)編程的基本功也是性能調(diào)優(yōu)的重要發(fā)力點(diǎn)。比如更新共享計(jì)數(shù)器完整操作是“讀舊值、加增量、寫(xiě)回”。你只給“加增量”那一步加鎖鎖等于白加你把從用戶提交請(qǐng)求到所有后續(xù)處理都鎖上那就成了串行程序。正確的做法是把“讀-加-寫(xiě)”作為一個(gè)臨界區(qū)范圍盡量小。5. 并發(fā)控制工具箱鎖、原子操作、等待通知5.1 互斥鎖最樸素也最可靠的方案互斥鎖Mutex是最基礎(chǔ)的同步原語(yǔ)。它保證任何時(shí)刻最多只有一個(gè)線程能持有鎖。線程在進(jìn)入臨界區(qū)前調(diào)用加鎖操作離開(kāi)時(shí)調(diào)用解鎖操作。如果鎖已經(jīng)被其他線程持有后來(lái)的線程就會(huì)阻塞等待直到鎖被釋放。用互斥鎖要注意兩點(diǎn)第一加鎖和釋放必須成對(duì)出現(xiàn)異?;蛱崆胺祷貢r(shí)也要釋放鎖否則其他線程會(huì)永久阻塞。現(xiàn)代語(yǔ)言提供了try-with-resources、RAII、defer之類的機(jī)制來(lái)避免手動(dòng)釋放的遺漏能用的盡量用。第二不要在持鎖期間調(diào)用可能長(zhǎng)時(shí)間阻塞的操作比如網(wǎng)絡(luò)請(qǐng)求、等待用戶輸入。這會(huì)讓所有線程都堵在一個(gè)鎖上系統(tǒng)吞吐量瞬間歸零。5.2 讀寫(xiě)鎖讀多寫(xiě)少場(chǎng)景的優(yōu)化讀寫(xiě)鎖把“讀”和“寫(xiě)”分開(kāi)對(duì)待。多個(gè)線程可以同時(shí)持有讀鎖但寫(xiě)鎖是排他的持有寫(xiě)鎖時(shí)其他線程既不能讀也不能寫(xiě)。這樣設(shè)計(jì)是因?yàn)椤凹冏x取共享數(shù)據(jù)”是安全的大家讀同一個(gè)值不會(huì)互相破壞。適用場(chǎng)景很典型配置表、路由表、詞庫(kù)等“讀多寫(xiě)少”的數(shù)據(jù)結(jié)構(gòu)。假如用普通互斥鎖所有讀線程都必須串行白白浪費(fèi)并發(fā)能力。換成讀寫(xiě)鎖讀線程可以并行只有偶爾的更新才需要獨(dú)占寫(xiě)鎖。但別天真地以為讀寫(xiě)鎖一定更快。如果寫(xiě)操作非常頻繁寫(xiě)鎖經(jīng)常被持有讀線程也頻繁等待加上讀寫(xiě)鎖本身的維護(hù)開(kāi)銷可能比普通互斥鎖還慢。選讀寫(xiě)鎖前先估一估讀寫(xiě)比例經(jīng)驗(yàn)上讀遠(yuǎn)多于寫(xiě)比如10:1以上才劃算。5.3 自旋鎖等著比切換更劃算自旋鎖的特點(diǎn)是線程拿不到鎖不進(jìn)入阻塞而是原地循環(huán)“轉(zhuǎn)圈”持續(xù)嘗試獲取鎖直到拿到為止。因?yàn)榫€程沒(méi)有睡過(guò)去省去了上下文切換的開(kāi)銷但代價(jià)是它占用著CPU空轉(zhuǎn)什么正事也不干。自旋鎖適合臨界區(qū)特別短、且鎖被持有的平均時(shí)間極短的場(chǎng)景。比如你要保護(hù)一個(gè)只有幾條指令的變量更新持鎖時(shí)間通常只有幾十納秒此時(shí)用互斥鎖光是切換線程的成本就超過(guò)了自旋空轉(zhuǎn)的成本自旋鎖反而更高效。實(shí)際使用時(shí)要小心如果臨界區(qū)較長(zhǎng)或者多個(gè)線程競(jìng)爭(zhēng)激烈自旋鎖會(huì)讓CPU浪費(fèi)大量時(shí)間在空轉(zhuǎn)上。Linux內(nèi)核里就大量使用自旋鎖保護(hù)短臨界區(qū)但它在臨界區(qū)不允許睡眠。你寫(xiě)用戶態(tài)代碼時(shí)下意識(shí)用互斥鎖通常更穩(wěn)妥只有確認(rèn)臨界區(qū)極短且經(jīng)過(guò)性能測(cè)試后再換成自旋。5.4 原子操作無(wú)鎖的基石如果把“讀-加-寫(xiě)”這種復(fù)合操作用一條特殊的CPU指令直接完成就不需要鎖了。這類由硬件提供支持、執(zhí)行過(guò)程中不可中斷的操作稱為原子操作。比如compare-and-swapCAS、fetch-and-add、load-link/store-conditional。CAS是最有代表性的一個(gè)它把“比較-交換”做成原子操作。線程讀取內(nèi)存值預(yù)期它是某個(gè)舊值如果是就寫(xiě)入新值如果不是說(shuō)明別人改過(guò)了那就重新讀取再試。這也就是所謂“無(wú)鎖編程”的基礎(chǔ)。無(wú)鎖意味著線程不會(huì)被阻塞但代價(jià)是編碼復(fù)雜度和調(diào)試難度遠(yuǎn)高于加鎖版本。高性能場(chǎng)景中某些關(guān)鍵路徑用原子操作替代鎖可以顯著減少爭(zhēng)用。比如統(tǒng)計(jì)請(qǐng)求計(jì)數(shù)用原子自增就比每次加鎖輕量得多。一個(gè)非常經(jīng)典的提醒原子操作保護(hù)的是“單條指令”的原子性不是你業(yè)務(wù)邏輯的原子性。你用它解決計(jì)數(shù)器問(wèn)題沒(méi)問(wèn)題用CAS去模擬一個(gè)多步驟的業(yè)務(wù)事務(wù)就很容易出錯(cuò)因?yàn)榕幚碇虚g時(shí)刻狀態(tài)的一致性需要額外手段保證。5.5 等待通知與條件變量很多時(shí)候線程要做的事情依賴某個(gè)“條件”成立。比如生產(chǎn)者線程往隊(duì)列里放數(shù)據(jù)消費(fèi)者線程只有在隊(duì)列非空時(shí)才能取數(shù)據(jù)。如果消費(fèi)者發(fā)現(xiàn)隊(duì)列為空它有兩種選擇一直問(wèn)“有數(shù)據(jù)了嗎”忙等待或者睡過(guò)去等生產(chǎn)者通知它“有數(shù)據(jù)了”再醒來(lái)。條件變量就是為第二種方案設(shè)計(jì)的。它通常配合互斥鎖使用線程在循環(huán)中檢查條件不滿足就調(diào)用等待接口同時(shí)釋放鎖另外的線程修改條件后調(diào)用通知接口喚醒等待線程。這里有個(gè)新手容易犯的錯(cuò)條件變量存在“虛假喚醒”線程被喚醒后條件可能還是不滿足所以必須在循環(huán)里重新檢查條件而不是用簡(jiǎn)單的if判斷。規(guī)范寫(xiě)法是# 偽代碼生產(chǎn)者消費(fèi)者模型中的消費(fèi)者 while queue.empty(): condition_var.wait() data queue.pop()這個(gè)while就是防止虛假喚醒以及多線程同時(shí)被喚醒時(shí)“搶不到數(shù)據(jù)”的保障。寫(xiě)完代碼后檢查自己是否用的是while而不是if這個(gè)小細(xì)節(jié)能幫你躲開(kāi)線上偶發(fā)的空隊(duì)列異常。6. 實(shí)踐中的四個(gè)大坑死鎖、活鎖、過(guò)量線程與偽共享6.1 死鎖互相等對(duì)方的鑰匙死鎖最經(jīng)典的定義一組線程中每個(gè)成員都在等待其他成員占用的資源沒(méi)人釋放全部永久卡死。舉個(gè)例子線程A先鎖lock1再鎖lock2線程B先鎖lock2再鎖lock1。如果A拿到lock1后、還沒(méi)拿到lock2時(shí)B恰好拿到了lock2接下來(lái)A等B釋放lock2B等A釋放lock1兩個(gè)線程永遠(yuǎn)等下去。避免死鎖最實(shí)用的原則有兩條按固定順序加鎖。所有線程都按lock1再到lock2的順序獲取鎖就不會(huì)出現(xiàn)環(huán)路等待。加鎖設(shè)置超時(shí)。拿不到鎖在一定時(shí)間內(nèi)放棄釋放已持有的鎖重試或有條件地失敗退出。實(shí)際排查死鎖時(shí)多數(shù)語(yǔ)言的線程Dump工具能幫你看到每個(gè)線程等待的鎖以及持有它的線程是誰(shuí)??吹健皟蓚€(gè)線程互相等待彼此持有的鎖”這個(gè)環(huán)死鎖所在的位置基本就鎖定了。6.2 活鎖與饑餓讓線程“吃不上飯”的另一種方式活鎖的情況是線程并沒(méi)有阻塞而是在不停地響應(yīng)另一個(gè)線程并讓出自己的執(zhí)行機(jī)會(huì)結(jié)果大家互相謙讓誰(shuí)也無(wú)法前進(jìn)。比如兩個(gè)并行任務(wù)在走廊相遇都想給對(duì)方讓路結(jié)果你閃到左邊我也閃到左邊你閃到右邊我也閃到右邊反復(fù)閃躲始終走不過(guò)去。活鎖比死鎖更難發(fā)現(xiàn)因?yàn)槌绦蚩雌饋?lái)還在“跑”只是沒(méi)有實(shí)際進(jìn)展。解決思路通常是引入隨機(jī)退避或者固定優(yōu)先級(jí)讓一方先走打破僵局。饑餓則是某些線程遲遲得不到CPU時(shí)間或鎖資源雖然最終可能執(zhí)行但等待時(shí)間遠(yuǎn)超合理范圍。常見(jiàn)原因是鎖的公平性設(shè)計(jì)不公平——高優(yōu)先級(jí)線程反復(fù)搶占低優(yōu)先級(jí)線程的資源導(dǎo)致低優(yōu)先級(jí)線程長(zhǎng)期得不到執(zhí)行。使用公平鎖能緩解部分饑餓問(wèn)題但公平鎖本身也有額外成本要結(jié)合實(shí)際場(chǎng)景權(quán)衡。6.3 線程不是越多越好上下文切換會(huì)吃掉性能很多人有個(gè)誤區(qū)并發(fā)能力線程數(shù)量。實(shí)際上線程太多時(shí)系統(tǒng)絕大部分時(shí)間在調(diào)度和切換真正干活的CPU時(shí)間被嚴(yán)重壓縮。經(jīng)驗(yàn)公式可以參考CPU密集型任務(wù)線程數(shù)通常設(shè)置為CPU核心數(shù)的1到2倍左右I/O密集型任務(wù)可以多一些但也不能無(wú)限增加。更精細(xì)的做法是壓測(cè)找到“拐點(diǎn)”即線程數(shù)繼續(xù)增加但吞吐量不再上升甚至下降的點(diǎn)。我現(xiàn)在做服務(wù)端代碼時(shí)會(huì)開(kāi)啟線程池的監(jiān)控指標(biāo)觀察活躍線程數(shù)、隊(duì)列等待時(shí)長(zhǎng)和任務(wù)耗時(shí)。一旦發(fā)現(xiàn)活躍線程數(shù)長(zhǎng)期接近上限且任務(wù)耗時(shí)激增第一個(gè)懷疑對(duì)象就是線程數(shù)配多導(dǎo)致的切換開(kāi)銷而不是業(yè)務(wù)代碼變慢了。6.4 偽共享一個(gè)詭異的性能黑洞偽共享是并發(fā)性能優(yōu)化中一個(gè)不那么直觀的坑。它的原理涉及CPU緩存CPU不是按單個(gè)變量去緩存數(shù)據(jù)而是按“緩存行”為單位常見(jiàn)大小是64字節(jié)。如果兩個(gè)線程需要頻繁訪問(wèn)兩個(gè)不同的變量但這兩個(gè)變量恰好落在同一條緩存行上CPU為了保證緩存一致性會(huì)把這整條緩存行在多個(gè)核之間反復(fù)同步。于是線程A修改變量X時(shí)線程B訪問(wèn)變量Y也會(huì)被拖慢盡管兩者根本沒(méi)有共享任何數(shù)據(jù)。解決辦法很直接讓兩個(gè)變量盡量分布到不同的緩存行上??梢酝ㄟ^(guò)填充字節(jié)、按緩存行大小對(duì)齊等方式實(shí)現(xiàn)。高級(jí)語(yǔ)言里一些框架直接提供了“緩存行填充”的工具類或注解比如Java的Contended、Go手寫(xiě)結(jié)構(gòu)體填充等。排查偽共享的典型現(xiàn)象是你用多個(gè)線程并行修改不同字段但性能上不去加鎖似乎也不是瓶頸CPU緩存命中率異常低。用性能分析工具查看各線程的緩存失效情況時(shí)如果發(fā)現(xiàn)大量cache line bouncing八九不離十就是偽共享。7. 一個(gè)實(shí)際案例從串行爬蟲(chóng)到并發(fā)任務(wù)隊(duì)列7.1 場(chǎng)景和數(shù)據(jù)用上面這些概念我拆一個(gè)真實(shí)做過(guò)的例子批量抓取外部網(wǎng)頁(yè)并解析。當(dāng)時(shí)要處理大約5000個(gè)URL每個(gè)請(qǐng)求的響應(yīng)耗時(shí)約200到500毫秒解析耗時(shí)約50毫秒。機(jī)器配置是4核8線程。串行版本的處理時(shí)間很容易估算假設(shè)平均每任務(wù)300毫秒響應(yīng)50毫秒解析5000個(gè)任務(wù)就是5000×350毫秒約1750秒接近半小時(shí)。CPU利用率不超過(guò)20%因?yàn)榇蟛糠謺r(shí)間都在等待網(wǎng)絡(luò)I/O。這種任務(wù)的特征非常典型I/O密集任務(wù)之間無(wú)共享狀態(tài)天然適合并發(fā)。唯一要注意的是不要對(duì)目標(biāo)系統(tǒng)造成過(guò)大壓力以及控制并發(fā)上限。7.2 串行版本先寫(xiě)一個(gè)串行的偽代碼作為基線for url in urls: html fetch(url) # 網(wǎng)絡(luò)I/O耗時(shí)200-500ms result parse(html) # CPU解析約50ms save(result)邏輯簡(jiǎn)單到不能再簡(jiǎn)單缺點(diǎn)是明顯的每次都要等網(wǎng)絡(luò)響應(yīng)網(wǎng)絡(luò)等待期間CPU和系統(tǒng)資源完全空閑。這也正是并發(fā)優(yōu)化最大的切入點(diǎn)。7.3 多線程版本與性能變化我換了線程池方案設(shè)計(jì)了兩個(gè)參數(shù)線程數(shù)和任務(wù)分片大小。初始線程數(shù)取16用Python的ThreadPoolExecutor跑with ThreadPoolExecutor(max_workers16) as pool: results pool.map(process_one, urls)運(yùn)行結(jié)果很直觀總耗時(shí)從約1750秒降到了約160秒提升約10倍。16個(gè)線程時(shí)已經(jīng)接近理論最優(yōu)值。因?yàn)榫W(wǎng)絡(luò)等待時(shí)間遠(yuǎn)大于CPU解析時(shí)間線程數(shù)量大于核心數(shù)幾倍是合理的但如果繼續(xù)沖到64個(gè)線程總耗時(shí)不僅沒(méi)有明顯下降反而因?yàn)檫^(guò)度請(qǐng)求和調(diào)度開(kāi)銷出現(xiàn)延遲波動(dòng)、對(duì)方服務(wù)器限流的問(wèn)題。這個(gè)對(duì)比告訴我們一個(gè)道理并發(fā)收益跟線程數(shù)的關(guān)系是倒U形的前期增長(zhǎng)明顯中途趨于平緩過(guò)了某個(gè)點(diǎn)開(kāi)始惡化。找到那個(gè)拐點(diǎn)通常要靠實(shí)測(cè)而不是拍腦袋。7.4 問(wèn)題定位與最終優(yōu)化跑完多線程版本后我遇到了一個(gè)經(jīng)典問(wèn)題部分請(qǐng)求超時(shí)。我一開(kāi)始以為是網(wǎng)絡(luò)波動(dòng)后來(lái)看了監(jiān)控才發(fā)現(xiàn)線程數(shù)拉高到64時(shí)短時(shí)間內(nèi)的并發(fā)請(qǐng)求量太大觸發(fā)了對(duì)方接口的限流。這說(shuō)明并發(fā)編程不只是“跑得快”還要尊重下游系統(tǒng)的承載能力。最終優(yōu)化方案包含三塊使用信號(hào)量限制最大并發(fā)數(shù)比如固定為20增加失敗重試和指數(shù)退避退避時(shí)間從1秒開(kāi)始每次翻倍最多重試3次處理完一批再拉下一批而不是一次性把5000個(gè)任務(wù)全塞進(jìn)去。優(yōu)化后總耗時(shí)回升到約180秒但系統(tǒng)穩(wěn)定性大幅提升整個(gè)批次沒(méi)有出現(xiàn)任務(wù)失敗CPU和內(nèi)存占用也都保持在合理范圍。7.5 我的體會(huì)和幾個(gè)可復(fù)用的建議在多個(gè)項(xiàng)目里用過(guò)并發(fā)之后我總結(jié)了幾條可以復(fù)用的經(jīng)驗(yàn)送給剛?cè)腴T的同學(xué)。第一個(gè)先搞清楚任務(wù)是I/O密集還是CPU密集。這直接決定了線程模型和線程數(shù)量的選擇也是并發(fā)設(shè)計(jì)的分水嶺。第二個(gè)并發(fā)代碼一定要加“限流閥”。不管線程池多大都要通過(guò)信號(hào)量或者隊(duì)列長(zhǎng)度限制同時(shí)進(jìn)行中的任務(wù)數(shù)量。無(wú)限并發(fā)是生產(chǎn)事故的溫床。第三個(gè)能共享數(shù)據(jù)盡量少共享不能少共享就老老實(shí)實(shí)加鎖。很多并發(fā)bug最終都繞不開(kāi)共享可變狀態(tài)。換一個(gè)角度思考——如果能把數(shù)據(jù)設(shè)計(jì)成不可變的、或者按線程隔離一切問(wèn)題從根上消失比任何高級(jí)同步原語(yǔ)都可靠。第四個(gè)性能調(diào)優(yōu)時(shí)先用監(jiān)控?cái)?shù)據(jù)說(shuō)話。CPU使用率、線程切換次數(shù)、鎖等待時(shí)間、任務(wù)隊(duì)列深度這幾個(gè)指標(biāo)能真實(shí)反映你的并發(fā)設(shè)計(jì)是否健康。沒(méi)有數(shù)據(jù)支撐的“感覺(jué)優(yōu)化”最后多半靠運(yùn)氣。這篇內(nèi)容偏基礎(chǔ)但如果能把進(jìn)程線程、并發(fā)并行、臨界區(qū)、鎖、死鎖這些概念的底層關(guān)系理清楚后續(xù)學(xué)任何語(yǔ)言的多線程API、任何并發(fā)框架都會(huì)順很多。