死锁与预防

352 阅读6分钟

死锁

死锁是指多个线程阻塞等待获得死锁中其他一些线程持有的锁。当多个线程同时需要相同的锁,但是以不同的顺序获得它们时,就会发生死锁。

例子

一个死锁的经典例子:线程1拿到了锁A,并试图抢占锁B,但锁B早已经被线程2抢占,并且线程2正在抢占锁A,结果是线程1永远拿不到锁B,线程2永远拿不到锁A。另外,两个线程都不会知晓这个结果,会一直阻塞等待。

Thread 1 locks A, waits for B
Thread 2 locks B, waits for A

当然,如果情况复杂一点,就会变成:

Thread 1 locks A, waits for B
Thread 2 locks B, waits for C
Thread 3 locks C, waits for D
Thread 4 locks D, waits for A

数据库死锁

数据库的事务是一种更复杂的死锁。事务可能包含多个SQL更新请求。当一个事务更新某个记录时,会对记录上锁,其他的事务无法更新。因此,同一事务中的每个更新请求可能会锁定数据库中的某些记录。

如果有需要同时更新同一记录的多个事务正在同时运行,则它们可能死锁。

Transaction 1, request 1, locks record 1 for update
Transaction 2, request 1, locks record 2 for update
Transaction 1, request 2, tries to lock record 2 for update.
Transaction 2, request 2, tries to lock record 1 for update.

由于锁是在不同的请求中获取的,并且并非提前知道给定事务所需的所有锁,因此很难检测或防止数据库事务中的死锁。

死锁的预防

预防死锁的办法是想办法破坏死锁的产生的必要条件。

  • 互斥
  • 占有且等待
  • 循环等待
  • 不可剥夺

具体方案有:

有序上锁

当多个线程需要相同的锁但以不同的顺序获得它们时,就会发生死锁。

如果确保任何线程始终以相同的顺序获取所有锁,则不会发生死锁。

Thread 1:
  lock A 
  lock B

Thread 2:
   wait for A
   lock C (when A locked)

Thread 3:
   wait for A
   wait for B
   wait for C

如果一个线程(如线程3)需要多个锁,则必须按确定的顺序进行操作。在获得排在前面的锁之前,它无法获得排在后面的锁。

例如,线程2或线程3都不能锁定C,直到它们首先锁定A。由于线程1持有锁A,因此线程2和3必须首先等待直到锁A解锁。然后,他们必须成功锁定A,然后才能尝试锁定B或C。

锁排序是一种简单而有效的防止死锁机制。但是前提是你知道获得任意一个锁之前,需要先获取哪个锁。

锁设置超时时间

另一个防止死锁的机制是对加锁过程设置超时时间,线程只会在给定的超时时间内尝试加锁,超时后将备份,释放所有已获取的锁,等待随机的时间重试。随机时间内其他尝试获取相同锁的线程都有机会获取所有释放的锁,从而程序会继续运行而不是阻塞。

Thread 1 locks A
Thread 2 locks B

Thread 1 attempts to lock B but is blocked
Thread 2 attempts to lock A but is blocked

Thread 1's lock attempt on B times out
Thread 1 backs up and releases A as well
Thread 1 waits randomly (e.g. 257 millis) before retrying.

Thread 2's lock attempt on A times out
Thread 2 backs up and releases B as well
Thread 2 waits randomly (e.g. 43 millis) before retrying.

在上面的示例中,线程2在线程1释放锁A后的约200毫秒内,很有可能成功获取A锁和B锁。然后,线程1将等待已经尝试获取锁A的线程2。当线程2完成时,线程1也将同时获取两个锁(除非线程2或另一个线程在两者之间获取锁)。

仅由于锁超时并不一定意味着线程已死锁,这也可能只是意味着持有锁的线程(导致另一个线程超时)需要很长时间才能完成其任务。

此外,如果有足够的线程争用同一资源,即使超时和备份,它们仍然有冒险尝试一次又一次地占用线程。 在重试之前,有2个线程在0到500毫秒之间等待,可能不会发生这种情况,但是有10或20个线程时,情况就不同了。然后,两个线程在重试之前(或者足够接近以引起问题)同时等待的可能性要高得多。

锁超时机制的问题在于,无法为Java中的同步块设置超时设置。必须创建一个自定义锁类或使用java.util.concurrency包。

死锁检测

死锁检测是一种较重的防止死锁的机制,主要针对无法进行锁排序和锁超时不可行的情况。

线程每次获取锁时,都会在线程和锁的数据结构(map,图等)中进行记录。此外,每当线程请求锁定时,此数据结构中也会对此进行记录。

当线程请求锁定但请求被拒绝时,线程可以遍历锁定图以检查死锁。例如,如果线程A请求锁7,但锁7由线程B持有,则线程A可以检查线程B是否请求了线程A持有的任何锁(如果有)。如果线程B发出请求,则发生死锁(线程A取得了锁1,请求了锁7,线程B取得了锁7,请求了锁1)。

当然,死锁方案可能比两个互相持有锁的线程要复杂得多。线程A可能等待线程B,线程B等待线程C,线程C等待线程D,线程D等待线程A。为了使线程A检测到死锁,它必须可传递地检查线程B所请求的所有锁。从线程B请求的锁中,线程A将到达线程C,然后到达线程D,线程D从中找到线程A本身持有的一个锁。然后,它知道发生了死锁。

那么,如果检测到死锁,线程将如何处理?

一种可能的操作是释放所有锁,备份,等待随机的时间,然后重试。这类似于更简单的锁定超时机制,除了线程仅在实际发生死锁时才进行备份。但是,如果许多线程在争夺相同的锁,则即使它们备份并等待,它们也可能反复陷入死锁。

更好的选择是确定或分配线程的优先级,以便仅备份一个(或几个)线程。其余线程继续获取所需的锁,就好像没有发生死锁一样。如果分配给线程的优先级是固定的,则相同的线程将始终被赋予更高的优先级。 为避免这种情况,可以在检测到死锁时随机分配优先级。