| 目录 |

39 多线程

C11 正式把多线程引入了 C 语言。它和 POSIX 线程196 惊人地相似——如果你用过的话。

如果你没用过,也别担心。我们会把它讲清楚。

不过要注意,我并不打算把这一章写成一份完整、经典的多线程操作指南197;那得专门去翻另一本很厚的书。抱歉!

线程是一个可选特性。如果 C11 及以上的编译器定义了 __STDC_NO_THREADS__,那么库中不会提供线程。他们为什么决定让这个宏用否定式的语义,我实在想不明白,但事实就是如此。

你可以这样检测:

#ifdef __STDC_NO_THREADS__
#error I need threads to build this program!
#endif

另外,构建时你可能需要指定某些链接器选项。在类 Unix 系统上,可以试着在命令行末尾加上 -lpthreads,以链接 pthreads 库198:

gcc -std=c11 -o foo foo.c -lpthreads

如果你的系统上出现链接器错误,那可能就是因为没有包含相应的库。

39.1 背景

线程是一种方式,能让你花钱买来的那些亮闪闪的 CPU 核心在同一个程序里为你干活。

通常,一个 C 程序只在单个 CPU 核心上运行。但如果你知道如何拆分工作,就可以把其中一部分交给若干线程,让它们同时干活。

虽然规范没有这么说,但在你的系统上,C(或者应它之请的操作系统)很可能会尝试把线程均衡地分配到你的所有 CPU 核心上。

如果你的线程比核心还多,也没关系。只是当它们都在争抢 CPU 时间时,你就无法完全获得那些收益了。

39.2 你能做的事

你可以创建一个线程。它会开始运行你指定的函数。而孵化它的父线程也会继续运行。

你也可以等待线程结束。这叫做汇合。

或者,如果你不在乎线程何时结束、也不想等,可以把它分离。

线程可以显式退出,也可以通过从自己的主函数返回来隐式收工。

线程还可以休眠一段时间,在其他线程运行时什么都不做。

主程序 main() 本身也是一个线程。

此外,我们还有线程局部存储、互斥量和条件变量。不过这些后面再说。现在先只看基础内容。

39.3 数据竞争与标准库

标准库中有一些函数(例如 asctime() 和 strtok())会返回或使用并非线程安全的 static 数据元素。但总的来说,除非另有说明,标准库都会努力做到线程安全199。

但要留个心眼。如果一个标准库函数在多次调用之间用某个不归你所有的变量维持状态,或者一个函数返回的指针指向你没传进去的东西,那它就不是线程安全的。

39.4 创建线程并等待线程

我们来搞点东西出来!

我们会创建一些线程(create),然后等待它们结束(join)。

不过我们得先弄明白一点点东西。

每一个线程都由一个 thrd_t 类型的不透明变量标识。它是你这个程序里每个线程独有的标识符。你创建一个线程时,它会被分配一个新的 ID。

另外,创建线程时你必须给它一个指向要运行的函数的指针,以及一个指向要传给它的实参的指针(如果没什么要传,就是 NULL)。

线程会从你指定的函数开始执行。

当你想等待某个线程结束时,必须指定它的线程 ID,这样 C 才知道该等哪一个。

所以基本思路是:

  1. 写一个函数来充当线程的“main”。它不是严格意义上的 main(),但与之类似。线程会从那里开始运行。
  2. 在主线程中用 thrd_create() 启动一个新线程,并把指向要运行的函数的指针传给它。
  3. 在那个函数里,让线程去做它该做的事。
  4. 与此同时,主线程可以继续做它该做的事。
  5. 当主线程决定这么做时,它可以调用 thrd_join() 等待子线程结束。一般来说,你必须对线程调用 thrd_join() 来做善后清理,否则就会泄漏内存200

thrd_create() 接受一个指向要运行的函数的指针,其类型为 thrd_start_t,也就是 int (*)(void *)。用“希腊语”说,就是“一个指向函数的指针,该函数接受一个 void* 作为实参,并返回 int”。

我们来创建一个线程!我们会从主线程用 thrd_create() 启动它来运行一个函数,再做些别的事情,然后用 thrd_join() 等它结束。我把线程的主函数命名为 run(),但你可以随便起名,只要类型与 thrd_start_t 匹配就行。

#include <stdio.h>
#include <threads.h>

// 这是线程将要运行的函数。名字可以随意。
//
// arg 是传给 `thrd_create()` 的参数指针。
//
// 父线程会从 `thrd_join()` 取回返回值
// 稍后。

int run(void *arg)
{
    int *a = arg;  // 我们将从 thrd_create() 传入一个 int*

    printf("THREAD: Running thread with arg %d\n", *a);

    return 12;  // 由 thrd_join() 取走的值(12 是随便选的)
}

int main(void)
{
    thrd_t t;  // t 用于保存线程 ID
    int arg = 3490;

    printf("Launching a thread\n");

    // 启动一个运行 run() 函数的线程,传入指向 3490 的指针
    // 作为实参。同时把线程 ID 存进 t:

    thrd_create(&t, run, &arg);

    printf("Doing other things while the thread runs\n");

    printf("Waiting for thread to complete...\n");

    int res;  // 保存线程退出时的返回值

    // 在这里等待线程结束;把返回值存到
    // res 中:

    thrd_join(t, &res);

    printf("Thread exited with return value %d\n", res);
}

看到我们在那里用 thrd_create() 去调用 run() 函数了吗?然后我们在 main() 里做了别的事情,接着停下来,用 thrd_join() 等待线程结束。

示例输出(你的可能不同):

Launching a thread
Doing other things while the thread runs
Waiting for thread to complete...
THREAD: Running thread with arg 3490
Thread exited with return value 12

你传给函数的 arg,其生命周期必须足够长,好让线程能在它消失之前取用它。另外,它还不能在新线程用上它之前就被主线程覆盖掉。

来看一个启动 5 个线程的示例。这里要注意的一点是,我们用了一个 thrd_t 数组来记录所有线程 ID。

#include <stdio.h>
#include <threads.h>

int run(void *arg)
{
    int i = *(int*)arg;

    printf("THREAD %d: running!\n", i);

    return i;
}

#define THREAD_COUNT 5

int main(void)
{
    thrd_t t[THREAD_COUNT];

    int i;

    printf("Launching threads...\n");
    for (i = 0; i < THREAD_COUNT; i++)

        // NOTE! 在下面这一行中,我们传入一个指向 i 的指针, 
        // 但每个线程看到的都是同一个指针。所以它们会
        // 打印出奇怪的东西,因为这里 i 的值会变
        // 是在主线程里!(后文还有详细说明。)

        thrd_create(t + i, run, &i);

    printf("Doing other things while the thread runs...\n");
    printf("Waiting for thread to complete...\n");

    for (int i = 0; i < THREAD_COUNT; i++) {
        int res;
        thrd_join(t[i], &res);

        printf("Thread %d complete!\n", res);
    }

    printf("All threads complete!\n");
}

启动这些线程时,我让 i 从 0 数到 4,然后把指向它的指针传给 thrd_create()。这个指针最终会到达 run() 例程,在那里我们会对它做一份拷贝。

够简单吧?输出如下:

Launching threads...
THREAD 2: running!
THREAD 3: running!
THREAD 4: running!
THREAD 2: running!
Doing other things while the thread runs...
Waiting for thread to complete...
Thread 2 complete!
Thread 2 complete!
THREAD 5: running!
Thread 3 complete!
Thread 4 complete!
Thread 5 complete!
All threads complete!

什么——?THREAD 0 哪去了?而且我们调用 thrd_create() 时 i 明明从没超过 4,怎么会有 THREAD 5?还有两个 THREAD 2?简直疯了!

这就进入了竞态条件的欢乐世界。主线程在线程来得及拷贝 i 之前就把它改了。实际上,i 一路涨到 5 并结束了循环,而最后一个线程还没机会拷贝它。

我们得有一个每个线程各自的变量可以引用,这样才能把它作为 arg 传进去。

我们可以用一个存放它们的大数组。或者也可以 malloc() 出空间(然后在某处释放——也许就在线程自己里。)

我们来试试这个:

#include <stdio.h>
#include <stdlib.h>
#include <threads.h>

int run(void *arg)
{
    int i = *(int*)arg;  // 复制这个实参

    free(arg);  // 这个用完了

    printf("THREAD %d: running!\n", i);

    return i;
}

#define THREAD_COUNT 5

int main(void)
{
    thrd_t t[THREAD_COUNT];

    int i;

    printf("Launching threads...\n");
    for (i = 0; i < THREAD_COUNT; i++) {

        // 为每个线程的实参分配一些空间:

        int *arg = malloc(sizeof *arg);
        *arg = i;

        thrd_create(t + i, run, arg);
    }

    // ...

注意第 27 到 30 行,我们为一个 int malloc() 出空间,并把 i 的值拷贝进去。每个新线程都会拿到一个自己刚 malloc() 出来的变量,我们把指向它的指针传给 run() 函数。

一旦 run() 在第 7 行做出自己的 arg 拷贝,它就会把那个 malloc() 出来的 int free() 掉。现在它有了自己的拷贝,想怎么处置都行。

运行一次,结果如下:

Launching threads...
THREAD 0: running!
THREAD 1: running!
THREAD 2: running!
THREAD 3: running!
Doing other things while the thread runs...
Waiting for thread to complete...
Thread 0 complete!
Thread 1 complete!
Thread 2 complete!
Thread 3 complete!
THREAD 4: running!
Thread 4 complete!
All threads complete!

这就对了!线程 0 到 4 全都生效了!

你的运行结果可能不同——线程如何被调度运行,超出了 C 规范的范围。在上面的例子里我们看到,直到线程 0 和 1 完成后,线程 4 才开始。确实,如果我再次运行,很可能得到不同的输出。我们无法保证线程的执行顺序。

39.5 分离线程

如果你想对一个线程“发射后不管”(也就是以后不必再对它调用 thrd_join()),可以用 thrd_detach() 做到。

这会剥夺父线程从子线程获取返回值的能力,但如果你不在乎这个,只希望线程能自己把自己收拾干净,那这就是该走的路。

基本上我们要这么做:

thrd_create(&t, run, NULL);
thrd_detach(t);

其中 thrd_detach() 调用是父线程在说:“嘿,我不打算用 thrd_join() 等这个子线程结束了。所以它结束时,你就自己把它清理掉吧。”

#include <stdio.h>
#include <threads.h>

int run(void *arg)
{
    (void)arg;

    //printf("Thread running! %lu\n", thrd_current()); // 不可移植!
    printf("Thread running!\n");

    return 0;
}

#define THREAD_COUNT 10

int main(void)
{
    thrd_t t;

    for (int i = 0; i < THREAD_COUNT; i++) {
        thrd_create(&t, run, NULL);
        thrd_detach(t);               // <-- 分离!
    }

    // 休眠一秒,好让所有线程都完成
    thrd_sleep(&(struct timespec){.tv_sec=1}, NULL);
}

注意,在这段代码里我们用 thrd_sleep() 让主线程休眠 1 秒——这个后面再细说。

另外,在 run() 函数里我保留了一行被注释掉的代码,它把线程 ID 当作 unsigned long 打印出来。这是不可移植的,因为规范没有规定 thrd_t 在底层到底是什么类型——说不定它是个 struct。不过那一行在我的系统上是能用的。

我运行上面的代码并打印线程 ID 时,看到了一个有趣的现象:有些线程的 ID 竟然是重复的!这看起来似乎不可能,但 C 允许在相应线程退出之后重用线程 ID。所以我看到的情况是:有些线程在其他线程被启动之前就已经跑完了。

39.6 线程局部数据

线程有意思的地方在于:除了局部变量之外,它们没有属于自己的内存。如果你要一个 static 变量或文件作用域变量,所有线程看到的都会是同一个变量。

这会导致竞态条件,于是你就会遇到怪事™。

看看这个例子。我们在 run() 的块作用域里有一个 static 变量 foo。这个变量对所有经过 run() 函数的线程都可见。于是各个线程实际上会互相踩脚。

每个线程把 foo 拷贝到局部变量 x(它不在线程之间共享——所有线程都有自己的调用栈)。所以它们应该是一样的,对吧?

而我们第一次打印它们时,它们确实一样201。但紧接着,我们又要确认它们仍然一样。

它们通常是一样的。但并非总是如此!

#include <stdio.h>
#include <stdlib.h>
#include <threads.h>

int run(void *arg)
{
    int n = *(int*)arg;  // 供人类区分用的线程编号

    free(arg);

    static int foo = 10;  // 在线程之间共享的静态值

    int x = foo;  // 自动局部变量——每个线程各有自己的一份

    // 我们刚刚用 foo 给 x 赋值,所以这里它们应该相等。
    // (在我所有的测试运行中它们都相等,但即便如此也没有保证!)

    printf("Thread %d: x = %d, foo = %d\n", n, x, foo);

    // 而这里它们也应该相等,但并非总是如此!
    // (有时相等,有时不相等!)

    // 实际情况是,另一个线程插了进来,给 foo 加了 1
    // 就在此刻,而这个线程的 x 还是之前的值!

    if (x != foo) {
        printf("Thread %d: Craziness! x != foo! %d != %d\n", n, x, foo);
    }

    foo++;  // 递增共享值

    return 0;
}

#define THREAD_COUNT 5

int main(void)
{
    thrd_t t[THREAD_COUNT];

    for (int i = 0; i < THREAD_COUNT; i++) {
        int *n = malloc(sizeof *n);  // 保存一个线程序列号
        *n = i;
        thrd_create(t + i, run, n);
    }

    for (int i = 0; i < THREAD_COUNT; i++) {
        thrd_join(t[i], NULL);
    }
}

下面是一份示例输出(不过每次运行都可能不同):

Thread 0: x = 10, foo = 10
Thread 1: x = 10, foo = 10
Thread 1: Craziness! x != foo! 10 != 11
Thread 2: x = 12, foo = 12
Thread 4: x = 13, foo = 13
Thread 3: x = 14, foo = 14

在线程 1 里,两次 printf() 之间,foo 的值不知怎么就从 10 变成了 11,可两次 printf() 之间明明没有任何自增操作!

是另一个线程钻了进去(从结果看大概是线程 0),趁线程 1 不注意把 foo 的值给自增了!

我们用两种不同的办法来解决这个问题。(如果你想让所有线程共享这个变量,又不互相踩脚,那就得往下读互斥量那一节。)

39.6.1 _Thread_local 存储类

先说最要紧的:来看看绕开这个问题的简单办法——_Thread_local 存储类。

基本上,我们只要把它拍到块作用域的 static 变量前面,一切就都能正常工作了!它告诉 C:每个线程都应该有自己的一份这个变量,这样谁也不会踩到谁的脚。

<threads.h> 头文件把 thread_local 定义为 _Thread_local 的别名,这样你的代码就不会显得那么丑。

我们把上一个例子里的 foo 改成 thread_local 变量,这样就不再共享那份数据了。

int run(void *arg)
{
    int n = *(int*)arg;  // 供人类区分用的线程编号

    free(arg);

    thread_local static int foo = 10;  // <-- 不再共享!!

运行之后得到:

Thread 0: x = 10, foo = 10
Thread 1: x = 10, foo = 10
Thread 2: x = 10, foo = 10
Thread 4: x = 10, foo = 10
Thread 3: x = 10, foo = 10

再没有怪问题了!

有一点:如果 thread_local 变量处于块作用域,那它必须是 static。规矩就是这么定的。(不过这没什么问题,因为非 static 变量本来就是每个线程各一份——每个线程都有它自己的非 static 变量。)

刚才说得有点绝对:块作用域的 thread_local 变量也可以是 extern 的。

39.6.2 另一种选择:线程特有存储

线程特有存储(TSS)是获取每线程数据的另一种方式。

还有一个额外的特性:这些函数允许你指定一个析构函数,当 TSS 变量被删除时会对其中的数据调用它。通常这个析构函数是 free(),用来自动清理每线程 malloc() 出来的数据。如果没什么需要销毁的,那就传 NULL。

析构函数的类型是 tss_dtor_t,它是一个指向函数的指针,该函数返回 void,并接受一个 void* 作为实参(这个 void* 指向变量中存储的数据)。换句话说,它是 void (*)(void*)——如果这能让你明白的话。我承认这大概并不能。看看下面的例子吧。

一般来说,thread_local 大概就是你的首选,但如果你喜欢析构函数这个思路,也可以采用那种办法。

用法有点怪:我们需要一个 tss_t 类型的变量保持存活,以便按线程分别表示这个值。接着用 tss_create() 初始化它,最终再用 tss_delete() 把它清除掉。注意,调用 tss_delete() 并不会运行所有析构函数——干这件事的是 thrd_exit()(或从运行函数返回)。tss_delete() 只是释放 tss_create() 分配的内存。

在中间阶段,线程可以调用 tss_set() 和 tss_get() 来设置和获取该值。

在下面的代码中,我们在创建线程之前先设置好 TSS 变量,然后在线程结束后做清理。

在 run() 函数中,各线程用 malloc() 为字符串分配一些空间,并把该指针存入 TSS 变量。

当线程退出时,析构函数(这里是 free())会为所有线程各调用一次。

#include <stdio.h>
#include <stdlib.h>
#include <threads.h>

tss_t str;

void some_function(void)
{
    // 取出这个字符串在当前线程中的值
    char *tss_string = tss_get(str);

    // 然后把它打印出来
    printf("TSS string: %s\n", tss_string);
}

int run(void *arg)
{
    int serial = *(int*)arg;  // 获取这个线程的序列号
    free(arg);

    // 用 malloc() 分配空间来保存这个线程的数据
    char *s = malloc(64);
    sprintf(s, "thread %d! :)", serial);  // 一个快乐的小字符串

    // 把这个 TSS 变量设为指向该字符串
    tss_set(str, s);

    // 调用一个会获取该变量的函数
    some_function();

    return 0;   // 等价于 thrd_exit(0)
}

#define THREAD_COUNT 15

int main(void)
{
    thrd_t t[THREAD_COUNT];

    // 新建一个 TSS 变量,free() 函数就是析构函数
    tss_create(&str, free);

    for (int i = 0; i < THREAD_COUNT; i++) {
        int *n = malloc(sizeof *n);  // 保存一个线程序列号
        *n = i;
        thrd_create(t + i, run, n);
    }

    for (int i = 0; i < THREAD_COUNT; i++) {
        thrd_join(t[i], NULL);
    }

    // 所有线程都结束了,所以我们也可以收尾了
    tss_delete(str);
}

再说一次,和 thread_local 相比,这种做法相当麻烦,所以除非你确实需要那种析构功能,否则我建议还是用它。

39.7 互斥量

如果你希望同一时间只允许一个线程进入某段关键代码,可以用互斥量202来保护该段。

例如,假设我们有一个 static 变量,希望把读取和写入作为两个操作完成,而不让另一个线程插到中间把它改坏,这时就可以用一个互斥量。

你可以获取互斥量,也可以释放它。如果你尝试获取并成功,就可以继续执行。如果尝试了但失败(因为别人正持有它),你就会阻塞203,直到互斥量被释放。

如果多个线程都在阻塞等待某个互斥量被释放,其中一个会被选中运行(在我们看来是随机的),其余的则继续休眠。

整体思路是:先用 mtx_init() 初始化一个互斥量变量,让它可以使用。

之后各线程可以调用 mtx_lock() 和 mtx_unlock() 来获取和释放该互斥量。

等我们完全用不到这个互斥量时,可以用 mtx_destroy() 销毁它——它是 mtx_init() 在逻辑上的反向操作。

先来看一段不使用互斥量的代码,它试图打印一个共享(static)的序列号,然后把它加一。由于我们在读取该值(用于打印)和写入该值(用于自增)时都没有加互斥量保护,线程可能会在这段临界区里互相干扰。

#include <stdio.h>
#include <threads.h>

int run(void *arg)
{
    (void)arg;

    static int serial = 0;   // 共享的静态变量!

    printf("Thread running! %d\n", serial);

    serial++;

    return 0;
}

#define THREAD_COUNT 10

int main(void)
{
    thrd_t t[THREAD_COUNT];

    for (int i = 0; i < THREAD_COUNT; i++) {
        thrd_create(t + i, run, NULL);
    }

    for (int i = 0; i < THREAD_COUNT; i++) {
        thrd_join(t[i], NULL);
    }
}

我运行它时,得到的结果大致如下:

Thread running! 0
Thread running! 0
Thread running! 0
Thread running! 3
Thread running! 4
Thread running! 5
Thread running! 6
Thread running! 7
Thread running! 8
Thread running! 9

显然,多个线程都挤了进去执行 printf(),还没等任何线程有机会更新 serial 变量。

我们想要做的,是把读取该变量和写入该变量包进同一段受互斥量保护的代码里。

我们会新增一个 mtx_t 类型的变量来表示互斥量,放在文件作用域中,初始化它,然后各线程就可以在 run() 函数里加锁和解锁。

#include <stdio.h>
#include <threads.h>

mtx_t serial_mtx;     // <-- 互斥量变量

int run(void *arg)
{
    (void)arg;

    static int serial = 0;   // 共享的静态变量!

    // 获取互斥量——所有线程都会在这个调用上阻塞,直到
    // 它们拿到锁:

    mtx_lock(&serial_mtx);           // <-- 获取互斥量

    printf("Thread running! %d\n", serial);

    serial++;

    // 数据的读取和写入都完成了,所以释放锁。这会
    // 解除其他线程在 mtx_lock() 调用上的阻塞:

    mtx_unlock(&serial_mtx);         // <-- 释放互斥量

    return 0;
}

#define THREAD_COUNT 10

int main(void)
{
    thrd_t t[THREAD_COUNT];

    // 初始化互斥量变量,指明这是一个普通的
    // 不带附加功能的互斥量:

    mtx_init(&serial_mtx, mtx_plain);        // <-- 创建互斥量

    for (int i = 0; i < THREAD_COUNT; i++) {
        thrd_create(t + i, run, NULL);
    }

    for (int i = 0; i < THREAD_COUNT; i++) {
        thrd_join(t[i], NULL);
    }

    // 互斥量用完了,销毁它:

    mtx_destroy(&serial_mtx);                // <-- 销毁互斥量
}

注意 main() 的第 38 行和第 50 行是如何初始化并销毁互斥量的。

而每个线程各自在第 15 行获取互斥量,在第 24 行释放它。

在 mtx_lock() 和 mtx_unlock() 之间的就是临界区,也就是我们不希望多个线程同时乱动的那段代码。

现在输出就正常了!

Thread running! 0
Thread running! 1
Thread running! 2
Thread running! 3
Thread running! 4
Thread running! 5
Thread running! 6
Thread running! 7
Thread running! 8
Thread running! 9

如果需要多个互斥量,没问题:多定义几个互斥量变量就行。

并且永远记住使用多个互斥量时的头号规则:按与加锁相反的顺序解锁!

39.7.1 不同的互斥量类型

前面提过,可以用 mtx_init() 创建几种不同类型的互斥量。(其中一些类型是按位或运算的结果,表中已有说明。)

类型 描述
mtx_plain 普通互斥量
mtx_timed 支持超时的互斥量
mtx_plain|mtx_recursive 递归互斥量
mtx_timed|mtx_recursive 支持超时的递归互斥量

“递归”的意思是:锁的持有者可以对同一把锁多次调用 mtx_lock()。(它必须解锁同样次数,其他线程才能取得该互斥量。)这有时能让编码轻松一些,特别是当你已经持有互斥量时又调用了某个需要锁该互斥量的函数。

而超时则给线程一个机会,让它在一段时间内尝试获取锁,如果在该时间内拿不到就放弃。

要使用带超时的互斥量,一定要用 mtx_timed 创建它:

mtx_init(&serial_mtx, mtx_timed);

然后等待它的时候,必须指定一个 UTC 时间,表示它将在何时解锁204。

来自 <time.h> 的 timespec_get() 函数在这里能帮上忙。它能把当前的 UTC 时间取到一个 struct timespec 里,正是我们需要的。事实上,它似乎就是为了这个目的而存在的。

它有两个字段:tv_sec 保存自纪元以来的秒数形式的当前时间,tv_nsec 则以“小数”部分的形式保存纳秒数(十亿分之一秒)。

所以你可以先把它填成当前时间,然后往上加一点,得到某个具体的超时时刻。

然后调用 mtx_timedlock() 而不是 mtx_lock()。如果它返回值 thrd_timedout,说明已经超时。

struct timespec timeout;

timespec_get(&timeout, TIME_UTC);  // 获取当前时间
timeout.tv_sec += 1;               // 超时时间设为现在之后 1 秒

int result = mtx_timedlock(&serial_mtx, &timeout));

if (result == thrd_timedout) {
    printf("Mutex lock timed out!\n");
}

除此之外,带超时的锁与普通锁完全一样。

39.8 条件变量

要写出高性能的多线程程序、并组合出更复杂的多线程结构,条件变量是我们还缺的最后一块拼图。

条件变量提供了一种机制,让线程可以休眠,直到另一个线程上发生某个事件。

换句话说,我们可能有一批线程已经跃跃欲试,但必须先等某个事件成立才能继续。基本上,在收到通知之前,它们一直被告知“等着!”。

这与互斥量是配合使用的,因为我们等待的东西通常取决于某些数据的值,而这些数据一般需要用互斥量保护。

要注意,从我们的角度看,条件变量本身并不持有任何具体数据。它只是 C 用来记录某个线程或某组线程处于等待/非等待状态的变量。

我们来写一个人为设计的程序:由主线程每次一个地读入 5 个数字为一组。当 5 个数字都输入后,子线程醒来,把这 5 个数字求和并打印结果。

这些数字会存进一个全局的共享数组,即将输入的数字在数组中的下标也一并存在那里。

由于这些是共享值,至少要让主线程和子线程都通过一个互斥量来访问它们。(主线程会向它们写数据,子线程会从它们读数据。)

但这样还不够。子线程需要阻塞(“休眠”),直到有 5 个数字被读入数组。然后父线程需要唤醒子线程,好让它干活。

而且它醒来时必须持有该互斥量。它确实会持有!当线程等待条件变量时,它在醒来时也会同时获取互斥量。

这一切都围绕一个额外的 cnd_t 类型变量展开,它就是条件变量。我们用 cnd_init() 函数创建这个变量,用完后再用 cnd_destroy() 函数销毁它。

但这一切是怎么运作的?先看看子线程要做什么的提纲:

  1. 用 mtx_lock() 锁定互斥量
  2. 如果还没有输入完所有数字,就用 cnd_wait() 在条件变量上等待
  3. 做需要做的工作
  4. 用 mtx_unlock() 解锁互斥量

与此同时,主线程会做这些事:

  1. 用 mtx_lock() 锁定互斥量
  2. 把刚读到的数字存进数组
  3. 如果数组已满,就用 cnd_signal() 通知子线程醒来
  4. 用 mtx_unlock() 解锁互斥量

如果你没有扫得太快(没关系——我不会介意),你可能会发现一个古怪之处:子线程必须持有互斥量锁才能等待信号,那主线程怎么能既持有互斥量锁又通知子线程呢?它们不可能同时持有锁!

它们确实没有同时持有!条件变量背后有一些幕后的魔法:当你调用 cnd_wait() 时,它会释放你指定的互斥量,线程随之休眠。而当有人通知该线程醒来时,它会重新获取这把锁,仿佛什么都没发生过。

cnd_signal() 这边则略有不同。它不对互斥量做任何操作。发信号的线程仍然必须手动释放互斥量,等待中的线程才能醒来。

关于 cnd_wait() 还有一点。你通常会在某个条件205尚未满足时调用 cnd_wait()(比如本例中,数字还没有全部输入完)。关键是:这个条件应该放在 while 循环里,而不是 if 语句里。为什么?

因为有个神秘现象叫虚假唤醒。有时在某些实现里,线程会看似毫无理由地从 cnd_wait() 的睡眠中被唤醒。[《X 档案》配乐]206。所以醒来后我们必须检查所需条件是否仍然真正满足。如果不满足,那就继续睡!

那就动手吧!先从主线程开始:

与此同时,子线程也在忙它自己的一套把戏:

代码在这里!好好研究一下,看看上面这些环节分别在哪里处理:

#include <stdio.h>
#include <threads.h>

#define VALUE_COUNT_MAX 5

int value[VALUE_COUNT_MAX];  // 共享的全局变量
int value_count = 0;   // 也是共享的全局变量

mtx_t value_mtx;   // 保护 value 的互斥量
cnd_t value_cnd;   // 针对 value 的条件变量

int run(void *arg)
{
    (void)arg;

    for (;;) {
        mtx_lock(&value_mtx);      // <-- 抓住互斥量

        while (value_count < VALUE_COUNT_MAX) {
            printf("Thread: is waiting\n");
            cnd_wait(&value_cnd, &value_mtx);  // <-- 条件等待
        }

        printf("Thread: is awake!\n");

        int t = 0;

        // 把所有数加起来
        for (int i = 0; i < VALUE_COUNT_MAX; i++)
            t += value[i];

        printf("Thread: total is %d\n", t);

        // 为主线程重置输入下标
        value_count = 0;

        mtx_unlock(&value_mtx);   // <-- 互斥量解锁
    }

    return 0;
}

int main(void)
{
    thrd_t t;

    // 生成一个新线程

    thrd_create(&t, run, NULL);
    thrd_detach(t);

    // 设置互斥量和条件变量

    mtx_init(&value_mtx, mtx_plain);
    cnd_init(&value_cnd);

    for (;;) {
        int n;

        scanf("%d", &n);

        mtx_lock(&value_mtx);    // <-- 锁定互斥量

        value[value_count++] = n;

        if (value_count == VALUE_COUNT_MAX) {
            printf("Main: signaling thread\n");
            cnd_signal(&value_cnd);  // <-- 发送条件信号
        }

        mtx_unlock(&value_mtx);  // <-- 解锁互斥量
    }

    // 清理(我知道上面是个无限循环,但我
    // 至少想装得规矩一点):

    mtx_destroy(&value_mtx);
    cnd_destroy(&value_cnd);
}

下面是一些示例输出(每行单独的数字是我的输入):

Thread: is waiting
1
1
1
1
1
Main: signaling thread
Thread: is awake!
Thread: total is 5
Thread: is waiting
2
8
5
9
0
Main: signaling thread
Thread: is awake!
Thread: total is 24
Thread: is waiting

在像这样的生产者—消费者场景中,这是条件变量的一种常见用法。如果没有办法让子线程在等待某个条件满足时休眠,它就只能被迫轮询,这会极大地浪费 CPU。

39.8.1 带超时的条件等待

cnd_wait() 有一个变体,允许你指定超时时间,从而可以停止等待。

由于子线程必须重新锁定互斥量,所以这并不意味着超时一到你就会立刻复活;你仍然要等其他线程释放互斥量。

但这确实意味着你不必一直等到 cnd_signal() 发生。

要做到这一点,请调用 cnd_timedwait() 而不是 cnd_wait()。如果它返回值 thrd_timedout,说明已经超时。

这个时间戳是 UTC 的绝对时间,而不是“从现在起”的相对时间。幸运的是,<time.h> 里的 timespec_get() 函数似乎正是为这种情况量身定做的。

struct timespec timeout;

timespec_get(&timeout, TIME_UTC);  // 获取当前时间
timeout.tv_sec += 1;               // 超时时间设为现在之后 1 秒

int result = cnd_timedwait(&condition, &mutex, &timeout));

if (result == thrd_timedout) {
    printf("Condition variable timed out!\n");
}

39.8.2 广播:唤醒所有等待中的线程

cnd_signal() 只唤醒一个线程继续工作。取决于你的逻辑怎么写,有时在条件满足后唤醒不止一个线程继续执行才更合理。

当然,它们中只有一个能拿到互斥量,但如果你遇到这样的情形:

这时你就需要广播唤醒,以确保至少有一个线程能跳出那个循环,好去启动下一个。

你问怎么做?

只需用 cnd_broadcast() 代替 cnd_signal() 即可。用法完全相同,只不过 cnd_broadcast() 会唤醒在该条件变量上等待的所有休眠线程。

39.9 让函数只运行一次

假设你有一个函数,可能会被多个线程运行,但说不准是什么时候,而且为它编写那套逻辑并不值得。

有个绕过去的办法:使用 call_once()。成千上万个线程都可以尝试运行该函数,但只有第一个算数207

为此,你需要声明一个特殊的标志变量,用来记录那件事是否已经运行过。你还需要一个要运行的函数,它不接受参数,也不返回值。

once_flag of = ONCE_FLAG_INIT;  // 像这样初始化

void run_once_function(void)
{
    printf("I'll only run once!\n");
}

int run(void *arg)
{
    (void)arg;

    call_once(&of, run_once_function);

    // ...

在这个例子中,无论有多少线程进入 run() 函数,run_once_function() 都只会被调用一次。


| 目录 |