【相關學習推薦:#python影片】
今天是Python專題的第25篇文章,我們一起來聊聊多執行緒開發當中死鎖的問題。
#死鎖的原理非常簡單,用一句話就可以描述完。就是當多線程訪問多個鎖的時候,不同的鎖被不同的線程持有,它們都在等待其他線程釋放出鎖來,於是便陷入了永久等待。例如A線程持有1號鎖,等待2號鎖,B線程持有2號鎖等待1號鎖,那麼它們永遠等不到執行的那天,這種情況就叫做死鎖。
關於死鎖有一個著名的問題叫做哲學家就餐問題,有5個哲學家圍坐在一起,他們每個人需要拿到兩個叉子才可以吃飯。如果他們同時拿起自己左手邊的叉子,那麼就會永遠等待右手邊的叉子釋放出來。這樣就陷入了永久等待,於是這些哲學家都會餓死。
這是一個很形象的模型,因為在電腦並發場景當中,一些資源的數量往往是有限的。很有可能出現多個線程搶佔的情況,如果處理不好就會發生大家都獲取了一個資源,然後在等待另外的資源的情況。
對於死鎖的問題有多種解決方法,這裡我們介紹比較簡單的一種,就是對這些鎖進行編號。我們規定當一個執行緒需要同時持有多個鎖的時候,必須要依照序號升序的順序對這些鎖進行存取。透過上下文管理器我們可以很容易地實現這一點。
#首先我們來簡單介紹一下上下文管理器,上下文管理器我們其實常使用,例如我們常用的with語句就是一個上下文管理器的經典使用。當我們透過with語句開啟檔案的時候,它會自動替我們處理好檔案讀取之後的關閉以及拋出異常的處理,可以節省我們大量的程式碼。
同樣我們也可以自己定義一個上下文處理器,其實很簡單,我們只需要實作__enter__和__exit__這兩個函數。 __enter__函數用來實現進入資源之前的操作和處理,那麼顯然__exit__函數對應的就是使用資源結束之後或是出現異常的處理邏輯。有了這兩個函數之後,我們就有了自己的上下文處理類別了。
我們來看一個範例:
class Sample: def __enter__(self): print('enter resources') return self def __exit__(self, exc_type, exc_val, exc_tb): print('exit') # print(exc_type) # print(exc_val) # print(exc_tb) def doSomething(self): a = 1/1 return adef getSample(): return Sample()if __name__ == '__main__': with getSample() as sample: print('do something') sample.doSomething()复制代码
當我們執行這段程式碼的時候,螢幕上列印的結果和我們的預期是一致的。
我們觀察__exit__函數,會發現它的參數有4個,後面的三個參數對應的是拋出異常的情況。 type對應異常的類型,val對應異常時的輸出值,trace對應異常拋出時的運行堆疊。這些資訊都是我們排查異常的時候經常需要用到的信息,透過這三個字段,我們可以根據我們的需要對可能出現的異常進行自訂的處理。
實現上下文管理器不一定要透過類別實現,Python當中也提供了上下文管理的註解,透過使用註解我們可以很方便地實現上下文管理。我們同樣也來看一個例子:
import timefrom contextlib import contextmanager@contextmanagerdef timethis(label): start = time.time() try: yield finally: end = time.time() print('{}: {}'.format(label, end - start)) with timethis('timer'): pass复制代码
在這個方法當中yield之前的部分相當於__enter__函數,yield之後的部分相當於__exit__。如果出現異常會在try語句當中拋出,那麼我們就寫except對例外處理即可。
了解了上下文管理器之后,我们要做的就是在lock的外面包装一层,使得我们在获取和释放锁的时候可以根据我们的需要,对锁进行排序,按照升序的顺序进行持有。
这段代码源于Python的著名进阶书籍《Python cookbook》,非常经典:
from contextlib import contextmanager# 用来存储local的数据_local = threading.local()@contextmanagerdef acquire(*locks): # 对锁按照id进行排序 locks = sorted(locks, key=lambda x: id(x)) # 如果已经持有锁当中的序号有比当前更大的,说明策略失败 acquired = getattr(_local,'acquired',[]) if acquired and max(id(lock) for lock in acquired) >= id(locks[0]): raise RuntimeError('Lock Order Violation') # 获取所有锁 acquired.extend(locks) _local.acquired = acquired try: for lock in locks: lock.acquire() yield finally: # 倒叙释放 for lock in reversed(locks): lock.release() del acquired[-len(locks):]复制代码
这段代码写得非常漂亮,可读性很高,逻辑我们都应该能看懂,但是有一个小问题是这里用到了threading.local这个组件。
它是一个多线程场景当中的共享变量,虽然说是共享的,但是对于每个线程来说读取到的值都是独立的。听起来有些难以理解,其实我们可以将它理解成一个dict,dict的key是每一个线程的id,value是一个存储数据的dict。每个线程在访问local变量的时候,都相当于先通过线程id获取了一个独立的dict,再对这个dict进行的操作。
看起来我们在使用的时候直接使用了_local,这是因为通过线程id先进行查询的步骤在其中封装了。不明就里的话可能会觉得有些难以理解。
我们再来看下这个acquire的使用:
x_lock = threading.Lock()y_lock = threading.Lock()def thread_1(): while True: with acquire(x_lock, y_lock): print('Thread-1')def thread_2(): while True: with acquire(y_lock, x_lock): print('Thread-2')t1 = threading.Thread(target=thread_1)t1.start()t2 = threading.Thread(target=thread_2)t2.start()复制代码
运行一下会发现没有出现死锁的情况,但如果我们把代码稍加调整,写成这样,那么就会触发异常了。
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-1')复制代码
因为我们把锁写成了层次结构,这样就没办法进行排序保证持有的有序性了,那么就会触发我们代码当中定义的异常。
最后我们再来看下哲学家就餐问题,通过我们自己实现的acquire函数我们可以非常方便地解决他们死锁吃不了饭的问题。
import threadingdef philosopher(left, right): while True: with acquire(left,right): print(threading.currentThread(), 'eating')# 叉子的数量NSTICKS = 5chopsticks = [threading.Lock() for n in range(NSTICKS)]for n in range(NSTICKS): t = threading.Thread(target=philosopher, args=(chopsticks[n],chopsticks[(n+1) % NSTICKS])) t.start()复制代码
关于死锁的问题,对锁进行排序只是其中的一种解决方案,除此之外还有很多解决死锁的模型。比如我们可以让线程在尝试持有新的锁失败的时候主动放弃所有目前已经持有的锁,比如我们可以设置机制检测死锁的发生并对其进行处理等等。发散出去其实有很多种方法,这些方法起作用的原理各不相同,其中涉及大量操作系统的基础概念和知识,感兴趣的同学可以深入研究一下这个部分,一定会对操作系统以及锁的使用有一个深刻的认识。
相关学习推荐:编程视频
以上是巧妙解決Python多執行緒死鎖問題的詳細內容。更多資訊請關注PHP中文網其他相關文章!