<audio id="audio" title="14 | 十面埋伏的并发：多线程真的很难吗？" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/98/24/98b9d6d400c1486999fb8677b324a224.mp3"></audio>

你好，我是Chrono。

今天，我们来聊聊“并发”（Concurrency）、“多线程”（multithreading）。

在20年前，大多数人（当然也包括我）对这两个词还是十分陌生的。那个时候，CPU的性能不高，要做的事情也比较少，没什么并发的需求，简单的单进程、单线程就能够解决大多数问题。

但到了现在，计算机硬件飞速发展，不仅主频上G，还有了多核心，运算能力大幅度提升，只使用单线程很难“喂饱”CPU。而且，随着互联网、大数据、音频视频处理等新需求的不断涌现，运算量也越来越大。这些软硬件上的变化迫使“并发”“多线程”成为了每个技术人都不得不面对的课题。

通俗地说，“并发”是指在一个时间段里有多个操作在同时进行，与“多线程”并不是一回事。

并发有很多种实现方式，而多线程只是其中最常用的一种手段。不过，因为多线程已经有了很多年的实际应用，也有很多研究成果、应用模式和成熟的软硬件支持，所以，对这两者的区分一般也不太严格，下面我主要来谈多线程。

## 认识线程和多线程

要掌握多线程，就要先了解线程（thread）。

线程的概念可以分成好几个层次，从CPU、操作系统等不同的角度看，它的定义也不同。今天，我们单从语言的角度来看线程。

**在C++语言里，线程就是一个能够独立运行的函数**。比如你写一个lambda表达式，就可以让它在线程里跑起来：

```
auto f = []()                // 定义一个lambda表达式
{
    cout &lt;&lt; &quot;tid=&quot; &lt;&lt;
        this_thread::get_id() &lt;&lt; endl;
};

thread t(f);                // 启动一个线程，运行函数f

```

任何程序一开始就有一个主线程，它从main()开始运行。主线程可以调用接口函数，创建出子线程。子线程会立即脱离主线程的控制流程，单独运行，但共享主线程的数据。程序创建出多个子线程，执行多个不同的函数，也就成了多线程。

多线程的好处你肯定能列出好几条，比如任务并行、避免I/O阻塞、充分利用CPU、提高用户界面响应速度，等等。

不过，多线程也对程序员的思维、能力提出了极大的挑战。不夸张地说，它带来的麻烦可能要比好处更多。

这个问题相信你也很清楚，随手就能数出几个来，比如同步、死锁、数据竞争、系统调度开销等……每个写过实际多线程应用的人，可能都有“一肚子的苦水”。

其实，多线程编程这件事“说难也不难，说不难也难”。这句话听上去好像有点自相矛盾，但却有一定的道理。为什么这么说呢？

说它不难，是因为线程本身的概念是很简单的，只要规划好要做的工作，不与外部有过多的竞争读写，很容易就能避开“坑”，充分利用多线程，“跑满”CPU。

说它难，则是因为现实的业务往往非常复杂，很难做到完美的解耦。一旦线程之间有共享数据的需求，麻烦就接踵而至，因为要考虑各种情况、用各种手段去同步数据。随着线程数量的增加，复杂程度会以几何量级攀升，一不小心就可能会导致灾难性的后果。

多线程涵盖的知识点太多，许多大师、高手都不敢自称精通，想用一节课把多线程开发说清楚是完全不可能的。

所以，今天我们只聚焦C++的标准库，了解下标准库为多线程编程提供了哪些工具，在语言层面怎么改善多线程应用。有了这个基础，你再去看那些专著时，就可以省很多力气，开发时也能少走些弯路。

首先，你要知道一个最基本但也最容易被忽视的常识：**“读而不写”就不会有数据竞争**。

所以，在C++多线程编程里读取const变量总是安全的，对类调用const成员函数、对容器调用只读算法也总是线程安全的。

知道了这一点，你就应该多实践[第7讲](https://time.geekbang.org/column/article/238486)里的做法，多用const关键字，尽可能让操作都是只读的，为多线程打造一个坚实的基础。

然后，我要说一个多线程开发的原则，也是一句“自相矛盾”的话：

> 
最好的并发就是没有并发，最好的多线程就是没有线程。


这又是什么意思呢？

简单来说，就是在大的、宏观的层面上“看得到”并发和线程，而在小的、微观的层面上“看不到”线程，减少死锁、同步等恶性问题的出现几率。

## 多线程开发实践

下面，我就来讲讲具体该怎么实践这个原则。在C++里，有四个基本的工具：仅调用一次、线程局部存储、原子变量和线程对象。

### 仅调用一次

程序免不了要初始化数据，这在多线程里却是一个不大不小的麻烦。因为线程并发，如果没有某种同步手段来控制，会导致初始化函数多次运行。

为此，C++提供了“仅调用一次”的功能，可以很轻松地解决这个问题。

这个功能用起来很简单，你要先声明一个**once_flag**类型的变量，最好是静态、全局的（线程可见），作为初始化的标志：

```
static std::once_flag flag;        // 全局的初始化标志

```

然后调用专门的**call_once()**函数，以函数式编程的方式，传递这个标志和初始化函数。这样C++就会保证，即使多个线程重入call_once()，也只能有一个线程会成功运行初始化。

下面是一个简单的示例，使用了lambda表达式来模拟实际的线程函数。你可以把[GitHub仓库](https://github.com/chronolaw/cpp_study/blob/master/section3/thread.cpp)里的代码下到本地，实际编译运行看看效果：

```
auto f = []()                // 在线程里运行的lambda表达式
{   
    std::call_once(flag,      // 仅一次调用，注意要传flag
        [](){                // 匿名lambda，初始化函数，只会执行一次
            cout &lt;&lt; &quot;only once&quot; &lt;&lt; endl;
        }                  // 匿名lambda结束
    );                     // 在线程里运行的lambda表达式结束
};

thread t1(f);            // 启动两个线程，运行函数f
thread t2(f);

```

call_once()完全消除了初始化时的并发冲突，在它的调用位置根本看不到并发和线程。所以，按照刚才说的基本原则，它是一个很好的多线程工具。

它也可以很轻松地解决多线程领域里令人头疼的“双重检查锁定”问题，你可以自己试一试，用它替代锁定来初始化。

### 线程局部存储

读写全局（或者局部静态）变量是另一个比较常见的数据竞争场景，因为共享数据，多线程操作时就有可能导致状态不一致。

但如果仔细分析的话，你会发现，有的时候，全局变量并不一定是必须共享的，可能仅仅是为了方便线程传入传出数据，或者是本地cache，而不是为了共享所有权。

换句话说，这应该是线程独占所有权，不应该在多线程之间共同拥有，术语叫“**线程局部存储**”（thread local storage）。

这个功能在C++里由关键字**thread_local**实现，它是一个和static、extern同级的变量存储说明，有thread_local标记的变量在每个线程里都会有一个独立的副本，是“线程独占”的，所以就不会有竞争读写的问题。

下面是示范thread_local的代码，先定义了一个线程独占变量，然后用lambda表达式捕获引用，再放进多个线程里运行：

```
thread_local int n = 0;        // 线程局部存储变量

auto f = [&amp;](int x)           // 在线程里运行的lambda表达式，捕获引用
{   
    n += x;                   // 使用线程局部变量，互不影响
    cout &lt;&lt; n;                // 输出，验证结果
};  

thread t1(f, 10);           // 启动两个线程，运行函数f
thread t2(f, 20);

```

在程序执行后，我们可以看到，两个线程分别输出了10和20，互不干扰。

你可以试着把变量的声明改成static，再运行一下。这时，因为两个线程共享变量，所以n就被连加了两次，最后的结果就是30。

```
static int n = 0;    // 静态全局变量
...                   // 代码与刚才的相同

```

和call_once()一样，thread_local也很容易使用。但它的应用场合不是那么显而易见的，这要求你对线程的共享数据有清楚的认识，区分出独占的那部分，消除多线程对变量的并发访问。

### 原子变量

那么，对于那些非独占、必须共享的数据，该怎么办呢？

要想保证多线程读写共享数据的一致性，关键是**要解决同步问题**，不能让两个线程同时写，也就是“互斥”。

这在多线程编程里早就有解决方案了，就是互斥量（Mutex）。但它的成本太高，所以，对于小数据，应该采用“**原子化**”这个更好的方案。

所谓原子（atomic），在多线程领域里的意思就是不可分的。操作要么完成，要么未完成，不能被任何外部操作打断，总是有一个确定的、完整的状态。所以也就不会存在竞争读写的问题，不需要使用互斥量来同步，成本也就更低。

但不是所有的操作都可以原子化的，否则多线程编程就太轻松了。目前，C++只能让一些最基本的类型原子化，比如atomic_int、atomic_long，等等：

```
using atomic_bool = std::atomic&lt;bool&gt;;    // 原子化的bool
using atomic_int  = std::atomic&lt;int&gt;;      // 原子化的int
using atomic_long = std::atomic&lt;long&gt;;    // 原子化的long

```

这些原子变量都是模板类atomic的特化形式，包装了原始的类型，具有相同的接口，用起来和bool、int几乎一模一样，但却是原子化的，多线程读写不会出错。

注意，我说了“几乎”这个词。它还是有些不同的，一个重要的区别是，**原子变量禁用了拷贝构造函数，所以在初始化的时候不能用“=”的赋值形式，只能用圆括号或者花括号**：

```
atomic_int  x {0};          // 初始化，不能用=
atomic_long y {1000L};      // 初始化，只能用圆括号或者花括号

assert(++x == 1);           // 自增运算

y += 200;                   // 加法运算
assert(y &lt; 2000);           // 比较运算 

```

除了模拟整数运算，原子变量还有一些特殊的原子操作，比如store、load、fetch_add、fetch_sub、exchange、compare_exchange_weak/compare_exchange_strong，最后一组就是著名的CAS（Compare And Swap）操作。

而另一个同样著名的TAS（Test And Set）操作，则需要用到一个特殊的原子类型atomic_flag。

它不是简单的bool特化（atomic<bool>），没有store、load的操作，只用来实现TAS，保证绝对无锁。</bool>

你能用这些原子变量做些什么呢？

最基本的用法是把原子变量当作线程安全的全局计数器或者标志位，这也算是“初心”吧。但它还有一个更重要的应用领域，就是实现高效的无锁数据结构（lock-free）。

但我**强烈不建议**你自己尝试去写无锁数据结构，因为无锁编程的难度比使用互斥量更高，可能会掉到各种难以察觉的“坑”（例如ABA）里，最好还是用现成的库。

遗憾的是，标准库在这方面帮不了你，虽然网上可以找到不少开源的无锁数据结构，但经过实际检验的不多，我个人觉得你可以考虑**boost.lock_free**。

### 线程

到现在我说了call_once、thread_local和atomic这三个C++里的工具，它们都不与线程直接相关，但却能够用于多线程编程，尽量消除显式地使用线程。

但是，必须要用线程的时候，我们也不能逃避。

C++标准库里有专门的线程类thread，使用它就可以简单地创建线程，在名字空间std::this_thread里，还有yield()、get_id()、sleep_for()、sleep_until()等几个方便的管理函数。因为它们的用法比较简单，资料也随处可见，我就不再重复了。

下面的代码同时示范了thread和atomic的用法：

```
static atomic_flag flag {false};    // 原子化的标志量
static atomic_int  n;               // 原子化的int

auto f = [&amp;]()              // 在线程里运行的lambda表达式，捕获引用
{
    auto value = flag.test_and_set();  // TAS检查原子标志量

    if (value) {
        cout &lt;&lt; &quot;flag has been set.&quot; &lt;&lt; endl;
    } else {
        cout &lt;&lt; &quot;set flag by &quot; &lt;&lt;
            this_thread::get_id() &lt;&lt; endl;  // 输出线程id
    }

    n += 100;                    // 原子变量加法运算

    this_thread::sleep_for(      // 线程睡眠
        n.load() * 10ms);        // 使用时间字面量
    cout &lt;&lt; n &lt;&lt; endl;
};                        // 在线程里运行的lambda表达式结束

thread t1(f);                // 启动两个线程，运行函数f
thread t2(f);

t1.join();                   // 等待线程结束    
t2.join();

```

但还是基于那个原则，我建议你不要直接使用thread这个“原始”的线程概念，最好把它隐藏到底层，因为“看不到的线程才是好线程”。

具体的做法是调用函数async()，它的含义是“**异步运行**”一个任务，隐含的动作是启动一个线程去执行，但不绝对保证立即启动（也可以在第一个参数传递std::launch::async，要求立即启动线程）。

大多数thread能做的事情也可以用async()来实现，但不会看到明显的线程：

```
auto task = [](auto x)                  // 在线程里运行的lambda表达式
{
    this_thread::sleep_for( x * 1ms);  // 线程睡眠
    cout &lt;&lt; &quot;sleep for &quot; &lt;&lt; x &lt;&lt; endl;
    return x;
};

auto f = std::async(task, 10);         // 启动一个异步任务
f.wait();                              // 等待任务完成

assert(f.valid());                    // 确实已经完成了任务
cout &lt;&lt; f.get() &lt;&lt; endl;              // 获取任务的执行结果

```

其实，这还是函数式编程的思路，在更高的抽象级别上去看待问题，异步并发多个任务，让底层去自动管理线程，要比我们自己手动控制更好（比如内部使用线程池或者其他机制）。

async()会返回一个future变量，可以认为是代表了执行结果的“期货”，如果任务有返回值，就可以用成员函数get()获取。

不过要特别注意，get()只能调一次，再次获取结果会发生错误，抛出异常std::future_error。（至于为什么这么设计我也不太清楚，没找到官方的解释）

另外，这里还有一个很隐蔽的“坑”，如果你不显式获取async()的返回值（即future对象），它就会**同步阻塞**直至任务完成（由于临时对象的析构函数），于是“async”就变成了“sync”。

所以，即使我们不关心返回值，也总要用auto来配合async()，避免同步阻塞，就像下面的示例代码那样：

```
std::async(task, ...);            // 没有显式获取future，被同步阻塞
auto f = std::async(task, ...);   // 只有上一个任务完成后才能被执行

```

标准库里还有mutex、lock_guard、condition_variable、promise等很多工具，不过它们大多数都是广为人知的概念在C++里的具体实现，用法上没太多新意，所以我就不再多介绍了。

## 小结

说了这么长时间，你可能会有些奇怪，这节课的标题里有线程，但我并没有讲太多线程相关的东西，更多的是在讲“不用线程”的思维方式。

所谓“当局者迷”，如果你一头扎进多线程的世界，全力去研究线程、互斥量、锁等细节，就很容易“钻进死胡同”“一条道走到黑”。

很多时候，我们应该跳出具体的编码，换个角度来看问题，也许就能够“柳暗花明又一村”，得到新的、优雅的解决办法。

好了，今天就到这里，做个小结：

1. 多线程是并发最常用的实现方式，好处是任务并行、避免阻塞，坏处是开发难度高，有数据竞争、死锁等很多“坑”；
1. call_once()实现了仅调用一次的功能，避免多线程初始化时的冲突；
1. thread_local实现了线程局部存储，让每个线程都独立访问数据，互不干扰；
1. atomic实现了原子化变量，可以用作线程安全的计数器，也可以实现无锁数据结构；
1. async()启动一个异步任务，相当于开了一个线程，但内部通常会有优化，比直接使用线程更好。

我再告诉你一个好消息：C++20正式加入了协程（关键字co_wait/co_yield/co_return）。它是用户态的线程，没有系统级线程那么多的麻烦事，使用它就可以写出开销更低、性能更高的并发程序。让我们一起期待吧！

## 课下作业

最后是课下作业时间，给你留两个思考题：

1. 你在多线程编程的时候遇到过哪些“坑”，有什么经验教训？
1. 你觉得async()比直接用thread启动线程好在哪里？

欢迎在留言区写下你的思考和答案，如果觉得今天的内容对你有所帮助，也欢迎分享给你的朋友。我们下节课见。

<img src="https://static001.geekbang.org/resource/image/3f/d2/3fee7b3958a1780a3441c49b89288dd2.jpg" alt="">
