Python3 多線程編程

一、線程的基本概念

引入進程的目的,是爲了使多道程序併發執行,以提高資源利用率和系統吞吐量;而引入線程,則是爲了減小程序在併發執行時所付出的時空開銷,提高操作系統的併發性能。

線程最直接的理解就是“輕量級進程”,它是一個基本的CPU執行單元,也是程序執行流的最小單元,由線程ID、程序計數器、寄存器集合和堆棧組成。線程是進程中的一個實體,是被系統獨立調度和分派的基本單位,線程自己不擁有系統資源,只擁有一點在運行中必不可少的資源,但它可與同屬一個進程的其他線程共享進程所擁有的全部資源。一個線程可以創建和撤銷另一個線程,同一進程中的多個線程之間可以併發執行。由於線程之間的相互制約,致使線程在運行中呈現出間斷性。線程也有就緒、阻塞和運行三種基本狀態。

引入線程後,進程的內涵發生了改變,進程只作爲除CPU以外系統資源的分配單元,線程則作爲處理機的分配單元。

二、線程與進程的比較

  1. 調度。在傳統的操作系統中,擁有資源和獨立調度的基本單位都是進程。在引入線程的操作系統中,線程是獨立調度的基本單位,進程是資源擁有的基本單位。在同一進程中,線程的切換不會引起進程切換。在不同進程中進行線程切換,如從一個進程內的線程切換到另一個進程中的線程時,會引起進程切換。

  2. 擁有資源。不論是傳統操作系統還是設有線程的操作系統,進程都是擁有資源的基本單位,而線程不擁有系統資源(也有一點必不可少的資源),但線程可以訪問其隸屬進程的系統資源。

  3. 併發性。在引入線程的操作系統中,不僅進程之間可以併發執行,而且多個線程之間也可以併發執行,從而使操作系統具有更好的併發性,提高了系統的吞吐量。

  4. 系統開銷。由於創建或撤銷進程時,系統都要爲之分配或回收資源,如內存空間、 I/O設備等,因此操作系統所付出的開銷遠大於創建或撤銷線程時的開銷。類似地,在進行進程切換時,涉及當前執行進程CPU環境的保存及新調度到進程CPU環境的設置,而線程切換時只需保存和設置少量寄存器內容,開銷很小。此外,由於同一進程內的多個線程共享進程的地址空間,因此,這些線程之間的同步與通信非常容易實現,甚至無需操作系統的干預。

  5. 地址空間和其他資源(如打開的文件):進程的地址空間之間互相獨立,同一進程的各線程間共享進程的資源,某進程內的線程對於其他進程不可見。

  6. 通信方面:進程間通信(IPC)需要進程同步和互斥手段的輔助,以保證數據的一致性,而線程間可以直接讀/寫進程數據段(如全局變量)來進行通信。

三、線程的屬性

在多線程操作系統中,把線程作爲獨立運行(或調度)的基本單位,此時的進程,已不再是一個基本的可執行實體。但進程仍具有與執行相關的狀態,所謂進程處於“執行”狀態,實際上是指該進程中某線程正在執行。線程的主要屬性如下:

線程是一個輕型實體,它不擁有系統資源,但每個線程都應有一個唯一的標識符和一個線程控制塊,線程控制塊記錄了線程執行的寄存器和棧等現場狀態。

不同的線程可以執行相同的程序,即同一個服務程序被不同的用戶調用時,操作系統爲它們創建成不同的線程。

同一進程中的各個線程共享該進程所擁有的資源。

線程是處理機的獨立調度單位,多個線程是可以併發執行的。在單CPU的計算機系統中,各線程可交替地佔用CPU;在多CPU的計算機系統中,各線程可同時佔用不同的CPU,若各個CPU同時爲一個進程內的各線程服務則可縮短進程的處理時間。

—個線程被創建後便開始了它的生命週期,直至終止,線程在生命週期內會經歷阻塞態、就緒態和運行態等各種狀態變化。

四、多線程編程

Python 中使用多線程用的是標準庫提供了兩個模塊:_threadthreading

threading 是高級模塊,對 _thread 進行了封裝,所以我們在開發多線程的時候都在使用 threading 這個高級模塊

代碼示例:

# !/usr/bin/env python3
# -*- coding: UTF-8 -*-

from threading import Thread
from threading import current_thread


def func(x, y):
    print('Func 線程名稱:', current_thread().name)
    print(x + y)


print('Main 線程名稱:', current_thread().name)

t1 = Thread(target=func, args=(1, 2))
t2 = Thread(target=func, args=(3, 4))
t3 = Thread(target=func, name='FuncThread', args=(5, 6))

t1.start()
t2.start()
t3.start()

t1.join()
t2.join()
t3.join()

執行結果:

Main 線程名稱: MainThread
Func 線程名稱: Thread-1 
3
Func 線程名稱: Thread-2 
Func 線程名稱: FuncThread
7
11

代碼解讀:

和多進程類似,多線程也需要一個執行函數,參數也類似,target 函數名,args 參數。

因爲 CPU 的最小調度單位就是線程(進程作是資源分配的最小單位)。任何進程都會有一個線程在幹活,我們把該線程稱爲主線程,主線程又可以啓動新的線程稱之爲子線程。

Python 的 threading 模塊有個 current_thread() 函數,它返回當前線程的實例。主線程實例的名字叫 MainThread,如果子線程的名字未指定,那麼它默認名字是自動以 Thread-1、Thread-2 。。。 這樣的名字命名的。

如果我們在創建線程的時候指定名稱 name='FuncThread' 那麼它的名字就是你指定的名字。

start() 開始執行線程

join() 等待線程執行完成

五、線程鎖 Lock

上面我們對進程和線程做了區分,知道了線程是共享數據的。那如果多個線程同時操作一個變量,把數據改亂了怎麼辦?

我們先來看一下該亂的情況

代碼示例:

# !/usr/bin/env python3
# -*- coding: UTF-8 -*-

from threading import Thread


counter = 0


def handle(n):
    global counter
    counter = counter + n
    counter = counter - n


def run(n):
    for i in range(10**6):
        handle(n)


t1 = Thread(target=run, args=(3,))
t2 = Thread(target=run, args=(6,))
t3 = Thread(target=run, args=(9,))

t1.start()
t2.start()
t3.start()

t1.join()
t2.join()
t3.join()

print(counter)

執行結果:

27

代碼解析:

我們定義了一個全局計數器變量 counter,初始值爲0,並且啓動 3 個線程,先加後減,理論上結果應該爲0。

但是,由於線程的調度是由操作系統決定的,當 t1、t2、t3 交替執行時,只要循環次數足夠多,counter 的結果就不一定是0了。

因爲是併發執行的,等第一個線程還沒把數據回寫到 counter 中,另一個線程就把裏面的值改變了,這就是多線程使用的不方便之處。

於是,我們在使用多線程的時候就需要給執行函數加一把鎖,一把鎖對應一把鑰匙,然後讓線程們開搶,誰先搶到誰先操作。其他的線程怎麼辦?不好意思,接着搶,啥時候搶到啥時候算。。。

有了這樣的機制,就不怕多線程之間併發執行而導致數據安全了,因爲同一時刻最多隻有一個線程持有該鎖。

創建一個鎖就是通過 Lock 來實現

代碼示例:

# !/usr/bin/env python3
# -*- coding: UTF-8 -*-

from threading import Thread, Lock


counter = 0
lock = Lock()


def handle(n):
    global counter
    counter = counter + n
    counter = counter - n


def run(n):
    for i in range(10**6):
        lock.acquire()      # 加鎖
        handle(n)
        lock.release()      # 解鎖


t1 = Thread(target=run, args=(3,))
t2 = Thread(target=run, args=(6,))
t3 = Thread(target=run, args=(9,))

t1.start()
t2.start()
t3.start()

t1.join()
t2.join()
t3.join()

print(counter)

執行結果:

0

代碼解讀:

lock = Lock() 創建一個線程鎖(也可以叫做:互斥鎖)

lock.acquire() 在使用之前先獲取該鎖,別的線程只能癡癡等待,啥也幹不了

lock.release() 在執行完成一定要切記釋放鎖,否則那些苦逼等待鎖的線程將永遠等待下去,成爲死線程

鎖的好處就是確保了某段關鍵代碼只能由一個線程從頭到尾完整地執行,壞處當然也很多,首先是阻止了多線程併發執行,包含鎖的某段代碼實際上只能以單線程模式執行,效率就大大地下降了。其次,由於可以存在多個鎖,不同的線程持有不同的鎖,並試圖獲取對方持有的鎖時,可能會造成死鎖,導致多個線程全部掛起,既不能執行,也無法結束,只能靠操作系統強制終止。

六、GIL

GIL [Global Interperter Lock] 全局解釋器鎖簡稱

首先需要明確一點就是,GIL 並不是 Python 語言的特性,它是在現實 Python 解釋器時引用的一個概念。GIL 只在 CPython 解釋器上存在。作用是保證同一時間內只有一個線程在執行。

在 Python 中,無論你啓多少個線程,你有多少個 cpu, Python 在執行的時候會淡定的在同一時刻只允許一個線程運行。

線程互斥鎖和GIL的區別

  1. 線程鎖是代碼層面的鎖,解決 Python 程序中多線程共享資源的問題(線程數據共共享,當各個線程訪問數據資源時會出現競爭狀態,造成數據混亂)

  2. GIL 是解釋層面的鎖,解決解釋器中多個線程的競爭資源問題(多個子線程在系統資源競爭是,都在等待對象某個部分資源解除佔用狀態,結果誰也不願意先解鎖,然後互相等着,程序無法執行下去)。

GIL 存在的意義

因爲 Python 的線程是調用操作系統的原生線程,這個原生線程就是 C 語言寫的原生線程。因爲 Python 是用 C 寫的,啓動的時候就是調用的 C 語言的接口。因爲啓動的 C 語言的遠程線程,那它要調這個線程去執行任務就必須知道上下文,所以 Python 要去調 C 語言的接口的線程,必須要把這個上限問關係傳給 Python,那就變成了一個我在加減的時候要讓程序串行才能一次計算。就是先讓線程 1,再讓線程 2。。。

每個線程在執行的過程中,Python 解釋器是控制不了的,因爲是調的 C 語言的接口,超出了 Python 的控制範圍,Python 的控制範圍是隻在 Python 解釋器這一層,所以 Python 控制不了 C 接口,它只能等結果。所以它不能控制讓哪個線程先執行,因爲是一塊調用的,只要一執行,就是等結果,這個時候4個線程獨自執行,所以結果就不一定正確了。
 
有了 GIL,就可以在同一時間只有一個線程能夠工作。雖然這 4 個線程都啓動了,但是同一時間我只能讓一個線程拿到這個數據。其他的幾個都乾等。Python 啓動的 4 個線程確確實實落到了這 4 個 cpu 上,但是爲了避免出錯。

其實線程鎖完全可以替代 GIL,但是 Python 的後續功能模塊都是加在 GIL 基礎上的,所以無法更改或去掉 GIL。這個是 Python 的一個開發時候,設計的一個缺陷,是 Python 語言最大的 BUG。

如何避免 GIL 帶來的影響

就目前情況來看,只能用 多進程協程 改善,或者使用其他語言寫的 Python 解釋器,如:Jython,當然了,你也可以自己寫一個 Python 的解釋器。

七、線程池

對於任務數量不斷增加的程序,每有一個任務就生成一個線程,最終會導致線程數量的失控。作爲新任務,這個時候,就要爲這些新的鏈接生成新的線程,線程數量暴漲。在之後的運行中,線程數量還會不停的增加,完全無法控制。所以,對於任務數量不端增加的程序,固定線程數量的線程池是必要的。

如何實現線程池

  1. threadpool 模塊

  2. concurrent.futures 模塊

  3. 自己構建線程池

由於 threadpool 是一個很老的模塊了,所以不推薦使用。有些時候由於業務的特殊性需要自己構建線程池,並不適用於大衆,所以現在只介紹一種方式:concurrent.futures 模塊

concurrent.futures 是 Python3 的內置模塊,我們可以直接使用

代碼示例:

#! /usr/bin/env python
# -*- coding: utf-8 -*-

from concurrent.futures import ThreadPoolExecutor


def main(x):
    return x * x


thread = ThreadPoolExecutor(4)

# Submit
datas = []
for i in range(10):
    data = thread.submit(main, i)
    datas.append(data)

for i in datas:
    print(i.result())


# Map
data = thread.map(main, range(10))
print(list(data))

執行結果:

0
1
4
9
16
25
36
49
64
81
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

代碼解讀:

ThreadPoolExecutor 構造實例的時候,傳入 max_workers 參數來設置線程池中最多能同時運行的線程數目,也可以省略參數,創建一個具有 4 線程的線程池 thread = ThreadPoolExecutor(4) 

在提交任務的時候,有兩種方式,一種是 submit,另一種是 map,兩者的主要區別在於:map可以保證輸出的順序, submit輸出的順序是亂的

submit() 函數來提交線程需要執行的任務(函數名和參數)到線程池中,並返回該任務的句柄(類似於文件、畫圖),submit 不是阻塞的,而是立即返回

map() 函數只需要提交一次目標函數,目標函數的參數放在一個迭代器(列表,字典)裏就可以,map 是阻塞的

done() 方法判斷該任務是否結束

cancel() 方法可以取消提交的任務,如果任務已經在線程池中運行了,就取消不了

result() 可以獲取任務的返回值,這個方法是阻塞的
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章