跳转至

14 十面埋伏的并发:多线程真的很难吗?

你好,我是Chrono。

今天,我们来聊聊“并发”(Concurrency)、“多线程”(multithreading)。

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

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

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

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

认识线程和多线程

要掌握多线程,就要先了解线程(thread)。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

知道了这一点,你就应该多实践第7讲里的做法,多用const关键字,尽可能让操作都是只读的,为多线程打造一个坚实的基础。

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

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

这又是什么意思呢?

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

多线程开发实践

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

仅调用一次

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

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

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

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

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

下面是一个简单的示例,使用了lambda表达式来模拟实际的线程函数。你可以把GitHub仓库里的代码下到本地,实际编译运行看看效果:

auto f = []()                // 在线程里运行的lambda表达式
{   
    std::call_once(flag,      // 仅一次调用,注意要传flag
        [](){                // 匿名lambda,初始化函数,只会执行一次
            cout << "only once" << 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 = [&](int x)           // 在线程里运行的lambda表达式,捕获引用
{   
    n += x;                   // 使用线程局部变量,互不影响
    cout << 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<bool>;    // 原子化的bool
using atomic_int  = std::atomic<int>;      // 原子化的int
using atomic_long = std::atomic<long>;    // 原子化的long

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

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

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

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

y += 200;                   // 加法运算
assert(y < 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),没有store、load的操作,只用来实现TAS,保证绝对无锁。

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

最基本的用法是把原子变量当作线程安全的全局计数器或者标志位,这也算是“初心”吧。但它还有一个更重要的应用领域,就是实现高效的无锁数据结构(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 = [&]()              // 在线程里运行的lambda表达式,捕获引用
{
    auto value = flag.test_and_set();  // TAS检查原子标志量

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

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

    this_thread::sleep_for(      // 线程睡眠
        n.load() * 10ms);        // 使用时间字面量
    cout << n << 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 << "sleep for " << x << endl;
    return x;
};

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

assert(f.valid());                    // 确实已经完成了任务
cout << f.get() << 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. 多线程是并发最常用的实现方式,好处是任务并行、避免阻塞,坏处是开发难度高,有数据竞争、死锁等很多“坑”;
  2. call_once()实现了仅调用一次的功能,避免多线程初始化时的冲突;
  3. thread_local实现了线程局部存储,让每个线程都独立访问数据,互不干扰;
  4. atomic实现了原子化变量,可以用作线程安全的计数器,也可以实现无锁数据结构;
  5. async()启动一个异步任务,相当于开了一个线程,但内部通常会有优化,比直接使用线程更好。

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

课下作业

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

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

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

精选留言(15)
  • 战斗机二虎🐯 👍(22) 💬(1)

    nginx的自旋锁实现就是一个非常成功的工程级的自旋锁

    2020-07-06

  • 哈哈 👍(21) 💬(2)

    虽然没有C++的多线程经验,但是记得其他语言的多线程,用IDE打断点调试是一个极难的问题

    2020-06-07

  • 青鸟飞鱼 👍(12) 💬(9)

    老师你好,假如一个变量用volatile修饰,是不是就可以不用加锁?

    2020-06-07

  • 风清扬 👍(6) 💬(1)

    async那里隐含的坑可以结合如下代码验证: ``` auto task = [](auto x) { //using namespace std::chrono_literals; this_thread::sleep_for( x * 1ms); cout << "sleep for " << x << endl; return x; }; //先输出Hello,world auto f = std::async(task, 100); cout << "Hello,world" << endl; #if 0 //先输出sleep for 100 std::async(task, 100); cout << "Hello,world" << endl; #endif return 0; ```

    2020-11-08

  • 范闲 👍(5) 💬(1)

    C++内怎么做异步呢? 1. Callback 2.多线程+Callback 但是这两个都有个问题,callback也是会阻塞的。如果有A B C D四个流程,B C D分别依赖于前一个的输出,这种callback就会调用栈太深,容易爆栈。 最近对异步编程模式产生了些疑问,应该怎么解决?

    2020-07-31

  • 二杠一 👍(5) 💬(3)

    老师,在多个线程里同时往输出流输出,怎么保证一个线程的单次输出操作是完整的 ? 比如"std::cout << x << std::endl;"怎么保证不会漏掉"std::endl"这个输出,只能加锁吗?

    2020-06-11

  • robonix 👍(4) 💬(1)

    (比如内部使用线程池或者其他机制)。那么async怎么跟线程池结合呢?老师有没有一些好的参考资料提供哈

    2020-06-15

  • 被讨厌的勇气 👍(4) 💬(2)

    当需要获取线程的结果时,使用async可以直接获取其结果;使用thread则需要通过共享数据来获取,需要使用锁、条件变量。 async的缺点是只能获取一次。若需要保证线程一直运行,多次获取其‘结果’时,只能使用thread + condition_variable,不知道这样理解对不对? thread_local的应用场景问题。若不需要共享数据,直接在lambad表达式中的捕获列表中进行值捕获不就每个线程一个副本了,没想出来必须使用thread_local的应用场景。

    2020-06-06

  • Stephen 👍(3) 💬(5)

    子线程会立即脱离主线程的控制流程,单独运行,但共享主线程的数据。这里指的是全局变量吧,像局部变量应该不行吧?

    2020-06-06

  • dog_brother 👍(2) 💬(1)

    老师,我们生产环境是c++ 11,但是我们几乎不使用std的thread,主要是pthread系列函数。听说是因为std thread的坑比较多。

    2021-04-24

  • Stephen 👍(2) 💬(1)

    1.遇到的坑:分不清当前变量是在主线程中还是在子线程中. 教训:子线程函数入口run中的内容才算是在子线程中. 2.async函数,是一种更高层的异步方式.它不必显式手写join函数来让主线程等待子线程.它可以用std::future类型的变量来接收线程函数运行的结果,并通过get()的方法来获得结果.这样就不必像thread那样提前定义一个全局变量,在线程函数中进行赋值操作. 此外async还可以指定线程创建策略--是立马执行还是延迟执行.

    2021-04-13

  • robonix 👍(2) 💬(1)

    老师,如果一个线程在写容器,另一个线程对容器调用只读算法也总是线程安全的吗?

    2020-06-12

  • Stephen 👍(1) 💬(3)

    老师,这里有一个问题,既然不想让子线程重复调用初始化函数,应该可以在子线程创建之前调用一次初始化函数,为何还要设计call_once()函数呢?我搜了下call_once的用法,网上大多给的是简单的打印,复杂一些的有单例模式的用法,但单例模式完全可以通过判断m_pSingleton是否为空的方法来做.所以就更加想要知道真实情况下使用的场景.所以想让老师给出一个场景,或者给一个链接.谢谢老师.

    2021-04-10

  • Geek_496123 👍(1) 💬(1)

    C++中的thread 和 C语言中的pthread_create有什么关系或者区别吗?还有windows中的多线程api,初学者,表示各种多线程,傻傻分不清楚。

    2020-07-03

  • eletarior 👍(1) 💬(2)

    线程 好开,不好关,Windows下有直接回收线程句柄强行关闭线程的api,但是很明显,这不是什么好的实践。线程最好的关闭方式 是让它自然消亡。 对于某些场景我们需要使用守护线程或者监控线程,里面跑着要一直循环执行的任务,那么这个任务怎么停止就是个大问题。假设我们有一个ui线程,里面有些指针,而这些指针被守护线程里的无限循环任务使用,当UI线程被关闭,这些指针被UI线程回收,而守护线程还在使用这些指针的话,程序就表现在用户关闭时崩溃了。 对于这种问题,第一层,我们会使用event之类的方式进行线程之间的通信(同步),在UI线程关闭前通知守护线程先关闭,所以循环的停止条件(之一)就是收到由UI线程关闭时发出的event。第二层,守护线程已经运行到循环体里了,此时并不会检查event,还是可能操作一个已经被UI线程回收的指针,我想到的解决思路就是要UI线程的关闭也不能任性,必须等守护线程自然消亡后才能回收指针资源。第三层,干脆不在守护线程里操作UI线程里的指针,把程序再设计下,肯定有更好的解决方案。 关于async 和thread,我个人还是使用thread居多,当然了,async是thread的上层封装,好处是,使用起来更自然,而且,线程的返回值接收起来也方便,如果使用thread,要想获得任务返回值,呃....。但是,缺点也是存在的,async并不是很好用,首先async是定义在future中的,使用者需要了解诸如future ,promise ,启动参数等概念,不然会有一些误用。另外,作为C++程序员,总还是想理解得更底层些的,线程你帮我用标准库thread封装了,那么现在异步操作这种小事,我得自己来😀。哈哈,偏题了,其实理性的讲,现代C++早就走出了道的掣肘,而追求更多术的内在了;标准库肯定会越来越庞大,更高层次的封装,更快的开发效率早就是大势所趋,不然,为啥单线程的nodejs能把异步回调玩的风生水起,让大伙趋之若鹜呢,嗯,说远了。

    2020-06-06