Python 的 concurrency 和 parallelization

李松錡
11 min readJun 9, 2018

--

前幾天把放在 safari reading list 裡的一篇文章拿出來看完,這是一篇關於 PPython 多執行緒的文章。在 CPU 單個核心效能提升幅度逐漸減少的這個時代,大家都朝著多核心的方向發展,如果我們能夠善用多核心平行運算的話,可以非常輕易的就讓程式的效能提升數倍。

Concurreny is not Parallelism

Python 有三個方法可以達到創造多執行緒,分別是:

  • Threading
  • Fork
  • Multiprocessing

這三者其實差異蠻大的,threading 最終只有使用一個 CPU Core 來運行,也就是說,在正常思路下,你的程式不只沒辦法透過分工給多個 CPU Core 來運算獲得加速外,還有可能因為要協調 thread 之間的互動跟資源分配,反而程式效能會下降;真正用了多個 CPU Core 的是 Fork 跟 Multiprocessing。什麼? 那我們到底要 threading 幹嘛呢? 且聽我細細道來。

首先大家最最常誤會的一件事就是 concurrency = parallelism。事實上這兩件事不等價,這邊就有一個 golang 相關 conference 的影片在解釋這件事。我用一個很簡單的例子來解釋 concurrency 跟 parallelism 到底差在哪裡。約莫在我小學的時候,那時候 CPU 還只有一個 core,即便在那個時候,你還是可以邊用 word 2003,同時還可以上網,同時還可以開著奇摩即時通。如果 CPU 只有一個運算單元,那這麼多程式為什麼可以同時運作呢? 這是因為你的作業系統會自動幫你分配,輪流讓每一隻程式都有時間可以被那顆唯一個 CPU 運算,所以你可以邊用 word 2003,同時奇摩即時通有人密你的時候電腦也會跳通知視窗。這就是 concurrency,有好幾隻程式可以運作,但一次只有一個程式可以佔用那顆唯一的 CPU,但因為運算資源有做時間分配,所以人覺得這些程式是同時運行的。時間推進到現在,你的電腦上可以有好幾個 CPU core,如此就有可能你同時開著網頁瀏覽器,同時在播放音樂,而網頁瀏覽器和播放器用的 core 不同,兩個程式在同一個時間點都在運作,這就是 parallelism。

回頭來看 threading library,threading 事實上是一種 green thread,是 Python 的 interpreter 這一支程式自己在做控制,當系統給 interpreter 運算資源時,interpreter 自己在裡面再分配哪個 thread 可以被運算,哪個 thread 還要等待。而因為 interpreter 對系統來說是一隻程式,interpreter 並沒有使用多核心的指令,因此最終 threading library 只能夠用一個 core,而且為了要協調及分配運算資源給這些 thread,Python 還引進了 GIL (Global Interpreter Lock) 來應對這些 thread。

Threading library 做到的事情就是 concurrency,因此可以想見 threading library 的應用場景就是 concurrency 的適用情境,例如我們在做應對使用者的介面時,可以只用一個 core,然後達到滿足人類多件事情要 “感覺上同時在處理” 的情境。除此之外,concurrency 也可以用來處理 I/O bound 的問題,例如我們寫了一隻爬蟲,當我們發了 request 之後就要等封包傳遞到對面的 server,然後 server 運算完才把 respond 傳回來給我們,這之間的時間都要等待,所以把正在等待的 thread 換下來,換上可以運算的 thread 繼續運算,就能夠讓運算資源很有效率的被利用。

Fork 和 multiprocessing 就不一樣了,他們會真的產生一個新的作業系統中的 process,然後這個 process 就會被作業系統納入資源分配的排程中,因此是可以讓兩個 process 分別被兩個 core 運算,也因此就可以讓多核心平行化處理,而使效能有倍數的成長。這兩者和 threading 在平行化的運算效能角度來看,是有很大的優勢,但和 threading 相比,也是有缺點的。最大的缺點就在於因為我們是實際產生一個新的 process 去運算,因此要付出的代價就是所有現有的變數都要拷貝一份到新的 process 中才能繼續運算,此外新的 process 也必需要跟作業系統重新要一個完整的 process 結構才能運行,在 memory 使用上就比 threading 來得大非常多,而且複製及 allocate 這麼大的 memory 也比較花時間,再者就是兩者間的溝通也已經變成是 process 對 process 之間的溝通,一個 process 在作業系統的管理下是無法去存取別的 process 的 memory 的,所以這也讓資料在彼此間傳遞變得更加複雜及花時間。Threading library 則沒有上面所提到的缺點,要產生一個新的 green thread 並不需要像作業系統要一個新的 process 結構,而且 memory 之間是共用的,所以也不需要特別複製一份新的,再者就是變數在彼此之間傳遞是很容易的。

Thread Safe

在瞭解了 threading 和產生新的 process 的優缺點及應用情境後,我們可以先來探討 threading 有什麼需要注意的。首先是 thread safe,因為所以的變數都是共用的,所以 Python 使用了 GIL 來控制,而且 GIL 是一個 Lock,不是 Mutex,因此一次只會有一個 thread 能夠拿到這個 Lock,有拿到 Lock 的人才能獲得運算資源。這使得 threading 一次只會有一個 thread 在運作,在這樣的情況下我們要考慮的事情就是:

會不會我一行 Python code 還沒運算完成, lock 就被別人拿走呢? 例如你有一行 Python code 是某某變數 = 一個 function 的運算結果,那麼很有可能這個 function 才運算到其中某一行,lock 就被人拿走,而拿走 lock 的人,會改到某個足以影響 function 繼續執行的變數,那麼你的運算就有可能出錯。

答案是會的,我們直接看一個例子:

上面這段 code 是產生 2000 個 thread,然後 thread 們都會去對一個 global variable 的值加一,最終我們期望應該要看到輸出數字 2000。接著我們用 bash 的 for 連續跑個 100 次看看

for i in {1..100}
do
python2 unsafe_threading.py
done
20000
20000
20000
20000
20000
19999
20000
19999
20000
20000
19998
19999
19999
20000
19998
20000
19998
20000
20000
20000
19999
...

因為在執行 n += 1 的時候,有的 thread 拿到的 n 還是舊的,所以會讓結果出錯。

因此我們要處理 thread safe 的問題。首先有個概念叫做 atomic operation,這個意思是這個 operation 要嘛就是做完了,或者根本沒做,他不會有做到一半但被 interpreter 打斷的情況,所以一但這個 thread 有 GIL,正在運算一個 atomic operation,interpreter 就不會把 GIL 給別人,要嘛只會在開始執行 atomic operation 之前就把 GIL 拿走,或者執行完 atomic operation 以後才能夠把 GIL 拿走。對於 atomic operation,我們就可以很放心的確定不會發生上面講的因為變數被改到而產生運算錯誤。

什麼樣的運算在 Python 中是 atomic 的呢? Python 在開始運作程式之前,其實會做一次 compile,會把你寫的 Python code compile 成 bytecode,這個 bytecode 裡面的意義長得類似這樣

LOAD_CONST 1(1)
INPLACE_ADD

看起來是有一點點像組語,而每一行 bytecode 都是 atomic 的,你可以透過 dis 這個 package 來觀察你寫的 Python code

In [1]: import disIn [2]: def test():
...: a = 1
...: a += 1
...:
In [3]: dis.dis(test)
2 0 LOAD_CONST 1 (1)
2 STORE_FAST 0 (a)
3 4 LOAD_FAST 0 (a)
6 LOAD_CONST 1 (1)
8 INPLACE_ADD
10 STORE_FAST 0 (a)
12 LOAD_CONST 0 (None)
14 RETURN_VALUE

dis 輸出的 2可以看到就是 a = 1 這行,由右而左,先 load 一個 const number 1,然後再把值放到 a這個變數中。然後 3可以看到一樣由右到左,先 load 變數 a 和 load const number 1,然後執行加法,接者存進 a 中,最後 return 這個 function。也因為 += 會被拆成好幾步,而不是 atomic operation,所以我們在上面的例子才會看到出錯的情形。

那麼什麼樣的 Python code 才會是 atomic operation 呢? 答案是取決於 把Python source code compile 成 bytecode 的 compiler 決定的。例如 list 的 sort 就是一個 atomic operation:

In [1]: import disIn [2]: def test():
...: l = [ 3, 5, 4]
...: l.sort()
...:
In [3]: dis.dis(test)
2 0 LOAD_CONST 1 (3)
2 LOAD_CONST 2 (5)
4 LOAD_CONST 3 (4)
6 BUILD_LIST 3
8 STORE_FAST 0 (l)
3 10 LOAD_FAST 0 (l)
12 LOAD_ATTR 0 (sort)
14 CALL_FUNCTION 0
16 POP_TOP
18 LOAD_CONST 0 (None)
20 RETURN_VALUE

list 的 sort 真正在執行 sort 的時候只有一行 CALL_FUNCTIONPOP_TOP 則只是 python 把 call 完的 function 從 stack 中 pop 掉,所以我們可以知道在執行 sort 的時候 GIL 是不會被拿走的,因此並不會出事。

使用強而有力的多核心

如前面所說 fork 和 multiprocessing 是產生一個新的作業系統的 process,所以彼此之間的 memory 是區隔開的,也就沒有 thread safe 的議題,不過 fork 是比較低層級的操作,相對來說使用上也比較繁瑣一點,而 multiprocessing 則幫你準備了一些好用的 function 和 control。

當我們要使用 fork 的時候,需要 import os,才能使用 os.fork 來產生新的 thread。此時 Python 會幫你跟作業系統要一個新的 process,把之前的 memory 拷貝一份過去,然後新的 process 在 os.fork 的回傳值會拿到 0,而 parent 拿到的回傳值則是一個大於零的整數,因此我們就可以透過這個區別來讓兩個 process 做不一樣的事情。

而 multiprocessing 則幫你包好很多部分,值得注意的一件事情是, multiprocessing 看起來似乎變數是共用的,但正如我前面所說,因為是重新跟作業系統要一個 process 來用,memory 也是事後才拷貝過去的,因此每個 process 之間的 memory 是分開的,改了 global 變數並不會影響到其他人。但 multiprocessing 也提供一塊 shared memory 提供 parent process 和 child process 之間公用。此外還要注意一點的是,在產生新的 process 的時候,Python 會把目前 memory 中的變數用 pickle 的模組壓起來然後傳給新的 process 再解開,因此如果遇到不能 pickle 的變數,就必須要額外處理。

最後的一些題外話

看完了 threading 和 multiprocessing 後,想必對 concurrency 和 parallelization 有更深的了解了,那可能大家還對文章中有一段話覺得有疑惑:

interpreter 並沒有使用多核心的指令,因此最終 threading library 只能夠用一個 core

難道有可以使用多核心指令的程式嗎? 答案是有的,你可以使用例如 OpenMP, PThreads, GCD 等等的 library。我在碩論的研究中為了要使用 CPU 的平行化,所以學了 OpenMP,其實只要有平行化的概念,真的是蠻容易的 (相較於用 GPU 的平行化)。而 PThreads 因為是比較底層的 library,比較繁瑣,所以當時就沒學了。後來發現 OpenMP 在 Apple 的 LLVM 上不支援,去查了以後才知道有 GCD 的存在,聽說設計上比 OpenMP 又更好一些,也許哪天可以來學學。

--

--