diff --git a/notes/计算机操作系统 - 死锁.md b/notes/计算机操作系统 - 死锁.md index cfcf22dc..0ac19600 100644 --- a/notes/计算机操作系统 - 死锁.md +++ b/notes/计算机操作系统 - 死锁.md @@ -24,6 +24,7 @@ * [A1 Java 线程死锁](#a1-java-线程死锁) * [A2 MySQL 死锁](#a2-mysql-死锁) * [A3 先查后插的间隙锁死锁](#a3-先查后插的间隙锁死锁) + * [A4 死锁线程的状态与 CPU 时间](#a4-死锁线程的状态与-cpu-时间) * [参考资料](#参考资料) @@ -103,7 +104,7 @@ ## 死锁预防 -在程序运行之前预防发生死锁。 +在程序运行之前预防发生死锁:四个必要条件缺一不可,设计时破坏其中任意一个,死锁就不可能发生。各种方法的代价不同,实际开发中最常用的是破坏环路等待。 ### 1. 破坏互斥条件 @@ -111,7 +112,7 @@ ### 2. 破坏占有和等待条件 -一种实现方式是规定所有进程在开始执行前请求所需要的全部资源。 +一种实现方式是规定所有进程在开始执行前请求所需要的全部资源。代价是资源被提前占用、利用率低,而且很多程序事先并不知道需要哪些资源。 ### 3. 破坏不可抢占条件 @@ -121,11 +122,11 @@ ### 4. 破坏环路等待 -给资源统一编号,进程只能按编号顺序来请求资源。 +给资源统一编号,进程只能按编号顺序来请求资源。这样所有进程都是从小编号往大编号等待,不可能形成环。它的代价最小,只要求每个加锁的地方遵守同一个顺序,例子见 [Java 线程死锁](#1-java-线程死锁) 和 [MySQL 死锁](#2-mysql-死锁)。 ## 死锁避免 -在程序运行时避免发生死锁。 +在程序运行时避免发生死锁。死锁避免不限制资源的请求方式,而是在每次分配前判断分配之后系统是否仍处于安全状态,不安全就让请求者等待。它要求事先知道每个进程最多需要多少资源,通用的操作系统和应用程序很难满足,因此主要见于教材和特定的资源分配系统。 ### 1. 安全状态 @@ -203,7 +204,9 @@ synchronized (first) { } ``` -两个线程各反向转账 10 万次,原来的写法每次运行都会卡住,按 id 顺序加锁后都能完成,两个账户的余额仍为 1000(JDK 21 实测,见[附录 A1](#a1-java-线程死锁))。使用 ReentrantLock 时还可以用 tryLock 设置超时,拿不到锁就释放已持有的锁再重试,破坏的是占有和等待条件。 +两个线程各反向转账 10 万次,原来的写法每次运行都会卡住,按 id 顺序加锁后都能完成,两个账户的余额仍为 1000(JDK 21 实测,见[附录 A1](#a1-java-线程死锁))。使用 ReentrantLock 时还可以用 tryLock 设置超时,拿不到锁就释放已持有的锁再重试,破坏的是占有和等待条件。例如获取第二把锁等待 10 ms 仍失败时释放第一把锁,随机等待 1~4 ms 后重试,同样的两个线程在 30~60 ms 内完成(见[附录 A4](#a4-死锁线程的状态与-cpu-时间))。等待时间要随机,否则两个线程可能总是同时释放、同时重试,谁都拿不到两把锁,这称为活锁。 + +需要注意的是,死锁的线程处于阻塞状态,并不消耗 CPU。上例卡住后,两个线程的状态都是 BLOCKED,之后 3 秒内各自消耗的 CPU 时间都不到 0.1 ms(JDK 21 实测,见[附录 A4](#a4-死锁线程的状态与-cpu-时间))。因此 CPU 占用很高时应先按 [CPU 占用过高](Java%20虚拟机.md#cpu-占用过高)排查死循环、频繁 GC 等问题;CPU 很低、请求却全部卡住,线程都停在加锁的地方,才更像死锁,用 jstack 或 jcmd 的 Thread.print 命令打印线程栈确认。程序中也可以定时调用 ThreadMXBean 的 findDeadlockedThreads() 检测死锁并告警。 ### 2. MySQL 死锁 @@ -333,9 +336,53 @@ ERROR 1062 (23000) at line 1: Duplicate entry '1024' for key 'payment.PRIMARY' 执行 SET GLOBAL innodb_print_all_deadlocks = ON 后重跑可重复读的例子,错误日志中同样记录了两个事务持有的间隙锁和等待的插入意向锁(lock_mode X locks gap before rec insert intention waiting)。 +### A4 死锁线程的状态与 CPU 时间 + +JDK 21.0.13,macOS(Apple M1 Pro)。用正文中的 transfer 让线程 T1、T2 反向转账直到卡住,等待 5 秒后,用 ThreadMXBean 记录两个线程此后 3 秒内消耗的 CPU 时间,再调用 findDeadlockedThreads(): + +```java +ThreadMXBean mx = ManagementFactory.getThreadMXBean(); +long c1 = mx.getThreadCpuTime(t1.threadId()), c2 = mx.getThreadCpuTime(t2.threadId()); +Thread.sleep(3000); +long d1 = mx.getThreadCpuTime(t1.threadId()) - c1, d2 = mx.getThreadCpuTime(t2.threadId()) - c2; +for (ThreadInfo ti : mx.getThreadInfo(mx.findDeadlockedThreads())) { ... } +``` + +```text +T1 状态 BLOCKED,T2 状态 BLOCKED +之后 3 秒内 T1、T2 消耗的 CPU 时间:62000 ns、35000 ns +findDeadlockedThreads:T1 等待 DeadlockDiag$Account@7291c18f,持有者 T2 +findDeadlockedThreads:T2 等待 DeadlockDiag$Account@1b2c6ec2,持有者 T1 +``` + +改用 ReentrantLock.tryLock 的版本(获取第二把锁最多等待 10 ms,失败则释放第一把锁,随机等待 1~4 ms 后重试),各转账 10 万次,运行 3 次: + +```java +static void transferTry(Account from, Account to, int amount) throws InterruptedException { + while (true) { + if (from.lock.tryLock(10, TimeUnit.MILLISECONDS)) { + try { + if (to.lock.tryLock(10, TimeUnit.MILLISECONDS)) { + try { from.balance -= amount; to.balance += amount; return; } + finally { to.lock.unlock(); } + } + } finally { from.lock.unlock(); } + } + Thread.sleep(ThreadLocalRandom.current().nextInt(1, 5)); + } +} +``` + +```text +tryLock 版本:完成,余额 1000 1000,用时 59 ms +tryLock 版本:完成,余额 1000 1000,用时 29 ms +tryLock 版本:完成,余额 1000 1000,用时 43 ms +``` + ## 参考资料 - Tanenbaum A S, Bos H. Modern Operating Systems [M]. Pearson, 2014. - [Deadlocks in InnoDB | MySQL 8.0 Reference Manual](https://dev.mysql.com/doc/refman/8.0/en/innodb-deadlocks.html) - [InnoDB Locking | MySQL 8.0 Reference Manual](https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html) - [The data_locks Table | MySQL 8.0 Reference Manual](https://dev.mysql.com/doc/refman/8.0/en/performance-schema-data-locks-table.html) +- [ThreadMXBean (Java SE 21 & JDK 21)](https://docs.oracle.com/en/java/javase/21/docs/api/java.management/java/lang/management/ThreadMXBean.html)