之并發(fā)編程(一))
前言并發(fā)編程concurrency是 Python 基礎(chǔ)里最容易會(huì)寫(xiě)但用錯(cuò)的一塊。原因不是 API 難而是它的正確用法完全取決于任務(wù)類(lèi)型同樣是開(kāi)十個(gè)線程跑一個(gè)循環(huán)I/O 密集的任務(wù)可能真的變快CPU 密集的任務(wù)則幾乎不會(huì)變快甚至更慢。不先判斷任務(wù)類(lèi)型就上并發(fā)是絕大多數(shù)性能問(wèn)題的根源。這一篇先把兩個(gè)地基鋪好一是并發(fā)concurrency與并行parallelism的區(qū)別二是 GIL全局解釋器鎖到底限制了什么。后面幾篇再展開(kāi)具體工具。本文示例按 Python 3.8 編寫(xiě)用到的time.perf_counter()、threading、queue都是標(biāo)準(zhǔn)庫(kù)可以直接運(yùn)行。一、并發(fā)不等于并行并發(fā)指的是多個(gè)任務(wù)在同一時(shí)間段內(nèi)交替推進(jìn)并行指的是多個(gè)任務(wù)在同一時(shí)刻同時(shí)推進(jìn)。一個(gè)人邊煮飯邊回消息是并發(fā)不是并行——他只是把等待的時(shí)間利用起來(lái)了。這個(gè)區(qū)分直接決定了工具的選擇任務(wù)類(lèi)型特征目標(biāo)合適工具CPU 密集大量計(jì)算幾乎不等待并行真同時(shí)multiprocessing、ProcessPoolExecutorI/O 密集大量等待網(wǎng)絡(luò) / 磁盤(pán)并發(fā)重疊等待threading、asyncio混合兩者都有拆分后分別處理組合使用判斷方法很樸素用time.perf_counter()量一下看時(shí)間花在算上還是花在等上。二、GIL 限制了什么GIL 是全局解釋器鎖Global Interpreter Lock準(zhǔn)確表述有三層缺一層就會(huì)理解偏它是 CPython 的實(shí)現(xiàn)細(xì)節(jié)不是 Python 語(yǔ)言的規(guī)定。語(yǔ)言規(guī)范并沒(méi)有要求必須有 GIL是 CPython 這個(gè)具體實(shí)現(xiàn)為了簡(jiǎn)化對(duì)象模型的內(nèi)存管理而引入的。其他實(shí)現(xiàn)例如 Jython、IronPython本來(lái)就不存在 GIL。它的效果是同一進(jìn)程內(nèi)同一時(shí)刻只有一個(gè)線程在執(zhí)行 Python 字節(jié)碼。也就是說(shuō)純 Python 代碼的多線程無(wú)法真正并行因?yàn)橥豢讨挥幸话焰i的持有者在跑。它在等待 I/O 時(shí)會(huì)釋放。線程發(fā)起網(wǎng)絡(luò)請(qǐng)求、讀寫(xiě)文件、time.sleep()這類(lèi)操作時(shí)會(huì)主動(dòng)放開(kāi) GIL讓別的線程去跑。第 2 條和第 3 條合起來(lái)給出了那句關(guān)鍵結(jié)論多線程對(duì) CPU 密集任務(wù)基本沒(méi)有加速效果對(duì) I/O 密集任務(wù)有效。那為什么還會(huì)有共享數(shù)據(jù)的競(jìng)爭(zhēng)問(wèn)題因?yàn)?GIL 只保證單條字節(jié)碼不被打斷而像x 1這樣的操作會(huì)被編譯成讀、加、寫(xiě)多條字節(jié)碼線程隨時(shí)可能在中間被切換。所以GIL 不能替代你寫(xiě)自己的鎖。關(guān)于GIL 是不是已經(jīng)從 Python 里消失了必須說(shuō)準(zhǔn)并沒(méi)有。PEP 703 提出的自由線程free-threading構(gòu)建在3.13 起是實(shí)驗(yàn)性的不是默認(rèn)構(gòu)建需要用--disable-gil編譯或安裝單獨(dú)標(biāo)記的可執(zhí)行文件通常叫python3.13t并可用PYTHON_GIL環(huán)境變量或-X gil1在運(yùn)行時(shí)重新啟用 GIL。官方文檔同時(shí)提醒去掉 GIL 不等于自動(dòng)變快自由線程構(gòu)建目前仍有可觀的單線程性能損失且存在待完善的 bug。所以現(xiàn)階段CPU 密集用多進(jìn)程仍然是主流解法。三、threading解決等待不解決計(jì)算threading.Thread的用法非常簡(jiǎn)單把要跑的函數(shù)交給它start()啟動(dòng)join()等待結(jié)束。join()很重要——不加它主線程可能在子線程干完活之前就退出了。# 適用于 Python 3.8import threadingimport timedef worker(name, seconds):print(f{name} 開(kāi)始)time.sleep(seconds) # 模擬 I/O 等待sleep 會(huì)釋放 GILprint(f{name} 結(jié)束)start time.perf_counter()threads [threading.Thread(targetworker, args(f任務(wù){(diào)i}, 1)) for i in range(3)]for t in threads:t.start()for t in threads:t.join()print(f耗時(shí)約 {time.perf_counter() - start:.2f} 秒)推演三個(gè)線程幾乎同時(shí)啟動(dòng)各自進(jìn)入time.sleep(1)并釋放 GIL于是三份等待重疊在一起總耗時(shí)大約 1 秒左右而不是 3 秒。變快的部分全部來(lái)自等被重疊了而不是算被分擔(dān)了。如果把time.sleep換成一段純計(jì)算循環(huán)總耗時(shí)反而可能比串行更長(zhǎng)因?yàn)榫€程切換本身有開(kāi)銷(xiāo)。這就是threading的定位它是等待的放大器不是計(jì)算的加速器。四、共享狀態(tài)必須加鎖多個(gè)線程讀寫(xiě)同一個(gè)變量時(shí)會(huì)出現(xiàn)競(jìng)爭(zhēng)race condition。典型癥狀是結(jié)果每次都不一樣或總是差一點(diǎn)。# 適用于 Python 3.8import threadingcounter 0lock threading.Lock()def increment(n):global counterfor _ in range(n):with lock: # 加鎖保護(hù)讀-改-寫(xiě)整個(gè)序列counter 1threads [threading.Thread(targetincrement, args(1000,)) for _ in range(4)]for t in threads:t.start()for t in threads:t.join()print(counter) # 期望 4000如果不加with lock:counter 1的讀、加、寫(xiě)三步可能交錯(cuò)執(zhí)行兩個(gè)線程都讀到同一個(gè)舊值結(jié)果小于 4000。用with lock:而不是手動(dòng)acquire()/release()是因?yàn)榧词寡h(huán)體里拋異常上下文管理器也保證鎖會(huì)被釋放避免死鎖。鎖的粒度要權(quán)衡鎖太大并行退化成串行鎖太小可能保護(hù)不全。原則是把必須整體完成才有意義的操作圈進(jìn)同一個(gè)臨界區(qū)像這里的讀-改-寫(xiě)就必須當(dāng)成一個(gè)整體。五、queue.Queue讓線程之間傳遞數(shù)據(jù)共享變量加鎖容易寫(xiě)錯(cuò)更好的模式是不共享內(nèi)存靠隊(duì)列通信。queue.Queue內(nèi)部實(shí)現(xiàn)了鎖它本身就是線程安全的多個(gè)線程同時(shí)put()和get()不會(huì)破壞內(nèi)部狀態(tài)。這是生產(chǎn)者-消費(fèi)者模式的標(biāo)準(zhǔn)做法。# 適用于 Python 3.8import queueimport threadingimport timedef producer(q, n):for i in range(n):q.put(i)time.sleep(0.01)q.put(None) # 哨兵值通知消費(fèi)者結(jié)束def consumer(q, results):while True:item q.get()if item is None:q.task_done()breakresults.append(item * item)q.task_done()q queue.Queue()results []p threading.Thread(targetproducer, args(q, 10))c threading.Thread(targetconsumer, args(q, results))p.start(); c.start()p.join(); c.join()print(results)推演生產(chǎn)者不斷往隊(duì)列里塞數(shù)字消費(fèi)者取出后平方并追加到results。用None當(dāng)哨兵值表示沒(méi)有更多數(shù)據(jù)了消費(fèi)者遇到它就退出循環(huán)。這里results.append()之所以不用加鎖是由于list.append在 CPython 上是原子操作但這是實(shí)現(xiàn)細(xì)節(jié)換一種操作比如先判斷再追加就不再安全所以更穩(wěn)妥的做法仍然是讓消費(fèi)者把結(jié)果也放進(jìn)隊(duì)列由主線程統(tǒng)一收集。Queue還有兩個(gè)常用的配套方法task_done()通知隊(duì)列這個(gè)任務(wù)處理完了join()阻塞到所有已放入的任務(wù)都被處理完。它們必須成對(duì)使用否則join()永遠(yuǎn)不會(huì)返回。常見(jiàn)坑點(diǎn)用多線程加速 CPU 密集任務(wù)。受 GIL 限制同一進(jìn)程內(nèi)同一時(shí)刻只有一個(gè)線程執(zhí)行字節(jié)碼純計(jì)算的多線程不會(huì)真正并行。? 把純計(jì)算的循環(huán)拆成 10 個(gè)線程期待加速 ? CPU 密集改用multiprocessing或ProcessPoolExecutor各自獨(dú)立解釋器、各自有 GIL。誤以為現(xiàn)在跑 Python 已經(jīng)不帶 GIL 了。PEP 703 的自由線程構(gòu)建在 3.13 起是實(shí)驗(yàn)性的、非默認(rèn)需要單獨(dú)的可執(zhí)行文件。? 新版 Python 去掉了 GIL多線程能加速計(jì)算了 ? 自由線程構(gòu)建是實(shí)驗(yàn)性的且去掉 GIL 不等于自動(dòng)變快單線程可能更慢。共享計(jì)數(shù)器不加鎖。counter 1不是原子操作多個(gè)線程交錯(cuò)執(zhí)行會(huì)丟失更新。?counter 1裸寫(xiě)在新線程里 ? 用with lock:把整個(gè)讀-改-寫(xiě)圈成臨界區(qū)。忘了join()。主線程不等子線程結(jié)束就繼續(xù)往下走甚至退出任務(wù)被中途丟棄輸出殘缺。?t.start()之后直接打印結(jié)果 ? 所有start()之后統(tǒng)一join()再匯總。手動(dòng)acquire()后異常導(dǎo)致鎖不釋放。函數(shù)中途拋異常release()永遠(yuǎn)執(zhí)行不到其他線程全部卡死。?lock.acquire()...lock.release()?with lock:上下文管理器異常時(shí)也能保證釋放。用threading.Thread的默認(rèn)返回值判斷任務(wù)成功。線程不會(huì)把異常傳回主線程的調(diào)用點(diǎn)異常只打印到 stderr主流程看起來(lái)一切正常。? 認(rèn)為t.join()沒(méi)報(bào)錯(cuò)就代表子線程沒(méi)出錯(cuò) ? 在線程內(nèi)部捕獲異常并記錄或改用concurrent.futures讓Future把異常帶回來(lái)。在queue.Queue上忘記哨兵值或task_done()。消費(fèi)者get()會(huì)永久阻塞或join()永遠(yuǎn)不返回。? 消費(fèi)者直接while True: q.get()而不設(shè)退出條件 ? 用哨兵值通知結(jié)束并讓put()與task_done()配對(duì)。以為time.sleep()之外的操作也會(huì)釋放 GIL。sleep因?yàn)橛忻鞔_的等待語(yǔ)義會(huì)釋放 GIL但并不是任意 Python 語(yǔ)句都這樣純 Python 循環(huán)持有 GIL 時(shí)不釋放。? 認(rèn)為任何調(diào)用都會(huì)讓出 GIL從而讓多線程自動(dòng)公平 ? 只在明確的 I/O 等待處期待線程切換CPU 密集不要指望它??偨Y(jié)概念準(zhǔn)確表述并發(fā)交替推進(jìn)重疊等待并行同時(shí)推進(jìn)需要多核真正分配GILCPython 實(shí)現(xiàn)細(xì)節(jié)同進(jìn)程內(nèi)同一刻只有一個(gè)線程執(zhí)行字節(jié)碼等待 I/O 時(shí)釋放線程適用I/O 密集CPU 密集無(wú)加速共享狀態(tài)用Lock保護(hù)臨界區(qū)GIL 不能替代鎖線程通信用線程安全的queue.Queue生產(chǎn)者-消費(fèi)者模式自由線程3.13 起實(shí)驗(yàn)性、非默認(rèn)不等于自動(dòng)變快把這活主要是在等還是在算這個(gè)問(wèn)題問(wèn)清楚并發(fā)編程的路就走對(duì)了一半在等用線程在算用多進(jìn)程。剩下的一半是把共享狀態(tài)管住——要么加鎖要么干脆不共享用隊(duì)列傳數(shù)據(jù)。