在上一篇文章中,我们详细介绍了在 Linux 平台下如何进行线程管理,包括线程的创建、等待与退出等操作。具体而言,主要是通过调用 Linux 原生 pthread 线程库提供的接口,例如 pthread_create 和 pthread_join 等。
需要注意的是,pthread 线程库所提供的接口遵循 POSIX 标准,因此主要适用于 Linux 及其他类 Unix 系统,例如 Unix 和 macOS。然而,在 Windows 平台下,我们无法直接使用 pthread 线程库来创建或管理线程,因为 Windows 提供了自己的一套线程管理接口。
C++ 作为一门跨平台语言,其编写的源代码应当能够在包括 Linux 和 Windows 在内的多种操作系统上编译和运行。无论在哪个平台,多线程编程都是常见的需求。自 C++11 标准起,C++ 正式在语言层面引入了对多线程的支持,这为我们提供了一套不依赖特定操作系统的标准线程库。本文将重点介绍 C++ 线程库的基本用法与特性。
前文已提到,C++ 是一门跨平台的语言。这意味着 C++ 代码不仅应在 Windows 平台上编译和运行,也应支持在 Linux 等平台上正确执行。对于程序员而言,编写代码时通常不希望针对不同平台分别实现。也就是说,我们不应为每个平台单独编写一套代码,而应使同一份代码能在多种平台上运行。
此时可能有读者会提出疑问:既然 C++ 采用面向对象的设计,将线程封装为类,其线程的相关操作对应类的成员函数(如线程等待对应 thread::join),那么这些成员函数的底层实现必然会封装操作系统提供的线程接口。例如在 Linux 平台下,join 会封装 pthread_join,而在 Windows 平台下,可能需要封装类似 WaitForSingleObject 接口。因此,thread 类在不同平台下必然有不同的实现方式。因为 Linux 和 Windows 操作系统所提供的线程管理接口确实存在根本差异。
C++ 线程库确实会为不同平台提供 thread 类的多套实现。关键在于,线程库必须能够识别当前代码运行所在的平台,从而选择对应的实现,以实现跨平台能力。这一机制是通过条件编译来实现的。
需要注意的是,若条件编译指令出现在 main 函数之外,则所包含的代码只能是全局/静态变量声明、类型定义或函数定义等非执行语句,而不能是如 printf 或 std::cout 这样的可执行语句。因为 C++ 程序的执行入口是 main 函数,所有可执行语句必须位于函数体内。如果需要在条件编译中包含可执行语句,应将这些语句置于 main 函数或其他函数内部。
需要注意的是,条件编译支持类似 if-else 语句的嵌套结构。通常的嵌套逻辑是:最外层使用 defined 运算符检查某个宏是否被定义;若已定义,则内层进一步判断该宏的具体取值,从而决定保留并编译哪一段代码。实现嵌套条件编译时,必须注意:每个 #if 预处理指令都必须有且仅有一个对应的 #endif 指令,用以标记该条件编译块的结束。在编写内层条件编译块时,务必在结尾处添加 #endif,以便编译器能够正确区分内层与外层的条件编译范围,从而进行准确编译。
基于上述内容,我们可以编写一个条件编译的示例。其基本逻辑是构建三个嵌套的条件编译结构:每个条件编译结构的最外层判断宏是否被定义,内层则根据宏的取值进行分支选择。某个条件块被编译,则会执行其中的打印语句。由于打印语句属于可执行代码,这里将整个条件编译块置于 main 函数内部:
#include<iostream>#define VERSION 0intmain(){
#if defined(VERSION)#if VERSION == 0
std::cout << "This is version1" << std::endl;
#elif VERSION == 1
std::cout << "This is version2" << std::endl;
#elif VERSION == 2
std::cout << "This is version2" << std::endl;
#else
std::cout << "unknown version" << std::endl;
#endif#endif#ifdef defined(PLATFORM)#if PLATFORM == "windows"
std::cout << "This is windows" << std::endl;
#else
std::cout << "This is Linux" << std::endl;
#endif#endif#ifdef defined(DEBUGMODE)#if DEBUGMODE == 1
std::cout << "mode1" << std::endl;
#else
std::cout << "mode2" << std::endl;
#endif#endifreturn0;
}
通过运行结果,我们可以进一步理解条件编译的工作机制。
理解条件编译的原理后,我们回到最初的问题:C++ 线程库是如何实现跨平台的?如前所述,C++ 线程库会为不同平台提供不同的 thread 类实现,但这些实现对外提供统一的接口。这意味着无论在 Windows 还是 Linux 平台,thread 类都具有一致的 join、detach 等接口,但其底层实现因平台而异,封装了各自平台的线程相关接口,这些细节对用户是透明的。
C++ 线程库识别当前运行平台的方式正是通过条件编译。不同平台的编译器(例如 Linux 下的 GCC,Windows 下的 MSVC 等)在编译 C++ 代码时,会隐式定义一个平台检测宏,用于标识代码所运行的平台。线程库在实现 thread 类时,会先检查当前程序中定义的平台宏,再通过条件编译选择保留对应平台的实现代码。这正是 C++ 线程库实现跨平台兼容的核心机制。
在了解了 C++ 线程库实现跨平台的原理之后,接下来的内容我们将重点讨论 Linux 平台下 thread 类的具体实现。
前文已提到,C++ 是一门面向对象的语言,其核心思想是'一切皆对象'。因此,C++ 为线程也设计了对应的 thread 类。在 thread 类出现之前,在 Linux 平台下编写多线程代码时,我们通常调用 Linux 线程库提供的接口来管理线程的生命周期,例如使用 pthread_create 创建线程,使用 pthread_join 等待线程结束。由于 Linux 操作系统本身使用 C 语言编写,其线程管理接口自然也遵循 C 语言风格,采用的是面向过程的管理方式。
C++ 实现线程的方式是将线程封装为类。原本我们需要调用 pthread_create 函数来创建线程,而现在只需直接创建一个 thread 对象即可。创建线程时必须为其提供执行上下文。如果使用 pthread_create,由于其接口是 C 风格的,我们只能传递一个函数指针,该指针所指向的全局函数或静态函数作为线程的执行入口。
而在 C++ 中,创建线程时同样需要提供执行上下文及相应参数,这部分功能由 thread 类的构造函数实现。该构造函数内部会封装 pthread_create 接口。值得注意的是,自 C++11 标准引入之后,线程的上下文不再局限于全局函数或静态函数,任何可调用对象(callable object)均可作为线程的执行入口,包括函数指针、仿函数(functor)、lambda 表达式以及函数包装器(如 std::function)等。因此,C++ 的线程库不仅保留了 Linux 平台下线程的基本属性,还在此基础上进行了扩展,融入了诸多 C++ 特性。如果仅使用 Linux 原生线程接口编写多线程代码,代码风格将是纯粹的 C 风格,无法充分利用 C++ 的强大特性。由此可见,C++ 的 thread 类功能十分强大。在学习 thread 类的过程中,我们将不断对比 Linux 原生线程库的使用方式。一旦熟练掌握 thread 类的使用,你将会发现其便利性,并在今后的多线程开发中更倾向于使用 C++ 线程库,毕竟其具备跨平台支持。
thread 类的强大之处不止于此。除了支持多种可调用对象作为线程执行上下文之外,更重要的是,传统的 pthread_create 接口对线程函数的原型有严格限制:返回值必须为 void*,参数必须为 void*。如果希望向线程函数传递多个参数,在 C 语言中通常需要将参数打包成结构体,并将结构体指针传递给线程函数,在线程函数内部再进行类型转换和解析。而在 C++ 中,我们可以直接将一个返回类型为 void,参数列表任意(包括任意类型、任意数量)的函数作为线程的执行上下文。
这意味着,在 C++ 中传递参数不再需要像传统方式那样间接进行,而是可以直接将目标函数及其参数传递给 thread 的构造函数。从上层理解,thread 的构造函数会接收可调用对象及其参数,并将参数依次传递给可调用对象。
#include<iostream>#include<thread>voidprint(size_t i, size_t j){
for (; i < j; i++) {
std::cout << "I am a thread" << std::endl;
}
}
intmain(){
std::thread thread1(print, 0, 2);
thread1.join();
return0;
}
thread::~thread() {
if (joinable()) {
std::terminate();
}
}
了解了如何创建和管理线程后,我们知道在 Linux 平台下,每个线程都有唯一的一个标识符用于区分。通过打印线程标识符,我们可以识别不同的线程。前文提到,std::thread 类内部维护了一个原生的线程标识符,即 pthread_t 类型的成员变量。同时,thread 类提供了 native_handle() 方法,用于返回该类所维护的原生线程 ID。
进一步观察 thread 类的设计,可以发现除了原生线程 ID 之外,其内部还维护了一个 id 类的成员变量。这两个成员变量的含义相同,均表示线程的标识符。有读者可能会产生疑问:既然 thread 类已持有一个 pthread_t 类型的原生线程 ID,为何还要额外维护一个 id 类对象?这样的设计是否会显得冗余?
下面我们来分析 id 类。thread 类内部定义了一个嵌套类——id 类,其成员变量正是原生线程 ID,即 pthread_t 类型变量。不过,我们关注的重点并不在于 id 类的成员变量,而在于其成员函数。可以发现,id 类提供了大量用于线程比较的运算符重载函数。前文已强调,C++ 是一门跨平台语言,需支持在 Windows、Linux 等不同平台上正确运行。
在不同平台下,thread 类内部维护的原生线程 ID 类型可能存在差异。C++ 引入 id 类的目的,正是为了屏蔽底层类型的差异,以统一的视角处理线程 ID。通过 id 类,可以抽象表示各平台下的线程 ID。在需要比较线程 ID 时,我们不应直接使用原生线程 ID 进行比较,而应调用 id 类所提供的运算符重载函数。
thread 类提供了 get_id() 方法,其返回值即为内部维护的 id 类类型变量。通过该方法,我们可以获取线程 ID,并进一步调用相应的运算符重载函数完成各种比较操作。
此外,id 类还重载了流插入运算符(operator<<),从而能够正确输出 ID 的值。
#include<iostream>#include<thread>intmain(){
std::thread thread1([]() {
int a = 1;
int b = 2;
a + b;
});
std::thread thread2([]() {
int a = 1;
int b = 2;
a + b;
});
if (thread1.get_id() > thread2.get_id()) {
std::cout << "thread 1 id :" << thread1.get_id() << " > "
<< "thread 2 id :" << thread2.get_id() << std::endl;
} else {
std::cout << "thread 2 id :" << thread2.get_id() << " > "
<< "thread 1 id :" << thread1.get_id() << std::endl;
}
thread1.join();
thread2.join();
return0;
}
除了 get_id() 方法,this_thread 命名空间中还有一个重要方法——yield()。我们只需理解其含义及用法,无需掌握具体实现细节。yield() 方法的作用是让出当前 CPU 时间片。需要注意的是,调用 yield() 后,线程并不会被移出就绪队列进入阻塞状态,而是主动释放当前占用的时间片,使 CPU 能够切换到其他线程执行。yield() 常与自旋锁结合使用。由于自旋锁需要通过忙等待(busy-waiting)不断检查锁状态,在单核 CPU 环境下,若某个线程持有锁但在执行过程中被切换,而调度到另一个正在自旋等待锁的线程,此时会出现无效自旋:因为单核 CPU 同一时刻只能执行一个线程,自旋线程占用 CPU 时间,却无法使持有锁的线程获得执行机会以释放锁。这种情况下,在自旋过程中,若检测到锁仍未释放,可调用 yield() 主动让出 CPU,以便持有锁的线程得以执行并释放锁。
除了递归锁,C++ 还提供了定时锁(timed mutex)。与普通互斥锁不同,当一个线程竞争互斥锁失败时,它会被移出就绪队列并进入阻塞状态。而定时锁在竞争失败时不会立即阻塞,而是会在指定时间内重复尝试获取锁。与自旋锁(spinlock)不同的是,自旋锁会持续占用 CPU 进行忙等待,而定时锁允许设置一个超时时间。若在超时时间内未成功获取锁,线程将主动放弃,避免长时间空转。
我们知道,导致数据不一致的原因在于对共享资源的访问操作不具备原子性。以上文提到的场景为例,多个线程对共享资源进行递增操作,而递增操作之所以不具原子性,是因为其在编译后会映射为三条基本指令:从内存读取数据到寄存器、执行算术运算、将结果写回内存。如果多个线程并发执行该操作,假设它们都执行了前两条指令后被切换,那么当这些线程依次执行写回内存的指令时,各个线程在重新被 CPU 调度时,其寄存器中保存的值可能已经过时,从而导致前一个线程的写入结果被后一个线程覆盖。
总线可分为三类:地址总线、数据总线和控制总线。地址总线用于传输物理地址。当 CPU 访问内存时,需通过地址总线将目标物理地址发送给内存。
需要注意的是,计算机底层有多个 I/O 设备,每个设备在某一时刻都可能向 CPU 发出数据传输请求,但它们与 CPU 之间共享同一根数据总线。这种情况类似于多线程并发访问共享资源,不过是发生在硬件层面。由于所有 I/O 设备(包括内存)共享数据总线,同一时刻只能有一个设备通过数据总线传输数据。如果有多个设备同时向总线发送数据,必然会导致数据冲突。因此,每个设备在使用数据总线前必须'锁定'总线。锁定方式是通过控制总线向总线仲裁器发送一个控制信号。总线仲裁器会接收多个设备的控制信号,并 通常 按照'先到先得'的原则分配总线的独占权 (取决于具体的调度策略),即控制信号最先到达仲裁器的设备获得总线使用权。
了解设备传输数据前需先锁定总线的机制后,我们来看现代多核 CPU 的情况。每个 CPU 核心均可运行独立的线程上下文,因此多核 CPU 能够实现真正的并发执行。在某一时刻,可能存在多个核心同时发起内存访问请求。由于地址总线只有一根,且为所有核心所共享,这些核心必须竞争地址总线的独占权。竞争方式仍是通过控制总线向仲裁器发送控制信号,最先到达的信号对应的核心将锁定地址总线,其他核心则必须等待该核心完成地址传输后才能依次获取总线使用权。
在 x86 架构下,CPU 支持一些原子操作,如递增、递减和位运算等。所谓原子操作,是指这些高级操作在编译后只对应一条基本指令。由于单条指令具备原子性,因此能保证这些操作不会出现数据不一致问题。这些指令通常带有 lock 前缀,其原理是让对内存的读和写操作连续执行,中途不被中断。
部分读者可能对'架构'这一术语感到陌生。架构可理解为 CPU 设计的蓝图,定义了 CPU 所能理解和执行的指令集合(即指令集)、寄存器结构、内存访问方式等。目前 Intel/AMD 公司生产的 CPU 主要采用 x86 架构,其支持的指令集为 CISC(复杂指令集)。CISC 的特点是指令长度可变,且一条指令可能包含多个底层操作(如同时涉及读内存和写内存)。与之相对的是 RISC(精简指令集),其指令长度固定,一条指令通常只对应一个基本操作。
但在现代 CPU 中,lock 前缀通常不再优先锁定总线,而是优先锁定对应的缓存行。这是因为每个 CPU 核心都有自己的一块缓存,其访问速度远高于内存。根据局部性原理,CPU 访问内存时不仅会加载目标数据,还会将其相邻数据(通常为一个缓存行的大小,一般为 64 字节)加载到缓存中。在访问内存前,CPU 会先检查缓存是否已有所需数据;如果未命中,才会按前述流程竞争总线。
由于每个核心均有缓存,大多数情况下 CPU 会直接访问缓存而非内存,这就引入了内存可见性问题。在多线程并发访问共享资源时,该资源很可能已被加载到多个核心的缓存中。此时,若某个线程竞争到锁并修改了共享资源(包括更新缓存并将结果写回内存),而其他核心的缓存中仍是旧值,那么当其他线程随后访问该资源时,由于缓存未更新,仍会读取到过期数据,从而导致数据不一致。
因此,现代 CPU 会实现一套缓存一致性协议,即 MESI 协议。所谓缓存一致性协议,是指每个缓存行(Cache Line)都会对应一个状态标记。在 MESI 协议中,状态主要分为四种:E(独占,Exclusive)、M(修改,Modified)、I(无效,Invalid)和 S(共享,Shared)。
在执行带有 lock 前缀的指令进行内存写操作时,会触发缓存一致性协议的执行。其基本原理是:首先检查当前核心中目标缓存行的状态。若状态为 E 或 M,说明仅有当前核心持有该缓存行,因此可直接修改缓存中的数据,并将状态置为 M(若原状态为 E)。若当前缓存行状态为 S,说明多个核心均持有最新数据副本,此时需向其他核心发出信号,使其将对应缓存行状态无效化(即从 S 改为 I),之后当前核心再修改自身缓存行数据,并将状态改为 M。若缓存行状态为 I,表明数据已失效或未加载,此时不区分具体情形,处理流程一致:先检查其他核心是否持有有效缓存行(即状态为 E 或 M)。若有,则直接从该核心缓存行读取数据,无需访问内存;若其他核心中存在状态为 S 的缓存行,则读取其中任意一个副本到本地,接着使其他所有 S 状态的缓存行无效化(改为 I),再修改数据并将当前状态设为 M;若所有核心中该缓存行状态均为 I,则需通过总线锁定机制,访问内存加载数据至缓存行,并将其状态设为 E 状态。
#include<iostream>#include<thread>#include<atomic>#include<mutex>int var = 0;
voidprint1(size_t num){
int i = 0;
while (i < num) {
var++;
i++;
}
}
voidprint2(size_t num){
int i = 0;
while (i < num) {
var++;
i++;
}
}
intmain(){
int num = 100000;
std::thread thread1(print1, num);
std::thread thread2(print2, num);
thread1.join();
thread2.join();
std::cout << "num :" << num << std::endl;
return0;
}
#include<iostream>#include<thread>#include<atomic>#include<mutex>
std::atomic<int> var = 0;
voidprint1(size_t num){
int i = 0;
while (i < num) {
var++;
i++;
}
}
voidprint2(size_t num){
int i = 0;
while (i < num) {
var++;
i++;
}
}
intmain(){
int num = 100000;
std::thread thread1(print1, num);
std::thread thread2(print2, num);
thread1.join();
thread2.join();
std::cout << "var :" << var << std::endl;
return0;
}
在此场景下,使用原子操作能更有效地保证数据一致性。需要补充说明的是,atomic 类提供了 load 和 store 成员函数。其中,load 用于读取并返回当前存储的值,store 则用于原子性地更新存储值。
bool flag = false;
int data = 0;
voidthreadfun1(){
data += 10;
flag = true;
std::this_thread::sleep_for(std::chrono::seconds(10));
}
voidthreadfun2(){
while (!flag);
std::cout << "data :" << data << std::endl;
}
线程 threadfun2 试图打印 threadfun1 修改后的 data 值。在单线程模型中,flag 在 data 修改后被置为 true,逻辑正确。但若发生指令重排,threadfun1 中可能先执行 flag=true,再执行 data+=10。这将导致 threadfun2 在 data 未完成修改时便跳出循环,读取到错误的数据值。
原子操作的 load 和 store 函数通过引入内存屏障(Memory Barrier)防止此类重排,确保多线程环境下的语义正确性。因此,在编写无锁并发程序时,应优先使用原子操作而非直接操作共享变量。
而 atomic 中的 load 和 store 函数的主要作用,正是为了在必要时对指令执行顺序施加约束,防止编译器或处理器进行可能影响程序正确性的重排。因此,load 和 store 函数通常会接受一个 memory_order 参数,该参数为编译时常量,用于指定所需的内存顺序语义。在大多数情况下,我们可以直接使用其默认参数,即顺序一致性(memory_order_seq_cst),它能够提供最严格的内存顺序保证,从而确保指令的执行顺序与代码书写顺序一致。
CAS 可以理解为一种包含两个步骤的原子操作:比较(Compare)和设置(Set)。正如其缩写所示,CAS 能够以原子方式完成比较和写入操作,整个过程不会被中断。我们仍以多线程并发访问整型共享资源并执行递增操作为例。递增操作本身不具备原子性,会导致数据不一致问题,其根本原因在于内存的读取和写入操作不是连续完成的,中间可能被中断。
// 伪代码表示 CAS 操作boolCAS(内存地址,期望值,新值){
if (*内存地址 == 期望值) {
*内存地址 = 新值;
returntrue; // 操作成功
} else {
returnfalse; // 操作失败
}
}
// 整个 CAS 操作是原子的,不会被中断
需要注意的是,现代处理器通常提供底层的 CAS 硬件指令。在 C++ 中,std::atomic 类提供了 compare_exchange_weak 成员函数,该函数接收期望值和新值作为参数。由于是成员函数,它会隐式传递 this 指针,因此当前对象的内存地址将作为写入目标。
compare_exchange_weak 函数底层直接调用硬件 CAS 指令,并通过检查处理器标志位来判断操作是否成功。然而,如果要修改的对象大小超过机器字长(例如自定义类型),则无法直接通过单条指令实现原子操作。此时,compare_exchange_weak 可能会通过加锁方式来模拟 CAS 行为,以保证操作的原子性。
CAS 的典型应用之一是自旋锁(spinlock)的实现。自旋锁的基本原理是不断检查锁的状态,而锁状态本质上是一个整型变量。通常,当该变量的值为 0 时表示锁处于未锁定状态,值为 1 时表示锁已被占用。在使用 CAS 操作时,我们期望的旧值(expected value)为 0,表示当前锁未被持有;而目标内存地址即为锁状态变量所在的内存地址。CAS 会检查该内存中的值是否为 0:如果不是,则返回 false,此时线程将继续自旋等待;若为 0,则成功将新值 1 写入内存,表示当前线程已获得锁。
除了自旋锁,CAS 还可用于实现无锁(lock-free)编程。在多线程并发访问共享资源时,若该访问操作不具备原子性,常见的同步策略包括加锁机制,另一种思路则是尝试无锁编程。以单链表的插入操作为例:假设存在一个由头指针和尾指针维护的单链表,其中尾指针始终指向链表的最后一个节点。现有一个 insert 函数用于在链表尾部插入新节点,其基本步骤包括:创建新节点,通过尾指针定位当前尾节点,将尾节点的 next 指针指向新节点,最后更新尾指针指向新节点。显然,这一系列操作并非原子操作,因此多线程并发调用 insert 函数时需进行同步。传统方法是使用锁,而另一种思路则是基于 CAS 实现无锁插入。
在创建新节点、修改尾节点的 next 指针以及更新尾指针的过程中,可能会发生线程切换,导致数据竞争。为此,我们需要引入第一次 CAS 检查:以尾节点的 next 指针作为操作对象,期望其值为 nullptr(即当前尾节点无后继),目标值为新节点的地址。若当前尾节点的 next 为 nullptr,则 CAS 操作成功,将新节点链接至链表尾部;否则,表示其他线程已修改了尾节点,本次操作失败。成功链接新节点后,还需执行第二次 CAS 操作以更新尾指针,确保其指向新的尾节点。
以下为无锁链表插入的示例代码:
structNode {
int data;
std::atomic<Node*> next; // 原子指针Node(int val) : data(val), next(nullptr) {}
};
classLockFreeLinkedList {
private:
std::atomic<Node*> head{nullptr};
std::atomic<Node*> tail{nullptr};
public:
voidpush_back(int value);
};
voidLockFreeLinkedList::push_back(int value){
Node* new_node = newNode(value);
while (true) {
Node* old_tail = tail.load(); // 读取当前尾节点// 第一次 CAS 检查:确认尾节点的 next 指针是否为 nullptrif (old_tail->next.compare_exchange_weak(nullptr, // 期望值:next 应为 nullptr(表示当前为尾节点)
new_node // 新值:将其设置为新节点
)) {
// CAS 操作成功,已将新节点链接至链表尾部// 第二次 CAS 检查:更新尾指针指向新节点
tail.compare_exchange_weak(old_tail, new_node);
break;
} else {
// CAS 操作失败,说明其他线程已修改尾节点的 next 指针// 协助完成尾指针更新,并重试当前操作
tail.compare_exchange_weak(old_tail, old_tail->next.load());
}
}
}