原文链接
原作者:Valentina Di Vincenzo
仅供个人学习、研究和欣赏
当我刚开始学 C++ 多线程的时候,我凌乱了。程序的复杂度如同一朵美丽的鲜花一样炸开了,并发的不确定行为要把我骨灰都给扬咯,当时我直接蒙了。如今你被我逮到了,我给你整个好活儿:这里是一份关于 C++ 并发和多线程的简易教程,放心,这个不会三天之内sa了你。
现在,来快速复习一些基本概念,然后来雅间尝点并发代码。
概念
什么是线程?
在进程被创建时,每个进程都有一个单独用来执行代码的线程,叫主线程。你可以向操作系统请求创建其他的线程,这些线程共享父进程的内存空间(代码区,数据区,其他操作系统资源,比如说打开的文件和信号)。另一方面,每个线程有各自的线程ID,栈,寄存器组和程序计数器。基本上来说,线程就是轻量的进程。在线程之间切换比进程快,线程间通信也比进程间通信更容易。
什么是并发?
随着时间,操作系统的任务调度器会给可用的 CPU 核心分配线程来运行,而且具有不确定性。这叫做硬件并发:多个线程并行运行在不同核心上,每个线程运行着程序的特定任务。
std::thread::hardware_concurrency()
函数能获取硬件真正能并行运行的任务数量。如果线程数量超过了这个限制,可能就会带来过多的任务切换(短时间内大量切换任务,给我们一种程序在并发的幻觉)。用 std::thread
实现基本线程操作
头文件:#include <thread>
创建线程
std::thread t(callable_object, arg1, arg2, ..)
创建了一个线程 t,线程会立刻调用 callable_object(arg1, arg2, ..)
。
callable_object 可以是函数指针、匿名函数、具有函数调用操作符重载的对象。
默认情况下 callable_object 是值传递,如果你想引用传递的话,就用 std::ref(arg)
包一下。
另外如果你想传递 unique_ptr 的话,你得用 std::move(arg)
移动给参数,毕竟 unique_ptr 没法复制。
线程生命周期
t.join()
以及 t.detach()
。
如果主线程退出了,其他所有仍在运行的副线程也会立马终止,没有任何办法恢复。为了避免这种情况发生,父线程对于子线程有两个选项:
- 调用子线程的
join()
方法,阻塞当前线程并等待子线程结束。 - 调用子线程的
detach()
方法,显式声明子线程可以在父线程退出后继续运行。
你能在这里找到示例代码。
4. 为什么需要同步?
因为线程都共享相同的内存空间和资源,很多操作都变得很苛刻:多线程操作需要同步原语。来讲讲为什么。
- 内存是鬼屋
有了多线程之后,内存没法再被当作是个“静态的仓库”了。想象一下:一个线程正在看摇摆阳跳舞,突然电视没了!他慌啊,打了 110…… 结果电话那头是“老八秘制小汉堡,即实惠又管饱”。咋回事?很简单,房子里百鬼夜行(鬼是其他线程):它们都在同一个房间,和同样的物体交互(这叫做 data race,数据争用),但是每个鬼看不见其他鬼。
为了整安全点儿,线程应该得声明它正在使用哪个东西,并且在使用东西之前先检查东西是否正在被使用。绿色线程有没有在看电视?有的话,那就不应该有其他人来碰电视(就算有的话,其他人也只应该来一起坐着看电视)。这可以通过互斥体来做到。
如果一个操作不是原子的,那么就可以在它工作到一半的时候观测它,毕竟这个操作并非_不可分割的_。
比如说写入一个64位值,分两次,每次写入32位。在操作进行的时候,另一个线程可以同时观测到:一半是刚写入的,另一半是原来的值,最后得到的结果是完全错误的值。
因此,这些操作必须得能展现出它们是“原子”操作,虽然它们可能原本不是。
a++
等同于
int tmp = a;
a = tmp + 1;
解决这个问题最简单的方法是使用 std::atomic
,这个模板类可以给很多类型做成原子操作。
在缓存刷新之前,数据的变更对于其他线程来说是不可见的。我们需要能够通报更改、保证正确读取数据的机制。
而且,为了提高效率,CPU 和/或 编译器可以重排指令。在一个并发程序里,这可能会导致不可预测的行为。我们有必要保证敏感部分的指令是按照原来的顺序执行的。
这是通过_能够表示内存屏障(代码中能够防止特定指令跨越的分割线)的同步原语_实现的,来确保核心一致性、防止乱序执行(在内存屏障内的指令不能被移动到外面)。
- 缓存一致性、乱序执行
代码范例
来看一些代码,自己也来测试一下多线程的不确定行为。
#include <thread>
#include <iostream>
#include <string>
void run(std::string threadName) {
for (int i = 0; i < 10; i++) {
std::string out = threadName + std::to_string(i) + "\n";
std::cout << out;
}
}
int main() {
std::thread tA(run, "A");
std::thread tB(run, "\tB");
tA.join();
tB.join();
}
可能的输出:
B0
A0
A1
A2
B1
A3
B2
B3
...
与单线程不同,每次执行都会产生不同且不可预测的输出(唯一能确定的就是数字在自增)。当指令执行顺序很重要的时候,会发生问题。
#include <thread>
#include <iostream>
#include <string>
void runA(bool& value, int i) {
if(value) {
//进入了if,value应该总是1
std::string out = "[ " + std::to_string(i) + " ] value " + std::to_string(value) + "\n";
std::cout << out;
}
}
void runB(bool& value) {
value = false;
}
int main() {
for(int i = 0; i < 20; i++) {
bool value = true; //true == 1
std::thread tA(runA, std::ref(value), i);
std::thread tB(runB, std::ref(value));
tA.join();
tB.join();
}
}
可能的输出:
...
[ 12 ] value 0
[ 13 ] value 1
[ 14 ] value 0
[ 15 ] value 0
[ 16 ] value 0
[ 17 ] value 0
[ 18 ] value 1
[ 19 ] value 0
...
(runA()
中打印输出的部分被套在了if中,只有 value
为 true
的时候才能打印,那么打印出来的 value
应该始终为 1
,但是输出中却有时打印了 0
)
这里发生了什么?——在线程 A 确定 value
是 true
之后,线程 B 修改了 value
的值。尽管 if
约束已经被打破,但是代码已经执行到了 if
代码块的内部。
如果两个线程访问相同数据,一个读、一个写,则无法保证哪个操作先执行。
对数据的访问必须进行同步
结论
概念信息量很大,但是你不需要现在立马就理解一切,重要的是抓住核心概念。
建议去把玩一下示例代码,看一看并发是怎样执行的。然后也去试着思考一下其他需要同步的地方,测试一下(比如说:如果有多个线程同时对一个队列进行出队操作呢?别忘了出队前先得检查一下队列是不是空的)。
(C++11 标准库引入了进行同步的机制,不依赖底层平台,所以这个系列将不会讨论 Linux 和 Windows 平台上特定的线程库。总之,核心概念都差不多。)
在下一篇文章,我们会看看互斥器同步原语,看看怎样最好地使用它。