多线程内幕

时间:2022-05-02
本文章向大家介绍多线程内幕,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

本文是HinusWeekly第三期的第二篇文章,第三期的主题就是多线程编程。本文试图从单核CPU的角度讨论并发编程的困难。

函数调用的过程,就是不断地创建栈帧,销毁栈帧。实际上,多线程程序的执行只是这个模型的一种推广,也就是每一个线程都拥有自己独立的栈空间。

我们看一下这个程序:

public class TestOne {
    public static void main(String[] args) throws Exception {
        Thread t1 = new Thread() {
            public void run() {
                int t = add(1, 2);
                System.out.println(t);
            }
        };

        Thread t2 = new Thread() {
            public void run() {
                int t = add(3, 4);
                System.out.println(t);
            }
        };

        t1.start();
        t2.start();

        t1.join();
        t2.join();
    }

    public static int add (int a, int b) {
        return a + b;
    }
}

多次运行这个程序,每一次得到的结果可能都不一样。有时候可能是"3,7",但有时候又可能是"7,3"。

这段程序的意思就是开启两个线程,一个计算1+2,一个计算3+4。虽然 t1.start 是在 t2.start之前调用的,但这并不意味着t1就一定会在t2之前打印出计算结果。

t1的执行和t2的执行实际上是交替执行的。(如果是在多核机器上,可能是在不同的核上去执行的)我们看下面的这张图:

左边代表 t1 运行时的栈空间,右边代表 t2 运行时的栈空间。在单核的情况下,CPU会在t1上干一会活,然后保存t1的现场,转到t2上再干一会儿,然后保存t2的现场,再转回t1上,把现场恢复了,从刚才停下的地方继续干t1的活儿。而所谓现场,在现阶段,我们就理解为栈空间,大致是不会错的(其实,还有很多东西是保存在control block中的,但我们不去抠那么细节的东西,学习一个新的知识就是这样,先掌握其大概,然后再逐步细化,而不要在一开始就追求面面俱到)。所以你就可以认为CPU在这两个栈空间之间切来切去。

至于干到什么时候停下来,转到隔壁去,以及如何能够保存现场,恢复现场,这些都是CPU和操作系统要关心的,做为Java程序员,我们是不必关心的(我希望读者能够理解这些机制,但是我们的课程内容有限,不可能做到面面俱到,所以我把这些内容都安排到作业里去了,希望读者能认真完成课后习题)。我们只知道,多个线程在并发执行的时候,其运行结果是不确定的,依赖于操作系统的调度。

这个现象就说明了多线程编程为什么这么困难。线程之间,各个指令的执行顺序是不确定的。而写程序,最怕的就是不确定性。我们再看一个例子:

public class TestTwo {
    public int total = 0;
    public static void main(String[] args) throws Exception{
        TestTwo test = new TestTwo();

        Thread t1 = new Thread() {
            public void run() {
                for (int i = 0; i < 5_000; i++) {
                    test.total += 1;
                }
            }
        };

        Thread t2 = new Thread() {
            public void run() {
                for (int i = 0; i < 5_000; i++) {
                    test.total += 1;
                }
            }
        };

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println(test.total);
    }
}

我运行这个例子三次,结果分别是10000,7515,7767。大家可以在不同的机器上多运行几次,你会发现,几乎每一次结果都不相同。

这个例子中,我们开启了两个线程,每个线程都对全局变量 test.total 执行加一的操作,每个线程执行5000次,那么两个线程就执行了一万次。可是为什么每一次的结果都不相同呢?

这是因为,做一次加法,实际上,包含了很多条机器指令。一条高级语言的语句,例如Java,C++等语言,会被翻译成多条机器指令来执行。机器指令是CPU真正看懂的指令。把高级语言翻译成机器语言的工作是由编译器完成的。

在Java中执行一次加一的操作,至少包含了以下几个步骤:

1. 将原来变量的值从内存读入到寄存器中

2. 在寄存器中执行加一操作

3. 把寄存器中的值写回到内存里去

当然,这是化简的情况,真实的情况比我这里写的要复杂得多。我们还是先抛去细节不讨论。这三个步骤就足够说明问题了。

假如,现在变量的值是10,线程1从内存中读到的值就是10,放入到寄存器rax里,这里CPU发生了线程间的切换。那么线程1会把当前的现场保存起来(rax里是10),然后切换到线程2,线程2也去内存中读取 total 的值,当然也是10,放入寄存器rax里,然后执行加一操作,rax里变为11,然后再把11写回到内存里,也就是说total已经变成了11,然后这时候,CPU又切换了线程,回到线程1,马上要做的事情就是恢复现场。刚才切换之前rax里的值是10,恢复完了以后还会是10,然后执行加一操作,变为11,再写回内存。这时就发生错误了。线程2的那次加一操作就被线程一给覆盖掉了。

上面的分析过程也是我们调试多线程编程的一种重要思路,就是随机推演一下CPU在什么时候切换,会带来什么样的问题。因为CPU在任何时候都是有可能切换的,所以有时候测试通过了,也未必意味着你的程序就是正确的,必须经得起这种理论的推敲才行。

后续的文章将会陆续介绍几种控制并发程序的方法。