python線程同步機制
所謂的線程同步機制其實就是鎖的使用,本文從引入鎖的原因開始講起,內容覆蓋比較簡單的鎖,難一點的下一篇文章講。
本文分為如下幾個部分
- 線程鎖(線程同步、互斥鎖)
- GIL鎖
- 死鎖
- RLock(遞歸鎖、可重入鎖)
線程鎖(線程同步、互斥鎖)
在多進程中,每一個進程都拷貝了一份數據,而多線程的各個線程則共享相同的數據。這使多線程佔用的資源更少,但是資源混用會導致一些錯誤,我們來看下面這個例子
import threadingimport timezero = 0def change_zero(): global zero for i in range(1000000): zero = zero + 1 zero = zero - 1th1 = threading.Thread(target = change_zero)th2 = threading.Thread(target = change_zero)th1.start()th2.start()th1.join()th2.join()print(zero)
change_zero
函數會將zero
變數加1再減1,按理說無論運行多少次,zero
變數都應該是0,但是上面代碼運行多次,總會出現不是0的情況(如果循環改為運行10000000次,則很難出現結果是0的情況了),說明不同線程一起修改zero
變數出現了錯亂,下面我們來看一下錯亂的起因是什麼樣的。(參考這裡)
zero = zero + 1
在python中會先產生一個中間變數,比如x1 = zero + 1
,然後再zero = x1
。
在不使用多線程時,運行兩次change_zero
函數是這樣的
初始:zero = 0th1: x1 = zero + 1 # x1 = 1th1: zero = x1 # zero = 1th1: x1 = zero - 1 # x1 = 0th1: zero = x1 # zero = 0th2: x2 = zero + 1 # x2 = 1th2: zero = x2 # zero = 1th2: x2 = zero - 1 # x2 = 0th2: zero = x2 # zero = 0結果:zero = 0
使用多線程,可能出現這樣的交叉影響
初始:zero = 0th1: x1 = zero + 1 # x1 = 1th2: x2 = zero + 1 # x2 = 1th2: zero = x2 # zero = 1th1: zero = x1 # zero = 1 問題出在這裡,兩次賦值,本來應該加2變成了加1th1: x1 = zero - 1 # x1 = 0th1: zero = x1 # zero = 0th2: x2 = zero - 1 # x2 = -1th2: zero = x2 # zero = -1結果:zero = -1
當循環次數非常多的時候就難免出現這樣的亂象,從而導致結果的錯誤。threading模塊提供了解決方法:線程鎖。
創建出一個鎖,在change_zero
函數中首先要獲得鎖才能繼續運行,最後釋放鎖。同一個鎖同一時間只能被一個線程使用,所以當一個線程使用鎖時,其他線程只能等著,等到鎖被釋放才能獲得鎖進行計算。代碼改為
import threadingimport timezero = 0lock = threading.Lock()def change_zero(): global zero for i in range(1000000): lock.acquire() zero = zero + 1 zero = zero - 1 lock.release()th1 = threading.Thread(target = change_zero)th2 = threading.Thread(target = change_zero)th1.start()th2.start()th1.join()th2.join()print(zero)
這樣做返回的結果每次都是0
注意幾點:
acquire
和release
時可以使用try finally
模式,保證鎖一定被釋放,否則可能有一些線程一直等著鎖卻等不到- 使用線程鎖雖然能解決變數混用造成的錯誤,但是也降低了運行效率,因為一個線程使用鎖運行時其他線程無法一起運行
- 一般不要用
acquire release
包含整個運行函數部分,而是只包含可能導致錯誤的那一步即可,否則就和使用單線程沒有區別了 - 使用多個鎖時可能兩個線程各持有一個鎖,並試圖獲得對方的鎖,這會造成死鎖,所有線程都耗在這裡了,需要人為終止
- 這個現象出現的情況:比如用多個線程讀寫同一份文件,要在讀寫整個過程前後加一個鎖保證讀寫過程不被影響
另外,lock是有上下文管理形式的,上面的代碼可以改寫為
import threadingimport timezero = 0lock = threading.Lock()def change_zero(): global zero for i in range(1000000): with lock: zero = zero + 1 zero = zero - 1th1 = threading.Thread(target = change_zero)th2 = threading.Thread(target = change_zero)th1.start()th2.start()th1.join()th2.join()print(zero)
最後解釋兩個常見的概念(來自百度百科)
- 線程同步:這裡的同步指按預定的先後次序進行運行,「同」字應是指協同、協助、互相配合。所謂同步,就是在發出一個功能調用時,在沒有得到結果之前,該調用就不返回,同時其它線程也不能調用這個方法。與非同步相對。
- 互斥鎖:防止兩條線程同時對同一公共資源(比如全局變數)進行讀寫的機制
GIL鎖
GIL鎖全稱為全局解釋鎖(Global Interpreter Lock),任何python線程在執行之前都需要先獲得GIL鎖,然後每執行一部分代碼,解釋器就會自動釋放GIL鎖,其他線程就可以競爭這個鎖,只有得到才能執行程序。
GIL鎖是很多人說python多線程雞肋的原因,但這其實是CPython解釋器存在的問題(換個解釋器可能就沒有GIL鎖了,不過當前絕大多數python程序都是用CPython解釋器)。
首先要聲明,GIL鎖隻影響CPU密集型程序的運行效率。對於IO密集型或者網頁請求這種程序,多線程的效率還是很高的,因為它們主要消耗的時間在於等待。
對於CPU密集型程序,GIL鎖的影響在於,有了它的存在,開啟多線程無法利用多核優勢,也就是只能用到一個核CPU來運行代碼,要想用到多個核只能開啟多進程(或者使用不帶有GIL鎖的解釋器)。鎖的存在使得一個時間只有一個線程在進行計算,所以即使開啟多線程,也無法同時運算,而只是線性地運算。因此有時開啟多線程運行CPU密集型程序,反而會降低運行效率,因為線程之間的切換與爭奪鎖會消耗更多的時間。
死鎖
鎖利用不當可能造成死鎖,我們來看下面一個例子
import threadingimport timelock1 = threading.Lock()lock2 = threading.Lock()class MyThread(threading.Thread): def print1(self): lock1.acquire() # 獲得第一個鎖 print(print1 first + threading.current_thread().name) time.sleep(1) lock2.acquire() # 未釋放第一個鎖就請求第二個鎖 print(print1 second + threading.current_thread().name) lock2.release() lock1.release() def print2(self): lock2.acquire() # 獲得第二個鎖 print(print2 first + threading.current_thread().name) time.sleep(1) lock1.acquire() # 未釋放第二個鎖就請求第一個鎖 print(print2 second + threading.current_thread().name) lock1.release() lock2.release() def run(self): self.print1() self.print2()th1 = MyThread()th2 = MyThread()th1.start()th1.join()th2.start()th2.join()print(finish)
上面的代碼是一個線程運行結束開始下一個線程,所以運行是不會有問題的,會輸出結果
print1 first Thread-1print1 second Thread-1print2 first Thread-1print2 second Thread-1print1 first Thread-2print1 second Thread-2print2 first Thread-2print2 second Thread-2finish
將start join
那部分改為
th1.start()th2.start()th1.join()th2.join()
即兩個線程會同時開啟,程序列印出
print1 first Thread-1print1 second Thread-1print2 first Thread-1print1 first Thread-2
就會停止,陷入死鎖狀態,程序永遠無法運行結束,根本原因在於:一個線程持有鎖1同時在請求鎖2,另一個線程持有鎖2同時在請求鎖1,二者不得到對方的鎖都不會放開自己的鎖,程序就這樣僵持下去了。
下面來分析一下細節
- 第一個線程先執行
print1
,獲得了鎖1,等待1秒。這時第二個線程已經開啟,企圖獲得鎖1,但是獲取不到於是等待 - 第一個線程等待時間結束,獲得鎖2,列印結束釋放兩把鎖。之後馬上開始執行
print2
,並獲得鎖2,等待1秒 - 這時第二個線程可以獲得鎖1了,開始執行
print1
,也等待1秒 - 等待時間結束,第一個線程持有鎖2企圖獲得鎖1,第一個線程持有鎖1企圖獲得鎖2,就陷入了僵局
(其實這個例子可以更簡化一點,直接兩個線程分別運行print1 print2
即可)
我們在寫多線程程序的時候,要注意避免死鎖的發生。
RLock(遞歸鎖、可重入鎖)
上面我們初始化鎖使用threading.Lock
,這是一種最低級的線程同步指令。
現在來試一試另一種threading.RLock
,它與Lock
的不同在於
- 同一個線程可以對
RLock
請求多次,而Lock
只能一次,不過請求多次時,acquire
的次數必須和release
次數相同 Lock
被一個線程acquire
後,可以由另一個線程release
,而RLock
必須是本線程
我們來看下面例子
import threadingimport timelock = threading.RLock()def myprint(): print(start) lock.acquire() lock.acquire() print(try rlock) lock.release() lock.release()myprint()
這個代碼會如期列印出
starttry rlock
如果我們用lock = threading.Lock()
,則自動構成死鎖,因為Lock
只能被請求一次,所以第二次會一直等待下去。
RLock
有什麼用呢?
下面是stackoverflow上的一個例子
編寫三個函數,他們之間有嵌套關係
def f(): g() h()def g(): h() do_something1()def h(): do_something2()
現在想給這些函數上鎖,用RLock
可以這樣寫
import threadinglock = threading.RLock()def f(): with lock: g() h()def g(): with lock: h() do_something1()def h(): with lock: do_something2()
但是Lock
就需要這樣寫
import threadinglock = threading.Lock()def f(): with lock: _g() _h()def g(): with lock: _g()def _g(): _h() do_something1()def h(): with lock: _h()def _h(): do_something2()
因為用Lock
時,調用的f
是上鎖的,裡面的g
不能還是上鎖的,就要多定義出一個_g
來表示未上鎖的函數。這就是可以被同一個線程獲得多次鎖的好處。
專欄信息
專欄主頁:python編程
專欄目錄:目錄
版本說明:軟體及包版本說明
推薦閱讀: