python多線程同步實例分析

python多線程同步實例分析
進程之間通信與線程同步是一個歷久彌新的話題,對編程稍有了解應該都知道,但是細說又說不清。一方面除了工作中可能用的比較少,另一方面就是這些概念牽涉到的東西比較多,而且相對較深。網絡編程,服務端編程,併發應用等都會涉及到。其開發和調試過程都不直觀。由於同步通信機制的原理都是相通的,本文希通過望藉助python實例來將抽象概念具體化。

閱讀之前可以參考之前的一篇文章:python多線程與多進程及其區別,瞭解一下線程和進程的創建。

python多線程同步
python中提供兩個標準庫thread和threading用於對線程的支持,python3中已放棄對前者的支持,後者是一種更高層次封裝的線程庫,接下來均以後者爲例。

同步與互斥

相信多數學過操作系統的人,都被這兩個概念弄混過,什麼互斥是特殊的同步,同步是多線程或多進程協同完成某一任務過程中在一些關鍵節點上進行的協同的關係等等。

其實這兩個概念都是圍繞着一個協同關係來進行的,可以通過一個具體的例子來清晰的表達這兩個概念:

有兩個線程,分別叫做線程A和線程B,其中線程A用來寫一個變量,線程B讀取線程A寫的變量,而且線程A先寫變量,然後線程B才能讀這個變量,那麼線程A和B之間就是一種同步關係;

複製代碼
===== 同步關係 =====
Thread A:
write(share_data)
V(S) # 釋放資源

Thread B:
P(S) # 獲取資源
read(share_data)
複製代碼
如果又來一個線程C,也要寫這個變量,那麼線程A和C之間就是一種互斥關係,因爲同時只能由一個線程寫該變量;

複製代碼
===== 互斥關係 =====
Thread A:
Lock.acquire(); # 獲得鎖
write(share_data)
Lock.release() # 釋放鎖

Thread C:
Lock.acquire(); # 獲得鎖
write(share_data)
Lock.release() # 釋放鎖
複製代碼
線程同步

主線程和其創建的線程之間各自執行自己的代碼直到結束。接下來看一下python線程之間同步問題.

交替執行的線程安全嗎?

先來看一下下面的這個例子:

複製代碼
share_data = 0

def tstart(arg):

time.sleep(0.1)
global share_data
for i in xrange(1000):
    share_data += 1

if name == '__main__':

t1 = threading.Thread(target = tstart, args = ('',))
t2 = threading.Thread(target = tstart, args = ('',))
t1.start()
t2.start()
t1.join()
t2.join()
print 'share_data result:', share_data

複製代碼
上面這段代碼執行結果share_data多數情況下會小於2000,上一篇文章介紹過,python解釋器CPython中引入了一個全局解釋器鎖(GIL),也就是任一時刻都只有一個線程在執行,但是這裏還會出問題,爲什麼?

根本原因在於對share_data的寫不是原子操作,線程在寫的過程中被打斷,然後切換線程執行,回來時會繼續執行被打斷的寫操作,不過可能覆蓋掉這段時間另一個線程寫的結果。

下面是一種可能的運算過程:

實際計算過程可能比上面描述的更復雜,可以從單個線程的角度來理解,如果不加同步措施,對於單個線程而言其完全感知不到其他線程的存在,讀取數據、計算、寫回數據。

如果在讀取數據之後,計算過程或者寫回數據前被打斷,當再次執行時,即使內存中的share_data已經發生了變化,但是該進程還是會從中斷的地方繼續執行,並將計算結果覆蓋掉當前的share_data的值;

這就是爲什麼每一時刻只有一個線程在執行,但是結果還是錯的原因。可以想象如果多個線程並行執行,不加同步措施,那麼計算過程會更加混亂。

感興趣的話可以使用一個全局列表的res,記錄下每個線程寫share_data的過程,可以比較直觀的看到寫的過程:

View Code
下面是一種可能的結果,可以看到兩個線程對share_data的確進行了2000次加一操作,但是結果卻不是2000.

View Code
這就是多線程寫操作帶來的線程安全問題。具體來說這種線程同步屬於互斥關係。接下來看一下python提供的多線程同步措施。

threading模塊的給python線程提供了一些同步機制,具體用法可以參照官網上的文檔說明。

Lock:互斥鎖,只能有一個線程獲取,獲取該鎖的線程才能執行,否則阻塞;
RLock:遞歸鎖,也稱可重入鎖,已經獲得該鎖的線程可以繼續多次獲得該鎖,而不會被阻塞,釋放的次數必須和獲取的次數相同纔會真正釋放該鎖;
Condition:條件變量,使得一個線程等待另一個線程滿足特定條件,比如改變狀態或某個值。然後會主動通知另一個線程,並主動放棄鎖;
Semaphore:信號鎖。爲線程間共享的有限資源提供一個”計數器”,如果沒有可用資源則會被阻塞;
Event:事件鎖,任意數量的線程等待某個事件的發生,在該事件發生後所有線程被激活;
Timer:一種計時器(其用法比較簡單,不算同步機制暫不介紹)
互斥鎖Lock

其基本用法非常簡單:

創建鎖:Lock()
獲得鎖:acquire([blocking])
釋放鎖:release()
複製代碼
import threading
import time
lock = threading.Lock() # step 1: 創建互斥鎖
share_data = 0

def tstart(arg):

time.sleep(0.1)
global share_data
if lock.acquire():       # step 2: 獲取互斥鎖,否則阻塞當前線程
    share_data += 1
lock.release()          # step 3: 釋放互斥鎖

if name == '__main__':

tlst = list()
for i in xrange(10):
    t = threading.Thread(target=tstart, args=('',))
    tlst.append(t)
for t in tlst:
    t.start()
tlst[2].join()
print("This is main function at:%s" % time.time())
print 'share_data result:', share_data

複製代碼
結果:

This is main function at:1564909315.86
share_data result: 7
上面的share_data結果有一定的隨機性,因爲我們只等待第二個線程執行結束就直接讀取結果然後結束主線程了。

不過從上面這個結果我們可以推斷出,當第三個線程結束且主線程執行到輸出share_data的結果時,至少七個線程完成了對share_data的加1操作;

重入鎖RLock

由於當前線程獲得鎖之後,在釋放鎖之前有可能再次獲取鎖導致死鎖。python引入了重入鎖。

複製代碼
import threading
import time
rlock = threading.RLock() # step 1: 創建重入鎖
share_data = 0

def check_data():

global share_data
if rlock.acquire():
    if share_data > 10:
        share_data = 0
rlock.release()

def tstart(arg):

time.sleep(0.1)
global share_data
if rlock.acquire():       # step 2: 獲取重入鎖,否則阻塞當前線程
    check_data()
    share_data += 1
rlock.release()          # step 3: 釋放重入鎖

if name == '__main__':

t1 = threading.Thread(target = tstart, args = ('',))
t1.start()
t1.join()
print("This is main function at:%s" % time.time())
print 'share_data result:', share_data

複製代碼
這個例子如果使用互斥鎖,就會導致當前線程阻塞。

信號量Semaphore

信號量有一個初始值,表示當前可用的資源數,多線程執行過程中會動態的加減信號量,信號量某一時刻的值表示的是當前還可以增加的執行的線程的數量;

信號量有兩種操作:

acquire(即P操作)
release(即V操作)
執行V操作的線程不受限制,執行P操作的線程當資源不足時會被阻塞;

複製代碼
def get_wait_time():

return random.random()/5.0

資源數0

S = threading.Semaphore(0)
def consumer(name):

S.acquire()
time.sleep(get_wait_time())
print name

def producer(name):

# time.sleep(0.1)
time.sleep(get_wait_time())
print name
S.release()

if name == "__main__":

for i in xrange(5, 10):
    c = threading.Thread(target=consumer, args=("consumer:%s"%i, ))
    c.start()
for i in xrange(5):
    p = threading.Thread(target=producer, args=("producer:%s"%i, ))
    p.start()
time.sleep(2)

複製代碼
下面是一種可能的執行結果:

View Code
條件Condition

接下來看一下另一種同步機制條件Condition,該同步條件不是很直觀,爲了更好的查看其工作過程,先定義一些函數:

複製代碼
def get_time():

return time.strftime("%Y-%m-%d %H:%M:%S")

def show_start_info(tname):

print '%s start at: %s' %(tname, get_time())

def show_acquire_info(tname):

print '%s acquire at: %s' % (tname, time.time())

def show_add_once_res(tname):

print '%s add: %s at: %s' % (tname, share_data, time.time())

def show_end_info(tname):

print 'End %s with: %s at: %s' % (tname, share_data, time.time())

def show_wait_info(tname):

print '%s wait at: %s' % (tname, time.time())

複製代碼
條件變量可以使線程已經獲得鎖的情況下,在條件不滿足的時候可以主動的放棄鎖,通知喚醒其他阻塞的線程;基本工作過程如下:

創建一個全局條件變量對象;
每一個線程執行前先acquire條件變量,獲取則執行,否則阻塞。
當前執行線程推進過程中會判斷一些條件,如果條件不滿足則wait並主動釋放鎖,調用wait會使當前線程阻塞;
判斷條件滿足,進行一些處理改變條件後,當前線程通過notify方法通知並喚醒其他線程,其他處於wait狀態的線程接到通知後會重新判斷條件,若滿足其執行條件就執行。注意調用notify不會釋放鎖;
不斷的重複這一過程,直到任務完成。
這裏有一點不好理解,當前線程修改了條件之後,通過notify通知其他線程檢查其各自的執行條件是否滿足,但是條件變量持有的唯一的鎖被當前線程擁有而且沒有釋放,那麼其他線程怎麼執行?

Python文檔給出的說明如下:

Note: an awakened thread does not actually return from its wait() call until it can reacquire the lock. Since notify() does not release the lock, its caller should.

也就是說notify通知喚醒的線程不會從其wait函數返回,並繼續執行,而是直到其獲得了條件變量中的鎖。調用notify的線程應該主動釋放鎖,因爲notify函數不會釋放。

那這裏就會有一個問題,當前線程修改了其他線程執行的條件,通知其他線程後並主動調用wait釋放鎖掛起自己,如果其他線程執行的條件均不滿足,那所有線程均會阻塞;

下面通過兩個線程交替打印字符"A"和“B”來說明條件變量的使用:

複製代碼
share_data, max_len = '#', 6
cond = threading.Condition()

def addA(tname):

show_start_info(tname)
cond.acquire()
time.sleep(1)
show_acquire_info(tname)
global share_data
while len(share_data) <= max_len:
    if share_data[-1] != 'A':
        share_data += 'A'
        time.sleep(1)
        cond.notify()
        show_add_once_res(tname)
    else:
        # show_wait_info(tname)
        cond.wait()
cond.release()
show_end_info(tname)

def addB(tname):

show_start_info(tname)
cond.acquire()
time.sleep(1)
show_acquire_info(tname)
global share_data
while len(share_data) <= max_len:
    if share_data[-1] != 'B':
        share_data += 'B'
        time.sleep(1)
        cond.notify()
        show_add_once_res(tname)
    else:
        # show_wait_info(tname)
        cond.wait()
cond.release()
show_end_info(tname)

if name == "__main__":

t1 = threading.Thread(target=addA, args=("Thread 1", ))
t2 = threading.Thread(target=addB, args=("Thread 2", ))
t1.start()
t2.start()
t1.join()
t2.join()
print "share_data:", share_data

複製代碼
結果:

View Code
結果中可以看出雙線程執行的過程以及時間節點。

事件Event

最後再來看一種簡單粗暴的線程間同步方式:Event.

該方式的核心就是使用事件控制一個全局變量的狀態:True or False。線程執行過程中先判斷變量的值,爲True就執行,否則調用wait阻塞自己;

當全局變量的狀態被set爲True時,會喚醒所有調用wait而進入阻塞狀態的線程;需要暫停所有線程時,使用clear將全局變量設置爲False;

下面使用一個兩個玩家擲骰子,一個裁判判斷勝負,共三輪的遊戲來演示一下事件event的使用;

複製代碼
E = threading.Event()
E.clear()
res1, res2, cnt, lst = 0, 0, 3, ("player2", 'both', 'player1')

def show_round_res():

print ("Stop! judging... %s win!" % lst[cmp(res1, res2) + 1])

def judge():

global cnt 
while cnt > 0:
    print 'start game!'
    E.set()
    time.sleep(1)
    E.clear()
    show_round_res()
    time.sleep(1)
    cnt -= 1
print "game over by judge!"

def player1():

global res1
while cnt > 0:
    if E.is_set():
        res1 = random.randint(1, 6)
        print "player1 get %d" % res1
        time.sleep(1.5)
        E.wait()
print "player1 quit!"

def player2():

global res2
while cnt > 0:
    if E.is_set():
        res2 = random.randint(1, 6)
        print "player2 get %d" % res2
        time.sleep(1.5)
        E.wait()
print "player2 quit!"

if name == "__main__":

t1 = threading.Thread(target=judge, args=( ))
t2 = threading.Thread(target=player1, args=( ))
t3 = threading.Thread(target=player2, args=( ))
t1.start()
t2.start()
t3.start()
t1.join()
E.set()

複製代碼
有一點需要注意的是,線程調用wait時,只有當變量值爲False時纔會阻塞當前線程,如果全局變量是True,會立即返回;

下面是一種可能的結果:

View Code
總結
上面是爲了解決線程安全問題所採取的一些同步措施。Python爲進程同步提供了類似線程的同步措施,例如鎖、信號量等。

不同於線程間共享進程資源,進程擁有獨立的地址空間,不同進程內存空間是隔離的。

因此對於進程我們通常關注他們之間的通信方式,後續會有文章介紹進程之間的通信。

原文地址https://www.cnblogs.com/yssjun/p/11297900.html

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章