Skip to main content

Python 中的多工處理原理介紹

這篇文章整理 Python 中各種多工處理方式,啟發自網路上錯誤且分散的資訊,著重在原理而不是語法,目的在提供一個全局的視角,方便選擇自己的任務適合哪種方式加速,藉此解決程式碼寫完了,可以動,但實際上用錯了的問題。筆者希望本文是 清晰、快速、正確、完整 的介紹文章,不過沒人能保證自己絕對正確,所以如果有任何錯誤煩請回報。

先備知識

筆者當年是在沒有先備知識的情況下直接搜尋多線程 (multithreading)/多進程 (multiprocessing) 導致浪費很多時間,所以這裡將前置知識一併附上。

全域直譯器鎖

在講多工之前一定要知道的 Python 基礎之一:全域直譯器鎖 (GIL, Global Interpreter Lock),我們一個一個名詞解釋。Global 指的就是整個 Python 程式運行時的全局環境,Interpreter 是 Python 的直譯器,目的是將程式碼轉換成字節碼在 Python 虛擬機上運行,Lock 則是將所有線程鎖定,三者組合在一起指的就是同一直譯器下,任一時間只有一個線程能執行字節碼

原理可以在三十秒內講完,但是相信這樣還是一知半解,所以我們簡單介紹他的前世今生。雖然多工處理在學術中早已被探討,但是電腦早期發展根本沒有什麼多核心 CPU,而多工處理需要考量的問題很多(共享資源和記憶體安全),所以 Python 乾脆就用 GIL 將多線程鎖定了。但是在三十年後的今天處理器隨便就是 10 核心 20 核心,這顯然會造成效能浪費,所以 no GIL 的討論度又逐漸上升,該功能在 Python 3.13t 中被初步實現

值得一提的是直譯器,由於我們可以選擇不同的直譯器 (CPython, PyPy 等等),其中只有 CPython 有 GIL,但是後者不在我們討論範圍內,絕大多數使用還是 CPython。

進程和線程

作業系統執行程式碼的單位分為進程 (process) 和線程 (thread),是撰寫多工程式時必須知道的基礎知識,這個段落簡單介紹進程與線程。

每個程式執行時,至少都會包含一個進程,在作業系統中,我們可以使用以下方式看到進程 ID(PID, process ID):

在任務管理員中的詳細資訊中可以看到所有正在運行的進程及其相應的 PID。
也可以使用命令提示字元輸入 `tasklist` 命令列出。

進程存活時,作業系統會分配給他一塊專屬的記憶體空間,裡面會放該程式的程式碼以及運行中佔用的記憶體。每個進程會包含一個或多個線程,線程可以理解成一個輕量化的進程,其角色是進程執行的基本單位,同進程的不同線程之間共享記憶體資源,而不同進程之間的溝同則需要調用作業系統的機制 (IPC, inter-process communication),此方式有性能開銷

非常重要

我們一定要記得,Python 鎖定的是「線程」之間,鎖定的單位是「字節碼」。

任務類型的區別

讀完上面讀者一定會很困惑,既然 GIL 都鎖定同一時間只能有一個線程執行程式碼,那多線程的還有什麼意義?既然我們都鎖定了,那即使有再多線程,同一時間也只能有一個任務被執行不是嗎?

讀者的疑問是正常的,上面的解釋也是正確的,問題的答案是


作業系統會自動幫你切換線程,即使同時只有一個線程可以執行程式碼,但是多線程之間每個線程不會卡死其他線程。


因此我們不必擔心一個線程在執行時會不會擋住其他線程,線程間可以自動切換並且執行各自的任務。可是光是切換程式速度怎麼會變快呢?這裡又要引入一個概念:任務瓶頸。程式碼可依據他的任務類型區分為計算瓶頸 (CPU bound) 和 I/O 瓶頸 (I/O bound) 的任務,分類方式是判斷程式主要花費時間是計算還是計算以外的任何任務。在 I/O bound 的任務中,程式執行大多數的時間都在等待。例如我們撰寫多線程程式,每個線程都想發送 HTTP 請求,而發送封包出去後就在等待伺服器回傳 HTTP response,這段時間 CPU 不會發呆乾等,作業系統會切換另一個線程發送他的 HTTP 請求,接受封包時也同理。

根據前述我們可以知道在 CPU bound 任務使用多線程不管怎麼切換計算任務也不會比較快。在 CPU bound 的任務中如果想要同時執行任務,我們可以使用多進程完成多工計算,每個進程都有獨立的 Python 直譯器,直譯器之間沒有鎖的問題,於是可以執行任務。附帶一提那為何不所有任務都使用多進程呢,因為每次建立和銷毀的開銷更大,進程之間的溝通又有特定機制,不如線程快速。

由以上說明,我們可以知道在 Python 中多線程和多進程的核心差異。

別忘記了

即使同時只有一個線程可以執行程式碼,Python 解釋器還是會自動幫你切換線程,使線程之間不會因為一個線程而卡死(術語:阻塞)其他線程,是網路文章中常常忘記提到的重點之一。

延伸閱讀

背景知識的延伸閱讀,此處請按照下方順序觀看,feel free to skip this section。

(這人不是隨便哪個阿貓阿狗出來亂拍影片的,是微軟工程師)

多工處理方式

有了上面的前置知識後,我們可以很清楚的知道要選擇哪種多工方式,這個章節將列出常見的多工方式。

多線程

Python 中的多線程有 threadingthreadPoolExecutor 模組,兩者基本相同,threading 模組是比較低階的方式,threadPoolExecutor 則是呼叫 threading 的高階模組。

對於 I/O 瓶頸任務,可以輕鬆的選擇多線程方式。

多進程

由於進程之間沒有 GIL,進程更適合於 CPU 密集型的任務,使 Python 充分利用多核處理器的優勢。注意進程內部還是有屬於自己的 GIL。

Python 中的多進程主要是 multiprocessingProcessPoolExecutor 模組,兩者關係同上。

不常見錯誤

這篇文章寫錯了! 蒐集資料時看到這篇文章筆者也很疑惑,但是經過實際測試無論是在 ARM (M1 Mac) 還是 x86 (i5-7400 ubuntu-server) 上面跑都是多線程和 baseline 差不多,多進程則進步明顯,不知道他的平台出了什麼問題。

這就是為甚麼我們需要知道原理才開始使用,能動的程式碼不見得對,了解原理才知道出現預期之外的狀況如何分析問題。

異步(非同步,事件迴圈、協程、async)

提醒

本段落專門指 Python 中的協程,否則根據異步定義:「不需要等待操作完成就可以繼續執行下一步的執行方式」連 callback function 都算是異步,並且每種語言的協程也不同,所以把範疇限制在 Python 的協程中 (關鍵字:asyncio)。

TL;DR: 協程就是一個人在等待某項工作(如等待文件下載)時,先切換去處理其他工作,線程和進程則是多個人同時工作1

協程 (coroutine) 和前面的觀念不一樣,本段落僅提供一個快速的概覽。協程對比線程和進程完全不同概念,無法類比,由「程式本身」管理,是一種允許在單一線程內實現多任務異步執行的程式「物件」,後兩者是是作業系統管理「執行單元」,由「作業系統」控制

協程使用協作式多工(cooperative multitasking),由程式碼主動讓出控制權,而不需要作業系統的上下文切換,因此開銷更小。反之,多進程和多線程是搶佔式多工 (preemptive multitasking),他們切換是不情不願,是被作業系統強制踢出換人工作。

協程透過 事件迴圈 (event loop) 調度。當程式執行到 await 語句(等待某項工作)時,事件迴圈會切換到下一個可執行的任務,藉此利用等待時間執行其他任務,因此特別適合 I/O bound 的任務,例如網路請求、檔案操作等任務。

提示

使用 async def 定義的函數會創建協程函式,調用該函式會返回一個協程物件。這個協程物件可以被事件迴圈調度執行,必須使用 await 關鍵字來等待協程的執行結果。

也可以把多線程和事件迴圈結合使用。


常見錯誤2

「協程可以被視為一個輕量級的線程」這句話引喻失義,牽強附會,是非常危險且具有誤導性的錯誤概念!

協程僅是 Python 的一種物件,透過事件迴圈管理,和實體的線程以及進程不同。前者透過軟體自行管理運行在 user space,後者需要 system call 透過作業系統建立,完全是不同東西,多工協作模式也不同,怎麼可以「被視為」呢?

這個錯誤認知會造成我們對協程對應的程式設計邏輯錯誤,對問題的本質產生誤解,浪費時間在錯誤的方向上除錯。如果要試圖用一句話概括協程,我們可以說是「單一執行緒內執行多個任務的輕量級並發方式,通過非阻塞方式切換來提高效率」,但絕對不是線程。

文章閱讀:這幾篇真的寫的很好,建議大家點進去看

分佈式計算

分佈式計算是將問題劃分成較小的子任務在多台電腦上分散處理來加速計算過程,並解決單台電腦無法處理的龐大數據或任務,例如數據大到記憶體放不下。

常見的分佈式計算套件有 Ray/Dask/PySpark。

硬體加速

會找多工處理都是想要加速程式速度,於是本文也把硬體加速放上來。

軟體很慢,硬體很快,正確使用硬體可以顯著提高計算速度,再怎麼多工都沒有正確使用硬體資源來的高效,就算只是使用 Numpy,他在底層的 C 實現中也都有調用硬體加速。筆者有專門一篇文章介紹 Python 的硬體加速,請見 Numba 教學:加速 Python 科學計算

名詞介紹

搜尋這些資訊時常常會看到以下名詞:阻塞、非阻塞、同步、異步、並行、並發,筆者認為考這些名詞非常沒用,浪費時間,只要清楚知道自己在做什麼本身就能對應到上面這些名詞,但是既然會有疑問就還是放上解釋。

  • 阻塞 (Blocking):需要等待某個條件才能繼續時,該操作被稱為阻塞,這會使執行流程暫停。

  • 非阻塞 (Non-blocking):不需要等待某個條件的完成,而是立即返回結果,允許程式繼續執行其他任務(之後完成會再跟主程式說我好了)。

  • 同步 (Synchronous):需要按照順序完成任務。

  • 異步 (Asynchronous):不需要照順序完成任務,如多線程、協程都屬於此範疇。

  • 並行 (Parallel):執行多個任務+同時

  • 並發 (Concurrent):執行多個任務+不是同時,例如透過快速上下文切換讓你以為他在同時處理多個任務。

  • 事件驅動 (Event-driven):基於事件的發生來觸發相應的操作,例如網絡前端應用。

  • 多工 (Multitasking):只要能「看起來像」同時處理多個任務都叫多工,以上全都是多工。

可見這些名詞不是簡單的一分為二,花時間記憶這個沒什麼用,重點是我們要知道自己寫的程式碼如何運作。

總結

本文主要是想澄清 Python 多工處理的基本知識,以及常見錯誤和被忽略的知識,這些問題在 SEO 很前面的文章隻字不提,導致初學者理解困難。本文總共澄清了以下幾點誤會:

  1. Python 中的 multiprocessing 和 multithreading 差異,多進程之間沒有鎖,多線程有鎖,多進程內部的線程有鎖(這麼核心且基本的觀念為何沒人講我不理解,每篇文章都只說多進程適合 CPU 瓶頸任務,導致知其然而不知其所以然)。

  2. GIL 到底鎖定了什麼,說了鎖卻不知道鎖了什麼不是很奇怪嗎?Python 鎖定的是字節碼,所以即使有 GIL,當指令需要超過一個字節碼來完成時還是會引發競爭危害。

  3. 有 GIL 的存在下的 multithreading 存在意義,很少文章提到自動切換線程,所以當讀者深入思考就會發現其矛盾,本文對此加強解釋。

  4. 快速且正確的解釋協程,其核心是一個事件迴圈不斷監視各個協程任務,遇到 await 時將 CPU 時間交換給下一個協程。也釐清錯誤觀念,不少文章寫他是輕量級線程。

其實這些問題都是筆者自學時的疑惑,理解後決定寫成一篇文章。

Footnotes

  1. 註:Python 中的線程被 GIL 鎖住,雖然有很多人但是一次只有一人工作。

  2. 此處專指 Python,Golang 的協程確實是輕量級線程。線程的定義是操作系統中最小的執行單位,每個線程有自己的 stack、register 和 program counter,所以透過事件迴圈就絕對不是輕量級線程,因為事件迴圈調度的協程並非真正的並行執行,而僅是靠協作式切換模擬出並發執行,不具備獨立的 stack 和 register