你好,我是宫文学。

现代的编程语言,开始越来越多地采用并发计算的模式。这也对语言的设计和编译技术提出了要求,需要能够更方便地利用计算机的多核处理能力。

并发计算需求的增长跟两个趋势有关:一是,CPU 在制程上的挑战越来越大,逼近物理极限,主频提升也越来越慢,计算能力的提升主要靠核数的增加,比如现在的手机,核数越来越多,动不动就 8 核、12 核,用于服务器的 CPU 核数则更多;二是,现代应用对并发处理的需求越来越高,云计算、人工智能、大数据和 5G 都会吃掉大量的计算量。

因此,在现代语言中,友好的并发处理能力是一项重要特性,也就需要编译技术进行相应的配合。现代计算机语言采用了多种并发技术,包括线程、协程、Actor 模式等。我会用三讲来带你了解它们,从而理解编译技术要如何与这些并发计算模式相配合。

这一讲,我们重点探讨线程模式,它是现代计算机语言中支持并发的基础模式。它也是讨论协程和 Actor 等其他话题的基础。

不过在此之前,我们需要先了解一下并发计算的一点底层机制:并行与并发、进程和线程。

并发的底层机制:并行与并发、进程与线程

我们先来学习一下硬件层面对并行计算的支持。

假设你的计算机有两颗 CPU,每颗 CPU 有两个内核,那么在同一时间,至少可以有 4 个程序同时运行。

后来 CPU 厂商又发明了超线程(Hyper Threading)技术,让一个内核可以同时执行两个线程,增加对 CPU 内部功能单元的利用率,这有点像我们之前讲过的流水线技术。这样一来,在操作系统里就可以虚拟出 8 个内核(或者叫做操作系统线程),在同一时间可以有 8 个程序同时运行。这种真正的同时运行,我们叫做并行(parallelism)。

图 1:虚拟内核与 CPU 真实内核的对应关系

可是仅仅 8 路并行,也不够用呀。如果你去查看一下自己电脑里的进程数,会发现运行着几十个进程,而线程数就更多了。

所以,操作系统会用分时技术,让一个程序执行一段时间,停下来,再让另一个程序运行。由于时间片切得很短,对于每一个程序来说,感觉上似乎一直在运行。这种“同时”能处理多个任务,但实际上并不一定是真正同时执行的,就叫做并发(Concurrency)。

实际上,哪怕我们的计算机只有一个内核,我们也可以实现多个任务的并发执行。这通常是由操作系统的一个调度程序(Scheduler)来实现的。但是有一点,操作系统在调度多个任务的时候,是有一定开销的:

  1. 一开始是以进程为单位来做调度,开销比较大。
  2. 在切换进程的时候,要保存当前进程的上下文,加载下一个进程的上下文,也会有一定的开销。由于进程是一个比较大的单位,其上下文的信息也比较多,包括用户级上下文(程序代码、静态数据、用户堆栈等)、寄存器上下文(各种寄存器的值)和系统级上下文(操作系统中与该进程有关的信息,包括进程控制块、内存管理信息、内核栈等)。

相比于进程,线程技术就要轻量级一些。在一个进程内部,可以有多个线程,每个线程都共享进程的资源,包括内存资源(代码、静态数据、堆)、操作系统资源(如文件描述符、网络连接等)和安全属性(用户 ID 等),但拥有自己的栈和寄存器资源。这样一来,线程的上下文包含的信息比较少,所以切换起来开销就比较小,可以把宝贵的 CPU 时间用于执行用户的任务。

总结起来,线程是操作系统做并发调度的基本单位,并且可以跟同一个进程内的其他线程共享内存等资源。操作系统会让一个线程运行一段时间,然后把它停下来,把它所使用的寄存器保存起来,接着让另一个线程运行,这就是线程调度原理。你要在大脑里记下这个场景,这样对理解后面所探讨的所有并发技术都很有帮助。

图 2:进程的共享资源和线程私有的资源

我们通常把进程作为资源分配的基本单元,而把线程作为并发执行的基本单元。不过,有的时候,用进程作为并发的单元也是比较好的,比如谷歌浏览器每打开一个 Tab 页,就新启动一个进程。这是因为,浏览器中多个进程之间不需要有互动。并且,由于各个进程所使用的资源是独立的,所以一个进程崩溃也不会影响到另一个。

而如果采用线程模型的话,由于它比较轻量级,消耗的资源比较少,所以你可以在一个操作系统上启动几千个线程,这样就能执行更多的并发任务。所以,在一般的网络编程模型中,我们可以针对每个网络连接,都启动一条线程来处理该网络连接上的请求。在第二个模块中我们分析过的MySQL就是这样做的。你每次跟 MySQL 建立连接,它就会启动一条线程来响应你的查询请求。

采用线程模型的话,程序就可以在不同线程之间共享数据。比如,在数据库系统中,如果一个客户端提交了一条 SQL,那么这个 SQL 的编译结果可以被缓存起来。如果另一个用户恰好也执行了同一个 SQL,那么就可以不用再编译一遍,因为两条线程可以访问共享的内存。

但是共享内存也会带来一些问题。当多个线程访问同样的数据的时候,会出现数据处理的错误。如果使用并发程序会造成错误,那当然不是我们所希望的。所以,我们就要采用一定的技术去消除这些错误。

Java 语言内置的并发模型就是线程模型,并且在语法层面为线程模型提供了一些原生的支持。所以接下来,我们先借助 Java 语言去了解一下,如何用编译技术来配合线程模型。

Java 的并发机制

Java 从语言层面上对并发编程提供了支持,简化了程序的开发。

Java 对操作系统的线程进行了封装,程序员使用 Thread 类或者让一个类实现 Runnable 接口,就可以作为一个线程运行。Thread 类提供了一些方法,能够控制线程的运行,并能够在多个线程之间协作。

从语法角度,与并发有关的关键字有 synchronized 和 volatile。它们就是用于解决多个线程访问共享内存的难题。

synchronized 关键字:保证操作的原子性

我们通过一个例子,来看看多个线程访问共享数据的时候,为什么会导致数据错误。

public class TestThread {
public static void main(String[] args) {
Num num = new Num();

    for (int i = 0; i < 3; i++) {  
        new NewThread(num).start();  
    }  
}  

}

//线程类 NewThread 对数字进行操作
class NewThread extends Thread {
private Num num;

public NewThread(Num num) {  
    this.num = num;  
}  

@Override  
public void run() {  
    for (int i = 0; i < 1000; i++)  
        num.add();  
    System.out.println("num.num:" + num.value);  
}  

}

//给数字加 1
class Num {
public int value = 0;
public void add() {
value += 1;
}
}

在这个例子中,每个线程对 Num 中的 value 加 1000 次。按说总有一个线程最后结束,这个时候打印出来的次数是 3000 次。可实际运行的时候,却发现很难对上这个数字,通常都要小几百。下面是几次运行的结果:

要找到其中的原因,最直接的方法,是从 add 函数的字节码入手研究。学习过编译原理之后,你要养成直接看字节码、汇编码来研究底层机制的习惯,这样往往会对问题的研究更加透彻。add 函数的字节码如下:

0: aload_0 #加载 Num 对象
1: dup #复制栈顶对象 (Num)
2: getfield #弹出一个 Num 对象,从内存取出 value 的值,加载到栈
5: iconst_1 #加载整数 1 到栈
6: iadd #执行加法,结果放到栈中
7: putfield #栈帧弹出加法的结果和 Num 对象,写字段值,即把 value 的值写回内存
10: return

看着这一段字节码,你是不是会重新回忆起加法的计算过程?它实际上是 4 个步骤:

  1. 从内存加载 value 的值到栈;
  2. 把 1 加载到栈;
  3. 从栈里弹出 value 的值和 1,并做加法;
  4. 把新的 value 的值存回到内存里。

这是一个线程执行的过程。如果是两个以上的线程呢?你就会发现有问题了。线程 1 刚执行 getfield 取回 value 的值,线程 2 也做了同样的操作,那么它们取到的值是同一个。做完加法以后,写回内存的时候,写的也是同一个值,都是 3。

图 3:多个线程访问共享内存的情况

这样一分析,你就能理解数字计算错误的原因了。总结起来,出现这种现象是因为对 value 的加法操作不符合原子性(Atomic)。原子性的意思是一个操作或者多个操作,要么全部执行,并且执行的过程不会被任何因素打断,要么就都不执行。如果对 value 加 1 是一个原子操作,那么线程 1 一下子就操作完了,value 的值从 2 一下子变成 3。线程 2 只能接着对 3 再加 1,无法在线程 1 执行到一半的时候,就已经介入。

解决办法就是,让这段代码每次只允许一个线程执行,不会出现多个线程交叉执行的情况,从而保证对 value 值修改的原子性。这个时候就可以用到 synchronized 关键字了:

public void add() {
synchronized(this){
value += 1;
}
}

这样再运行示例程序,就会发现,总有一个线程打印出来的值是 3000。这证明确实一共对 value 做了 3000 次加 1 的运算。

那 synchronized 关键字作用的原理是什么呢?要回答这个问题,我们还是要研究一下 add() 方法的字节码。

在字节码中,你会发现两个特殊的指令:monitorenter 和 monitorexit 指令,就是它们实现了并发控制。

查看字节码的描述,我们可以发现 monitorenter 的作用,是试图获取某个对象引用的监视器(monitor)的所有权。什么是监视器呢?在其他文献中,你可能会读到“锁”的概念。监视器和锁其实是一个意思。这个锁是关联到一个 Num 对象的,也就是代码中的 this 变量。只有获取了这把锁的程序,才能执行块中的代码,也就是“value += 1”。

具体来说,当程序执行到 monitorenter 的时候,会产生下面的情况:

  1. 如果监视器的进入计数是 0,线程就会进入监视器,并将进入计数修改为 1。这个时候,该线程就拥有了该监视器。
  2. 如果该线程已经拥有了该监视器,那么就重新进入,并将进入计数加 1。
  3. 如果其他线程拥有该监视器,那么该线程就会被阻塞(block)住,直到监视器的进入计数变为 0,然后再重新试图获取拥有权。

monitorexit 指令的机制则比较简单,就是把进入计数减 1。如果一段程序被当前线程进入了多次,那么也会退出同样的次数,直到进入计数为 0。

总结起来,我们用了锁的机制,保证被保护的代码块在同一时刻只能被一个线程访问,从而保证了相关操作的原子性

到这里了,你可能会继续追问:如何保证获取锁的操作是原子性的?如果某线程看到监视器的进入计数是 0,这个时候它就进去,但在它修改进入计数之前,如果另一个线程也进去了怎么办,也修改成 1 怎么办?这样两个线程会不会都认为自己获得了锁?

这个担心是非常有必要的。实际上,要实现原子操作,仅仅从软件角度做工作是不行的,还必须要有底层硬件的支持。具体是如何支持的呢?我们还是采用一贯的方法,直接看汇编代码。

你可以用第 13 讲学过的方法,获取 Num.add() 方法对应的汇编代码,看看在汇编层面,监视器是如何实现的。我截取了一段汇编代码,并标注了其中的一些关键步骤,你可以看看。

汇编代码首先会跳到一段代码去获取监视器。如果获取成功,那么就跳转回来,执行后面对 value 做加法的运算。

我们再继续看一下获取监视器的汇编代码:

你特别需要注意的是 cmpxchg 指令:它能够通过一条指令,完成比较和交换的操作。查看Intel 的手册,你会发现更详细的解释:把 rax 寄存器的值与 cmpxchg 的目的操作数的值做对比。如果两个值相等,那么就把源操作数的值设置到目的操作数;否则,就把目的操作数的值设置到 rax 寄存器。

那 cmpxchg 指令有什么用呢?原来,通过这样一条指令,计算机就能支持原子操作。

比如,监视器的计数器的值,一开始是 0。我想让它的值加 1,从而获取这个监视器。首先,我根据 r11 寄存器中保存的地址,从内存中读出监视器初始的计数,发现它是 0;接着,我就把这个初始值放入 rax;第三步,我把新的值,也就是 1 放入 r10 寄存器。最后,我执行 cmpxchg 指令:

cmpxchg QWORD PTR [r11],r10

这个指令把当前的监视器计数,也就是内存地址是 r11 的值,跟 rax 的值做比较。如果它俩相等,仍然是 0,那就意味着没有别的程序去修改监视器计数。这个时候,该指令就会把 r10 的值设置到监视器计数中,也就是修改为 1。如果有别的程序已经修改了计数器的值,那么就会把计数器现在的值写到 rax 中。

补充:实际执行的时候,r10 中的值并不是简单的 0 和 1,而是获取了 Java 对象的对象头,并设置了其中与锁有关的标志位。

所以,通过 cmpxchg 指令,要么获得监视器成功,要么失败,肯定不会出现两个程序都以为自己获得了监视器的情况。

正因为 cmpxchg 在硬件级把原来的两个指令(比较指令和交换指令,Compare and Swap)合并成了一个指令,才能同时完成两个操作:首先看看当前值有没有被改动,然后设置正确的值。这也是 Java 语言中与锁有关的 API 得以运行的底层原理,也是操作系统和数据库系统加锁的原理。

不过,在汇编代码中,我们看到 cmpxchg 指令前面还有一个 lock 的前缀。这是起什么作用的呢?

原来呀,cmpxchg 指令在一个内核中执行的时候,可以保证原子性。但是,如果两个内核同时执行这条指令,也可能再次发生两个内核都去写入,从而都认为自己写成功了的情况。lock 前缀的作用,就是让这条指令在同一时间,只能有一个内核去执行。

所以说,要从根本上保证原子性,真不是一件容易的事情。不过,不管怎么说,通过 CPU 的支持,我们确实能够实现原子操作了,能让一段代码在同一个时间只让一个线程执行,从而避免了多线程的竞争现象。

上面说的 synchronized 关键字,是采用了锁的机制,保证被保护的代码块在同一时刻只能被一个线程访问,从而保证了相关操作的原子性。Java 还有另一个与并发有关的关键字,就是 volatile。

volatile 关键字:解决变量的可见性问题

那 volatile 关键字是针对什么问题的呢?我先来告诉你答案,它解决的是变量的可见性(Visibility)。

你可以先回想一下自己是不是遇到过这个问题:在并发计算的时候,如果两个线程都需要访问同一个变量,其中线程 1 修改了变量的值,那在多个 CPU 的情况下,线程 2 有的时候就会读不到最新的值。为什么呢?

图 4:不同线程使用不同的高速缓存的情形

因为 CPU 里都有高速缓存,用来提高 CPU 访问内存数据的速度。当线程 1 写一个值的时候,它不一定会马上被写回到内存,这要根据高速缓存的写策略来决定(这有点像你写一个文件到磁盘上,其实不会即时写进去,而是会先保存到缓冲区,然后批量写到磁盘,这样整体效率是最高的)。同样,当线程 2 读取这个值的时候,它可能是从高速缓存读取的,而没有从内存刷新数据,所以读到的可能是个旧数据,即使内存中的数据已经更新了。

volatile 关键字就是来解决这个问题的。它会告诉编译器:有多个线程可能会修改这个变量,所以当某个线程写数据的时候,要写回到内存,而不仅仅是写到高速缓存;当读数据的时候,要从内存中读,而不能从高速缓存读。

在下面的示例程序中,两个线程共享了同一个 Num 对象,其中线程 2 会去修改 Num.value 的值,而线程 1 会读取 Num.value 的值。

public class TestVolatile {
public static void main(String[] args) {
new TestVolatile().doTest();
}

public void doTest(){  
    Num num = new Num();  
    new MyThread1(num).start();  
    new MyThread2(num).start();          
}  

//线程 1:读取 Num.value 的值。如果该值发生了变化,那么就打印出来。  
class MyThread1 extends Thread {  
    private Num num;  
    public MyThread1(Num num) {  
        this.num = num;  
    }  

    @Override  
    public void run() {  
        int localValue = num.value;  
        while (localValue < 10){    
            if (localValue != num.value){ //发现 num.value 变了  
                System.out.println("Value changed to: " + num.value);  
                localValue = num.value;  
            }  
        }  
    }  
}  

//线程 2:修改 Num.value 的值。  
class MyThread2 extends Thread {  
    private Num num;  
    public MyThread2(Num num) {  
        this.num = num;  
    }  

    @Override  
    public void run() {  
        int localValue = num.value;  
        while(num.value < 10){  
            localValue ++;  
            System.out.println("Change value to: " + localValue);  
            num.value = localValue;  
            try {  
                Thread.sleep(500);  
            } catch (InterruptedException e) {  
                e.printStackTrace();  
            }  
        }  
    }  
}  

class Num {  
    public volatile int value = 0;  //用 volatile 关键字修饰 value  
}  

}

如果 value 字段的前面没有 volatile 关键字,那么线程 1 经常不能及时读到 value 的变化:

而如果加了 volatile 关键字,那么每次 value 的变化都会马上被线程 1 检测到:

通过这样一个简单的例子,你能更加直观地理解为什么可见性是一个重要的问题,并且能够看到 volatile 关键字的效果。所以,volatile 关键字的作用,是让程序在访问被修饰的变量的内存时,让其他处理器能够见到该变量最新的值。那这是怎么实现的呢?

原来这里用到了一种叫做内存屏障(Memory Barriers)的技术。简单地说,编译器要在涉及 volatile 变量读写的时候,执行一些特殊的指令,让其他处理器获得该变量最新的值,而不是自己的一份拷贝(比如在高速缓存中)。

根据内存访问顺序的不同,这些内存屏障可以分为四种,分别是 LoadLoad 屏障、StoreStore 屏障、LoadStore 屏障和 StoreLoad 屏障。以 LoadLoad 屏障为例,它的指令序列是:

Load1 指令
LoadLoad 屏障
Load2 指令

在这种情况下,LoadLoad 屏障会确保 Load1 的数据在 Load2 和后续 Load 指令之前,被真实地加载。

我们看一个例子。在下面的示例程序中,列出了用到 Load1 指令和 Load2 指令的场景。这个时候,编译器就要在这两条指令之间插入一个 LoadLoad 屏障:

class Foo{
volatile int a;
int b, c;
void foo(){
int i, j;
i = a; // Load1 指令,针对 volatile 变量
j = b; // Load2 指令,针对普通变量
}
}

关于另几种内存屏障的说明,以及在什么时候需要插入内存屏障指令,你可以看下这篇文章。

另外,不同的 CPU,对于这四类屏障所对应的指令是不同的。下图也是从上面那篇文章里摘出来的:

可以看到,对于 x86 芯片,其中的 LoadStore、LoadLoad 和 StoreStore 屏障,都用一个 no-op 指令,等待前一个指令执行完毕即可。这就能确保读到正确的值。唯独对于 StoreLoad 的情况,也就是我们 TestVolatile 示例程序中一个线程写、另一个线程读的情况,需要用到几个特殊的指令之一,比如 mfence 指令、cpuid 指令,或者在一个指令前面加锁(lock 前缀)。

总结起来,其实 synchronized 关键字也好,volatile 关键字也好,都是用来保证线程之间的同步的。只不过,synchronized 能够保证操作的原子性,但付出的性能代价更高;而 volatile 则只同步数据的可见性,付出的性能代价会低一点。

在 Java 语言规范中,在多线程情况下与共享变量访问有关的内容,被叫做 Java 的内存模型,并单独占了一节。这里面规定了在对内存(包括类的字段、数组中的元素)做操作的时候,哪些顺序是必须得到保证的,否则程序就会出错。

这些规定跟编译器的实现,有比较大的关系。编译器在做优化的时候,会对指令做重排序。在重排序的时候,一定要遵守 Java 内存模型中对执行顺序的规定,否则运行结果就会出错。

课程小结

今天这一讲,我主要以 Java 语言为例,讲解了多线程的原理,以及相关的程序语义。多个线程如果要访问共享的数据,通常需要进行同步。synchronized 关键字能通过锁的机制,保证操作的原子性。而 volatile 关键字则能通过内存屏障机制,在不同的处理器之间同步共享变量的值。

你会发现,在写编译器的时候,只有正确地理解了这些语义和原理,才能生成正确的目标代码,所以这一讲的内容你必须要理解。学会今天这讲,还有一个作用,就是能够帮助你加深对多线程编程的底层机制的理解,更好地编写这方面的程序。

其他语言在实现多线程机制时,所使用的语法可能不同,但底层机制都是相同的。通过今天的讲解,你可以举一反三。

我把今天这讲思维导图也整理出来了,供你参考:

一课一思

在你之前的项目经验中,有没有遇到并发处理不当而导致的问题?你是怎么解决的呢?欢迎分享你的经验。

参考资料

  1. The JSR-133 Cookbook for Compiler Writers,介绍了编译器如何为 Java 语言实现内存屏障。
  2. Java 语言规范中对内存模型的相关规定。