[Python原理] 執行緒用於阻斷式I/O,避免用於平行處理

[Python原理] 執行緒用於阻斷式I/O,避免用於平行處理

原文擷自[Effective Python中文版],加入自己的實作測試練習

Python的標準實作叫作CPython。Cpython會以兩個步驟來執行一個Python程式

首先,先剖析(parses)程式碼,並將它們轉換成bytecode。

然後它使用一種以堆疊(LIFO)為基礎的直譯器來執行這些bytecode。

這些bytecode直譯器在Python程式執行的過程中有些狀態必須被維護並保持一致。

Python以一種叫作global interpreter lock(GIL)的機制來強制要求一致性。

 

GIL是一種互斥鎖,它保護CPython不被強占式的多執行緒處理

在這種處理方式下,一個執行緒(thread)會中斷其他執行緒以取得程式的控制權。

GIL能夠防執這些中斷不會毀損直譯器的狀態,確保每個byte code指令都能以CPython與C擴充功能模組來正確處理。

雖然Python支援多緒執行(multiple threads of execution),GIL會導致一次只有其中一個執行緒能往前推進。

因此假如使用執行緒是為了平行計算以加速python程式,那結果將會讓我們大失所望。

 

以factorize(因式分解)為例:

from time import time
def factorize(number):
    for i in range(1, number+1):
        if number % i == 0:
            yield i

numbers = [2139079, 1214759, 1516637, 1852285]
start = time()
for number in numbers:
    print(list(factorize(number)))
end = time()
print('花費 %.3f 秒' % (end-start))


from threading import Thread
class FactorizeThread(Thread):
    def __init__(self, number):
        super().__init__()
        self.number = number

    def run(self):
        print(list(factorize(self.number)))

threads = []
start = time()
for number in numbers:
    thread = FactorizeThread(number)
    thread.start()
    threads.append(thread)

for thread in threads:
    thread.join()

end = time()
print('花費 %.3f 秒' % (end-start))

[1, 101, 21179, 2139079]
[1, 7, 13, 49, 91, 637, 1907, 13349, 24791, 93443, 173537, 1214759]
[1, 19, 79823, 1516637]
[1, 5, 271, 1355, 1367, 6835, 370457, 1852285]
花費 0.647 秒
[1, 19, 79823, 1516637]
[1, 7, 13, 49, 91, 637, 1907, 13349, 24791, 93443, 173537, 1214759]
[1, 5, 271, 1355, 1367, 6835, 370457, 1852285]
[1, 101, 21179, 2139079]
花費 0.797 秒

多執行緒版本(下)所花費的時間甚至比依序的factorize還多一些些。

原本預期可能可以縮短為1/4的時間,建立執行緒與協調它們的運行也需要花費資源

以雙核心的機器上,可能至少可以快上1/2,但我們可能都沒想過在有多個cpu可運用的情況下,這些執行緒效能更差

這恰恰展示了GIL對在標準CPython直譯器中執行的程式所造成的影響。

有其他辦法可以讓CPyton運用多核心的計算能力,但無法用在標準的Thread類別。

 

既然知道有些這限制,那為何Python還要支援多執行緒呢?

首先,多執行緒可以輕易的讓我們的程式看起來像在同一時間做多件事情一樣。管理同時任務雜耍般的行為,是我 們自己很難實作的工作(考慮使用Coroutines來共時執行許多函式)

藉由執行緒,我們可以把這種呆難的工作交由Python去處理。讓它以看似平行的方式執行函式。

Python支援執行緒的第二個原因是為了處理阻斷式I/O,這會在Python進行某種類型的系統呼叫(system call)時發生。

阻斷式I/O包含了像是讀寫檔案、與網路互動、與裝置(例顯示器)通訊等等的工作。執行緒能夠協助我們處理阻斷式I/O,讓我們的程式不必等候OS回應我的請求時間。

from threading import Thread
from time import time
import requests
#假設一個網路請求,每次都會對特定網站進行呼叫,這牽扯到Blocking I/O的議題
def slow_request_call():
    try:
        print(requests.get( 'https://api.github.com/user', auth=('paul', '12345'), timeout=10).content[0:100])
    except Exception as err:
        print(str(err))

start = time()
for _ in range(5):
    slow_request_call()
end = time()
print('花費 %.3f 秒' % (end-start))


start = time()
threads = []
for _ in range(5):
    thread = Thread(target=slow_request_call)
    thread.start()
    threads.append(thread)

def compute_task(index):
    #....
    print("Do Task #%s" % index)

for i in range(5):
    compute_task(i)

for thread in threads:
    thread.join()
end = time()
print('花費 %.3f 秒' % (end-start))

執行Console結果如下:

b'{“message”:”Bad credentials”,”documentation_url”:”https://developer.github.com/v3″}’
b'{“message”:”Bad credentials”,”documentation_url”:”https://developer.github.com/v3″}’
b'{“message”:”Bad credentials”,”documentation_url”:”https://developer.github.com/v3″}’
b'{“message”:”Bad credentials”,”documentation_url”:”https://developer.github.com/v3″}’
b'{“message”:”Bad credentials”,”documentation_url”:”https://developer.github.com/v3″}’
花費 6.425 秒
Do Task #0
Do Task #1
Do Task #2
Do Task #3
Do Task #4
b'{“message”:”Bad credentials”,”documentation_url”:”https://developer.github.com/v3″}’
b'{“message”:”Bad credentials”,”documentation_url”:”https://developer.github.com/v3″}’
b'{“message”:”Bad credentials”,”documentation_url”:”https://developer.github.com/v3″}’
b'{“message”:”Bad credentials”,”documentation_url”:”https://developer.github.com/v3″}’
b'{“message”:”Bad credentials”,”documentation_url”:”https://developer.github.com/v3″}’
花費 0.963 秒

這個平行處理的時間比循序處理的時間幾乎快了五倍。

這裡顯示出,那些網路請求呼叫都會從多個Python執行緒以平行的的方式執行。既使它們也受到GIL的限制。

GIL使我們的Python程式碼無法以平行方式運作,但它對系統呼叫沒有負面效應。這之所以行的通,是因為Python執行緒會在即將進行網路請求之前釋放GIL

而只要網路請求一完成工作,它們就會取回GIL。

至於,除了執行緒之外,還有其他很多方式可以處理阻斷式I/O的方式,例如asyncio這個內建模組,這些替代方案都有它們的好處,但也有選擇下的成本,包含重構你的程式碼

以符合不同執行模型。而使用執行緒是平行進行阻斷式I/O最簡單的方式,只需要稍微調整程式就可以了

2 Replies to “[Python原理] 執行緒用於阻斷式I/O,避免用於平行處理”

發佈回覆給「Tom」的留言 取消回覆

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *