Chang-LeHung

并发程序的噩梦——数据竞争

前言

在本文当中我主要通过不同线程对同一个数据进行加法操作的例子,层层递进,使用忙等待、synchronized和锁去解决我们的问题,切实体会为什么数据竞争是并发程序的噩梦。

问题介绍

在本文当中会有一个贯穿全文的例子:不同的线程会对一个全局变量不断的进行加的操作!然后比较结果,具体来说我们设置一个静态类变量data,然后使用两个线程循环10万次对data进行加一操作!!!

像这种多个线程会存在同时对同一个数据进行修改操作的现象就叫做数据竞争数据竞争会给程序造成很多不可预料的结果,让程序存在许多漏洞。而我们上面的任务就是一个典型的数据竞争的问题。

并发不安全版本

在这一小节我们先写一个上述问题的并发不安全的版本:

public class Sum {

    public static int data;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 100000; i++)
                data++;
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 100000; i++)
                data++;
        });

        t1.start();
        t2.start();
        // 让主线程等待 t1 和 t2
        // 直到 t1 和 t2 执行完成
        t1.join();
        t2.join();
        System.out.println(data);
    }
}
// 输出结果
131888

上面两个线程执行的结果最终都会小于200000,为什么会出现这种情况呢?

我们首先来看一下内存的逻辑布局图:

data全局变量保存在主内存当中,当现成开始执行的时候会从主内存拷贝一份到线程的工作内存当中,也就是线程的本地内存,在本地进行计算之后就会将本地内存当中的数据同步到主内存

我们现在来模拟一下出现问题的过程:

  • 主内存data的初始值等于0,两个线程得到的data初始值都等于0。

  • 现在线程一将data加一,然后线程一将data的值同步回主内存,整个内存的数据变化如下:

  • 现在线程二data加一,然后将data的值同步回主内存(将原来主内存的值覆盖掉了):

我们本来希望data的值在经过上面的变化之后变成2,但是线程二覆盖了我们的值,因此在多线程情况下,会使得我们最终的结果变小。

忙等待(Busy waiting)

那么我们能在不使用锁或者synchronized的情况实现上面这个任务吗?答案是可以,这种方法叫做忙等待。具体怎么做呢,我们可以用一个布尔值flag去标识是那个线程执行sum++,我们先看代码然后进行分析:

public class BusyWaiting {

    public static int sum;
    public static boolean flag;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                // 当 flag == false 的时候这个线程进行
                // sum++ 操作然后让 flat = true
                // 让另外一个线程进行 sum++ 操作
                while (flag);
                sum++;
                flag = true;
                System.out.println("Thread 1 : " + sum);
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                // 当 flag = true 的之后这个线程进行
                // sum++ 操作然后让 flat = false
                // 让另外一个线程进行 sum++ 操作
                while (!flag) ;
                sum++;
                flag = false;
                System.out.println("Thread 2 : " + sum);
            }
        });
        t1.start();
        t2.start();
        // 让主线程等待 t1 和 t2
        // 直到 t1 和 t2 执行完成
        t1.join();
        t2.join();
        System.out.println(sum);
    }
}

上面代码的流程是一个线程进行完sum++操作之后会将自己的flag变成另外一个值,然后自己的while循环当中的条件会一直为true自己就会一直处于while循环当中,然后另外一个线程的while循环条件会变成false,则另外一个线程会执行sum++操作,然后将flag变成另外一个值,然后线程又开始执行了......

忙等待中数据竞争的BUG

但是上面的代码会出现问题,就是在执行一段时间之后两个线程都会卡死,都会一直处于死循环当中。这是因为一个线程在更新完flag之后,另外一个线程的flag值没有更新,也就是说两个线程的flag值不一样,这样就都会处于死循环当中。出现这个问题的原因是一个线程更新完flag之后,另外一个线程的flag使用的还是旧值。

比如在某个时刻主内存当中的flag的值等于false线程t1线程t2当中的flag的值都等于false,这个情况下线程t1是可以进行sum++操作的,然后线程t1进行sum++操作之后将flag的值改成true,然后将这个值同步更新回主内存,但是此时线程t2拿到的还是旧值false,他依旧处于死循环当中,就这样两个线程都处于死循环了。

上面的问题本质上是一个数据可见性的问题,也就是说一个线程修改了flag的值,另外一个线程拿不到最新的值,使用的还是旧的值,而在java当中给我们提供了一种机制可以解决数据可见性的问题。在java当中我们可以使用volatile去解决数据可见性的问题。当一个变量被volatile关键字修饰时,对于共享资源的写操作先要修改工作内存,但是修改结束后会立刻将其刷新到主内存中。当其他线程对该volatile修饰的共享资源进行了修改,则会导致当前线程在工作内存中的共享资源失效,所以必须从主内存中再次获取。这样的话就可以保证共享数据的可见性了。因为某个线程如果修改了volatile修饰的共享变量时,其他线程的值会失效,然后重新从主内存当中加载最新的值,关于volatile的内容还是比较多,在本文当中我们只谈他在本文当中作用。

修改后的正确代码如下:

public class BusyWaiting {

    public static volatile int sum;
    public static volatile boolean flag;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                // 当 flag == false 的时候这个线程进行
                // sum++ 操作然后让 flat = true
                // 让另外一个线程进行 sum++ 操作
                while (flag);
                sum++;
                flag = true;
                System.out.println("Thread 1 : " + sum);
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                // 当 flag = true 的之后这个线程进行
                // sum++ 操作然后让 flat = false
                // 让另外一个线程进行 sum++ 操作
                while (!flag) ;
                sum++;
                flag = false;
                System.out.println("Thread 2 : " + sum);
            }
        });
        t1.start();
        t2.start();
        // 让主线程等待 t1 和 t2
        // 直到 t1 和 t2 执行完成
        t1.join();
        t2.join();
        System.out.println(sum);
    }
}

上面的sum也需要使用volatile进行修饰,因为如果某个线程++之后,如果另外一个线程没有更新最新的值就进行++的话,在数据更新回主内存的时候就会覆盖原来的值,最终的结果就会变小,因此需要使用volatile进行修饰。

在上文当中我们主要分析了如何使用忙等待来解决我们的问题,但是忙等待有一个很大的问题就是线程会不断的进行循环,这很消耗CPU资源,在下文当中我们将主要介绍两种方法解决本文开头提出的问题,而且可以不进行循环操作,而是将线程挂起,而不是一直在执行。

synchronized并发安全版本

synchronizedjava语言的关键字,它可以用来保证程序的原子性。在并发的情况下我们可以用它来保证我们的程序在某个时刻只能有一个线程执行,保证同一时刻只有一个线程获得synchronized锁对象。

public class Sum01 {
    public static int sum;

    public static synchronized void addSum() {
        for (int i = 0; i < 100000; i++)
            sum++;
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(Sum01::addSum);
        Thread t2 = new Thread(Sum01::addSum);

        t1.start();
        t2.start();
        // 让主线程等待 t1 和 t2
        // 直到 t1 和 t2 执行完成
        t1.join();
        t2.join();

        System.out.println(sum);
    }
}

// 输出结果
200000

上面的代码addSum方法加入了synchronized进行修饰,在java当中被synchronized的静态方法在同一个时刻只能有一个线程能够进入,也就是说上面的代码会让线程t1或者t2先执行addSum函数,然后另外一个线程在进行执行,那这个跟串行执行就一样了。那么我们就可以不在静态方法上加synchronized的关键字,可以使用静态代码块:

public class Sum02 {

    public static int sum;
    public static Object lock = new Object();

    public static void addSum() {
        for (int i = 0; i < 100000; i++)
            synchronized (lock) {
            sum++;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(Sum02::addSum);
        Thread t2 = new Thread(Sum02::addSum);
        t1.start();
        t2.start();
        // 让主线程等待 t1 和 t2
        // 直到 t1 和 t2 执行完成
        t1.join();
        t2.join();

        System.out.println(sum);
    }
}

上面代码虽然没有使用用synchronized修饰的静态方法,但是上面的代码使用了用synchronized修饰的同步代码块,在每一个时刻只能有一个线程执行下面这段代码:

// synchronized 修饰的代码块称作同步代码块 ,
// lock 是一个 全局的静态类变量 只有竞争到 lock 对象的线程
// 才能够进入同步代码块 同样的每一个时刻只能有一个线程进入
synchronized (lock) {
    sum++;
}

上面代码虽然没有使用静态同步方法(synchronized修饰的静态方法),但是有同步代码块(synchronized修饰的代码块),在一个代码当中会有100000次进入同步代码块,这里也花费很多时间,因此上面的代码的效率也不高。

其实我们可以用一个临时变量存储100000次加法的结果,最后一次将结果加入到data当中:

public class Sum03 {
    public static int sum;
    public static Object lock = new Object();

    public static void addSum() {
        int tempSum = 0;
        for (int i = 0; i < 100000; i++)
            tempSum++;
        synchronized (lock) {
            sum += tempSum;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(Sum03::addSum);
        Thread t2 = new Thread(Sum03::addSum);
        t1.start();
        t2.start();
        // 让主线程等待 t1 和 t2
        // 直到 t1 和 t2 执行完成
        t1.join();
        t2.join();

        System.out.println(sum);
    }
}

使用锁进行临界区的保护

临界区:像这种与数据竞争有关的代码块叫做临界区,比如上文代码当中的sum++

java当中除了使用synchronized形成同步方法或者同步代码块的方法保证多个线程访问临界区的顺序,还可以使用锁对临界区进行保护。在java当中一个非常常用的锁就是可重入锁ReentrantLock,可以保证在lockunlock之间的区域在每一个时刻只有一个线程在执行。

import java.util.concurrent.locks.ReentrantLock;

public class Sum04 {

    public static int sum;
    public static ReentrantLock lock = new ReentrantLock();


    public static void addSum() {
        int tempSum = 0;
        for (int i = 0; i < 100000; i++)
            tempSum++;
        lock.lock();
        try {
            sum += tempSum;
        }catch (Exception ignored) {

        }finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(Sum04::addSum);
        Thread t2 = new Thread(Sum04::addSum);
        t1.start();
        t2.start();
        // 让主线程等待 t1 和 t2
        // 直到 t1 和 t2 执行完成
        t1.join();
        t2.join();

        System.out.println(sum);
    }
}

synchronized和锁对可见性的影响

在上文当中我们仅仅提到了synchronized和锁可以保证在每一个时刻只能有一个线程在临界区当中执行,这一点其实前面的忙等待也可以实现,但是后面我们提到了第一版忙等待的实现是有错误的,在程序运行的时候程序会陷入死循环。因此synchronized和锁也会有这样的问题,但是为什么synchronized和锁可以使得程序能够正常执行呢?原因如下:

  • synchronized关键字能够保证可见性,synchronized 关键字能够不仅能够保证同一时刻只有一个线程获得锁,并且还会确保在锁释放之前,会将对变量的修改刷新到主内存当中。
  • JDK给我们提供的锁工具(比如ReentrantLock)也能够保证可见性,锁的lock方法能够保证在同一时刻只有一个线程获得锁然后执行同步方法,并且会确保在锁释放(锁的unlock方法)之前会将对变量的修改刷新到主内存当中。

数据竞争的知名后果——死锁

死锁是由于多个线程相互之间竞争资源而形成的一种相互僵持的局面。

在前面的忙等待当中的那个错误如果不仔细思考是很容易忽略的,由此可见数据竞争给并发编程带来的难度。下面一个典型的存在死锁可能的代码:

public class DeadLock {

    public static Object fork = new Object();
    public static Object chopsticks = new Object();

    public static void fork() {
        System.out.println(Thread.currentThread().getName() +  "想获得叉子");
        synchronized (fork) {
            System.out.println(Thread.currentThread().getName() +  "已经获得叉子");
            System.out.println(Thread.currentThread().getName() +  "想获得筷子");
            synchronized (chopsticks) {
                System.out.println(Thread.currentThread().getName() +  "已经获得筷子");
            }
        }
    }

    public static void chopsticks() {
        System.out.println(Thread.currentThread().getName() +  "想获得筷子");
        synchronized (chopsticks) {
            System.out.println(Thread.currentThread().getName() +  "已经获得筷子");
            System.out.println(Thread.currentThread().getName() +  "想获得叉子");
            synchronized (fork) {
                System.out.println(Thread.currentThread().getName() +  "已经获得叉子");
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(DeadLock::fork);

        Thread t2 = new Thread(DeadLock::chopsticks);
        t1.start();
        t2.start();

        t1.join();
        t2.join();
    }
}
// 输出结果
Thread-0想获得叉子
Thread-1想获得筷子
Thread-0已经获得叉子
Thread-0想获得筷子
Thread-1已经获得筷子
Thread-1想获得叉子
// 在这里已经卡死
  • 线程1想先获得叉子,再获得筷子。
  • 线程2想先获得筷子,再获得叉子。

假如有一个状态线程1已经获得叉子,线程2已经获得筷子,现在线程1想获得线程2手中的筷子,线程2想获得线程1的叉子,由此产生一个窘境,两个线程都想从对方获得东西,也就是说两个线程都无法得到资源,两个线程都卡死了,因而产生了死锁。根据上面的分析我们可以发现死锁产生最根本的原因还是数据竞争。

死锁产生的条件

  • 互斥条件:一个资源只能被一个线程占有,如果其他线程想得到这个资源必须由其他线程释放。
  • 不剥夺条件:一个线程获得的资源在没有使用完之前不能被其他线程强行获得,只能由线程主动释放。
  • 请求保持条件:至少保持了一个资源而且提出新的资源请求,比如上面线程1获得了叉子又请求筷子。
  • 循环等待条件:线程1请求线程2的资源,线程2请求线程3的资源,....,线程\(N\)请求线程1的资源。

如果想破坏死锁,那就去破坏上面四个产生死锁的条件即可。

总结

在本篇文章当中主要通过一道并发的题,通过各种方法去解决,在本文当中最重要的例子就是忙等待了,在忙等待的例子当中我们分析我们程序的问题,体会了数据竞争问题的复杂性,他会给我们的并发程序造成很多的问题,比如后面提到的死锁的问题。除此之外我们还介绍了关于volatilesynchronized还有锁对数据可见性的影响。

以上就是本文所有的内容了,希望大家有所收获,我是LeHung,我们下期再见!!!(记得点赞收藏哦!)


更多精彩内容合集可访问项目:https://github.com/Chang-LeHung/CSCore

关注公众号:一无是处的研究僧,了解更多计算机(Java、Python、计算机系统基础、算法与数据结构)知识。

相关文章: