快速瞭解Python併發程式設計的工程實現(上)

GoT陽仔發表於2019-05-29

關於我
程式設計界的一名小程式猿,目前在一個創業團隊任team lead,技術棧涉及Android、Python、Java和Go,這個也是我們團隊的主要技術棧。

0x00 前言

前面的文章中Python協程的概念和實現做了簡單地介紹。為了對Python併發程式設計有更加全面地認識,我也對Python執行緒和程式的概念和相關技術的使用進行了學習,於是有了這篇文字。

0x01 執行緒與程式

當我們在手機或者PC上開啟一個應用時,作業系統就會建立一個程式例項,並開始執行程式裡的主執行緒,它有獨立的記憶體空間和資料結構。執行緒是輕量級的程式。在同一個程式裡,多個執行緒共享記憶體和資料結構,執行緒間的通訊也更加容易。

0x02 使用執行緒實現併發

熟悉Java程式設計的同學就會發現Python中的執行緒模型與Java非常類似。下文我們將主要使用Python中的執行緒模組threading包。(對於低階別的API模組thread不推薦初學者使用。本文所有程式碼將使用Python 3.7的環境)

threading

要使用執行緒我們要匯入threading包,這個包是在_thread包(即上文提到的低階別thread模組)的基礎上封裝了許多高階的API,在開發中應當首選threading包。

常見地,有兩種方式構建一個執行緒:通過Thread的建構函式傳遞一個callable物件,或繼承Thread類並重寫run方法。

import threading

import time


def do_in_thread(arg):
    print('do in thread {}'.format(arg))
    time.sleep(2)


if __name__ == '__main__':
    start_time = time.time()
    
    t1 = threading.Thread(target=do_in_thread, args=(1,), name='t1')
    t2 = threading.Thread(target=do_in_thread, args=(2,), name='t2')

    t1.start()
    t2.start()
    
    # join方法讓主執行緒等待子執行緒執行完畢
    t1.join()
    t2.join()
    print("\nduration {} ".format(time.time() - start_time))
    
# do in thread 1
# do in thread 2
# duration 2.001628875732422 

複製程式碼

還可以通過繼承threading.Thread類定義執行緒

import threading

import time


def do_in_thread(arg):
    print('do in thread {}'.format(arg))
    time.sleep(2)

    
class MyThread(threading.Thread):

    def __init__(self, arg):
        super().__init__()
        self.arg = arg

    def run(self):
        start_time = time.time()
        do_in_thread(self.arg)
        print("duration {} ".format(time.time() - start_time))


def start_thread_2():
    start_time = time.time()

    print("duration {} ".format(time.time() - start_time))


if __name__ == '__main__':
    mt1 = MyThread(3)
    mt2 = MyThread(4)

    mt1.start()
    mt2.start()
    
    # join方法讓主執行緒等待子執行緒執行完畢
    mt1.join()
    mt2.join() 
    
    
# do in thread 3
# do in thread 4
# duration 2.004937171936035 
複製程式碼

join方法的作用是讓呼叫它的執行緒等待其執行完畢。

class threading.Thread(group=None, target=None, name=None, args=(), kwargs={}, *, daemon=None)
複製程式碼

定義執行緒時可以通過指定構造方法的name引數設定執行緒名稱。
target用於指定callable物件,將在run方法中被呼叫。
args設定target物件被呼叫時的引數,型別是元組(),例如上文中的do_in_thread(arg)方法的引數。
kwargs是一個字典型別的引數,也用於target物件的引數。
daemon設定守護執行緒的標識,如果設定為True那麼這個執行緒就是守護執行緒,此時如果主執行緒結束了,那麼守護執行緒也會立即被殺死。所以當有在守護執行緒中開啟檔案、資料庫等資源操作時,釋放資源就有可能出錯。

執行緒池

程式中若有大量的執行緒建立和銷燬,則對效能影響較大。我們可以使用執行緒池。同樣地,它的APIJava極為相似。

Executor
concurrent.futures.Executor
複製程式碼

這是一個抽象類,定義了執行緒池的介面。

  • submit(fn, *args, **kwargs)
    執行fn(args,kwargs) 並會返回一個future物件,通過future可獲取到執行結果
  • map(func, *iterables, timeout=None, chunksize=1)
    這個方法與map(func,*iterables)類似
  • shutdown(wait=True)
    關閉執行緒池
from concurrent.futures import ThreadPoolExecutor
# 使用max_workers引數指定執行緒池中執行緒的最大數量為2
with ThreadPoolExecutor(max_workers=2) as executor:
    # 提交任務到執行緒池
    future = executor.submit(pow, 2, 31) # 計算2^31
    future2 = executor.submit(pow, 1024, 2)
    # 使用future 獲取執行結果
    print(future.result())
    print(future2.result())

# 執行結果
# 2147483648
# 1048576
複製程式碼
同步

若有多個執行緒對同一個資源或記憶體進行訪問或操作就有會產生競爭條件。
Python提供了鎖、訊號量、條件和事件等同步原語可幫忙我們實現執行緒的同步機制。

Lock

Lock有兩種狀態:lockedunlocked。它有兩個基本方法:acquire()release(),且都是原子操作的。
一個執行緒通過acquire()獲取到鎖,Lock的狀態變成locked,而其它執行緒呼叫acquire()時只能等待鎖被釋放。 當執行緒呼叫了release()Lock的狀態就變成了unlocked,此時其它等待執行緒中只有一個執行緒將獲得鎖。

import threading

share_mem_lock = 0
share_mem = 0
count = 1000000

locker = threading.Lock()


def add_in_thread_with_lock():
    global share_mem_lock
    for i in range(count):
        locker.acquire()
        share_mem_lock += 1
        locker.release()


def minus_in_thread_with_lock():
    global share_mem_lock
    for i in range(count):
        locker.acquire()
        share_mem_lock -= 1
        locker.release()


def add_in_thread():
    global share_mem

    for i in range(count):
        share_mem += 1


def minus_in_thread():
    global share_mem

    for i in range(count):
        share_mem -= 1


if __name__ == '__main__':
    t1 = threading.Thread(target=add_in_thread_with_lock)
    t2 = threading.Thread(target=minus_in_thread_with_lock)

    t3 = threading.Thread(target=add_in_thread)
    t4 = threading.Thread(target=minus_in_thread)

    t1.start()
    t2.start()

    t3.start()
    t4.start()

    t1.join()
    t2.join()

    t3.join()
    t4.join()

    print("share_mem_lock : ", share_mem_lock)
    print("share_mem : ", share_mem)

# 執行結果
# share_mem_lock :  0
# share_mem :  51306
複製程式碼

沒有使用鎖機制的程式碼執行後最後的值很有可能就不為0。而有鎖的程式碼則可以保證同步。

RLock

RLockReentrant Lock,就是可以重複進入的鎖,也叫遞迴鎖。它有3個特點:

  • 誰獲取鎖誰釋放。如A執行緒獲取了鎖,那麼只有A執行緒才能釋放該鎖
  • 同一執行緒可重複多次獲取該鎖。即可以呼叫acquire多次
  • acquire多少次,對應release就多少次,且最後一次release才會釋放鎖。
Condition

條件是另一種同步原語機制。其實它的內部是封裝了RLock,它的acquire()release()方法就是RLock的方法。
Condition常用的API還有wait()notify()notify_all()方法。 wait()方法會釋放鎖,然後進入阻塞狀態直到其它執行緒通過notify()notify_all()喚醒自己。wait()方法重新獲取到鎖就會返回。
notify()會喚醒其中一個等待的執行緒,而notify_all()會喚醒所有等待的執行緒。
需要注意的是notify()notify_all()執行後並不會釋放鎖,只有呼叫了release()方法後鎖才會釋放。
讓我們看一個來自於《Python並行程式設計手冊》中的一個生產者與消費者例子

from threading import Thread, Condition
import time

items = []
condition = Condition()


class consumer(Thread):

    def __init__(self):
        Thread.__init__(self)

    def consume(self):
        global condition
        global items
        # 獲取鎖
        condition.acquire()
        if len(items) == 0:
            # 當items為空時,釋放了鎖,並等待生產者notify
            condition.wait()
            print("Consumer notify : no item to consume")
        # 開始消費
        items.pop()
        print("Consumer notify : consumed 1 item")
        print("Consumer notify : items to consume are " + str(len(items)))
        # 消費之後notify喚醒生產者,因為notify不會釋放鎖,所以還要呼叫release釋放鎖
        condition.notify()
        condition.release()

    def run(self):
        for i in range(0, 10):
            time.sleep(2)
            self.consume()


class producer(Thread):

    def __init__(self):
        Thread.__init__(self)

    def produce(self):
        global condition
        global items
        condition.acquire()
        if len(items) == 5:
            # 若items時滿的,則執行wait,釋放鎖,並等待消費者notify
            condition.wait()
            print("Producer notify : items producted are " + str(len(items)))
            print("Producer notify : stop the production!!")
        # 開始生產
        items.append(1)
        print("Producer notify : total items producted " + str(len(items)))
        # 生產後notify消費者,因為notify不會釋放鎖,所以還執行了release釋放鎖。
        condition.notify()
        condition.release()

    def run(self):
        for i in range(0, 10):
            time.sleep(1)
            self.produce()


if __name__ == "__main__":
    producer = producer()
    consumer = consumer()
    producer.start()
    consumer.start()
    producer.join()
    consumer.join()

複製程式碼
Semaphore

訊號量內部維護一個計數器。acquire()會減少這個計數,release()會增加這個計數,這個計數器永遠不會小於0。當計數器等於0時,acquire()方法就會等待其它執行緒呼叫release()
還是藉助一個生產者與消費者的例子來理解

# -*- coding: utf-8 -*-

"""Using a Semaphore to synchronize threads"""
import threading
import time
import random

# 預設情況內部計數為1,這裡設定為0。
# 若設定為負數則會丟擲ValueError
semaphore = threading.Semaphore(0)


def consumer():
    print("consumer is waiting.")
    # 獲取一個訊號量,因為初始化時內部計數設定為0,所以這裡一開始時是處於等待狀態
    semaphore.acquire()
    # 開始消費
    print("Consumer notify : consumed item number %s " % item)


def producer():
    global item
    time.sleep(2)
    # create a random item
    item = random.randint(0, 1000)
    # 開始生產
    print("producer notify : produced item number %s" % item)
    # 釋放訊號量, 內部計數器+1。當有等待的執行緒發現計數器大於0時,就會喚醒並從acquire方法中返回
    semaphore.release()


if __name__ == '__main__':
    for i in range(0, 5):
        t1 = threading.Thread(target=producer)
        t2 = threading.Thread(target=consumer)
        t1.start()
        t2.start()
        t1.join()
        t2.join()
    print("program terminated")

複製程式碼

訊號量經常會用於資源容量確定的場景,比如資料庫連線池等。

Event

事件線上程間的通訊方式非常簡單。一個執行緒傳送事件另一個執行緒等待接收。
Event物件內部維護了一個bool變數flag。通過set()方法設定該變數為Trueclear()方法設定flagFalsewait()方法會一直等待直到flag變成True

結合例子

# -*- coding: utf-8 -*-

import time
from threading import Thread, Event
import random

items = []
event = Event()

class consumer(Thread):
    def __init__(self, items, event):
        Thread.__init__(self)
        self.items = items
        self.event = event

    def run(self):
        while True:
            time.sleep(2)
            # 等待事件
            self.event.wait()
            # 開始消費
            item = self.items.pop()
            print('Consumer notify : %d popped from list by %s' % (item, self.name))


class producer(Thread):
    def __init__(self, integers, event):
        Thread.__init__(self)
        self.items = items
        self.event = event

    def run(self):
        global item
        while True:
            time.sleep(2)
            # 開始生產
            item = random.randint(0, 256)
            self.items.append(item)
            print('Producer notify : item N° %d appended to list by %s' % (item, self.name))
            print('Producer notify : event set by %s' % self.name)
            # 傳送事件通知消費者消費
            self.event.set()
            print('Produce notify : event cleared by %s ' % self.name)
            # 設定事件內部變數為False,隨後消費者執行緒呼叫wait()方法時,進入阻塞狀態
            self.event.clear()


if __name__ == '__main__':
    t1 = producer(items, event)
    t2 = consumer(items, event)
    t1.start()
    t2.start()
    t1.join()
    t2.join()

複製程式碼
Timer

定時器TimerThread的子類。用於處理定時執行的任務。啟動定時器使用start(),取消定時器使用cancel()

from threading import Timer

def hello():
    print("hello, world")

t = Timer(3.0, hello)
t.start()  # 3秒後 列印 "hello, world"
複製程式碼
with語法

LockRLockConditionSemaphore可以使用with語法。 這幾個物件都實現擁有acquire()release()方法,且都實現了上下文管理協議。

with some_lock:
    # do something...
複製程式碼

等價於

some_lock.acquire()
try:
    # do something...
finally:
    some_lock.release()
複製程式碼

0x03 小結

本文主要介紹Python中執行緒的使用,主要是對threading模組中Thread物件、執行緒池Executor常見用法的展示。還了解了執行緒的同步原語LockRLockConditionSemaphoreEvent以及TimerAPI的使用。

0x04 引用

相關文章