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

注意幾點:

  • acquirerelease時可以使用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編程

專欄目錄:目錄

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


推薦閱讀:

TAG:Python | Python開發 | 多線程 |