第七章

  • c++对并行的支持是一大进步,而且也只是个开始。

  • 标准库中有两个future模板std::futurestd::shared_future,大部分情况下这两者的差异不重要,书中讲到的future通常同时表示这两者。

35. 尽量用任务式的程序设计取代线程式的设计

  • 假如要非同步执行doAsyncWork函数,有两种选择:

    1. 使用线程式(thread-based)的设计:std::threaad t(doAsyncWork);
    2. 使用任务式(task-based)的设计:auto fut = std::async(doAsyncWork);
  • 通常任务式的设计有更多好处:

    1. 有返回值。线程式设计是没办法直接获取返回值的。而std::async提供get函数轻松获取返回值。
    2. 支持异常捕获。通过get函数可以取得异常。而线程式的设计一旦异常就导致程序终止(通过调用std::terminate)。
  • 几个概念要区分清楚:

    1. 硬件线程(hardware thread):现代的计算机CPU在每个核心上都提供一个以上的线程。
    2. 软件线程(software thread):操作系统进程内部的线程,排在硬件线程上执行。一旦阻塞(如果等待IO操作、等待互斥量),硬件线程就会切换到其他未阻塞的软件线程来执行。
    3. std::thread:这是C++的类,是底层软件线程在C++的句柄(handle)。当然,一个std::thread也可能不代表任何底层软件线程(而是代表null),可能是因为处于刚刚被构造的状态、曾经作为移动操作的来源等。
  • 多线程编程会有两个棘手情况:

    1. 软件线程资源耗尽:软件线程是有限资源,操作系统是限制了线程数量的,如果超出会抛出std::system_error异常。
    2. 过度订阅(oversubscription):线程数量太多,一个线程被系统不停地调度到不同的CPU核心上运行,线程上下文切换成本变得很高。
  • std::async在面对上述两个棘手问题时是有做了一些工作的:std::async不保证会建立新的软件线程。当上述两个棘手情况发生时,计算工作会被调度到调用get或wait操作的线程。当然,这可能会对调用get或wait操作的线程造成影响,如果这个线程是负责处理GUI的,那可能会导致界面失去响应。第36条会讲解启动策略来配置这个行为。

  • 当然,std::thread也有其适用场景:

    1. 需要存取底层线程实现API:有时候需要操作底层的pthread或者Windows threads,因为他们提供了更丰富的功能。std::thread会提供native_handle成员函数。
    2. 需要针对硬件环境、软件逻辑优化线程的使用:软件逻辑比较特殊,或者硬件特性比较特殊。这种情况下需要开发者自己来调整调度方法。
    3. 要实现标准库没有提供的线程特性:比如线程池。

36. 有必要非同步时就指定std::launch::async

  • std::async有两种启动策略(launch policy):

    1. std::launch::async:表示计算必须以非同步的方式在另外一个线程执行。
    2. std::launch::deferred:表示计算会延迟(defferred)执行,会在返回的future调用get或者wait时才执行。调用者线程会被阻塞到计算完成。
  • std::async的默认启动策略,是以上两种方式的:

// 以下两个future具有相同的启动策略
auto fut1 = std::async(f);
auto fut2 = std::async(std::launch::async | std::launch::deferred, f);
  • 当我们在线程t上执行auto fut = std::async(f);时,我们使用了默认的启动策略,我们会面临以下问题:

    1. 无法预测f是否会跟当前线程t并行执行。f可能会延迟执行。
    2. 无法预测f是否会在执行fut的get和wait的线程里执行。尤其是当在t里执行get或wait时,更是没办法预测。
    3. 无法预测f是否被执行。因为fut的get和wait可能根本没有被调用。
    4. 由于第1、2点,有时不太适合使用thread local变量。这会使用线程本地存储(thread local storage, TLS),但我们无法预测f执行时会存取那个线程的变量。
    5. 由于第3点,wait_forwait_until这类看起来可以等待到f运行结束的调用,实际上可能永远不会等到。
  • 要想知道一个future对象是否是deffered,没有直接的接口获取,只能通过wait_for(0)的返回码是否是std::future_status::deferred来判断。

  • 如果想要保证f一定是与调用线程异步执行的,就指定启动策略为std::launch::async

37. 确保std::thread在所有路径都是unjoinable

  • 所有std::thread的状态不是joinable(可链接)就是unjoinable(不可连接)。

  • joinable表示这个std::thread对象关联了一个底层线程。

  • 为什么我们要关注是否可连接的状态呢?这一点非常重要:对一个joinable的std::thread对象调用析构函数会导致程序崩溃退出

echo:我亲自试了一下,确实是会导致coredump。    代码里就是简单的声明了一个thread对象(当然要指定其执行函数)。屏幕上会输出:     terminate called without an active exception     [1] 11661 abort (core dumped) ./a.out

  • std::thread的状态是unjoinable的情况列举如下:

    1. 使用默认构造函数构造的std::thread对象。因为它没有要执行的函数,因此一定是不可连接的。
    2. 曾经作为移动操作来源的std::thread对象。因为底层的线程已经被移动到了其他对象。
    3. std::thread已经被join了,join结束后的std::thread对象已经执行完毕,不在对应底层的线程。
    4. std::thread已经被detach了,这会切断std::thread跟底层线程的关系。

echo:上面的这几种情况描述在std::thread::~thread()文档也有描述。

  • 只要不是以上情况的,均视为joinable。

echo:因此我们要注意,一个thread对象如果没有被join过,即使底层线程已经执行完毕了,thread对象也仍然是joinable的。因为线程是异步执行的,系统并不知道你将来是否会调用join,所以就要求要么join过,要么detach过,否则就是joinable的。

我们可能会想,为什么不让thread对象默认就join或者默认就detach。 书中也给出了标准委员会的考虑:    1. 默认join:不符合一般直觉。这会导致声明一个线程对象直接就阻塞了。    2. 默认detach:有些场合是需要join()的。比如,线程里用到了调用者代码里的栈变量,这时调用者需要等待线程执行完毕才能弹出栈。 标准委员会基于这些考虑,决定不能默认join和默认detach。

  • 我们在写代码的时候,可能不小心就会发生调用joinable对象的析构函数的情况:
bool func()
{
    // 声明了一个线程对象,线程的工作就是打印一句话。
    // 这个线程对象是joinable的。
    std::thread t([](){ cout << "do some work" << endl; });

    if (condition_satisfied()) // 如果条件满足,就join()等待线程执行完
    {
        t.join();
    }

    // 如果条件没满足,函数返回,线程对象的析构函数会被调用,这是程序会崩溃退出。
    // 一种正确的方案,是调用detach:
    //    if (t.joinable())
    //        t.detach();

    return false;
}

注意,上面这段代码的正解中,我们先判断了t.joinable()才进行了detach操作。这时因为:对unjoinable的thread对象调用detach会引发崩溃。 这里就会有个隐藏的问题:如果我们调用t.detach()前,有另外的线程也调用了detach(),就会引发崩溃。因此这里开发者自己要确认这里是否会存在并发调用的情况。

  • 诸如continue、break、goto这类语句,都可能会引发这个问题。更隐蔽的,析构函数中对成员变量的析构顺序也可能会引发这个问题。例如成员变量中有个线程对象,该线程使用了其他成员变量,那开发者就要确保线程在其他成员变量析构前结束。

  • 因此,虽然本章标题说要保证thread对象从定义到离开调用范围的所有路径都要保证unjoinable,但实际上实操起来是比较复杂的。

echo:线程坑太多了,还是多进程安全些。

38. 注意线程对象处理析构函数的行为的差异

  • 这段代码auto fut = std::async(f);将启动一个线程执行函数f,函数的执行结果会保存在某个地方,然后调用者在调用fut对象的get函数时会通过这个地方得到执行结果。这个保存执行结果的地方是一个通道对象,它是将结果从计算线程发送到调用线程的通道,通常是一个std::promise对象。

  • 上面的代码,我们面临这样的情况:

    • 线程可能在fut对象构造之前就执行完毕。因此通道对象肯定不能属于线程,否则线程执行完毕就把通道对象给释放了。
    • 那我们想,通道对象可以属于调用线程,并通过构造函数传递给fut对象。但shared_future可能会导致多个future对象共用通道对象的情况发生。
  • 基于以上的考虑,通道对象通常是以堆变量的形式实现的(这完全看具体标准库的实现),多个future对象会共用这个通道对象。

  • future对象其实代表了一个底层线程,这跟std::thread是一样的。因此第36条的规则对future也是有效的(把joinable的线程析构会导致崩溃,必须join或者detach)。future对象的析构函数会自动等待线程执行完毕(自动join),因此可能会导致阻塞。

  • 注意,如果是launch::deferred状态启动的async调用,则future对应的线程是unjoinable的,因为线程根本都没有启动。因此这种future对象的析构函数是肯定不会导致阻塞的了。

  • 如果多个future对象共享了一个通道,那这些future对象析构时,最后一个被析构的future对象的析构函数会等待线程执行完毕。

39. 考虑使用void future作为一次性事件通讯

  • 我们经常要实现这样的功能:A线程通知B线程,B线程被通知后才开始进行某项工作。通常,实现这种功能有两种思路。

  • 思路一:使用条件变量。B线程等待条件变量,A线程在某个时刻设置条件变量。这个思路有两个缺点:

    1. B线程在等待条件变量前,有可能A线程早都已经设置过条件变量了,导致B线程会永久等待下去。要解决这个问题,就必须在等待条件变量前先判断下条件变量是否已经被设置了,如果没被设置才能开始等待。而且这个检查必须在互斥量的保护下才能保证正确工作,因为这里是两个操作(一次判断,一次等待,不是原子的)。
    2. 条件变量的等待有可能被“伪唤醒”,我们要写代码处理这种情况。
  • 思路二:使用布尔标志位。B线程循环检查该标志位,A线程在某个时刻设置此标志位。这个思路的缺点是,B线程要付出轮询的成本。

  • 更复杂但好一些的做法是结合上面两种思路,使用条件变量来避免轮询。

  • 实际上,我们还有更精简、更安全的做法,这种方法不需要使用条件变量、互斥量、标志位。而是使用future天然的需要wait()一次的特性。两个线程的通信使用第38条中提到的通道来进行(std::promise对象)

// 声明通道对象
std::promise<void> p; // 因为不需要在通道里真的传输数据,所以用void作为模板参数。

// A线程的代码
p.set_value(); // 很简单,这就相当于做了通知了。

// B线程的代码
p.get_future().wait(); // 很简单,不需要担心通知和等待的顺序,不需要担心伪唤醒。

  • 上面这种方法虽然非常简单易用,也有其局限性:

    1. 每个通道只能使用一次。不能反复使用。
    2. 由于通道是堆变量,因此有new/delete的成本。
  • 如果不在意上面的两点缺点,这是最精简、最安全的通知方法了。原来老的思路实在是太复杂。

40. 并行用std::atomic,特殊内存用volatile

  • 首先要知道volatile跟并行没什么关系,本章的重点是std::atomic。

  • std::atomic可以比mutex更高效的完成原子操作。

  • std::atomic可以防止编译器优化执行顺序,保证处理器多核对内存访问的顺序。

    echo:类似内存屏障的功能,可以让atomic的复制操作前的内存写操作一定同步到各个核。

  • volatile只是用于特殊内存,比如显示器、各种输入输出设备的memory-mapped I/O。编译器对这类内存访问的优化会导致跟设备交互发生问题。