前言
在并发编程中,死锁指的是一种特定的情况,即无法取得进展,程序被锁定在其当前状态。在大多数情况下,这种现象是由于不同的锁对象(用于线程同步)之间缺乏协调,或者处理不当造成的。在这一节中,我们将讨论一个思想实验,通常被称为餐饮哲学家问题,以说明死锁的概念及其原因;从这里开始,你将学习如何在 Python 并发程序中模拟这个问题。
哲学家就餐问题
哲学家就餐(Dining philosophers problem)问题是计算机科学中的一个经典问题,用来演示在并发计算中多线程同步时产生的问题。
在 1971 年,著名的计算机科学家艾兹格·迪科斯彻提出了一个同步问题,即假设有五台计算机都试图访问五份共享的磁带驱动器。稍后,这个问题被托尼·霍尔重新表述为哲学家就餐问题。这个问题可以用来解释死锁和资源耗尽。
假如有 5 个哲学家,围坐在一起,每个人面前有一碗饭和一只筷子。在这里每个哲学家可以看做是一个独立的线程,而每只筷子可以看做是一个锁。
他们每个人都需要两个叉子来吃饭。如果他们同时拿起他们左边的叉子,那么它将一直等待右边的叉子被释放。每个哲学家可以处在静坐、 思考、吃饭三种状态中的一个。需要注意的是,每个哲学家吃饭是需要两只筷子的,这样问题就来了:如果每个哲学家都拿起自己左边的筷子, 那么他们五个都只能拿着一只筷子坐在那儿,直到饿死。此时他们就进入了死锁状态。
下面是一个简单的使用死锁避免机制解决“哲学家就餐问题”的实现:
import threading # The philosopher thread def philosopher(left, right): while True: with acquire(left,right): print(threading.currentThread(), 'eating') # The chopsticks (represented by locks) NSTICKS = 5 chopsticks = [threading.Lock() for n in range(NSTICKS)] # Create all of the philosophers for n in range(NSTICKS): t = threading.Thread(target=philosopher, args=(chopsticks[n],chopsticks[(n+1) % NSTICKS])) t.start()
最后,要特别注意到,为了避免死锁,所有的加锁操作必须使用 acquire() 函数。如果代码中的某部分绕过 acquire 函数直接申请锁,那么整个死锁避免机制就不起作用了。
死锁的常见例子
另一个例子是在银行账户上:假如要在两个银行账户之间执行交易,你必须确保两个账户都被锁定,不受其他交易的影响,以达到正确的资金转移量。在这里,这个类比并不完全成立--哲学家对应的是锁定账户的交易(分叉)--但同样的技术困难也会出现。
其他的例子包括电商秒杀系统,多个用户抢一个商品,不允许一个数据库被多个客户同时修改。
死锁也是由一个并发程序需要同时具备的条件来定义的,这样才会发生死锁。这些条件是由计算机科学家 Edward G. Coffman, Jr .首先提出的,因此被称为 Coffman 条件。这些条件如下:
- 至少有一个资源必须处于不可共享的状态。这意味着该资源被一个单独的进程(或线程)持有,不能被其他人访问; 在任何时间内,该资源只能被单个的进程(或线程)访问和持有。这个条件也被称为相互排斥。
- 有一个进程(或线程)同时访问一个资源并等待其他进程(或线程)持有的另一个资源。换句话说,这个进程(或线程)需要访问两个资源来执行其指令,其中一个它已经持有,另一个它正在等待其他进程(或线程)。这种情况被称为保持和等待。
- 只有在有特定指令让进程(或线程)释放资源的情况下,才能由持有这些资源的进程(或线程)来释放。这就是说,除非进程(或线程)自愿主动地释放资源,否则该资源仍处于不可共享的状态。这就是无抢占条件。
- 最后一个条件叫做循环等待。顾名思义,这个条件规定了一组进程(或线程)的存在,因此这组进程中的第一个进程(或线程)正在等待第二个进程(或线程)释放资源,而第二个进程(或线程)又需要等待第三个进程(或线程);最后,这组进程中的最后一个进程(或线程)正在等待第一个进程。
造成线程死锁的常见例子包括:
- 一个在自己身上等待的线程(例如,试图两次获得同一个互斥锁)
- 互相等待的线程(例如,A 等待 B,B 等待 A)
- 未能释放资源的线程(例如,互斥锁、信号量、屏障、条件、事件等)
- 线程以不同的顺序获取互斥锁(例如,未能执行锁排序)