# 并发:介绍

经典观点:一个程序只有一个执行点(一个程序计数器,存放执行的指令),那么所有的执行都是顺序执行的。但是多线程(multi-threaded)程序有多个执行点,此处区别进程,多线程程序中,线程之间共享地址空间,能够访问到相同的数据。

类比进程,当两个线程运行在一个处理器上,从运行 A 线程到运行 B 线程,一定会发生上下文切换(context switch),进程的状态保存到 PCB 中,线程的状态保存到 TCB(Thread Control Block)中。唯一与进程上下文切换不同的是,线程切换上下文,地址空间保持不变,也就是不需要切换当前使用的页表

在并发中最经典的程序问题就是多个线程累加同一个变量:

static volatile int counter = 0;
void *
mythread(void *arg){
    printf("%s: begin\n", (char *) arg);
    int i;
    for (i = 0; i < 1e7; i++) {
    	counter = counter + 1;
    }
    printf("%s: done\n", (char *) arg);
	return NULL;
}
int
main(int argc, char *argv[]){
    pthread_t p1, p2;
    printf("main: begin (counter = %d)\n", counter);
    Pthread_create(&p1, NULL, mythread, "A");
    Pthread_create(&p2, NULL, mythread, "B");
    
    // join waits for the threads to finish
    Pthread_join(p1, NULL);
    Pthread_join(p2, NULL);
    printf("main: done with both (counter = %d)\n", counter);
    return 0;
}

希望最终 counter 得到的结果是 20000000,但是往往结果不是对的,而且每次都不一样。关键的指令在于:

mov 0x8049a1c, %eax
add $0x1, %eax
mov %eax, 0x8049a1c

变量 counter 位于地址 0x8049a1c 。在这 3 条指令中,先用 x86 的 mov 指令,从内存地址处取出值,放入 eax 。然后,给 eax 寄存器的值加 1(0x1)。最后, eax 的值被存回内存中相同的地址。

这其实就是 c 代码中的 counter = counter + 1整个程序失败的点在于这行 c 代码不是原子执行。如果线程 A 在执行完 add 指令后中断,此时 counter 还是 50,A 线程的 eax=51 ,轮到 B 执行完这三行指令,此时 counter=51 ,再次轮到 A,就会将 51 再次存入 counter ,相当于 A 线程在本次循环中并没有使 counter 增加。

由于执行这段代码的多个线程可能导致竞争状态,因此我们将此段代码称为临界区 (critical section)。临界区是访问共享变量(或更一般地说,共享资源)的代码片段,一定不能由多个线程同时执行

我们真正想要的代码就是的互斥(mutual exclusion)。这个属性保证了如果一个线程在临界区内执行,其他线程将被阻止进入临界区。

这些术语都是 Dijkstra 创造的,此处给出一些术语补充:

  • 临界区(critical section)是访问共享资源的一段代码,资源通常是一个变量或数据结构。
  • 竞态条件(race condition)出现在多个执行线程大致同时进入临界区时,它们都试图更新共享的数据结构,导致了令人惊讶的(也许是不希望的)结果。
  • 不确定性(indeterminate)程序由一个或多个竞态条件组成,程序的输出因运行而异,具体取 决于哪些线程在何时运行。这导致结果不是确定的(deterministic),而我们通常期望计算机系统给出确 定的结果。
  • 为了避免这些问题,线程应该使用某种互斥(mutual exclusion)原语。这样做可以保证只有一 个线程进入临界区,从而避免出现竞态,并产生确定的程序输出。

为了保证安全的访问临界区,有两种方法,要么对临界区的操作是原子操作,要么就是对临界区的操作上锁。将一系列动作原子化(atomic)背后的想法可以简单用一个短语表达:“全部或没有”。要么都发生了,要么都没有发生,不会出现中间状态。

本部分最重要的部分就是对临界区和资源的理解,本质上来说,我们需要锁住的不是临界区,而是临界区访问的资源,有可能很多个不同的临界区都会访问同一个资源。

# 并发:锁

锁的本质就是一个变量,现实生活中,锁的状态只有打开和关闭两种状态(别和我扯什么撬开状态),锁变量其实也就是表示锁的状态,最简单的就是 0 表示打开,1 表示关闭。复杂点的,我们也可以保存:持有锁的线程,请求获取锁的线程队列,但这些信息会隐藏起来,锁的使用者不会发现。

在 c 语言中, lock()unlock() 函数分别表示尝试获取锁和释放锁。在其他语言中,也有类似的方法。

# 评价锁

我们需要一些标准来评判一把锁的实现效果:

  • 互斥性:这是最基本的,阻止多个线程进入临界区。
  • 公平性fairness ):当锁可用时,是否每一个竞争线程有公平的机会抢到锁?这其实考验的是等待队列的设计,像 AQS 使用的就是先进先出。
  • 性能:是使用锁之后增加的时间开销。考虑这个是非常必要的,因为有时真的只有一个线程在执行,但是它还是要执行上锁解锁的操作,当然,还有其他需要考虑的场景。

# 控制中断

最早的互斥方案之一就是在临界区关闭中断,也就是说,此时线程 A 在临界区不会发生上下文切换。这个解决方案是为单处理器 系统开发的。

void lock() {
    DisableInterrupts();
}
void unlock() {
    EnableInterrupts();
}

控制中断是使用特殊的硬件指令,背后的实现是由硬件支持的, CAS 也是由硬件支持的。这种方法优点就是简单,但是缺点十分明显:

  • 这种方法要求我们允许所有调用线程执行特权操作(打开 / 关闭中断),但是恶意程序一开始调用关闭中断就会一直霸占处理器。

  • 多处理器不适用。多个线程在多 CPU 上,每个线程都试图进入同一个临界区,关闭中断也没用。

  • 关闭中断导致中断丢失,可能会导致严重的系统问题。假如磁盘设备完成了读 取请求,但 CPU 错失了这一事实,那么,操作系统如何知道去唤醒等待读取的进程?

  • 效率低。与正常指令执行相比,现代 CPU 对于关闭和打 开中断的代码执行得较慢。

# 原子交换

如果不依赖硬件,我们可以这么实现一个锁:

typedef struct lock_t {int flag;} lock_t;
void init(lock_t *mutex) {
    mutex->flag = 0; // 0 表示没有上锁
}
void lock(lock_t *mutex) {
    while(mutex->flag == 1) {
        // 什么都不做,空等待,这就叫做 spin-wait   自旋
    }
    mutex->flag = 1;
}
void unlock(lock_t *mutex) {
    mutex->flag = 0;
}

可以看到这种锁有一些问题:

  • 正确性:你可能会觉得上述代码完全没有问题的,但是如果 AB 两个线程都调用 lock 函数,假设 A 先调用,然后经过了 while 循环,此时发生中断,轮到 B,经过 while 循环,又发生中断,那么此时两个线程相当于都拿到了锁,因为他们之后都会执行 mutex->flag = 1; 的操作。导致问题的本质就是 —— 上锁的过程不是原子的
  • 性能:可以看到,如果一个线程拿不到锁,就会一直自旋,直到它的时间片执行完或者拿到锁。自旋等待在等待其他线程释放锁的 时候会浪费时间。尤其是在单处理器上,一个等待线程等待的目标线程甚至无法运行(至 少在上下文切换之前)!

为了解决这些问题,我们需要硬件的支持 —— 原子交换。它们基本上在不同的平台上做同样的事,通常称为测试并设置指令(test-and-set)。回顾上述代码,我们在 lock() 函数中的行为可以归为:测试 flag ,设置 flag

这个指令的工作内容,我们用 c 代码演示一下:

int TestAndSet(int *old_ptr, int new) {
    int old = *old_ptr;
    *old_ptr = new;
    return old;
}

只不过这个操作是原子进行的。它原子的实现了:给变量赋予新值并返回旧值。那我们的锁的实现代码就可以改为:

void lock(lock_t *mutex) {
    while(TestAndSet(&mutex->flag, 1) == 1)
        ;// spin-wait
}

只有当旧值为 0 才算此次上锁成功。这样就是原子上锁了,尽管还是没有解决自旋浪费性能的问题。这样的锁就叫做自旋锁(spin lock)。

自旋锁不提供公平性,在竞争状态下,自旋的线程甚至会永远自旋(回忆一下进程切换,如果不人为的设置一些 FIFO 什么的,有些进程会被饿死)。同时,自旋锁在单 CPU 下开销很大,在多 CPU 情况该性能不错。

上文讲了测试并切换 TAS ,其实还有比较并切换 CAS ,c 语言伪代码为:

int compareAndSwap(int *ptr, int expected, int new) {
    int actual = *ptr;
    if(actual == expected)
        *ptr = new;
    return actual;
}

同样是返回旧值(你可以思考一下为什么返回的是旧值),总体思路就是,检测变量的旧值是否是期望值,如果是,就赋新值,返回旧值。如果用 CAS 实现上述自旋锁:

void lock(lock_t *mutex) {
    while(CompareAndSet(&mutex->flag ,0, 1) == 1) {
        ; //spin-wait
    }
}

# 解决自旋

  • 让出时间片,即在要自旋的时候,放弃 CPU。
void lock(lock_t *mutex) {
    while(CompareAndSet(&mutex->flag ,0, 1) == 0) {
        yield();
    }
}

我们假定操作系统提供原语 yield() ,线程可以调用它主动放弃 CPU, 让其他线程运行。 yield() 系统调用能让线程由 running 变为 readying 。但是这个方法没有处理本质问题:频繁的上下文切换。线程饿死的情况也没解决。

  • 使用队列:休眠代替自旋

前面一些方法的真正问题是存在太多的偶然性。因此,我们必须显式地施加某种控制,决定锁释放时,谁能抢到锁(这样才能保证公平的)。

处于休眠状态的线程永远不会分配到 CPU 资源,当等待的事件出现,休眠状态的线程才会转换到可运行的状态。

在 c 语言中,Solaris 提供了两个调用: park()/unpark(ThreadID) 。前者能够让调用线程休眠,后者会唤醒 ThreadID 标识的线程。使用两个调用实现锁,可以让调用者在获取不到锁时休眠,在锁可用时被唤醒,看一下《操作系统导论》里面给的例子:

typedef struct lock_t {
    int flag;
    int guard;
    queue_t *q;
} lock_t;
void lock_init(lock_t *m) {
    m->flag = 0;
    m->guard = 0;
    queue_init(m->q);
}
void lock(lock_t *m) {
    while(TestAndSet(&m->guard, 1) == 1)
        ;// spin-wait
    
    if(m->flag == 0) {
        m->flag = 1; // 获得锁
        m->guard = 0;
    } else {
        queue_add(m->q, gettid());
        m->guard = 0;
        park();
    }
}
void unlock(lock_t *m) {
    while(TestAndSet(&m->guard, 1) == 1) 
        ;// spin-wait
    
    if(queue_empty(m->q)) 
        m->flag = 0; // 没有线程竞争锁
    else 
        unpark(queue_remove(m->q)); // 从队首取出线程,不需要改变 flag
    m->guard = 0;
}

下面解释一下代码:

  • flag :表示锁是否被占用,0 表示没有线程占用锁,1 表示有线程
  • guard :注意,线程获取锁并不是一个原子过程( lock 函数),但是我们应该让 lock()/unlock() 在某些代码保证并发安全。之前我们的做法:
void lock(lock_t *mutex) {
    while(CompareAndSet(&mutex->flag ,0, 1) == 1) {
        ; //spin-wait
    }
}

确实能保证 lock 内部的并发安全,但是使用 flag 自旋的缺点上文也详述过。所以不能用锁的状态来自旋。此处使用 guard 来自旋。当 guard=0 时,表示没有线程正在改变锁的状态;当 guard=1 时,表示有线程正在改变锁的状态,此时不允许任何线程也跟着来修改锁的状态。

锁的状态包括了 flag,queue。

所以其他线程都会先围绕 guard 自旋一会。这个过程非常短,因为线程修改锁的状态花费的时间也非常短

  • 释放锁时,如果队列里还有线程在等待,不需要改变 flag 。举个例子,假设临界区代码为:
lock(&my_lock);
count++; // 临界区
unlock(&my_lock);

假设只有 AB 两个线程,线程 A 在获取锁后,B 也获取锁,失败后 B 加入等待队列。当 A 执行到 unlock() 代码后,唤醒 B 线程,也许 B 不会立刻被分配时间片,但是此时 flag 仍然为 1,其他线程哪怕拿到时间片获取锁,也需要休眠进入队列。直到 B 分配到时间片,B 从哪里开始继续执行呢?

queue_add(m->q, gettid());
m->guard = 0;
park(); // 从这里被唤醒,也就是 lock () 函数的结尾

相当于在 lock() 结束, count++; 之前被唤醒。在临界区被唤醒,天生自带锁。

# 参考

https://www.jianshu.com/p/3e79ae25bfb6

https://pages.cs.wisc.edu/~remzi/OSTEP/Chinese/26.pdf

https://pages.cs.wisc.edu/~remzi/OSTEP/Chinese/28.pdf