python執行緒通訊與生產者消費者模式

dwzb發表於2018-03-08

本文首發於知乎

本文主要講解生產者消費者模式,它基於執行緒之間的通訊。

生產者消費者模式是指一部分程式用於生產資料,一部分程式用於處理資料,兩部分分別放在兩個執行緒中來執行。

舉幾個例子

  • 一個程式專門往列表中新增數字,另一個程式專門提取數字進行處理,二者共同維護這樣一個列表
  • 一個程式去抓取待爬取的url,另一個程式專門解析url將資料儲存到檔案中,這相當於維護一個url佇列
  • 維護ip池,一個程式在消耗ip進行爬蟲,另一個程式看ip不夠用了就啟動開始抓取

我們可以想象到,這種情況不使用併發機制(如多執行緒)是難以實現的。如果程式線性執行,只能做到先把所有url抓取到列表中,再遍歷列表解析資料;或者解析的過程中將新抓到的url加入列表,但是列表的增添和刪減並不是同時發生的。對於更復雜的機制,執行緒程式更是難以做到,比如維護url列表,當列表長度大於100時停止填入,小於50時再啟動開始填入。

本文結構

本文思路如下

  • 首先,兩個執行緒維護同一個列表,需要使用鎖保證對資源修改時不會出錯
  • threading模組提供了Condition物件專門處理生產者消費者問題
  • 但是為了呈現由淺入深的過程,我們先用普通鎖來實現這個過程,通過考慮程式的不足,再使用Condition來解決,讓讀者更清楚Condition的用處
  • 下一步,python中的queue模組封裝了Condition的特性,為我們提供了一個方便易用的佇列結構。用queue可以讓我們不需要了解鎖是如何設定的細節
  • 執行緒安全的概念解釋
  • 這個過程其實就是執行緒之間的通訊,除了Condition,再補充一種通訊方式Event

本文分為下面幾個部分

  • Lock與Condition的對比
  • 生產者與消費者的相互等待
  • Queue
  • 執行緒安全
  • Event

Lock與Condition的對比

下面我們實現這樣一個過程

  • 維護一個整數列表integer_list,共有兩個執行緒
  • Producer類對應一個執行緒,功能:隨機產生一個整數,加入整數列表之中
  • Consumer類對應一個執行緒,功能:從整數列表中pop掉一個整數
  • 通過time.sleep來表示兩個執行緒執行速度,設定成Producer產生的速度沒有Consumer消耗的快

程式碼如下

import time
import threading
import random
class Producer(threading.Thread):
# 產生隨機數,將其加入整數列表
def __init__(self, lock, integer_list):
threading.Thread.__init__(self)
self.lock = lock
self.integer_list = integer_list
def run(self):
while True: # 一直嘗試獲得鎖來新增整數
random_integer = random.randint(0, 100)
with self.lock:
self.integer_list.append(random_integer)
print('integer list add integer {}'.format(random_integer))
time.sleep(1.2 * random.random()) # sleep隨機時間,通過乘1.2來減慢生產的速度
class Consumer(threading.Thread):
def __init__(self, lock, integer_list):
threading.Thread.__init__(self)
self.lock = lock
self.integer_list = integer_list
def run(self):
while True: # 一直嘗試去消耗整數
with self.lock:
if self.integer_list: # 只有列表中有元素才pop
integer = self.integer_list.pop()
print('integer list lose integer {}'.format(integer))
time.sleep(random.random())
else:
print('there is no integer in the list')
def main():
integer_list = []
lock = threading.Lock()
th1 = Producer(lock, integer_list)
th2 = Consumer(lock, integer_list)
th1.start()
th2.start()
if __name__ == '__main__':
main()
複製程式碼

程式會無休止地執行下去,一個產生,另一個消耗,擷取前面一部分執行結果如下

integer list add integer 100
integer list lose integer 100
there is no integer in the list
there is no integer in the list
... 幾百行一樣的 ...
there is no integer in the list
integer list add integer 81
integer list lose integer 81
there is no integer in the list
there is no integer in the list
there is no integer in the list
......
複製程式碼

我們可以看到,整數每次產生都會被迅速消耗掉,消費者沒有東西可以處理,但是依然不停地詢問是否有東西可以處理(while True),這樣不斷地詢問會比較浪費CPU等資源(特別是詢問之後不只是print而是加入計算等)。

如果可以在第一次查詢到列表為空的時候就開始等待,直到列表不為空(收到通知而不是一遍一遍地查詢),資源開銷就可以節省很多。Condition物件就可以解決這個問題,它與一般鎖的區別在於,除了可以acquire release,還多了兩個方法wait notify,下面我們來看一下上面過程如何用Condition來實現

import time
import threading
import random
class Producer(threading.Thread):
def __init__(self, condition, integer_list):
threading.Thread.__init__(self)
self.condition = condition
self.integer_list = integer_list
def run(self):
while True:
random_integer = random.randint(0, 100)
with self.condition:
self.integer_list.append(random_integer)
print('integer list add integer {}'.format(random_integer))
self.condition.notify()
time.sleep(1.2 * random.random())
class Consumer(threading.Thread):
def __init__(self, condition, integer_list):
threading.Thread.__init__(self)
self.condition = condition
self.integer_list = integer_list
def run(self):
while True:
with self.condition:
if self.integer_list:
integer = self.integer_list.pop()
print('integer list lose integer {}'.format(integer))
time.sleep(random.random())
else:
print('there is no integer in the list')
self.condition.wait()
def main():
integer_list = []
condition = threading.Condition()
th1 = Producer(condition, integer_list)
th2 = Consumer(condition, integer_list)
th1.start()
th2.start()
if __name__ == '__main__':
main()
複製程式碼

相比於LockCondition只有兩個變化

  • 在生產出整數時notify通知wait的執行緒可以繼續了
  • 消費者查詢到列表為空時呼叫wait等待通知(notify

這樣結果就井然有序

integer list add integer 7
integer list lose integer 7
there is no integer in the list
integer list add integer 98
integer list lose integer 98
there is no integer in the list
integer list add integer 84
integer list lose integer 84
.....
複製程式碼

生產者與消費者的相互等待

上面是最基本的使用,下面我們多實現一個功能:生產者一次產生三個數,在列表數量大於5的時候停止生產,小於4的時候再開始

import time
import threading
import random
class Producer(threading.Thread):
def __init__(self, condition, integer_list):
threading.Thread.__init__(self)
self.condition = condition
self.integer_list = integer_list
def run(self):
while True:
with self.condition:
if len(self.integer_list) > 5:
print('Producer start waiting')
self.condition.wait()
else:
for _ in range(3):
self.integer_list.append(random.randint(0, 100))
print('now {} after add '.format(self.integer_list))
self.condition.notify()
time.sleep(random.random())
class Consumer(threading.Thread):
def __init__(self, condition, integer_list):
threading.Thread.__init__(self)
self.condition = condition
self.integer_list = integer_list
def run(self):
while True:
with self.condition:
if self.integer_list:
integer = self.integer_list.pop()
print('all {} lose {}'.format(self.integer_list, integer))
time.sleep(random.random())
if len(self.integer_list) < 4:
self.condition.notify()
print("Producer don't need to wait")
else:
print('there is no integer in the list')
self.condition.wait()
def main():
integer_list = []
condition = threading.Condition()
th1 = Producer(condition, integer_list)
th2 = Consumer(condition, integer_list)
th1.start()
th2.start()
if __name__ == '__main__':
main()
複製程式碼

可以看下面的結果體會消長過程

now [33, 94, 68] after add
all [33, 94] lose 68
Producer don't need to wait
now [33, 94, 53, 4, 95] after add
all [33, 94, 53, 4] lose 95
all [33, 94, 53] lose 4
Producer don't need to wait
now [33, 94, 53, 27, 36, 42] after add
all [33, 94, 53, 27, 36] lose 42
all [33, 94, 53, 27] lose 36
all [33, 94, 53] lose 27
Producer don't need to wait
now [33, 94, 53, 79, 30, 22] after add
all [33, 94, 53, 79, 30] lose 22
all [33, 94, 53, 79] lose 30
now [33, 94, 53, 79, 60, 17, 34] after add
all [33, 94, 53, 79, 60, 17] lose 34
all [33, 94, 53, 79, 60] lose 17
now [33, 94, 53, 79, 60, 70, 76, 21] after add
all [33, 94, 53, 79, 60, 70, 76] lose 21
Producer start waiting
all [33, 94, 53, 79, 60, 70] lose 76
all [33, 94, 53, 79, 60] lose 70
all [33, 94, 53, 79] lose 60
all [33, 94, 53] lose 79
Producer don't need to wait
all [33, 94] lose 53
Producer don'
t need to wait
all [33] lose 94
Producer don't need to wait
all [] lose 33
Producer don'
t need to wait
there is no integer in the list
now [16, 67, 23] after add
all [16, 67] lose 23
Producer don't need to wait
now [16, 67, 49, 62, 50] after add
複製程式碼

Queue

queue模組內部實現了Condition,我們可以非常方便地使用生產者消費者模式

import time
import threading
import random
from queue import Queue
class Producer(threading.Thread):
def __init__(self, queue):
threading.Thread.__init__(self)
self.queue = queue
def run(self):
while True:
random_integer = random.randint(0, 100)
self.queue.put(random_integer)
print('add {}'.format(random_integer))
time.sleep(random.random())
class Consumer(threading.Thread):
def __init__(self, queue):
threading.Thread.__init__(self)
self.queue = queue
def run(self):
while True:
get_integer = self.queue.get()
print('lose {}'.format(get_integer))
time.sleep(random.random())
def main():
queue = Queue()
th1 = Producer(queue)
th2 = Consumer(queue)
th1.start()
th2.start()
if __name__ == '__main__':
main()
複製程式碼

Queue

  • get方法會移除並賦值(相當於list中的pop),但是它在佇列為空的時候會被阻塞(wait)
  • put方法是往裡面新增值
  • 如果想設定佇列最大長度,初始化時這樣做queue = Queue(10)指定最大長度,超過這個長度就會被阻塞(wait)

使用Queue,全程不需要顯式地呼叫鎖,非常簡單易用。不過內建的queue有一個缺點在於不是可迭代物件,不能對它迴圈也不能檢視其中的值,可以通過構造一個新的類來實現,詳見這裡

下面消防之前Condition方法,用Queue實現生產者一次加3個,消費者一次消耗1個,每次都返回當前佇列內容,改寫程式碼如下

import time
import threading
import random
from queue import Queue
# 為了能檢視佇列資料,繼承Queue定義一個類
class ListQueue(Queue):
def _init(self, maxsize):
self.maxsize = maxsize
self.queue = [] # 將資料儲存方式改為list
def _put(self, item):
self.queue.append(item)
def _get(self):
return self.queue.pop()
class Producer(threading.Thread):
def __init__(self, myqueue):
threading.Thread.__init__(self)
self.myqueue = myqueue
def run(self):
while True:
for _ in range(3): # 一個執行緒加入3個,注意:條件鎖時上在了put上而不是整個迴圈上
self.myqueue.put(random.randint(0, 100))
print('now {} after add '.format(self.myqueue.queue))
time.sleep(random.random())
class Consumer(threading.Thread):
def __init__(self, myqueue):
threading.Thread.__init__(self)
self.myqueue = myqueue
def run(self):
while True:
get_integer = self.myqueue.get()
print('lose {}'.format(get_integer), 'now total', self.myqueue.queue)
time.sleep(random.random())
def main():
queue = ListQueue(5)
th1 = Producer(queue)
th2 = Consumer(queue)
th1.start()
th2.start()
if __name__ == '__main__':
main()
複製程式碼

得到結果如下

now [79, 39, 64] after add
lose 64 now total [79, 39]
now [79, 39, 9, 42, 14] after add
lose 14 now total [79, 39, 9, 42]
lose 42 now total [79, 39, 9]
lose 27 now total [79, 39, 9, 78]
now [79, 39, 9, 78, 30] after add
lose 30 now total [79, 39, 9, 78]
lose 21 now total [79, 39, 9, 78]
lose 100 now total [79, 39, 9, 78]
now [79, 39, 9, 78, 90] after add
lose 90 now total [79, 39, 9, 78]
lose 72 now total [79, 39, 9, 78]
lose 5 now total [79, 39, 9, 78]
複製程式碼

上面限制佇列最大為5,有以下細節需要注意

  • 首先ListQueue類的構造:因為Queue類的原始碼中,put是呼叫了_putget呼叫_get_init也是一樣,所以我們重寫這三個方法就將資料儲存的型別和存取方式改變了。而其他部分鎖的設計都沒有變,也可以正常使用。改變之後我們就可以通過呼叫self.myqueue.queue來訪問這個列表資料
  • 輸出結果很怪異,並不是我們想要的。這是因為Queue類的原始碼中,如果佇列數量達到了maxsize,則put的操作wait,而put一次插入一個元素,所以經常插入一個等一次,迴圈無法一次執行完,而print是在插入三個之後才有的,所以很多時候其實加進去值了卻沒有在執行結果中顯示,所以結果看起來比較怪異。所以要想靈活使用還是要自己來定義鎖的位置,不能簡單依靠queue

另外,queue模組中有其他類,分別實現先進先出、先進後出、優先順序等佇列,還有一些異常等,可以參考這篇文章官網

執行緒安全

講到了Queue就提一提執行緒安全。執行緒安全其實就可以理解成執行緒同步。

官方定義是:指某個函式、函式庫在多執行緒環境中被呼叫時,能夠正確地處理多個執行緒之間的共享變數,使程式功能正確完成。

我們常常提到的說法是,某某某是執行緒安全的,比如queue.Queue是執行緒安全的,而list不是。

根本原因在於前者實現了鎖原語,而後者沒有。

原語指由若干個機器指令構成的完成某種特定功能的一段程式,具有不可分割性;即原語的執行必須是連續的,在執行過程中不允許被中斷。

queue.Queue是執行緒安全的,即指對他進行寫入和提取的操作不會被中斷而導致錯誤,這也是在實現生產者消費者模式時,使用List就要特意去加鎖,而用這個佇列就不用的原因。

Event

EventCondition的區別在於:Condition = Event + Lock,所以Event非常簡單,只是一個沒有帶鎖的Condition,也是滿足一定條件等待或者執行,這裡不想說很多,只舉一個簡單的例子來看一下

import threading
import time
class MyThread(threading.Thread):
def __init__(self, event):
threading.Thread.__init__(self)
self.event = event
def run(self):
print('first')
self.event.wait()
print('after wait')
event = threading.Event()
MyThread(event).start()
print('before set')
time.sleep(1)
event.set()
複製程式碼

可以看到結果

first
before set
複製程式碼

先出現,1s之後才出現

after wait
複製程式碼

歡迎關注我的知乎專欄

專欄主頁:python程式設計

專欄目錄:目錄

版本說明:軟體及包版本說明

相關文章