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):
- Windows
- macOS
- Linux
在任務管理員中的詳細資訊中可以看到所有正在運行的進程及其相應的 PID。
也可以使用命令提示字元輸入 `tasklist` 命令列出。
在活動監視器中可以看到所有工作的 PID。
使用 `htop` 。
進程存活時,作業系統會分配給他一塊專屬的記憶體空間,裡面會放該程式的程式碼以及運行中佔用的記憶體。每個進程會包含一個或多個線程,線程可以理解成一個輕量化的進程,其角色是進程執行的基本單位,同進程的不同線程之間共享記憶體資源,而不同進程之間的溝同則需要調用作業系統的機制 (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】天使还是魔鬼?GIL的前世今生。一期视频全面了解GIL!
- 【python】听说Python的多线程是假的?它真的没有存在的价值么?
- 【python】听说因为有GIL,多线程连锁都不需要了?
(這人不是隨便哪個阿貓阿狗出來亂拍影片的,是微軟工程師)
多工處理方式
有了上面的前置知識後,我們可以很清楚的知道要選擇哪種多工方式,這個章節將列出常見的多工方式。
多線程
Python 中的多線程有 threading
和 threadPoolExecutor
模組,兩者基本相同,threading 模組是比較低階的方式,threadPoolExecutor 則是呼叫 threading 的高階模組。
對於 I/O 瓶頸任務,可以輕鬆的選擇多線程方式。