随着摩尔定律逼近失效和多核处理器快速发展,多线程编程变得越来越重要。本文将系统介绍在 C++ 中如何使用 STL 实现多线程编程。多线程编程博大精深,本文并不介绍多线程算法或多线程编程方法,而是把重点放在了 C++ 的多线程库上。如果你不懂多线程,那看完本文估计也还是不懂;如果你懂多线程,那看完本文你就可以用 C++ 编写多线程程序了。
本文属于“C++ 前言语法”板块,因此要求编译器至少支持 C++20 标准。
本文的上半部分已完结,下半部分正在近日填坑。欢迎点赞、收藏、关注!
以上参考资料中,除了第一项,其余有引用的,会通过角标注明。没有用角标注明的,说明是我看过,但并没有引用其中作者的观点,读者可以将他们作为扩展资料阅读。
这个我不解释了,这篇文章假设你已经了解线程的基本概念,不知道的话上网搜索吧。只送一句话:
自 C++11 起,就可以使用 thread
库进行一系列与子线程管理相关的操作。
#include
thread
对象 thread
库主要提供了 thread
对象,利用 thread
对象可以创建并管理子线程。
#include
#include
int ans[2];
int f(int n)
{
if (n <= 1)
return 1;
return f(n - 1) + f(n - 2);
}
void interface(int idx, int n)
{
ans[idx] = f(n);
}
int main()
{
std::thread t1(interface, 0, 34);
std::thread t2(interface, 1, 35);
t1.join();
t2.join();
std::cout << "f(34) = " << ans[0] << "\n"
<< "f(35) = " << ans[1] << std::endl;
}
运行结果:
f(34) = 9227465
f(35) = 14930352
可见,thread
对象在构造时即自动创建子线程并运行。使用 thread
对象的好处时能够传递任意类型的参数给目标函数,这与较底层的创建线程的行为不同。 有关 thread
对象构造函数的更多行为,见 cpp reference,非常有必要看看,这里暂时不展开论述;这其中涉及到参数是否为引用的问题,程序 11 提供了一个这样的例子。
任何一个线程对象在创建子线程后都需要合并(join)或者分离(detach),且两者间只能选其一。否则,线程对象在被析构时将会抛出一个错误。
join
方法会等待一个线程结束,这意味着调用 join
方法时会阻塞调用方线程,除非线程对象对应的线程已经运行完成。在操作系统内部,join
方法的调用会导致运行完成的线程的资源被回收,因此 join
方法是必要的。
如果不希望等待一个线程结束,而是任它自己运行,则应该在创建线程后或者其他适当的时机调用 detach
方法。在操作系统内部,被分离的线程在运行结束后会自动被进行资源回收。而在 C++ 层面,一个线程对象调用 detach
方法后,该对象就不再管理任何线程,而变成了一个空线程对象。空线程对象不能再进行 join
或者 detach
,但它能够直接被正常析构。
程序 1 给出了使用 join
方法的合理例子。下面的程序 2 给出一个使用 detach
方法的不合理例子。
#include
#include
#include
void virus()
{
for (int i = 0; i < 4000; i++)
std::cout << "你的手机已中病毒,点击下载最新版浏览器\n";
}
int main()
{
std::thread t(virus);
t.detach();
std::string name;
std::cin >> name;
std::cout << "君の名は" << name;
}
病毒制作者当然不希望在弹出下载消息的时候你无法操作手机(堵塞主线程),因此使用子线程输出信息后撒手不管(detach)就是最佳选择。由于这是多线程,因此在病毒输出消息时,你仍然可以输入你的名字。
手快的朋友可能发现,如果过早地完成了输入,程序会发生运行时错误。你想,调用了 cin
对象的析构函数后,病毒仍然在使用 cin
对象进行输出,这能不报错吗?这个也引出了后面我们对多线程编程一系列问题的讨论。
除了通过 detach
方法得到一个空线程对象,thread
对象也存在默认构造函数。通过默认构造函数构造出的 thread
对象也是一个空线程对象。 问题是,如何让这个空线程对象工作起来呢?
#include
class daemon
{
std::thread t_background; // 默认构造函数:空线程对象。
void background_routine()
{
while (true);
}
void launch()
{
// 启动线程:该怎么写?
}
};
这时需要注意:线程对象是不可复制构造和复制赋值的,一个线程不可能被两个线程对象管理。所以不能使用 t_background = std::thread(...);
的方式来新建线程。
有两个解决方法:
t_background
,再 placement new。#include
int main()
{
std::thread t;
t.~thread();
new(&t) std::thread([]() // placement new + lambda
{
for (int i = 0; i < 1e9; i++);
});
t.join();
}
看起来就不太聪明的样子呢。
#include
int main()
{
std::thread t;
t = std::move(std::thread([]()
{
for (int i = 0; i < 1e9; i++);
}));
t.join();
}
这时程序 3 中的问题可以说是解决了一半,另一半问题是:如何将类成员函数作为新线程的入口函数。
#include
class daemon
{
std::thread t_background; // 默认构造函数:空线程对象。
void background_routine()
{
while (true);
}
void launch()
{
t_background = std::move(
std::thread(&daemon::background_routine, this)); // 注意这里。
}
};
总结:与 std::bind
类似,参数形如 &类名::成员函数名, this
。
更正:程序 5 和程序 6 中的
std::move
可以省略,因为其括号内的表达式本身就是右值,赋值时本来就会进行移动赋值。
jthread
对象 自 C++20 起,STL 标准库支持一种新的线程对象,名为 jthread
。jthread
是 thread
的扩展(但不是子类),某些基本的表现与 thread
类似:
thread
相同。若不采用默认构造,则线程会被新建并执行。jthread
对象管理,即 jthread
没有复制构造和复制赋值。但有移动构造和移动赋值。jthread
对象可以手动 join
或者 detach
。 与 thread
的首个不同点是,如果管理的线程既没有 join
又没有 detach
,jthread
对象会在析构时自动 join
。1这可以简化某些情况下的代码。
#include
#include
void f()
{
for (int i = 0; i < 1e9; i++);
}
int main()
{
std::jthread t1(f);
std::jthread t2(f);
}
无需手动调用 join
方法,jthread
会在析构时自动等待线程运行结束。
stop_token
) jthread
的另一个重要功能是它内含了一个请求线程尽快停止运行的标志对象。若该标志对象的状态被置为了“停止”,则 jthread
对象管理的线程应当根据这个状态自行结束,例如,从 while(true)
循环中 break
出来。
子线程的入口函数如何获取这个标志对象,总不能让这个子线程获取到管理它的 jthread
对象吧?正确的做法是:令子线程的入口函数的第一个参数为一个类型为 stop_token
(stop_token
类即为前文所说的标志对象)的变量,则 jthread
在创建线程时会把该标志对象传递给子线程入口函数。见程序 81。
#include
#include
#include
void f(std::stop_token st, std::string str) // 虽然传的不是引用,但肯定是有关联的。
{
while (!st.stop_requested()) // 判断标志对象的状态是否为被请求停止。
{
std::cout << str;
std::flush(std::cout);
// 把线程挂起一段时间,不然太臭了。
using namespace std::chrono_literals; // 重载字面量运算符 ""ms
std::this_thread::sleep_for(100ms);
}
}
int main()
{
std::jthread jt(f, "啊"); // "啊" 是参数 2,不是参数 1。参数 1 是 stop_token。
std::string temp;
std::getline(std::cin, temp);
jt.request_stop(); // 请求停止。效果是设置标志对象的状态为停止。
}
总结:jthread
的关键方法是 request_stop
,用于请求被管理的线程停止。stop_token
的关键方法是 stop_requested
,用于获知是否被请求停止。
补充五点:
jthread
被析构时,如果没有手动 join
或 detach
,则会先自动请求停止,再自动合并。这意味着程序 8 中的最后一句 jt.request_stop();
不是必要的,因为会自动执行。jthread
与 thread
兼容,入口函数的第一个参数并不是 stop_token
,这也行吗?jthread
的实现中,会判断传入函数的类型,如果能够以 f(stop_token, ...)
的形式传参,则传入标志对象。否则直接以 f(...)
的形式传参,忽略 stop_token
,此时理论上标志对象不起任何作用。这也是为什么前文强调 stop_token
必须为函数的第一个参数。stop_token
本身是线程安全的。简而言之,你直接用就没问题。jthread
似乎无法解决这个问题。jthread
管理的线程结束时,我们是可以收到通知的,方法是使用 std::stop_callback
。这里暂不讨论,见 cpp reference 对 stop_callback
的介绍。提多线程就一定要提线程安全,提线程安全就一定要提到下面这个自增程序。
#include
#include
int ans;
void inc()
{
for (int i = 0; i < 1e8; i++)
ans++;
}
int main()
{
std::thread t1(inc);
std::thread t2(inc);
t1.join();
t2.join();
std::cout << ans << std::endl;
}
可能的输出:
135895847
反正你就是得不到 200000000
!出现这个问题的原因是,两个线程指令的执行顺序可能是:
线程 1:取内存到寄存器(mov)
线程 2:取内存到寄存器
线程 1:寄存器自增(inc)
线程 1:写寄存器至内存(mov)
线程 2:寄存器自增
线程 2:写寄存器至内存
虽然程序执行了两次“寄存器自增”,但最终效果不相当于只让内存中的值增加了 1 吗?这说明,在多线程环境中,有些代码被多个线程同时执行时是不安全的。换句话说,同一时间,至多只能有一个线程在执行某段代码。需要注意的是,这只是最简单常见的情形:同一段代码,多线程执行,引发数据竞争。还有更多更复杂的线程不安全的情形,我们将在后面看到。
针对这一问题,有以下解决方案。
atomic
对象 至少需要 C++11。需包含
。
#include
前文已经分析了程序⑨中 1+1 都算错的原因。如果程序⑨中的自增操作本身就只需要一条指令,不就没这个问题了吗?如果真有这样的“指令”,那么称这种(在逻辑上)不可再分割运行的程序是原子的。
最底层的硬件层面可能不存在这样的“指令”,但是在高级语言层面却总存在一段原子的程序。atomic
对象即为我们封装了原子的程序。它是一个类模板,程序 10 展现了它最简单的用法。
#include
#include
#include
std::atomic<int> ans;
void inc()
{
for (int i = 0; i < 1e8; i++)
ans++;
}
int main()
{
std::thread t1(inc);
std::thread t2(inc);
t1.join();
t2.join();
std::cout << ans << std::endl;
}
运行结果:
200000000
正确!且非常实用,只需要把原来的 int
改成 std::atomic
即可。
之所以程序 10 能够这么简单,是因为 atomic
类对 int
这样的整数型(满足概念 std::integral
的类型)进行了偏特化,重载了赋值、自增、自减等一系列写操作(当然是原子的),并且重载了类型转换运算符。
自 C++20 起,std::atomic
才实现了 float
、double
这种浮点类型的偏特化。
最后一种特殊的类型是指针类型。atomic
对指针类型实现了偏特化,并且自 C++20 起,atomic
对智能指针实现了偏特化。
标准库提供了一系列函数,例如 __atomic_add_fetch
,来实现对整数的一系列原子操作。atomic
类包装了对这些函数的调用,使得我们能够用平常的方式来使用原子化的整数。对再内部的运行原理超出了我们的讨论范围。
可以看出,__atomic_add_fetch
等函数的存在决定了 atomic
对整数类型的偏特化存在,指针类型和浮点数类型同理。那对于其他类型,atomic
仍能保证其原子性吗?答案是否定的,或者说,这样的 atomic
对象完全不便于你使用。
对于没有偏特化的类型,atomic
对象只支持下面三种基本操作:
load
)。store
)。exchange
)。 相当于你无法调用一个对象的成员函数,这能忍吗?这不能忍。但 atomic
对象也没有办法,因为确实并不是所有类型的所有成员函数都是可以轻易原子化的。
atomic_ref
对象 为了引入 atomic_ref
,见下面的程序。
#include
#include
#include
#include
void inc(int& num) { for (int i = 0; i < 1e8; i++) num++; }
void inc_n(int& num, int n)
{
std::vector<std::thread> vec;
for (int i = 0; i < n; i++)
vec.emplace_back(inc, std::ref(num)); // 注意引用必须用 std::ref 包装,见前文对 std::thread 参数的解释。
for (auto& t : vec)
t.join();
}
int main()
{
int n = 0; // 局部自动变量。
inc_n(n, 2); // 用多线程加 2 次 1e8。
std::cout << n << std::endl;
}
可能的运行结果:
109726353
程序 11 用最简单的示意代码描述了一个情形:一个用于修改作为引用传入的参数的函数,内部使用多线程进行加速。尽管整个程序没有全局变量,但仍然会因此引发线程安全问题。
在程序 11 中,如何在不修改 main
函数的情况下解决这个问题?自然会想到,如果 inc_n
能够替我们解决这个问题就好了。目前的解决方法是:将 inc
函数的参数类型更换为 atomic
,在 inc_n
函数内部额外引入一个 atomic
。
自 C++20 起,新引入的 atomic_ref
对象可以避免上述额外引入的 atomic
。它为非原子的类型(如 int
)提供了一个原子的引用,透过它看原来的对象,可以我们操作的是一个原子的对象。见程序 12。
#include
#include
#include
#include
// 注意参数类型不可为 int&。
void inc(std::atomic_ref<int> num) { for (int i = 0; i < 1e8; i++) num++; }
void inc_n(int& num, int n)
{
std::vector<std::thread> vec;
for (int i = 0; i < n; i++)
vec.emplace_back(inc, std::atomic_ref(num)); // 注意不可再用 std::ref。
for (auto& t : vec)
t.join();
}
int main()
{
int n = 0;
inc_n(n, 2);
std::cout << n << std::endl;
}
当然这么做是要有一定限制的,你总不能再写一个 int&
类型的函数然后新建线程调用,这样做仍然会有数据竞争的问题。更多的限制条件,见 cpp reference 文档的介绍。
atomic
对象相同的局限性 atomic
与 atomic_ref
对象最大的局限性是:它们都只能很好地支持已经偏特化的类型,即整数、浮点数(自 C++20)、指针和智能指针(自 C++20)。对于其他类型,你也最多只能做最基本的读、写和读写操作,可以说完全无法使用。如果类型是不平凡的,你甚至无法通过编译(平凡的定义我也不太懂,但举两个例子,一个只包含整数的结构体肯定是平凡的,而一个 vector
绝不是平凡的)。
诚然,出现这个问题最主要的原因是,只有整数、浮点数这种简单的东西才有可能使得它们的每一个过程都原子化。vector
有这么多复杂的操作,不可能都做到原子化。并且,要使得一个 vector
是线程安全的,首先得搞清楚,什么样的 vector
才能叫做线程安全的,这已经足够复杂了。不像整数,线程安全可以简单地认为是当有多个线程进行 n n n 次加法时,运行完后该整数确实加了 n n n 次;只要加法满足这个条件,别的运算也可以同理定义。
所以进一步,要实现一个线程安全的 vector
,就肯定得用别的工具了。
counting_semaphore
对象自 C++20 起,STL 才提供了内置的信号量对象。但由于信号量是一种非常简单的工具、历史悠久(基于信号量的线程同步方法由 Dijkstra 提出2)、运用广泛,因此我们把信号量放在前面。
需包含 semaphore
头。
#include
信号量在数据域上仅仅是一个非负整数,表示一个计数器。该计数器表示还可以有多少个访问者进行访问。如果有访问者进行访问,则计数器减一。当访问结束时,计数器(默认)加一。如果开始进行访问时计数器为零,则线程阻塞,直至计数器非零(由于有其他访问者结束访问,计数器数值增加),访问才真正开始。显然,该计数器需要是线程安全的,即该计数器的加减操作需要是原子的,这由信号量的内部实现保证;否则,可能会出现两个线程同时真正开始访问,而计数器只减少了 1 的情况。
可见,计数器的初始值是一个值得关注的东西。对于 C++20 提供的 counting_semaphore
,信号量计数器的初始值由构造函数显式地给出,但这个初始值不能大于信号量允许的最大值,而这个最大值由模板参数给出,默认为一个极大值。作为一个特例情况,最大值为 1 的信号量称为二元信号量2。二元信号量在处理变量跨线程共享问题方面有着非常重要的应用,因此 C++20 为它提供了一个单独的类型:
using binary_semaphore = std::counting_semaphore<1>;
根据以上描述,我们在下面列出信号量的重要方法。
acquire
:开始访问,无返回值。运行结束后信号量计数器减一。release
:结束访问,无返回值。效果是信号量计数器(默认)加一。还可以指定信号量计数器增加的值,但必须非负,且不能使得计数器的值大于最大值。try_acquire_for
:尝试开始访问,至多阻塞一个给定的时间,如果超时,说明开始访问失败,返回 false
,否则说明访问成功,返回 true
,计数器减一。 重点是前两个方法,要实现前面的描述确实也只需要前两个方法。第三个方法的问题在于,文档指出,允许 try_acquire_for
虚假地返回 false
,即使计数器非零。所以我们不把重点放在该方法上。
读者应该注意,在上面的描述中,并没有要求 acquire
和 release
一定是配对的。只要信号量对象存在,那么可以随时进行 acquire
和 release
。
考察以下情形:有 n n n 个任务要进行,每个任务之间没有数据需要共享。但每个任务都需要很多 CPU 时间,为了保证运行效率,减少运行过程中进行的线程切换,要求至多只能有处理器线程个线程同时运行。程序 13 使用信号量模拟了这一情形。
#include
#include
#include
#include
std::counting_semaphore s(std::thread::hardware_concurrency());
std::binary_semaphore bs(1);
void submitted_code(int id)
{
s.acquire(); // 使用信号量限制访问者数量。
for (int i = 0; i < 1e9; i++);
bs.acquire(); // 使用二元信号量保证同一时刻至多有一个线程执行输出的代码。
std::cout << id << " accepted." << std::endl;
bs.release();
s.release();
}
int main()
{
std::vector<std::thread> ts;
for (int i = 0; i < std::thread::hardware_concurrency() * 2; i++)
ts.emplace_back(submitted_code, i);
for (auto& t : ts)
t.join();
}
可能的运行结果(没错我电脑 8 核 16 线程):
14 accepted.
5 accepted.
4 accepted.
6 accepted.
3 accepted.
1 accepted.
13 accepted.
11 accepted.
0 accepted.
2 accepted.
8 accepted.
9 accepted.
15 accepted.
10 accepted.
7 accepted.
12 accepted.
18 accepted.
17 accepted.
23 accepted.
19 accepted.
16 accepted.
22 accepted.
21 accepted.
29 accepted.
24 accepted.
28 accepted.
20 accepted.
30 accepted.
25 accepted.
31 accepted.
27 accepted.
26 accepted.
出现前 16 行后,会过一会儿再出现后 16 行。
程序 13 中我们使用了二元信号量 bs
来保证 std::cout
的线程安全。std::cout
由其他人实现,是一个复杂对象,也不能简单地看作是数据的共享,显然不能原子化。要使得使用 std::cout
的程序仍然是线程安全的,可以要求在同一时刻至多只有一个线程执行相关代码,而这正符合二元信号量的功能。作为对比,程序 14 展示了不使用二元信号量的结果。
#include
#include
#include
#include
std::counting_semaphore s(std::thread::hardware_concurrency());
void submitted_code(int id)
{
s.acquire(); // 使用信号量限制访问者数量。
// 为了突出效果,略去循环代码。
// 不使用二元信号量。
std::cout << id << " accepted." << std::endl;
s.release();
}
int main()
{
std::vector<std::thread> ts;
for (int i = 0; i < std::thread::hardware_concurrency() * 2; i++)
ts.emplace_back(submitted_code, i);
for (auto& t : ts)
t.join();
}
可能的运行结果:
039 accepted.4 accepted.
6 accepted.1 accepted.
accepted.
28 accepted.21 accepted.
30 accepted.
15 accepted.
7 accepted.
2 accepted.
12 accepted.
22 accepted.
18 accepted.
17 accepted.
19 accepted.
13 accepted.
16 accepted.
26 accepted.
27 accepted.
29 accepted.
31 accepted.
accepted.25 accepted.
14 accepted.
23 accepted.
8 accepted.11 accepted.
24 accepted.
20 accepted.
5 accepted.
10 accepted.
即使 cout
的 <<
运算符是原子的,std::cout << id << " accepted." << std::endl;
这整句代码也一定不是线程安全的!在这个例子中,线程安全定义的复杂性就显现出来了。从程序 13 和程序 14 我们可以学到,通过使用二元信号量使得同一时刻至多只有一个线程运行指定代码,能让很多代码按我们的想法正确地运行,即保证了线程安全。称这种手段为互斥访问2。
程序 8 的“补充五点”中,第 4 点提出了一个这样的问题:
聪明的读者很快就发现问题:程序 8 中,在请求停止后,我们仍然会等待至多 100 毫秒,因为子线程可能恰好准备开始 sleep 100 毫秒。如何避免这 100 毫秒的等待呢?
下面,我们利用二元信号量的计数器功能,配合 try_acquire_for
方法来重写一个完美的程序 8。
#include
#include
#include
#include
std::binary_semaphore bs{ 0 };
void f()
{
while (true)
{
std::cout << "啊";
std::flush(std::cout);
// 把线程挂起一段时间,不然太臭了。
using namespace std::chrono_literals; // 重载字面量运算符 ""ms
if (bs.try_acquire_for(100ms)) // 如果等待成功(bs.release() 已调用),则退出。
break;
}
}
int main()
{
std::thread t(f);
std::string temp;
std::getline(std::cin, temp);
bs.release();
t.join();
}
即使将代码中的 100ms
改成 10s
,按下回车键程序也能立刻退出,问题得到解决!程序 15 说明,信号量本身是一个非负整数(二元信号量本身是一个布尔值)的性质是可以加以利用的。
不过,查阅文档可以发现,你并不能直接获得信号量计数器的值,事实上确实不能:信号量的一个可能实现中,该计数器是一个 private
的原子整数。无法在不产生其他影响或不编写失格代码获取信号量计数器的值,可能是信号量的共性。
万一程序 15 中的 try_acquire_for
总是为我直接返回 false
怎么办?实在不放心可以看一看具体实现:
// 摘自 (MSVC)
template <class _Rep, class _Period>
_NODISCARD bool try_acquire_for(const chrono::duration<_Rep, _Period>& _Rel_time) {
auto _Deadline = _Semaphore_deadline(_Rel_time);
for (;;) {
// "happens after release" ordering is provided by this exchange, so loads and waits can be relaxed
// TRANSITION, GH-1133: should be memory_order_acquire
unsigned char _Prev = _Counter.exchange(0);
if (_Prev == 1) {
return true;
}
_STL_VERIFY(_Prev == 0, "Invariant: semaphore counter is non-negative and doesn't exceed max(), "
"possibly caused by preconditions violation (N4861 [thread.sema.cnt]/8)");
const auto _Remaining_timeout = __std_atomic_wait_get_remaining_timeout(_Deadline);
if (_Remaining_timeout == 0) {
return false;
}
__std_atomic_wait_direct(&_Counter, &_Prev, sizeof(_Prev), _Remaining_timeout);
}
}
没有直接返回 false
的代码,那就放心用吧!
线程同步的机制一引入,就会引出一个著名的问题:死锁。死锁可以定义为:线程被阻塞,且等待的条件永远不可能为真2。程序 16 使用信号量制造了一个死锁。
#include
#include
std::binary_semaphore s{ 1 };
std::vector<unsigned> seq;
void make_seq(unsigned target)
{
if (target == 0)
return;
s.acquire();
seq.push_back(target);
make_seq(target - 1);
s.release();
}
int main()
{
make_seq(1);
make_seq(2);
}
这是一个单线程程序,所以这个例子有点拙劣了,但它确实是一个死锁。调用 make_seq(1)
后,程序正常运行;但调用 make_seq(2)
会导致程序在进入第二层递归时等待二元信号量释放;但二元信号量释放的条件是第二层递归运行结束!所以这个等待将会永远地持续下去,便构成了一个死锁。
死锁当然是要避免的,但并不是一个容易事。这里我们不再展开讨论。
程序 13 是一个使用信号量进行互斥访问(bs
) 和 资源调度(s
) 的极简例子,这也是线程安全问题中最主要的两个解决手段。要实现这两点,除了信号量,还有其他模型,在过去的 C++ 中主要的模型为互斥锁和条件变量2。相比之下,信号量更为简单清晰,但限于信号量在 C++20 中才被支持,某些功能,例如 try_acquire_for
,它们的定义不太良好,所以学习互斥锁和条件变量是很有必要的。
longji - C++20 jthread ↩︎ ↩︎
《深入理解计算机系统》, 机械工业出版社. Randal E. Bryant, David R. O’Hallaron 著. 龚奕利、贺莲 译. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎