巧妙解决Python多线程死锁问题

巧妙解决Python多线程死锁问题

【相关学习推荐:python视频】

今天是Python专题的第25篇文章,我们一起来聊聊多线程开发当中死锁的问题。

死锁

死锁的原理非常简单,用一句话就可以V ? S - - K ~ ) s描述完。就是当多线程访问多个锁的时候,不同的锁被不同的线程持有,它们都在等待其他线程释放出锁来,于是便陷入了永久等待。比如A线程持有1号锁,等待2号锁,B线程持有2号锁等待1号锁,那么它们永远也等不到执行的那天,这种情况就叫做死锁。

关于死锁有一个著名的问题叫[ & c哲学家就餐问题,有5个哲Q n E ^ q学家围坐在一起,他们每个人需要拿到两个叉子才可以吃饭。如果他们同时拿起自己左手边的叉子,那么就会永远等待右手边的叉子释放出来。这样就陷入了永久等待# u I C v,于是这些哲学家都会饿死。

巧妙解决Python多线程死锁问题
img

这是一个很形象的模型,因为在计算机并发场景当中,一些资源的数量往往是有限的。很+ X L有可能出现多个线程抢占的情况,如果处理不好就会发生大家都获取了一个资源,c s X然后在等待另外的资源的情况。

对于死锁的问题有多种解决方法,这里我们介绍比较简单的一种,就是对这些锁进行编号。我们规定当一个线程需要同时持有多个锁的时候,= & M X A + d须要按照序号升序的顺序对这些锁进行访问。通过上k r ] y I x下文管理器我们可以很容易实现这一点。

上下文管理器

6 C 5先我O + h 1 u _ l , o们来简单介绍一下上下文管理器,上下文管理器我们其实经常使用,比如我们经常使用的w % w e K ; vith语句就是一个上下文管理器K n h g的经典使用。当我们通过with语句打开文件的时候,它会自动替我们处理好文n O A N件读取之后的关闭以及抛出异常的处理,可以节约我们大量的代码。

同样我们也可以自己定义一个上下文处理器,其实很简单,我们只需要实现_n 9 v : P_enter__和__exit__这两个函数即可。__enter__函数用来实现进入资源之前的操作和处理,那么显然__exit__函数对应的就是使用资源结束之后或者是出现异常的c Q M % ] e j K p处理逻辑。有了这两个函数之后,我们就有了自己的上下文处理类了。

h P g M f ^们来看一个样例:

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)        # priv @ u % L Lnt(exc_tb)    def doSomething(self):        a = 1/1        return adef getSamplh K X + `  0 ,e():    rH S b Keturn Samplp n ^ 4 + g ae()if __name__ == '__main__':    with getSampleI g S i P #() as sample:        print('do somethS l g P A Oing')        sample.doSomething()复制代码

当我们运行这段代码的时候,屏幕上打@ ` I n / & }印的结果和我们的预期是一致的。

巧妙解决Python多线程死锁问题
image-202008030915586{ n :32

我们观察一下__exit__函数,会发Y 1 V 0 9现它的参数有4个,后面的三个参数对应的是抛出& ( C t异常的情况3 R : f c Z 7 )。type对应异常的类型,val对应Z | C L M 9 c J B异常时的输出值,trace对应异常抛出时的运行堆栈。这些信息都是X c `我们排查异常的时候经常需要用到的信息,通过这三个字段,我们可以根据我们的8 K 7 v需要对可能出现的异常进行自定义的处理。

实现上下文管理器并不一定要通过类实现,Python当中也提供了上下文管理的注解,通过使用注解我们可以很方便地实现上下文管理。我们同样也来看一个例子:

import timefrom contextlib import contextmanager@contextmanagerdef timethis(label):    start = time.time()    try:        yield    finally:        end = time.time()        print('{}: {}'.format(label,- ; T * Z $ Q s end - start)^ k 1 & $ 0 C a)                with timethis('timer'):    pass复制代码

在这个方法当中yield之前的部分相当于p x A x 7__enter__函数,yield之后的部分相[ , { l _ | j V T当于__exit__。如果出现异常会在try) U 语句当中抛出,那么我们编写except对异常进行处理即可。

避免死锁

了解了上下文管理器之后,我们要做的就是在lock的外面包装一h x d Z 7 f % 3,使# Q c 4 _ Y | G =得我们在获取和释放锁的时候可以根据我们的需要,对锁进行排序,按照升. i { $ 1 8 J Z序的顺序进行持有。

这段代码源于Pyth( 5 Don的著名进阶书籍《Python cookbook》,非常经典:

f| c 2 u Urom contextlib import coc c ~ S F 3nteb * q *xtmanagerw _ i#F h I S t ` 用来存储local的数据_local = threadi} P !ng.local()@contextmanagerdef ab 7 + x Ocquire(*locks): # 对锁按照id进行排序    locks = sorted(locks, key=lW V % O Nambda x: id(x))    # 如果已经持有锁当中的序号有比当前更大的,说明策略失败    acquired = getattr(_local,'acquired',[])    if acquiru k | o ged and max(id(lock) for lock in acquired) >= id(locks[0]):        raise RuntimeError('Lock Order Violation')    # 获取所有锁    acqui6 8 Z Srek t i C P Hd.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):]复制代码

这段代码写得非常漂亮,可读性很高,逻辑我们都应该w } X能看懂,但是有一个小问题是这里用到了$ E M z Athreading.local这个组件。

它是一个多线程场景当I P ~ O 6 w f q 3中的共享变量,虽然说是共享的,但是对于每个线程来说读取到的值都是独立的。听起来有些难以理解,其实我们可以将它理解成一个dict,dict的key是每一个线程的id,value是一个存储数据的dict。每个线程R T =在访问locu L Oal变量的时候,都相当于先通过线程id获取了一个独立的dict,再对这个diM g . Z B : { cct进行的操作。

看起来我们在使用的时候直接使用了_local,这是因为通i q t U T d L c过线程id先进行查询的步骤在其中 = - C r封装了。g I b不明就里的话可能会觉得有些难以理解。

我们再来看下这个acquire的使用:

x_lock = threading.Lock()y_lock = threading.Lock()def thread_1():    while True:        with acquire(x_low f % ? * * ; )ck, y_lop p 9  T ( u _ck):            print('Thrx d p ) d B $ead-1')def thread_2():    while True:        with acquire(y_lock, x_z 3 x Z 6 Y J ; Flock):            print('Thread-2')p [ L ft1 = threading.ThX Z &read(targetq [ G s $=thread_1)t1.start()t2 = threading.Thread(target=thread_2)t2.start()复制代码

运行一下会发现没有出现死锁的情况,但I Q E f {如果我们把代码稍加调整,写成这样] 3 x r W v s g [,那Z Z &么就会触发异常了。

def thread_1():    while True:        with acquire(x_lock):            with acquire(R U U s Dy_loT m p Pck):             print('Tz O _ C hhread-1')def thread_2():    while True0 + v 0 3 } d:        with acquire(y_lock):            with acquire(x_lock):             print('Threa{ 5 6d-1')复制代码

因为我们把锁写成了层次结构,这样就没v F -办法进行排序保证持有的有序性了,那么就会触发我们代码当中定义的异常。

最后我们再来看下哲学家就餐问题,通过我们自己实现的acquire函数我们可以非常方便地解决他们死锁吃不了饭的问题。

import threadingdef ph( r ? x ! u [ ~ilosopher(left, right):    while True:5 O 0 T j v        with acquire(left,right):             prink e ] e E . n Ht(threading.currentThread(),8 Z w % = o 'eating')# 叉子的数量NSTICKS = 5chopsticks = [threading.Lock() for n in/ H J ? ? t j rae A Lnge(NSTICKS)]for n iR 7 C - E mn range(NSTICKS):    t = threading.Thread(target=philosopher,                         args=(chopsticks[n],chopsticks[(n+1) % NSTICKS]))    t.start()复制代码

总结

关于死锁的问题,对锁进行排序只是其中的一种解决方v 7 8 ] c f,除此之外还有很多解决死锁的模 v G X f D _型。比如我们可以让线程在尝试持有新的锁失U { a 5 W |败的时候主动放弃所有目前已经持有的锁,比如我们可以设置机制检测死锁的发生并对其进行处理等m s R = L = Q G等。发散出去其实有很n W G | P D多种方法,这些方8 * ^ s o (法起作用的原理各不相同,其中涉及大量操作系统的基础V J % I F概念和知识,感兴趣的同学可# 3 W . + 4 C 4 1以深} d M + R I l入研究一下这个部分,一定会对操作系统以及锁的使用有一个深刻的认识。

相关学习推荐:编程视频

以上就是巧妙解决Python多线程T 5 J j _ /死锁问题的详细内容。

巧妙解决Python多线程死锁问题
死锁上下文管理器避免死锁总结