线程上下文切换与死锁

本节内容主要是对死锁进行深入的讲解,具体内容如下:

  • 理解线程的上下文切换,这是本节的辅助基础内容,从概念层面进行理解即可。

  • 了解什么是线程死锁,在并发编程中,线程死锁是一个致命的错误,死锁的概念是本节的重点之一。

  • 了解线程死锁的必备4要素,这是避免死锁的前提,只有了解死锁的必备要素,才能找到避免死锁的方式。

  • 掌握死锁的实现,通过代码实例,进行死锁的实现,深入体会什么是死锁,这是本节的重难点之一。

  • 掌握如何避免线程死锁,我们能够实现死锁,也可以避免死锁,这是本节内容的核心。

线程的上下文切换

在多线程编程中,线程个数一般都大于 CPU 的个数,而每个 CPU 同一时刻只能被一个线程使用,为了让用户感觉多个线程是在同时被执行的,CPU 资源的分配采用了时间段轮转的策略,也就是给每个线程分配一个时间段,线程在给定的时间段内占用 CPU 执行任务。

当前线程使用完时间段后,就会处于就绪状态并让出 CPU,让其他线程占用,这就是上下文切换,从当前线程的上下文切换到了其他线程中。

那么就有一个问题,让出 CPU 的线程等下次轮到自己占有 CPU 时如何知道自己之前运行到哪里了?因此在切换线程上下文时需要保存当前线程的执行现场,当再次执行时根据保存的执行现场信息恢复执行现场。

当前线程的 CPU 时间段使用完或者是当前线程被其他线程中断时,当前线程就会释放执行权。那么此时执行权就会被切换给其他的线程进行任务的执行,一个线程释放执行权,另一个线程获取执行权,这就是所谓的上下文切换机制。

线程死锁

死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的互相等待的现象,在无外力作用的情况下,这些线程会一直相互等待而无法继续运行下去。

如图21.2所示的死锁状态,线程A己经持有了资源1,它同时还想申请资源2,可是此时线程B已经持有了资源2,线程A只能等待。

反观线程B持有了资源2,它同时还想申请资源1,但是资源1已经被线程A持有,线程B只能等待。所以线程A和线程B就因为相互等待对方已经持有的资源,而进入了死锁状态。

那么,线程死锁的必备要素都有哪些呢?具体如下。

  • 互斥条件:进程要求对所分配的资源进行排他性控制,即在一段时间内某资源仅为一个进程所占有。此时若有其他进程请求该资源,则请求进程只能等待。

  • 不可剥夺条件:进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能由获得该资源的进程自己来释放(只能是主动释放,如 yield 释放 CPU 执行权)。

  • 请求与保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放。

  • 循环等待条件:指在发生死锁时,必然存在一个如图21.3所示的、线程请求资源的环形链,即线程集合 {T0,T1,T2,…,Tn} 中的 T0 正在等待一个 T1 占用的资源,T1 正在等待 T2 占用的资源,以此类推,Tn 正在等待己被 T0 占用的资源。

image 2024 03 06 12 11 32 453
Figure 1. 图21.2 死锁状态
image 2024 03 06 12 11 56 936
Figure 2. 图21.3 循环等待条件

【例21.2】死锁的实现(实例位置:资源包\TM\sl\21\2)

创建两个线程,名称分别为 threadA 和 threadB;创建两个资源(使用 newObject() 创建即可),名称分别为 resourceA 和 resourceB;threadA 持有 resourceA 并申请资源 resourceB;threadB 持有 resourceB 并申请资源 resourceA;为了确保发生死锁现象,请使用 sleep 方法创造该场景;执行代码,看是否会发生死锁,即线程 threadA 和 threadB 互相等待。代码如下:

public class Demo {
    private static Object resourceA = new Object();   // 创建资源 resourceA
    private static Object resourceB = new Object();   // 创建资源 resourceB

    public static void main(String[] args) throws InterruptedException {
        // 创建线程 threadA
        Thread threadA = new Thread(new Runnable() {
            @Override
            public void run() {
                Synchronized (resourceA) {
                    System.out.println(Thread.currentThread().getName() + "获取 resourceA。");
                    try {
                        // sleep 1000 毫秒,确保此时 resourceB 已经进入 run() 方法的同步模块中
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + "开始申请 resourceB。");
                    synchronized (resourceB) {
                        System.out.println(Thread.currentThread().getName() + "获取 resouceB。");
                    }
                }
            }
        });
        threadA.setName("threadA");
        // 创建线程 threadB
        Thread threadB = new Thread(new Runnable() {
            @Override
            public void run() {
                Synchronized (resourceB) {
                    System.out.println(Thread.currentThread().getName() + "获取 resourceB。");
                    try {
                        // sleep 1000 毫秒,确保此时 resourceB 已经进入 run() 方法的同步模块中
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + "开始申请 resourceA。");
                    synchronized (resourceA) {
                        System.out.println(Thread.currentThread().getName() + "获取 resouceA。");
                    }
                }
            }
        });
        threadB.setName("threadB");
        threadA.start();
        threadB.start();
    }
}
java

运行结果如图21.4所示。

image 2024 03 06 12 44 06 748
Figure 3. 图21.4 运行结果

threadA 首先获取了 resourceA,获取的方式是代码 synchronized(resourceA),然后沉睡 1000 毫秒;在 threadA 沉睡过程中,threadB 获取了 resourceB,然后使自己沉睡 1000 毫秒;当两个线程都苏醒时,此时可以确定 threadA 获取了 resourceA,threadB 获取了 resourceB,这就达到了我们做的第一步,线程分别持有自己的资源;开始申请资源,threadA 申请资源 resourceB,threadB 申请资源 resourceA,无奈 resourceA 和 resourceB 都被各自线程持有,两个线程均无法申请成功,最终达成死锁状态。

image 2024 03 06 12 45 28 592
Figure 4. 图21.4 运行结果

避免死锁

要想避免死锁,只需要破坏掉至少一个构造死锁的必要条件即可,学过操作系统的读者应该都知道,目前只有请求并持有和环路等待条件是可以被破坏的。造成死锁的原因其实和申请资源的顺序有很大关系,使用资源申请的有序性原则就可避免死锁。

下面修改例21.2,在其他条件保持不变的情况下,仅对之前的 threadB 的代码做如下修改,以避免死锁。代码如下:

public class Demo1 {

    // 创建线程 threadB
    Thread threadB = new Thread(new Runnable() {
        @Override
        public void run() {
            Synchronized (resourceA) {    // 修改1
                System.out.println(Thread.currentThread().getName() + "获取 resourceB。");  // 修改3
                try {
                    // sleep 1000 毫秒,确保此时 resourceB 已经进入 run() 方法的同步模块中
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + "开始申请 resourceA。");  // 修改4
                synchronized (resourceB) {                                                    // 修改2
                    System.out.println(Thread.currentThread().getName() + "获取 resouceA。");  // 修改5
                }
            }
        }
    });

}
java

运行结果如图21.5所示。

image 2024 03 06 12 50 09 941
Figure 5. 图21.5 运行结果

threadA 首先获取了 resourceA,获取的方式是代码 synchronized(resourceA),然后沉睡 1000 毫秒;在 threadA 沉睡过程中,threadB 想要获取 resourceA,但是 resourceA 目前正被沉睡的 threadA 持有,因此 threadB 等待 threadA 释放 resourceA;1000 毫秒后,threadA 苏醒了,释放了 resourceA,此时等待的 threadB 获取到了 resourceA,然后 threadB 使自己沉睡 1000 毫秒;threadB 沉睡过程中,threadA 申请 resourceB 成功,继续执行成功后,释放 resourceB;1000 毫秒后,threadB 苏醒了,继续执行获取 resourceB,执行成功。综上,threadA 和 threadB 按照相同的顺序对 resourceA 和 resourceB 依次进行访问,避免了互相交叉持有等待的状态,因而避免了死锁的发生。