Python筆記二之多執行緒

發表於2023-12-12

本文首發於公眾號:Hunter後端

原文連結:Python筆記二之多執行緒

這一篇筆記介紹一下在 Python 中使用多執行緒。

注意:以下的操作都是在 Python 3.8 版本中試驗,不同版本可能有不同之處,需要注意。

本篇筆記目錄如下:

  1. 概念
  2. 多執行緒的使用示例
    daemon
    run()
  3. 執行緒物件的屬性和設定
  4. 執行緒模組相關函式

    1. threading.active_count()
    2. threading.current_thread()
    3. threading.enumerate()
  5. 執行緒的異常和函式結果獲取
  6. 執行緒池

    1. result()
    2. done()
    3. exception()
    4. cancel()
    5. running()
  7. 如何探索出最佳的執行緒池執行緒數量

1、概念

關於程式與執行緒的概念,這裡簡單介紹下。

一個程式是一個獨立的執行環境,包括程式碼、資料和系統資源等,每個程式都有自己的記憶體空間、檔案描述符、環境變數等。

而執行緒存在於程式中,共享程式內的記憶體和資源。

至於多程式與多執行緒,多程式可以充分利用計算機的多核 CPU,適用於 CPU 密集型的任務,,比如進行大量計算操作

而多執行緒則適用於涉及到大量的 IO 操作的任務,比如網路請求,檔案讀寫等,在 Python 中有一個 GIL 的概念,它的全稱是 Global Interpreter Lock,為全域性直譯器鎖。

GIL 的存在是為了使同一時刻只有一個執行緒在執行 Python 程式碼,保護直譯器的內部資料避免收到併發訪問的影響。

所以 Python 中的多執行緒操作實際上是在多個執行緒中進行切換,以此來實現想要的併發效果。

2、多執行緒的使用示例

前面介紹了 Python 中多執行緒的操作適用於 IO 密集型的任務,所以這裡以訪問某個介面為例介紹一下多執行緒的使用。

那個介面我們這裡用 Flask 建立一個伺服器,其內容如下:

# app/__init__.py

from flask import Flask
import time

def create_app():
    app = Flask(__name__)

    @app.route("/test/<int:delay>")
    def test(delay):
        time.sleep(delay)
        return str(time.time())

    return app 

這個介面透過 delay 引數可以指定介面的休眠時間返回,比如 /test/4,那麼介面響應時間大約會是 4 秒。

在 Python 中,用到多執行緒的模組是 threading 模組,以下是一個使用示例:

import threading
import time

import requests

def get_response(url):
    response = requests.get(url)
    print(response.content)

def test_multi_threading():
    url = "http://192.168.1.6:5000/test/2"
    threads = []

    for i in range(20):
        threads.append(threading.Thread(target=get_response, args=(url,)))

    for t in threads:
        t.start()

    for t in threads:
        t.join()

def test_single():
    url = "http://192.168.1.6:5000/test/2"

    for i in range(5):
        get_response(url)

if __name__ == "__main__":
    start_time = time.time()
    test_multi_threading()
    print("執行耗時:", time.time() - start_time)
    
    start_time = time.time()
    test_single()
    print("執行耗時:", time.time() - start_time)

在這裡我們可以比對單個執行緒執行五次,需要的時間大約是 10 秒,而使用多執行緒的方式雖然呼叫了 20 次介面,但是耗時大約只有 2 秒,這就是多執行緒在 IO 密集型的情況下的好處。

接下來具體介紹下多執行緒的使用方法:

def test_multi_threading():
    url = "http://192.168.1.6:5000/test/2"
    threads = []

    for i in range(20):
        threads.append(threading.Thread(target=get_response, args=(url,)))

    for t in threads:
        t.start()

    for t in threads:
        t.join()

在這裡,我們透過 threading.Thread() 的方式建立一個執行緒,然後透過 .start() 方法開始執行緒活動。

接著透過 join() 方法阻塞呼叫這個方法的執行緒,在這裡也就是主執行緒,等待 t 執行緒完成後再執行主執行緒後面的操作。

如果我們嘗試註釋掉 t.join() 這兩行,那麼主執行緒就會不等待 t 執行緒直接往後面執行,造成我們後面在主函式里計算的時間不準確。

daemon

可以根據這個引數設定執行緒是否為守護執行緒,所有執行緒建立的時候預設都不是守護執行緒,如果需要設定執行緒為守護執行緒,需要額外做設定。

守護執行緒是一種特殊型別的執行緒,生命週期受到主執行緒的影響,也就是說當主執行緒結束時,守護執行緒會被強制終止,它不會阻止主執行緒的正常執行,主執行緒也不會像其他執行緒呼叫了 join() 一樣被阻塞。

守護執行緒通常用於執行一些輔助性任務,比如日誌記錄、定時任務等,示例如下,我們開啟了一個守護執行緒用於定時 print() 某些資訊:

def print_info():
    while True:
        print("daemon threading, curr_time:", time.time())
        time.sleep(1)


def test_daemon_threading():
    base_url = "http://192.168.1.6:5000/test/"

    t1 = threading.Thread(target=get_response, args=(base_url + str(6),))
    t2 = threading.Thread(target=get_response, args=(base_url + str(2),))

    daemon_t = threading.Thread(target=print_info, args=(), daemon=True)

    t1.start()
    t2.start()
    daemon_t.start()

    t1.join()
    t2.join()

這樣,守護執行緒 daemon_t 就會在後臺一直迴圈列印資訊,直到主執行緒結束,守護執行緒也會被強制終止。

run()

run()start() 方法都和執行緒的執行有關。

start() 用於啟動執行緒,執行緒變數呼叫 start() 後,比如前面的 t.start(),會立即開始執行執行緒,且執行緒的執行與主執行緒並行進行。

run() 定義的是執行緒內的執行邏輯,是執行緒的入口點,表示的是執行緒活動的方法,執行緒開啟後就會呼叫 run() 方法,執行執行緒的任務。

在執行 start() 方法後,執行緒會自動呼叫 run() 方法,以此來執行執行緒內需要呼叫的函式,我們可以透過重寫 run() 方法來實現我們想要的定製化功能,比如在後面我們就是透過重寫 run() 方法來實現執行緒的異常資訊以及函式的結果返回的,

3、執行緒物件的屬性和設定

執行緒本身有一些屬性可以用於設定和獲取,我們先建立一條執行緒:

t1 = threading.Thread(target=get_response, args=(base_url + str(6),))

檢視執行緒名稱

執行緒名稱只是用於標記執行緒的,並無實際意義,根據使用者設定而定,比如前面建立了執行緒,預設名為 Thread-1,我們可以透過下面的兩個操作獲取,兩個操作是等效的:

t1.name
t1.getName()

設定執行緒名稱

設定執行緒名稱的方法如下:

t1.setName("test_thread")

判斷執行緒是否存活

在未進行 start() 操作前,不是存活狀態:

t1.is_alive()
# False

判斷執行緒是否是守護執行緒

t1.daemon
t1.isDaemon()
# False

設定執行緒為守護執行緒

將執行緒設定為守護執行緒:

t1.setDaemon(True)

True 為是,False 為否

4、執行緒模組相關函式

對於 threading 模組,有一些函式可以用於進行相關操作,比如當前存活的執行緒物件,異常處理等。

接下來先介紹這些函式及其功能,之後會用一個示例應用上這些函式

1. threading.active_count()

返回當前存活的 Thread 物件的數量

2. threading.current_thread()

返回當前對應呼叫者的執行緒

3. threading.enumerate()

列表形式返回當前所有存活的 Thread 物件

接下來我們修改 print_info() 函式,運用我們剛剛介紹的這幾種函式:

def print_info():
    while True:
        active_count = threading.active_count()
        print("當前存活的執行緒數量為:", active_count)
        for thread in threading.enumerate():
            print("存活的執行緒分別是:", thread.getName())
        print("當前所處的的執行緒名稱為:", threading.current_thread().getName())
        print("\n")
        time.sleep(1)

還是執行 test_daemon_threading() 就可以看到對應的輸出資訊。

5、執行緒的異常和函式結果獲取

Python 中使用 threading 模組建立的執行緒中的預設異常以及函式執行結果是不會被主執行緒捕獲的,因為執行緒是獨立執行的,我們可以透過定義全域性的變數,比如 dict 或者佇列來獲取對應的資訊。

這裡介紹一下透過改寫 run() 方法來實現我們的功能。

import threading
import traceback
import time
import request

def get_response(url):
    response = requests.get(url)
    if url.endswith("2"):
        1/0
    return time.time()

def print_info():
    while True:
        active_count = threading.active_count()
        print("當前存活的執行緒數量為:", active_count)
        for thread in threading.enumerate():
            print("存活的執行緒分別是:", thread.getName())
        print("當前所處的的執行緒名稱為:", threading.current_thread().getName())
        print("\n")
        time.sleep(1)

class MyThread(threading.Thread):
    def __init__(self, func, *args, **kwargs):
        super(MyThread, self).__init__()
        self.func = func
        self.args = args
        self.kwargs = kwargs
        self.result = None
        self.is_error = None
        self.trace_info = None

    def run(self):
        try:
            self.result = self.func(*self.args, **self.kwargs)
        except Exception as e:
            self.is_error = True
            self.trace_info = traceback.format_exc()

    def get_result(self):
        return self.result if self.is_error is not True else None


def test_get_exception_and_result():
    base_url = "http://192.168.1.6:5000/test/"

    t1 = MyThread(get_response, base_url + str(3))
    t2 = MyThread(get_response, base_url + str(2))

    daemon_t = MyThread(print_info)
    daemon_t.setDaemon(True)

    t1.start()
    t2.start()
    daemon_t.start()

    t1.join()
    t2.join()

    print(t1.get_result())
    print(t2.is_error)
    print(t2.trace_info)

if __name__ == "__main__":
    test_get_exception_and_result()

在這裡,我們呼叫 get_response 函式時,透過判斷 delay 的值,手動觸發了報錯,以及新增了一個 return 返回值,且透過 MyThread 這個重寫的 threading.Thread 來進行操作,獲取到執行緒執行是否有異常,以及異常資訊,以及函式返回的結果。

6、鎖

如果有時候多個執行緒需要訪問同一個全域性變數,可能會導致資料不一致的問題,我們使用執行緒裡的鎖來控制對相關資源的訪問,以此來確保執行緒安全,下面是一個示例:

import threading

counter = 0
lock_counter = 0
lock = threading.Lock()


def test_no_lock():
    global counter
    for i in range(1000000):
        counter += 1
        counter -= 1


def run_no_lock_thread():
    t1 = threading.Thread(target=test_no_lock)
    t2 = threading.Thread(target=test_no_lock)

    t1.start()
    t2.start()

    t1.join()
    t2.join()


def test_lock():
    global lock_counter
    for i in range(1000000):
        lock.acquire()
        lock_counter += 1
        lock_counter -= 1
        lock.release()


def run_lock_thread():
    t1 = threading.Thread(target=test_lock)
    t2 = threading.Thread(target=test_lock)

    t1.start()
    t2.start()

    t1.join()
    t2.join()


if __name__ == "__main__":
    print("before: ", counter)
    run_no_lock_thread()
    print("after: ", counter)

    print("before: ", lock_counter)
    run_lock_thread()
    print("after: ", lock_counter)

在上面的示例中,透過比對兩個加鎖和不加鎖的情況下全域性變數的值,可以發現,多執行幾次的話,可以看法 counter 的值並不總是為 0 的,而 lock_counter 的值的結果一直是 0。

我們透過這種加鎖的方式來保證 lock_counter 的值是安全的。

鎖的引入我們使用的是:

lock = threading.Lock()

獲取以及釋放的方法是:

lock.acquire()

lock.release()

在這裡對於 lock.acquire() 獲取鎖,有兩個引數,blockingtimeout

blocking 表示是否阻塞,預設為 True,表示如果鎖沒有被釋放,則會一直阻塞到鎖被其他執行緒釋放,為 False 的話,則表示不阻塞地獲取鎖,獲取到返回為 True,沒有獲取到返回為 False

lock.acquire()
# 返回為 True,表示獲取到鎖

lock.acquire()
lock.acquire(blocking=True)
# 這兩個操作都是阻塞獲取鎖,因為前一個操作已經獲取到鎖,所以這一步會被一直阻塞


is_lock = lock.acquire(blocking=False)
# 不阻塞的獲取鎖,如果拿到了鎖並加鎖,則返回為 True,否則返回為 False,表示沒有拿到鎖

還有一個引數為 timeout,表示 blockingTrue,也就是阻塞的時候,等待的秒數之後,超時沒有拿到鎖,返回為 False

release() 表示為鎖的釋放,沒有返回值,當前面獲取鎖之後,可以透過 lock.release() 的方式釋放鎖。

locked() 返回為布林型資料,判斷是否獲得了鎖。

7、執行緒池

我們可以透過執行緒池的方式來自動管理我們的執行緒,用到的模組是 concurrent.futures.ThreadPoolExecutor

以下是一個使用示例:

from concurrent.futures import ThreadPoolExecutor
import concurrent.futures


def get_response(url):
    return True


with ThreadPoolExecutor(max_workers=8) as executor:
    future_list = [executor.submit(get_response, base_url) for _ in range(20)]

    for future in concurrent.futures.as_completed(future_list):
        print(future.result()

在這裡,首先例項化一個執行緒池,然後輸入 max_workers 引數,表示執行緒池開啟的最大的執行緒數。

之後透過 submit() 方法向執行緒池提交兩個任務,並返回一個 Future 物件,我們可以透過這個 Future 物件獲取執行緒函式執行的各種情況,比如執行緒函式的返回結果,執行緒異常情況等。

在這裡有一個 concurrent.futures.as_completed() 輸入的是一個 Future 列表,會按照 任務完成的順序 逐個返回已經完成的 Future 物件,這個完成,可以是執行緒函式執行完成,也可以是出現異常的結果。

接下來介紹一下 Future 物件的幾個方法,在此之前,我們設定一下用於試驗的基本資料:

from concurrent.futures import ThreadPoolExecutor
import concurrent.futures
import requests
import time

def get_response(url):
    response = requests.get(url)
    if url.endswith("2"):
        1/0
    return time.time()

base_url = "http://192.168.1.6:5000/test/"
executor = ThreadPoolExecutor(max_workers=2)

future_1 = executor.submit(get_response, base_url + "3")
future_2 = executor.submit(get_response, base_url + "2")

其中,future_1 執行緒是正常執行,future_2 線上程裡執行報錯了。

1. result()

用於獲取執行緒執行的函式返回的結果,如果執行緒還未完成,那麼呼叫這個方法會阻塞,直到返回結果。

而如果執行緒裡函式執行異常了,呼叫 result() 方法會重新丟擲異常,希望程式正常執行的話,可以加上一個 try-except 操作,或者先透過後面的 exception()方法進行判斷。

我們呼叫 future_1.result() 可以正常返回,而 future_2.result() 會重新報異常。

2. done()

返回一個布林值,表示執行緒是否已經完成:

future_1.done() # True
future_2.done() # True

執行緒執行發生異常也屬於完成。

3. exception()

如果執行緒執行發生異常,可以用這個方法來獲取異常物件,如果沒有異常就會返回 None

future_2.exception()
# ZeroDivisionError('division by zero')

4. cancel()

嘗試取消執行緒的執行,如果執行緒還沒有開始執行,執行緒會被標記為取消狀態,如果執行緒已經在執行中或者執行完畢,則不會被取消:

future.cancel()

判斷一個執行緒是否已經被取消,使用方法 cancelled(),返回布林型資料

5. running()

判斷執行緒是否還在執行中,比如下面的操作:

future_3 = executor.submit(get_response, base_url + "65")
future_3.running()  # True

8、如何探索出最佳的執行緒池執行緒數量

對於執行緒池中執行緒的數量需要指定多少個,是一個需要探索的問題。

比如需要判斷我們的任務是否是 IO 密集型的,比如網路請求等,這種的話可以設定相對較高,但也並非無限高,因為等待的過程中,執行緒間的切換也是一部分開銷。

在執行真正的任務前,我們可以透過一小部分任務來進行效能測試,逐步調整執行緒池的執行緒數量,然後觀察伺服器的記憶體啊,CPU 利用率啊,以及整個操作的消耗時間等,來綜合判斷出比較適合的執行緒數量作為最終的結果。

如果想獲取更多後端相關文章,可掃碼關注閱讀:

相關文章