Python併發程式設計之談談執行緒中的“鎖機制”(三)

Python程式設計時光發表於2019-03-04

大家好,併發程式設計 進入第三篇。

今天我們來講講,執行緒裡的鎖機制


本文目錄

  • 何為Lock( 鎖 )?
  • 如何使用Lock( 鎖 )?
  • 為何要使用鎖?
  • 可重入鎖(RLock)
  • 防止死鎖的加鎖機制
  • 飽受爭議的GIL(全域性鎖)


. 何為Lock( 鎖 )?

何為 Lock( 鎖 ),在網上找了很久,也沒有找到合適的定義。可能 這個詞已經足夠直白了,不需要再解釋了。

但是,對於新手來說,我還是要說下我的理解。

我自己想了個生活中例子來看下。

有一個奇葩的房東,他家裡有兩個房間想要出租。這個房東很摳門,家裡有兩個房間,但卻只有一把鎖,不想另外花錢是去買另一把鎖,也不讓租客自己加鎖。這樣租客只有,先租到的那個人才能分配到鎖。X先生,率先租到了房子,並且拿到了鎖。而後來者Y先生,由於鎖已經已經被X取走了,自己拿不到鎖,也不能自己加鎖,Y就不願意了。也就不租了,換作其他人也一樣,沒有人會租第二個房間,直到X先生退租,把鎖還給房東,可以讓其他房客來取。第二間房間才能租出去。

換句話說,就是房東同時只能出租一個房間,一但有人租了一個房間,拿走了唯一的鎖,就沒有人再在租另一間房了。

回到我們的執行緒中來,有兩個執行緒A和B,A和B裡的程式都加了同一個鎖物件,當執行緒A率先執行到lock.acquire()(拿到全域性唯一的鎖後),執行緒B只能等到執行緒A釋放鎖lock.release()後(歸還鎖)才能執行lock.acquire()(拿到全域性唯一的鎖)並執行後面的程式碼。

這個例子,是不是讓你清楚了什麼是鎖呢?


. 如何使用Lock( 鎖 )?

來簡單看下程式碼,學習如何加鎖,獲取鑰匙,釋放鎖。

import threading

# 生成鎖物件,全域性唯一
lock = threading.Lock()

# 獲取鎖。未獲取到會阻塞程式,直到獲取到鎖才會往下執行
lock.acquire()

# 釋放鎖,歸回倘,其他人可以拿去用了
lock.release()
複製程式碼

需要注意的是,lock.acquire() 和 lock.release()必須成對出現。否則就有可能造成死鎖。

很多時候,我們雖然知道,他們必須成對出現,但是還是難免會有忘記的時候。
為了,規避這個問題。我推薦使用使用上下文管理器來加鎖。

import threading

lock = threading.Lock()
with lock:
    # 這裡寫自己的程式碼
    pass
複製程式碼

with 語句會在這個程式碼塊執行前自動獲取鎖,在執行結束後自動釋放鎖。


. 為何要使用鎖?

你現在肯定還是一臉懵逼,這麼麻煩,我不用鎖不行嗎?有的時候還真不行。

那麼為了說明鎖存在的意義。我們分別來看下,不用鎖的情形有怎樣的問題。

定義兩個函式,分別在兩個執行緒中執行。這兩個函式 共用 一個變數 n

def job1():
    global n
    for i in range(10):
        n+=1
        print('job1',n)

def job2():
    global n
    for i in range(10):
        n+=10
        print('job2',n)

n=0
t1=threading.Thread(target=job1)
t2=threading.Thread(target=job2)
t1.start()
t2.start()
複製程式碼

看程式碼貌似沒什麼問題,執行下看看輸出

job1 1
job1 2
job1 job2 13
job2 23
job2 333
job1 34
job1 35
job2
job1 45 46
job2 56
job1 57
job2
job1 67
job2 68 78
job1 79
job2
job1 89
job2 90 100
job2 110
複製程式碼

是不是很亂?完全不是我們預想的那樣。

解釋下這是為什麼?因為兩個執行緒共用一個全域性變數,又由於兩執行緒是交替執行的,當job1 執行三次 +1 操作時,job2就不管三七二十一 給n做了+10操作。兩個執行緒之間,執行完全沒有規矩,沒有約束。所以會看到輸出當然也很亂。

加了鎖後,這個問題也就解決,來看看

def job1():
    global n, lock
    # 獲取鎖
    lock.acquire()
    for i in range(10):
        n += 1
        print('job1', n)
    lock.release()


def job2():
    global n, lock
    # 獲取鎖
    lock.acquire()
    for i in range(10):
        n += 10
        print('job2', n)
    lock.release()

n = 0
# 生成鎖物件
lock = threading.Lock()

t1 = threading.Thread(target=job1)
t2 = threading.Thread(target=job2)
t1.start()
t2.start()
複製程式碼

由於job1的執行緒,率先拿到了鎖,所以在for迴圈中,沒有人有許可權對n進行操作。當job1執行完畢釋放鎖後,job2這才拿到了鎖,開始自己的for迴圈。

看看執行結果,真如我們預想的那樣。

job1 1
job1 2
job1 3
job1 4
job1 5
job1 6
job1 7
job1 8
job1 9
job1 10
job2 20
job2 30
job2 40
job2 50
job2 60
job2 70
job2 80
job2 90
job2 100
job2 110
複製程式碼

這裡,你應該也知道了,加鎖是為了對鎖內資源(變數)進行鎖定,避免其他執行緒篡改已被鎖定的資源,以達到我們預期的效果。

為了避免大家忘記釋放鎖,後面的例子,我將都使用with上下文管理器來加鎖。大家注意一下。


. 可重入鎖(RLock)

有時候在同一個執行緒中,我們可能會多次請求同一資源(就是,獲取同一鎖鑰匙),俗稱鎖巢狀。

如果還是按照常規的做法,會造成死鎖的。比如,下面這段程式碼,你可以試著執行一下。會發現並沒有輸出結果。

import threading

def main():
    n = 0
    lock = threading.Lock()
    with lock:
        for i in range(10):
            n += 1
            with lock:
                print(n)

t1 = threading.Thread(target=main)
t1.start()
複製程式碼

是因為,第二次獲取鎖時,發現鎖已經被同一執行緒的人拿走了。自己也就理所當然,拿不到鎖,程式就卡住了。

那麼如何解決這個問題呢。

threading模組除了提供Lock鎖之外,還提供了一種可重入鎖RLock,專門來處理這個問題。
threading模組除了提供Lock鎖之外,還提供了一種可重入鎖RLock,專門來處理這個問題。

import threading

def main():
    n = 0
    # 生成可重入鎖物件
    lock = threading.RLock()
    with lock:
        for i in range(10):
            n += 1
            with lock:
                print(n)

t1 = threading.Thread(target=main)
t1.start()
複製程式碼

執行一下,發現已經有輸出了。

1
2
3
4
5
6
7
8
9
10
複製程式碼

需要注意的是,可重入鎖,只在同一執行緒裡,放鬆對鎖鑰匙的獲取,其他與Lock並無二致。


. 防止死鎖的加鎖機制

在編寫多執行緒程式時,可能無意中就會寫了一個死鎖。可以說,死鎖的形式有多種多樣,但是本質都是相同的,都是對資源不合理競爭的結果。

以本人的經驗總結,死鎖通常以下幾種

  • 同一執行緒,巢狀獲取同把鎖,造成死鎖。
  • 多個執行緒,不按順序同時獲取多個鎖。造成死鎖

對於第一種,上面已經說過了,使用可重入鎖。

主要是第二種。可能你還沒明白,是如何死鎖的。

舉個例子。

執行緒1,巢狀獲取A,B兩個鎖,執行緒2,巢狀獲取B,A兩個鎖。
由於兩個執行緒是交替執行的,是有機會遇到執行緒1獲取到鎖A,而未獲取到鎖B,在同一時刻,執行緒2獲取到鎖B,而未獲取到鎖A。由於鎖B已經被執行緒2獲取了,所以執行緒1就卡在了獲取鎖B處,由於是巢狀鎖,執行緒1未獲取並釋放B,是不能釋放鎖A的,這是導致執行緒2也獲取不到鎖A,也卡住了。兩個執行緒,各執一鎖,各不讓步。造成死鎖。

經過數學證明,只要兩個(或多個)執行緒獲取巢狀鎖時,按照固定順序就能保證程式不會進入死鎖狀態。

那麼問題就轉化成如何保證這些鎖是按順序的?

有兩個辦法

  • 人工自覺,人工識別。
  • 寫一個輔助函式來對鎖進行排序。

第一種,就不說了。

第二種,可以參考如下程式碼

import threading
from contextlib import contextmanager

# Thread-local state to stored information on locks already acquired
_local = threading.local()

@contextmanager
def acquire(*locks):
    # Sort locks by object identifier
    locks = sorted(locks, key=lambda x: id(x))

    # Make sure lock order of previously acquired locks is not violated
    acquired = getattr(_local,'acquired',[])
    if acquired and max(id(lock) for lock in acquired) >= id(locks[0]):
        raise RuntimeError('Lock Order Violation')

    # Acquire all of the locks
    acquired.extend(locks)
    _local.acquired = acquired

    try:
        for lock in locks:
            lock.acquire()
        yield
    finally:
        # Release locks in reverse order of acquisition
        for lock in reversed(locks):
            lock.release()
        del acquired[-len(locks):]
複製程式碼

如何使用呢?

import threading
x_lock = threading.Lock()
y_lock = threading.Lock()

def thread_1():

    while True:
        with acquire(x_lock):
            with acquire(y_lock):
                print('Thread-1')

def thread_2():
    while True:
        with acquire(y_lock):
            with acquire(x_lock):
                print('Thread-2')

t1 = threading.Thread(target=thread_1)
t1.daemon = True
t1.start()

t2 = threading.Thread(target=thread_2)
t2.daemon = True
t2.start()
複製程式碼

看到沒有,表面上thread_1的先獲取鎖x,再獲取鎖y,而thread_2是先獲取鎖y,再獲取x
但是實際上,acquire函式,已經對xy兩個鎖進行了排序。所以thread_1hread_2都是以同一順序來獲取鎖的,是不是造成死鎖的。


. 飽受爭議的GIL(全域性鎖)

在第一章的時候,我就和大家介紹到,多執行緒和多程式是不一樣的。

多程式是真正的並行,而多執行緒是偽並行,實際上他只是交替執行。

是什麼導致多執行緒,只能交替執行呢?是一個叫GILGlobal Interpreter Lock,全域性直譯器鎖)的東西。

什麼是GIL呢?

任何Python執行緒執行前,必須先獲得GIL鎖,然後,每執行100條位元組碼,直譯器就自動釋放GIL鎖,讓別的執行緒有機會執行。這個GIL全域性鎖實際上把所有執行緒的執行程式碼都給上了鎖,所以,多執行緒在Python中只能交替執行,即使100個執行緒跑在100核CPU上,也只能用到1個核。

需要注意的是,GIL並不是Python的特性,它是在實現Python解析器(CPython)時所引入的一個概念。而Python直譯器,並不是只有CPython,除它之外,還有PyPyPsycoJPythonIronPython等。

在絕大多數情況下,我們通常都認為 Python == CPython,所以也就默許了Python具有GIL鎖這個事。

都知道GIL影響效能,那麼如何避免受到GIL的影響?

  • 使用多程式代替多執行緒。
  • 更換Python直譯器,不使用CPython


                                           關注公眾號,獲取最新文章
                                                             關注公眾號,獲取最新文章


相關文章