【C++】多线程与并发 1 - 概念入门

warning: 这篇文章距离上次修改已过244天,其中的内容可能已经有所变动。
原文链接
原作者:Valentina Di Vincenzo
仅供个人学习、研究和欣赏

当我刚开始学 C++ 多线程的时候,我凌乱了。程序的复杂度如同一朵美丽的鲜花一样炸开了,并发的不确定行为要把我骨灰都给扬咯,当时我直接蒙了。如今你被我逮到了,我给你整个好活儿:这里是一份关于 C++ 并发和多线程的简易教程,放心,这个不会三天之内sa了你

现在,来快速复习一些基本概念,然后来雅间尝点并发代码。

概念

1. 什么是线程?

在进程被创建时,每个进程都有一个单独用来执行代码的线程,叫主线程。你可以向操作系统请求创建其他的线程,这些线程共享父进程的内存空间(代码区,数据区,其他操作系统资源,比如说打开的文件和信号)。另一方面,每个线程有各自的线程ID,栈,寄存器组和程序计数器。基本上来说,线程就是轻量的进程。在线程之间切换比进程快,线程间通信也比进程间通信更容易。

2. 什么是并发?

随着时间,操作系统的任务调度器会给可用的 CPU 核心分配线程来运行,而且具有不确定性。这叫做硬件并发:多个线程并行运行在不同核心上,每个线程运行着程序的特定任务。

info:std::thread::hardware_concurrency() 函数能获取硬件真正能并行运行的任务数量。如果线程数量超过了这个限制,可能就会带来过多的任务切换(短时间内大量切换任务,给我们一种_程序在并发_的幻觉)。

task-switching.pngtask-switching.png

3. 用 std::thread 实现基本线程操作

  • 头文件 | #include <thread>
  • 创建线程 | std::thread t(callable_object, arg1, arg2, ..)
    这个函数创建了一个线程 t,而 t 线程会调用 callable_object(arg1, arg2, ..)。也就是说可调用对象 callable_object(例如函数指针,匿名函数,具有函数调用操作符重载的对象)会被立刻新线程附(可选)参数调用。默认情况可调用对象是值传递,如果你想引用传递的话,就用 std::ref(arg) 包一下。另外如果你想传递 unique_ptr 的话,你得用 std::move(arg) 移动给参数,毕竟 unique_ptr 没法复制。
  • 线程生命周期 | t.join() 以及 t.detach()
    如果主线程退出了,其他所有仍在运行的副线程也会立马终止,没有任何恢复的可能性。为了避免这种情况发生,父线程对于子线程有两个选项:

    • 调用子线程的 join() 方法,阻塞当前线程并等待子线程结束。
    • 调用子线程的 detach() 方法,显式声明子线程可以在父线程退出后继续运行。
Error:牢记:线程对象只能移动,不能复制。

你能在这里找到示例代码。

4. 为什么需要同步?

因为线程都共享相同的内存空间和资源,很多操作都变得很苛刻:多线程操作需要同步原语。来讲讲为什么。

  • 内存是鬼屋
    有了多线程之后,内存没法再被当作是个“静态的仓库”了。想象一下:一个线程正在看摇摆阳跳舞,突然电视没了!他慌啊,打了 110…… 结果电话那头是“老八秘制小汉堡,即实惠又管饱”。咋回事?很简单,房子里百鬼夜行(鬼是其他线程):它们都在同一个房间,和同样的物体交互(这叫做 data race,数据争抢),但是每个鬼看不见其他鬼。

data-racing.pngdata-racing.png

为了整安全点儿,线程应该得声明它正在使用哪个东西,并且在使用东西之前先检查东西是否正在被使用。绿色线程有没有在看电视?有的话,那就不应该有其他人来碰电视(就算有的话,其他人也只应该来一起坐着看电视)。这可以通过互斥体来做到。

  • 整点儿原子操作!
    大多数操作都不是原子的。如果一个操作不是原子的,那么就可以在它工作到一半的时候观测它,毕竟这个操作不是_不可分割的_。比如说写入一个64位值,分两次,每次写入32位。在操作进行的时候,另一个线程可以同时观测到:一半是刚写入的,另一半是原来的值,最后得到的结果是完全错误的值。因此,这些操作必须得能展现出它们是“原子”操作,尽管它们根本不是。
    Error: 注意:自增不是原子操作
    a++ 等同于 int tmp = a; a = tmp + 1;

    解决这个问题最简单的方法是使用 std::atomic,这个模板类可以给很多类型做成原子操作。
  • 缓存一致性、乱序执行
    每个 CPU 核心都试着通过在本地缓存中储存最近的数据来提高效率。如果有多个线程运行在不同的核心上,缓存中储存的值可能就不再是正确的,最终缓存会刷新。在缓存刷新之前,数据的变更对于其他线程来说是不可见的。我们需要能够通报更改、保证正确读取数据的机制。而且,为了提高效率,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中,只有 valuetrue 的时候才能打印,那么打印出来的 value 应该始终为 1,但是输出中却有时打印了 0

这里发生了什么?——在线程 A 确定 valuetrue 之后,线程 B 修改了 value 的值。尽管 if 约束已经被打破,但是代码已经执行到了 if 代码块的内部。

如果两个线程访问相同数据,一个读、一个写,则无法保证哪个操作先执行。

对数据的访问必须进行同步

结论

概念信息量很大,但是你不需要现在立马就理解一切,重要的是抓住核心概念。

建议去把玩一下示例代码,看一看并发是怎样执行的。然后也去试着思考一下其他需要同步的地方,测试一下(比如说:如果有多个线程同时对一个队列进行出队操作呢?别忘了出队前先得检查一下队列是不是空的)。

(C++11 标准库引入了进行同步的机制,不依赖底层平台,所以这个系列将不会讨论 Linux 和 Windows 平台上特定的线程库。总之,核心概念都差不多。)

在下一篇文章,我们会看看互斥器同步原语,看看怎样最好地使用它。

评论已关闭