項(xiàng)目地址:https://git.io/pytips
要說到線程(Thread)與協(xié)程(Coroutine)似乎總是需要從并行(Parallelism)與并發(fā)(Concurrency)談起,關(guān)于并行與并發(fā)的問題秒咐,Rob Pike 用 Golang 小地鼠燒書的例子給出了非常生動(dòng)形象的說明拧额。簡單來說并行就是我們現(xiàn)實(shí)世界運(yùn)行的樣子,每個(gè)人都是獨(dú)立的執(zhí)行單元常遂,各自完成自己的任務(wù)纳令,這對應(yīng)著計(jì)算機(jī)中的分布式(多臺(tái)計(jì)算機(jī))或多核(多個(gè)CPU)運(yùn)作模式;而對于并發(fā)克胳,我看到最生動(dòng)的解釋來自Quora 上 Jan Christian Meyer 回答的這張圖:
并發(fā)對應(yīng)計(jì)算機(jī)中充分利用單核(一個(gè)CPU)實(shí)現(xiàn)(看起來)多個(gè)任務(wù)同時(shí)執(zhí)行泊碑。我們在這里將要討論的 Python 中的線程與協(xié)程僅是基于單核的并發(fā)實(shí)現(xiàn),隨便去網(wǎng)上搜一搜(Thread vs Coroutine)可以找到一大批關(guān)于它們性能的爭論毯欣、benchmark馒过,這次話題的目的不在于討論誰好誰壞,套用一句非常套路的話來說酗钞,拋開應(yīng)用場景爭好壞都是耍流氓腹忽。當(dāng)然在硬件支持的條件下(多核)也可以利用線程和協(xié)程實(shí)現(xiàn)并行計(jì)算来累,而且 Python 2.6 之后新增了標(biāo)準(zhǔn)庫 multiprocessing
(PEP 371)突破了 GIL 的限制可以充分利用多核,但由于協(xié)程是基于單個(gè)線程的窘奏,因此多進(jìn)程的并行對它們來說情況是類似的嘹锁,因此這里只討論單核并發(fā)的實(shí)現(xiàn)。
要了解線程以及協(xié)程的原理和由來可以查看參考鏈接中的前兩篇文章着裹。Python 3.5 中關(guān)于線程的標(biāo)準(zhǔn)庫是 threading
领猾,之前在 2.x 版本中的 thread
在 3.x 之后更名為 _thread
,無論是2.7還是3.5都應(yīng)該盡量避免使用較為底層的 thread/_thread
而應(yīng)該使用 threading
骇扇。
創(chuàng)建一個(gè)線程可以通過實(shí)例化一個(gè) threading.Thread
對象:
from threading import Thread
import time
def _sum(x, y):
print("Compute {} + {}...".format(x, y))
time.sleep(2.0)
return x+y
def compute_sum(x, y):
result = _sum(x, y)
print("{} + {} = {}".format(x, y, result))
start = time.time()
threads = [
Thread(target=compute_sum, args=(0,0)),
Thread(target=compute_sum, args=(1,1)),
Thread(target=compute_sum, args=(2,2)),
]
for t in threads:
t.start()
for t in threads:
t.join()
print("Total elapsed time {} s".format(time.time() - start))
# Do not use Thread
start = time.time()
compute_sum(0,0)
compute_sum(1,1)
compute_sum(2,2)
print("Total elapsed time {} s".format(time.time() - start))
Compute 0 + 0...
Compute 1 + 1...
Compute 2 + 2...
0 + 0 = 0
1 + 1 = 2
2 + 2 = 4
Total elapsed time 2.002729892730713 s
Compute 0 + 0...
0 + 0 = 0
Compute 1 + 1...
1 + 1 = 2
Compute 2 + 2...
2 + 2 = 4
Total elapsed time 6.004806041717529 s
除了通過將函數(shù)傳遞給 Thread
創(chuàng)建線程實(shí)例之外摔竿,還可以直接繼承 Thread
類:
from threading import Thread
import time
class ComputeSum(Thread):
def __init__(self, x, y):
super().__init__()
self.x = x
self.y = y
def run(self):
result = self._sum(self.x, self.y)
print("{} + {} = {}".format(self.x, self.y, result))
def _sum(self, x, y):
print("Compute {} + {}...".format(x, y))
time.sleep(2.0)
return x+y
threads = [ComputeSum(0,0), ComputeSum(1,1), ComputeSum(2,2)]
start = time.time()
for t in threads:
t.start()
for t in threads:
t.join()
print("Total elapsed time {} s".format(time.time() - start))
Compute 0 + 0...
Compute 1 + 1...
Compute 2 + 2...
0 + 0 = 0
1 + 1 = 2
2 + 2 = 4
Total elapsed time 2.001662015914917 s
根據(jù)上面代碼執(zhí)行的結(jié)果可以發(fā)現(xiàn),compute_sum/t.run
函數(shù)的執(zhí)行是按照 start()
的順序少孝,但 _sum
結(jié)果的輸出順序卻是隨機(jī)的继低。因?yàn)?_sum
中加入了 time.sleep(2.0)
,讓程序執(zhí)行到這里就會(huì)進(jìn)入阻塞狀態(tài)稍走,但是幾個(gè)線程的執(zhí)行看起來卻像是同時(shí)進(jìn)行的(并發(fā))袁翁。
有時(shí)候我們既需要并發(fā)地“跳過“阻塞的部分,又需要有序地執(zhí)行其它部分婿脸,例如操作共享數(shù)據(jù)的時(shí)候粱胜,這時(shí)就需要用到”鎖“。在上述”求和線程“的例子中狐树,假設(shè)每次求和都需要加上額外的 _base
并把計(jì)算結(jié)果累積到 _base
中焙压。盡管這個(gè)例子不太恰當(dāng),但它說明了線程鎖的用途:
from threading import Thread, Lock
import time
_base = 1
_lock = Lock()
class ComputeSum(Thread):
def __init__(self, x, y):
super().__init__()
self.x = x
self.y = y
def run(self):
result = self._sum(self.x, self.y)
print("{} + {} + base = {}".format(self.x, self.y, result))
def _sum(self, x, y):
print("Compute {} + {}...".format(x, y))
time.sleep(2.0)
global _base
with _lock:
result = x + y + _base
_base = result
return result
threads = [ComputeSum(0,0), ComputeSum(1,1), ComputeSum(2,2)]
start = time.time()
for t in threads:
t.start()
for t in threads:
t.join()
print("Total elapsed time {} s".format(time.time() - start))
Compute 0 + 0...
Compute 1 + 1...
Compute 2 + 2...
0 + 0 + base = 1
1 + 1 + base = 3
2 + 2 + base = 7
Total elapsed time 2.0064051151275635 s
這里用上下文管理器來管理鎖的獲取和釋放褪迟,相當(dāng)于:
_lock.acquire()
try:
result = x + y + _base
_base = result
finally:
_lock.release()
死鎖
線程的一大問題就是通過加鎖來”搶奪“共享資源的時(shí)候有可能造成死鎖冗恨,例如下面的程序:
from threading import Lock
_base_lock = Lock()
_pos_lock = Lock()
_base = 1
def _sum(x, y):
# Time 1
with _base_lock:
# Time 3
with _pos_lock:
result = x + y
return result
def _minus(x, y):
# Time 0
with _pos_lock:
# Time 2
with _base_lock:
result = x - y
return result
由于線程的調(diào)度執(zhí)行順序是不確定的,在執(zhí)行上面兩個(gè)線程 _sum/_minus
的時(shí)候就有可能出現(xiàn)注釋中所標(biāo)注的時(shí)間順序味赃,即 # Time 0
的時(shí)候運(yùn)行到 with _pos_lock
獲取了 _pos_lock
鎖掀抹,而接下來由于阻塞馬上切換到了 _sum
中的 # Time 1
,并獲取了 _base_lock
心俗,接下來由于兩個(gè)線程互相鎖定了彼此需要的下一個(gè)鎖傲武,將會(huì)導(dǎo)致死鎖,即程序無法繼續(xù)運(yùn)行城榛。根據(jù) 我是一個(gè)線程 中所描述的揪利,為了避免死鎖,需要所有的線程按照指定的算法(或優(yōu)先級)來進(jìn)行加鎖操作狠持。不管怎么說疟位,死鎖問題都是一件非常傷腦筋的事,原因之一在于不管線程實(shí)現(xiàn)的是并發(fā)還是并行喘垂,在編程模型和語法上看起來都是并行的甜刻,而我們的大腦雖然是一個(gè)(內(nèi)隱的)絕對并行加工的機(jī)器绍撞,卻非常不善于將并行過程具象化(至少在未經(jīng)足夠訓(xùn)練的時(shí)候)。而與線程相比得院,協(xié)程(尤其是結(jié)合事件循環(huán))無論在編程模型還是語法上傻铣,看起來都是非常友好的單線程同步過程。后面第二部分我們再來討論 Python 中協(xié)程是如何從”小三“一步步扶正上位的:D
祥绞。
參考
- Python 中的進(jìn)程、線程蜕径、協(xié)程两踏、同步、異步丧荐、回調(diào)
- 我是一個(gè)線程
- Concurrency is not Parallelism
- A Curious Course on Coroutines and Concurrency
- PyDocs: 17.1. threading — Thread-based parallelism
- PyDocs: 18.5.3. Tasks and coroutines
- [譯] Python 3.5 協(xié)程究竟是個(gè)啥
- 協(xié)程的好處是什么缆瓣? - crazybie 的回答
- Py3-cookbook:第十二章:并發(fā)編程
- Quora: What are the differences between parallel, concurrent and asynchronous programming?