
引用在构建高性能应用时并发编程是我们经常采用的一种技巧。它通过在程序的运行进程内提供可控制粒度更细的“线程”从而将程序的整体功能拆分为更小的独立任务单元并以此来进一步利用多核 CPU 的运算资源。对于 C11 标准之前的 C 语言来说想要构建多线程应用只能依赖于所在平台上的专有接口比如 Unix 与类 Unix 平台上广泛使用的 POSIX 模型以及后起之秀 OpenMP 模型等。这些模型提供的编程接口以及所支持平台都有很大的不同。因此对于那时的 C 语言来说想要编写高可移植性的多线程应用仍需要花费很大功夫。而自 C11 标准后C 语言为我们专门提供了一套通用的并发编程接口你可以通过标准库头文件 threads.h 与 stdatomic.h 来使用它们。其中threads.h 中包含有与线程控制、互斥量、条件变量以及线程本地存储相关的接口而 stdatomic.h 中则包含有与原子操作相关的接口。这些接口提供了多种不同方式可用来避免多线程应用在运行过程中可能遇到的各类线程同步问题。C11 标准的发布理论上使构建可移植的多线程 C 应用成为可能但现实情况却并非这样理想。各类 C 标准库对 C11 中并发编程接口的支持程度不同比如 GlibcGNU C 标准库在其 2018 年中旬发布的 2.28 版本中才将相关接口进行了较为完整的实现。这就导致了 C11 标准中的这些重要特性至今2022 年初仍然没有得到较为广泛的应用。因此接下来我将用两讲的篇幅为你从零介绍有关并发编程的一些基础知识以及 C11 并发编程相关接口的基本使用方式。当然由于并发编程是一种完全不同的编程模型短短几千字是无法详细梳理完所有相关内容的。但我希望通过对这两讲内容的学习你能够对使用 C 语言开发多线程应用有一个全新的认识并以此为起点在未来进行更加深入的了解。这一讲就让我们来一起了解下并发编程的主角线程以及我们在围绕它进行多线程开发时可能遇到的一些常见问题。进程 vs 线程相信你对“进程”这个概念应该比较熟悉那么线程与进程有哪些区别呢我们来一起看一看。默认情况下操作系统会为每一个运行中的程序创建一个相应的进程以作为它的运行实例。而进程中则包含与该程序有关的一系列运行时信息比如 VAS、进程 ID、处理器上下文如通用目的寄存器与指令寄存器中的值、进程状态以及操作系统分配给该进程的相关资源等。这些信息被统一存放在内核提供的名为“进程控制块PCB”的数据结构中。现代 CPU 通常会采用“抢占式”调度算法来进行多个进程之间的任务切换过程。比如当某个进程的执行时间超过一定阈值或开始等待 IO 响应或所在系统出现硬件中断等情况时内核任务调度器可能会将该进程挂起并将 CPU 资源“转移”给其他进程使用。通过这种方式即使是在单核 CPU 上操作系统也能够实现用户可观测的多任务处理过程。当然具体的调度算法还会考虑进程的优先级权重、历史执行情况等因素。调度器只有在经过“综合评定”后才会作出选择。而相较于进程线程则为程序提供了更细粒度的运行单元。对于大多数传统操作系统实现来说在默认情况下每一个进程内部都会存在至少一个线程。其中进程负责划分不同程序所享有资源的边界而线程则在共享程序运行资源的情况下负责程序某个子任务的具体执行过程。此时任务调度器也将以线程作为最小的调度实体来更加精细地组织程序的运行。因此通过增加进程中线程的个数我们便能够将程序需要完成的任务进行更具体的划分比如将 IO 相关操作单独分配给一个线程来执行并同时进一步利用多核 CPU 的计算资源。同进程类似每一个线程所具有的不同状态信息被保存在内核中名为“线程控制块TCB”的数据结构中。其中包含有与特定线程运行紧密相关的处理器上下文、线程 ID、所属进程以及状态信息等等。可以看到的是PCB 与 TCB 两者内部所包含的内容会有一定重合。这是由于在某些操作系统上单进程单线程应用的运行可能会直接使用 PCB来同时保存程序的资源描述信息与执行状态信息。而当运行多线程应用时才会配合使用 PCB 与 TCB。这里你可以通过下图来直观地对比进程与线程之间的区别和联系。当然具体的实现方式并不唯一。聊完了基本的理论知识接下来就让我们看看如何在 C 代码中创建线程。线程的基本控制借助 threads.h 头文件提供的接口我们可以实现对线程的创建、等待、阻塞、分离以及重新调度等操作。来看下面这段示例代码#include threads.h #include stdio.h int run(void *arg) { thrd_t id thrd_current(); // 返回该函数运行所在线程的标识符 printf((const char*) arg, id); return thrd_success; } int main(void) { #ifndef __STDC_NO_THREADS__ thrd_t thread; int result; // 创建一个线程 thrd_create(thread, run, Hello C11 thread with id: %lu.\n); if (thrd_join(thread, result) thrd_success) { // 等待其他线程退出 printf(Thread returns %d at the end.\n, result); } #endif return 0; }可以看到在 main 函数的开头我们首先通过判断宏 STDC_NO_THREADS 是否存在来决定是否“启用”多线程相关的代码。C11 标准中规定若编译器实现不支持并发编程相关接口则需要定义该宏。然后在条件编译指令 ifndef 的内部我们定义了类型为 thrd_t 的变量 thread。该类型为由具体实现定义的用于标识某个线程的唯一对象。接下来在代码的第 13 行我们通过 thrd_create 函数创建了一个线程。这里传入该函数的第二个参数为一个 thrd_start_t 类型的函数指针该指针所指向函数将会在这个新线程内被执行其接收到的参数为 thrd_create 函数调用时传入的第三个参数。当新线程创建完毕后其执行过程便与 main 函数所在线程发生了“分离”。因此为了防止 main 函数执行结束前即整个程序退出前新线程内的代码还没有执行完成。这里我们便需要通过 thrd_join 函数来让 main 函数所在线程“等待”新线程的执行结束。当然除此之外如果仅是为了能让新线程顺利执行完毕我们也可以用 thrd_exit 函数来达到这个目的。这里你可以思考下具体应该怎样做并在评论区留下你的答案。接下来函数 run 在新线程中被执行。通过函数 thrd_current我们可以得到当前代码执行所在线程的唯一标识符。然后调用 printf 函数我们将该标识符的值打印了出来。至此新线程执行完毕并终止执行流程又回到 main 函数所在线程中。实际上除了我们在上面例子中使用到的以 “thrd_” 开头的函数外C11 还为我们提供了一些其他的线程控制函数。这里我将它们整理到了下面的表格中供你参考随着多线程应用的功能变得逐渐复杂共享变量可能会被多个线程同时访问。并且不同线程可能会以不同的先后顺序来执行程序中的同一段代码。除此之外现代 CPU 采用的特殊指令处理方式使得程序的实际执行流程与对应的汇编代码可能也不完全一致。而上述这三个因素都确实会在某些情况下影响多线程应用的正常执行。下面我们就来了解一下与上述这三种情况相对应的三个概念即数据竞争、竞态条件以及指令重排。数据竞争从定义上来讲数据竞争Data Race是指在一个多线程环境中有两个及以上的线程在同一时间对同一块内存中的数据进行了非原子操作且其中至少有一个是写操作。在这种情况下该数据值的最终状态可能与程序语义上希望表达的计算结果不一致。来看下面这个例子#include threads.h #include stdio.h #define THREAD_COUNT 20 #define THREAD_LOOP 100000000 long counter 0; // 全局变量用来记录线程的累加值 int run(void* data) { for (int i 0; i THREAD_LOOP; i) counter; // 在线程中递增全局变量的值 printf(Thread %d terminates.\n, *((int*) data)); return thrd_success; } int main(void) { #ifndef __STDC_NO_THREADS__ int ids[THREAD_COUNT]; // 用于存放线程序号的数组 thrd_t threads[THREAD_COUNT]; for (int i 0; i THREAD_COUNT; i) { ids[i] i 1; thrd_create(threads[i], run, ids i); // 创建 THREAD_COUNT 个线程 } for (int i 0; i THREAD_COUNT; i) thrd_join(threads[i], NULL); // 让当前线程等待其他线程执行完毕 printf(Counter value is: %ld.\n, counter); // 输出 counter 变量最终结果 #endif return 0; }在这段代码中我们在 main 函数内创建了 20 个线程由宏常量 THREAD_COUNT 指定并让这些线程同时对全局变量 counter 进行值递增操作。由于 counter 的初始值被设置为 0因此如果代码按照我们的预期执行20 个线程分别对该全局变量递增 1 亿次程序在退出前打印的 counter 变量值应该为 20 亿。但实际情况可能并非如此下面我们具体看下。在非优化情况下编译并多次运行这段代码你会发现程序打印出的 counter 变量值并不稳定。在某些情况下这个值是准确的而某些情况下却小于这个数字。这个问题便是由于多线程模型下的数据竞争引起的。对于上面这个例子来说编译器可能会将 run 函数内的 counter 变量自增语句编译为如下所示的几条机器指令的组合mov eax, DWORD PTR counter[rip] add eax, 1 mov DWORD PTR counter[rip], eax在这种情况下当多个线程在 CPU 的调度下交错运行时便可能会发生这样一种情况某个线程刚刚执行完上述代码的第一条指令将变量 counter 的值暂存在了寄存器 rax 中。此时操作系统开始调度线程将当前线程挂起并开始执行另一个线程的代码。新的线程在执行递增时由于需要再次从 counter 所在的原内存地址中读入数据因此该值与上一个线程读取到的数据是相同的。而这便会导致这两个线程在递增后得到的结果值也完全相同两个线程对 counter 变量的两次递增过程仅使得它的原值增加了 1。不仅如此哪怕编译器在优化情况下可以将上述递增语句实现为仅一条汇编指令数据竞争的问题仍可能会存在。比如编译器将该递增操作实现为机器指令 add DWORD PTR counter[rip], 1这里使用 RIP-relative 寻址。现代 x86-64 处理器在处理这条 CISC 风格的机器指令时可能会将其拆分为对应的三种不同“微指令uOp”LOAD、ADD、STORE。其中LOAD 指令会首先从给定内存地址处读出当前的数据值ADD 指令则会根据用户传入的立即数参数来计算出更新后的数据值最后STORE 指令会将这个结果数据值更新到对应的内存中。同之前多条机器指令的实现类似这些微指令在操作系统的线程调度下也可能存在着交替执行的过程因此也有着产生数据竞争的风险。竞态条件竞态条件Race Condition是指由于程序中某些事件的发生时机与顺序不一致从而影响程序运行正确性的一种缺陷。在某些情况下数据竞争的存在可能会导致竞态条件的出现但两者的出现实际上并没有太多联系有部分人认为数据竞争是竞态条件的一种但也有人持反对意见。不同于数据竞争的是对程序中竞态条件的判断可能是非常困难的。竞态条件并没有可以精确到具体操作和行为上的定义因此它是否产生完全取决于程序的具体设计以及是否存在可能影响程序运行的外部非确定性变化。比如我们来看下面这段仅含有竞态条件但并没有数据竞争的示例代码#include threads.h #include stdio.h #include stdatomic.h #include stdlib.h #include time.h #define THREAD_COUNT 10 atomic_int accountA 100000000; // 转出账户初始金额 atomic_int accountB 0; // 转入账户初始金额 int run(void* v) { int _amount *((int*) v); // 获得当前线程的转移金额 for(;;) { // 首先判断转出账户金额是否足够不够则直接退出 if (accountA _amount) return thrd_error; atomic_fetch_add(accountB, _amount); // 将金额累加到转入账户 atomic_fetch_sub(accountA, _amount); // 将金额从转出账户中扣除 } } int main(void) { #if !defined(__STDC_NO_THREADS__) !defined(__STDC_NO_ATOMICS__) thrd_t threads[THREAD_COUNT]; srand(time(NULL)); for (int i 0; i THREAD_COUNT; i) { int amount rand() % 50; // 为每一个线程生成一个随机转移金额 thrd_create(threads[i], run, amount); } for (int i 0; i THREAD_COUNT; i) thrd_join(threads[i], NULL); printf(A: %d\nB: %d, accountA, accountB); #endif return 0; }在这段代码中我们以简化的方式实现了一个基本的金融场景即账户 A 向账户 B 进行多次转账且每次转账的金额都并不固定。在 main 函数的第 22~25 行我们通过创建多个线程的方式来模拟两个账户之间分批、多次的财产转移过程。其中每个线程会使用不同的固定份额来进行转账过程而随机数 amount 便表示了这个额度。线程开始运行后在代码第 10 行的 run 函数内我们通过一个线程本地变量 _amount 来存放传入的、当前线程需要使用的固定份额。接下来在一个无限循环中线程会通过以下三个步骤来完成两个账户之间的转账过程判断账户 A 的剩余资金是否足够进行转账。若否则直接退出将转账的金额累加到账户 B 中将转账的金额从账户 A 中扣除。而当所有线程都退出时则表示当前账户 A 中的剩余金额无法再满足任何一个线程以它的固定金额为单位进行转账。最后通过 printf 函数我们将两个账户内的剩余资金打印了出来。按照我们对程序的理解此时两个账户中的金额应该都大于 0且两者之和为 1 亿。可以看到程序的整个执行流程十分简单。不仅如此我们还使用了原子操作来保证程序对账户变量 accountA 与 accountB 的修改过程是不会产生数据竞争的。有关原子操作的更多内容我会在下一讲中为你介绍这里你可以先对它的用法有一个基本了解。到这里程序的逻辑看起来没有任何问题。但当我们真正运行它时却可能会得到如下所示的输出结果A: -46 B: 100000046可以看到账户 A 的剩余金额变为了负数程序运行出现了异常。如果仔细分析上述代码的执行逻辑你会发现多个线程在对账户变量进行修改时虽然没有数据竞争但程序的不恰当设计导致其存在着竞态条件。当多个线程在同时执行 run 函数内的逻辑时操作系统中的任务调度器可能会在任意时刻暂停某个线程的执行转而去运行另一个线程。因此便可能出现这样一种情况某个线程以原子形式执行了代码的第 14 行语句将金额累加到账户 A。而此时调度器将执行流程转移给另一个线程。该线程在上一个线程还没有完成对账户 B 的扣减操作前便直接使用未同步的值参与了下一次的转账操作。因此在这种情况下程序的正确性实际上依赖于各个线程之间按照一定顺序的执行过程。那么应该如何修复这个程序呢学完下一讲后你就可以结合这两讲的知识尝试解决这个问题了。最后我们再来看另一个与并发编程密切相关的话题指令重排。指令重排现代编译器和处理器通常会采用名为“指令重排”乱序执行的一种的技术来进一步提升程序的运行效率。这种技术会在尽量不影响程序可观测执行结果的情况下对生成的机器指令或它们的实际执行顺序进行适当的重新排序。对于编译器来说其表象是源代码中语句的出现顺序与对应汇编代码的实现顺序不一致。而对于处理器来说则是程序在真正执行时产生副作用的顺序比如变量赋值与汇编代码中指令的出现顺序不一致。对多线程应用来说即使编译器可以从静态分析的视角来确保汇编指令的重排不影响程序的可观测执行结果但当多个线程被调度到不同的物理 CPU 上执行时不同 CPU 之间一般无法共享对应线程指令在执行时的重排信息。因此当线程之间存在数据依赖关系时程序的运行时正确性可能会受到影响。比如我们来看下面这个例子#include threads.h #include stdio.h #include stdatomic.h #if !defined(__STDC_NO_ATOMICS__) atomic_int x 0, y 0; #endif int run(void* v) { x 10; y 20; // 变量 y 的值可能被优先更新 } int observe(void* v) { while(y ! 20) ; // 忙等待 printf(%d, x); // 只在 x 被更新后打印 } int main(void) { #if !defined(__STDC_NO_THREADS__) thrd_t threadA, threadB; thrd_create(threadA, run, NULL); thrd_create(threadB, observe, NULL); thrd_join(threadA, NULL); thrd_join(threadB, NULL); #endif return 0; }在这段代码中我们在 main 函数内生成了两个线程分别对应于函数 run 和 observe。其中observe 线程在执行时会通过“忙等待”的方式不断查询变量 y 的状态并在发现其值变更为 20 后再继续执行接下来的 printf 函数。而 run 线程在执行时仅会简单地将变量 x 更新为值 10然后再将变量 y 的值更新为 20。这是我们通过查看上述 C 代码得出的结论。但由于指令重排的存在run 函数在程序实际执行时其内部对变量 x 与 y 的值变更过程可能与我们在 C 代码中观察到的顺序并不一致。在某些情况下变量 y 的值可能会被优先更新。而如果此时 observe 线程被重新调度则 printf 语句便会打印出并非我们所期望的值。为此C 语言为我们提供了相应的被称为“内存顺序Memory Order”的一系列枚举值。通过配合特定的库函数一起使用我们能够明确规定编译器及处理器应该如何对某段代码的指令重排进行约束。下一讲在“使用原子操作”小节中我们还会回顾上面的这个例子。总结好了讲到这里今天的内容也就基本结束了。最后我来给你总结一下。这一讲我主要介绍了与 C 并发编程有关的一些基本概念。当然这些概念本身都较为通用它们也可以应用在其他编程语言的环境中。我们首先对比了进程与线程两者之间的不同。其中进程主要划分了运行程序所享有的资源边界而线程则在共享进程资源的情况下独立负责不同子任务的执行流程。通过使用多线程程序可以利用多核 CPU 的计算资源做到真正的任务并行。接下来我介绍了如何在 C 代码中创建线程。C 标准将与线程控制相关的接口整合在了名为 threads.h 的头文件中。借助 thrd_create、thrd_join 等函数我们可以实现对线程的创建、等待、阻塞、分离等一系列操作。最后多线程应用的正确执行离不开我们对程序的合理设计。我还讲解了由数据竞争、静态条件以及指令重排等因素引起的潜在问题。掌握了这些知识再合理使用我将在下一讲中介绍的其他 C11 并发编程特性你就能在一定程度上解决和避免这些问题。