Python廣為使用的併發處理庫futures使用入門與內部原理

老錢發表於2018-06-12

在使用Python處理任務時,限於單執行緒處理能力有限,需要將任務並行化,分散到多個執行緒或者是多個程式去執行。

concurrent.futures就是這樣一種庫,它可以讓使用者可以非常方便的將任務並行化。這個名字有點長,後面我直接使用詞彙concurrent來代替concurrent.futures。

Python廣為使用的併發處理庫futures使用入門與內部原理

concurrent提供了兩種併發模型,一個是多執行緒ThreadPoolExecutor,一個是多程式ProcessPoolExecutor。對於IO密集型任務宜使用多執行緒模型。對於計算密集型任務應該使用多程式模型。

為什麼要這樣選擇呢?是因為Python GIL的存在讓Python虛擬機器在進行運算時無法有效利用多核心。對於純計算任務,它永遠最多隻能榨乾單個CPU核心。如果要突破這個瓶頸,就必須fork出多個子程式來分擔計算任務。而對於IO密集型任務,CPU使用率往往是極低的,使用多執行緒雖然會加倍CPU使用率,但是還遠遠到不了飽和(100%)的地步,在單核心可以應付整體計算的前提下,自然是應該選擇資源佔用少的模式,也就是多執行緒模式。

接下來我們分別嘗試一下兩種模式來進行平行計算。

多執行緒

Python廣為使用的併發處理庫futures使用入門與內部原理
多執行緒模式適合IO密集型運算,這裡我要使用sleep來模擬一下慢速的IO任務。同時為了方便編寫命令列程式,這裡使用Google fire開源庫來簡化命令列引數處理。

# coding: utf8
# t.py

import time
import fire
import threading
from concurrent.futures import ThreadPoolExecutor, wait


# 分割子任務
def each_task(index):
    time.sleep(1)  # 睡1s,模擬IO
    print "thread %s square %d" % (threading.current_thread().ident, index)
    return index * index  # 返回結果


def run(thread_num, task_num):
    # 例項化執行緒池,thread_num個執行緒
    executor = ThreadPoolExecutor(thread_num)
    start = time.time()
    fs = []  # future列表
    for i in range(task_num):
        fs.append(executor.submit(each_task, i))  # 提交任務
    wait(fs)  # 等待計算結束
    end = time.time()
    duration = end - start
    s = sum([f.result() for f in fs])  # 求和
    print "total result=%s cost: %.2fs" % (s, duration)
    executor.shutdown()  # 銷燬執行緒池


if __name__ == '__main__':
    fire.Fire(run)
複製程式碼

執行python t.py 2 10,也就是2個執行緒跑10個任務,觀察輸出

thread 123145422131200 square 0thread 123145426337792 square 1

thread 123145426337792 square 2
 thread 123145422131200 square 3
thread 123145426337792 square 4
thread 123145422131200 square 5
thread 123145426337792 square 6
thread 123145422131200 square 7
thread 123145426337792 square 8
thread 123145422131200 square 9
total result=285 cost: 5.02s
複製程式碼

我們看到計算總共花費了大概5s,總共sleep了10s由兩個執行緒分擔,所以是5s。讀者也許會問,為什麼輸出亂了,這是因為print操作不是原子的,它是兩個連續的write操作合成的,第一個write輸出內容,第二個write輸出換行符,write操作本身是原子的,但是在多執行緒環境下,這兩個write操作會交錯執行,所以輸出就不整齊了。如果將程式碼稍作修改,將print改成單個write操作,輸出就整齊了(關於write是否絕對原子性還需要進一步深入討論)

# 分割子任務
def each_task(index):
    time.sleep(1)  # 睡1s,模擬IO
    import sys
    sys.stdout.write("thread %s square %d\n" % (threading.current_thread().ident, index))
    return index * index  # 返回結果
複製程式碼

我們再跑一下python t.py 2 10,觀察輸出

thread 123145438244864 square 0
thread 123145442451456 square 1
thread 123145442451456 square 2
thread 123145438244864 square 3
thread 123145438244864 square 4
thread 123145442451456 square 5
thread 123145438244864 square 6
thread 123145442451456 square 7
thread 123145442451456 square 9
thread 123145438244864 square 8
total result=285 cost: 5.02s
複製程式碼

接下來,我們改變引數,擴大到10個執行緒,看看所有任務總共需要多久完成

> python t.py 10 10
thread 123145327464448 square 0
thread 123145335877632 square 2
thread 123145331671040 square 1
thread 123145344290816 square 4
thread 123145340084224 square 3
thread 123145348497408 square 5
thread 123145352704000 square 6
thread 123145356910592 square 7
thread 123145365323776 square 9
thread 123145361117184 square 8
total result=285 cost: 1.01s
複製程式碼

可以看到1s中就完成了所有的任務。這就是多執行緒的魅力,可以將多個IO操作並行化,減少整體處理時間。

多程式

Python廣為使用的併發處理庫futures使用入門與內部原理

相比多執行緒適合處理IO密集型任務,多程式適合計算密集型。接下來我們要模擬一下計算密集型任務。我的個人電腦有2個核心,正好可以體驗多核心計算的優勢。

那這個密集型計算任務怎麼模擬呢,我們可以使用圓周率計算公式。

Python廣為使用的併發處理庫futures使用入門與內部原理

通過擴大級數的長度n,就可以無限逼近圓周率。當n特別大時,計算會比較緩慢,這時候CPU就會一直處於繁忙狀態,這正是我們所期望的。

好,下面開寫多程式平行計算程式碼

# coding: utf8
# p.py

import os
import sys
import math
import time
import fire
from concurrent.futures import ProcessPoolExecutor, wait


# 分割子任務
def each_task(n):
    # 按公式計算圓周率
    s = 0.0
    for i in range(n):
        s += 1.0/(i+1)/(i+1)
    pi = math.sqrt(6*s)
    # os.getpid可以獲得子程式號
    sys.stdout.write("process %s n=%d pi=%s\n" % (os.getpid(), n, pi))
    return pi


def run(process_num, *ns):  # 輸入多個n值,分成多個子任務來計算結果
    # 例項化程式池,process_num個程式
    executor = ProcessPoolExecutor(process_num)
    start = time.time()
    fs = []  # future列表
    for n in ns:
        fs.append(executor.submit(each_task, int(n)))  # 提交任務
    wait(fs)  # 等待計算結束
    end = time.time()
    duration = end - start
    print "total cost: %.2fs" % duration
    executor.shutdown()  # 銷燬程式池


if __name__ == '__main__':
    fire.Fire(run)
複製程式碼

通過程式碼可以看出多程式模式在程式碼的編寫上和多執行緒沒有多大差異,僅僅是換了一個類名,其它都一摸一樣。這也是concurrent庫的魅力所在,將多執行緒和多程式模型抽象出了一樣的使用介面。

接下來我們執行一下python p.py 1 5000000 5001000 5002000 5003000,總共計算4次pi,只用一個程式。觀察輸出

process 96354 n=5000000 pi=3.1415924626
process 96354 n=5001000 pi=3.14159246264
process 96354 n=5002000 pi=3.14159246268
process 96354 n=5003000 pi=3.14159246272
total cost: 9.45s
複製程式碼

可以看出來隨著n的增大,結果越來越逼近圓周率,因為只用了一個程式,所以任務是序列執行,總共花了大約9.5s。

接下來再增加一個程式,觀察輸出

> python p.py 2 5000000 5001000 5002000 5003000
process 96529 n=5001000 pi=3.14159246264
process 96530 n=5000000 pi=3.1415924626
process 96529 n=5002000 pi=3.14159246268
process 96530 n=5003000 pi=3.14159246272
total cost: 4.98s
複製程式碼

從耗時上看縮短了接近1半,說明多程式確實起到了計算並行化的效果。此刻如果使用top命令觀察程式的CPU使用率,這兩個程式的CPU使用率都佔到了接近100%。

如果我們再增加2個程式,是不是還能繼續壓縮計算時間呢

> python p.py 4 5000000 5001000 5002000 5003000
process 96864 n=5002000 pi=3.14159246268
process 96862 n=5000000 pi=3.1415924626
process 96863 n=5001000 pi=3.14159246264
process 96865 n=5003000 pi=3.14159246272
total cost: 4.86s
複製程式碼

看來耗時不能繼續節約了,因為只有2個計算核心,2個程式已經足以榨乾它們了,即使再多加程式也只有2個計算核心可用。

深入原理

concurrent用的時候非常簡單,但是內部實現並不是很好理解。在深入分析內部的結構之前,我們需要先理解一下Future這個物件。在前面的例子中,executor提交(submit)任務後都會返回一個Future物件,它表示一個結果的坑,在任務剛剛提交時,這個坑是空的,一旦子執行緒執行任務結束,就會將執行的結果塞到這個坑裡,主執行緒就可以通過Future物件獲得這個結果。簡單一點說,Future物件是主執行緒和子執行緒通訊的媒介。

Python廣為使用的併發處理庫futures使用入門與內部原理
Future物件的內部邏輯簡單一點可以使用下面的程式碼進行表示

class Future(object):

    def __init__(self):
        self._condition = threading.Condition()  # 條件變數
        self._result = None
    
    def result(self, timeout=None):
        self._condition.wait(timeout)
        return self._result
        
    def set_result(self, result):
        self._result = result
        self._condition.notify_all()
複製程式碼

主執行緒將任務塞進執行緒池後得到了這個Future物件,它內部的_result還是空的。如果主執行緒呼叫result()方法獲取結果,就會阻塞在條件變數上。如果子執行緒計算任務完成了就會立即呼叫set_result()方法將結果填充進future物件,並喚醒阻塞在條件變數上的執行緒,也就是主執行緒。這時主執行緒立即醒過來並正常返回結果。

執行緒池內部結構

主執行緒和子執行緒互動分為兩部分,第一部分是主執行緒如何將任務傳遞給子執行緒,第二部分是子執行緒如何將結果傳遞給主執行緒。第二部分已經講過了是通過Future物件來完成的。那第一部分是怎麼做到的呢?

Python廣為使用的併發處理庫futures使用入門與內部原理

如上圖所示,祕密就在於這個佇列,主執行緒是通過佇列將任務傳遞給多個子執行緒的。一旦主執行緒將任務塞進任務佇列,子執行緒們就會開始爭搶,最終只有一個執行緒能搶到這個任務,並立即進行執行,執行完後將結果放進Future物件就完成了這個任務的完整執行過程。

執行緒池的缺點

concurrent的執行緒池有個重大的設計問題,那就是任務佇列是無界的。如果佇列的生產者任務生產的太快,而執行緒池消費太慢處理不過來,任務就會堆積。如果堆積一直持續下去,記憶體就會持續增長直到OOM,任務佇列裡堆積的所有任務全部徹底丟失。使用者使用時一定要注意這點,並做好適當的控制。

程式池內部結構

程式池內部結構複雜,連concurent庫的作者自己也覺得特別複雜,所以在程式碼裡專門畫了一張ascii圖來講解模型內部結構

Python廣為使用的併發處理庫futures使用入門與內部原理

Python廣為使用的併發處理庫futures使用入門與內部原理

我覺得作者的這張圖還不夠好懂,所以也單獨畫了一張圖,請讀者們仔細結合上面兩張圖,一起來過一邊完整的任務處理過程。

  1. 主執行緒將任務塞進TaskQueue(普通記憶體佇列),拿到Future物件
  2. 唯一的管理執行緒從TaskQueue獲取任務,塞進CallQueue(分散式跨程式佇列)
  3. 子程式從CallQueue中爭搶任務進行處理
  4. 子程式將處理結果塞進ResultQueue(分散式跨程式佇列)
  5. 管理執行緒從ResultQueue中獲取結果,塞進Future物件
  6. 主執行緒從Future物件中拿到結果

這個複雜的流程中涉及到3個佇列,還有中間附加的管理執行緒。那為什麼作者要設計的這麼複雜,這樣的設計有什麼好處?

首先,我們看這張圖的左半邊,它和執行緒池的處理流程沒有太多區別,區別僅僅是管理執行緒只有一個,而執行緒池的子執行緒會有多個。這樣設計可以使得多程式模型和多執行緒模型的使用方法保持一致,這就是為什麼兩個模型使用起來沒有任何區別的原因所在——通過中間的管理執行緒隱藏了背後的多程式互動邏輯。

然後我們再看這張圖的右半邊,管理執行緒通過兩個佇列來和子程式們進行互動,這兩個佇列都是跨程式佇列(multiprocessing.Queue)。CallQueue是單生產者多消費者,ResultQueue是多生產者單消費者。

CallQueue是個有界佇列,它的上限在程式碼裡寫死了為「子程式數+1」。如果子程式們處理不過來,CallQueue就會變滿,管理執行緒就會停止往裡面塞資料。但是這裡也遭遇了和執行緒池一樣的問題,TaskQueue是無界佇列,它的內容可不管消費者是否在持續(管理執行緒)消費,TaskQueue會無限制的持續生長,於是最終也會會導致OOM。

跨程式佇列

程式池模型中的跨程式佇列是用multiprocessing.Queue實現的。那這個跨程式佇列內部細節是怎樣的,它又是用什麼高科技來實現的呢

筆者仔細閱讀了multiprocessing.Queue的原始碼發現,它使用無名套接字sockerpair來完成的跨程式通訊,socketpair和socket的區別就在於socketpair不需要埠,不需要走網路協議棧,通過核心的套接字讀寫緩衝區直接進行跨程式通訊。

Python廣為使用的併發處理庫futures使用入門與內部原理

當父程式要傳遞任務給子程式時,先使用pickle將任務物件進行序列化成位元組陣列,然後將位元組陣列通過socketpair的寫描述符寫入核心的buffer中。子程式接下來就可以從buffer中讀取到位元組陣列,然後再使用pickle對位元組陣列進行反序列化來得到任務物件,這樣總算可以執行任務了。同樣子程式將結果傳遞給父程式走的也是一樣的流程,只不過這裡的socketpair是ResultQueue內部建立的無名套接字。

multiprocessing.Queue是支援雙工通訊,資料流向可以是父到子,也可以是子到父,只不過在concurrent的程式池實現中只用到了單工通訊。CallQueue是從父到子,ResultQueue是從子到父。

總結

concurrent.futures框架非常好用,雖然內部實現機制異常複雜,讀者也無需完全理解內部細節就可以直接使用了。但是需要特別注意的是不管是執行緒池還是程式池其內部的任務佇列都是無界的,一定要避免消費者處理不及時記憶體持續攀升的情況發生。

今天,作者新書《深入理解RPC》正式上線,限時優惠9.9元,感興趣的讀者點選下面的連線進行閱讀

深入理解 RPC : 基於 Python 自建分散式高併發 RPC 服務

Python廣為使用的併發處理庫futures使用入門與內部原理

Python廣為使用的併發處理庫futures使用入門與內部原理

相關文章