《Effective Modern C++》学习笔记之条款三十七:使std::thread型别对象在所有路径皆不可联结

每个std::thread型别对象皆处于两种状态之一:可联结或不可联结。

可联结的std::thread对应底层以异步方式已运行或者可运行的线程。std::thread型别对象对应的底层线程若处于阻塞或等待调度,则它可联结。std::thread型别对象对应的底层线程如已运行结束,则也认为其可联结。

不可联结的std::thread的意思如你所想:std::thread不处于以上可联结的状态。不可联结的std::thread型别对象包括:

  • 默认构造的std::thread:此类std::thread没有可以执行的函数,因此也没有对应的底层执行线程。
  • 已移动的std::thread:移动操作的结果是,一个std::thread所对应的底层执行线程(若有)被对应到另外一个std::thread。
  • 已联结的std::thread:联结后,std::thread型别对象不再对应至已结束运行的底层执行线程。
  • 已分离的std::thread:分离操作会把std::thread型别对象和它对应的底层执行线程之间的连接断开。

std::thread可联结性之所以重要的原因之一是因为:如果可联结的线程对象的析构函数被调用,则程序的执行就终止了。

举个栗子说明,假设我们有一个函数doWork,它接受一个筛选器函数filter和一个最大值maxVal作为形参。doWork会校验它做计算的条件全部成立,然后会针对筛选器选出的0到maxVal之间的值实施计算。如果筛选是费时的,而条件检验也是费时的,那么并发地做这两件事就是合理的。

我们可能会撰写出这样的代码:

constexpr auto tenMillion = 10000000;           //关于constexpr参见Item 15

bool doWork(std::function<bool(int)> filter,    //返回值代表计算是否执行了
            int maxVal = tenMillion)            //关于function参见Item2
{
    std::vector<int> goodVals;
    std::thread t([&filter, maxVal,&goodVals]
        {
            for (auto i = 0; i <= maxVal; i++)   
            { if (filter(i))  goodVals.push_back(i); }
        });
    auto nh = t.native_handle();                //使用t的低级句柄设定t的优先级
    ...
    if (conditionsAreSatisfied()) {
        t.join();                               //让t结束执行
        performComputation(goodVals);           
        return true;                            //计算已实施
    }
    return false;                               //计算未实施
}

以上的原型代码有诸多问题,以下一一道来:

1. 更有可读性的写法
在C++14中,单引号可以作为数字分隔符:

constexpr auto tenMillion = 10'000'000;     //C++14

2. 线程开始之前设置优先级
示例代码中在线程t开始执行之后才去设置它的优先级,这有点像,烈马已经脱缰跑走后才关上马厩的门。更好的设计是以暂停状态启动线程t

3. 核心问题的地方
如果conditionsAreSatisfied()返回true,则一切都好;但如果它返回false或抛出异常,那么在doWork的末尾调用std::thread型别对象t的析构函数是,它会处于可联结状态,从而导致程序执行终止。

你可能会想知道,std::thread的析构函数为何会这样运作。不终止程序难道不行吗??

因为别无选择,其他的选择比这么做更加糟糕,分别是:

  • 隐式join:在这种情况下,std::thread的析构函数会等待底层异步执行线程完成。这听上去合理,但却可能导致难以追踪的性能异常。例如,如果conditionsAreSatisfied()早已返回false了,doWork却还在等待所有值上遍历筛选,这是违反直觉的。
  • 隐式detach:在这种情况下,std::thread的析构函数会分离std::thread型别对象与底层执行线程之间的连接。而该底层执行线程会继续执行。这听起来和join途径相比在合理性方面并不逊色,但它导致的调试问题会更加要命。例如,在doWork内goodVals是个通过引用捕获的局部变量,它会在lambda式内被修改(通过对push_back的调用)。然后,假如lambda式以异步方式运行时,conditionsAreSatisfied()返回了false。那种情况下,doWork会直接返回,它的局部变量(包括goodVals)会被销毁,doWork的栈帧会被弹出,可是线程却仍然在doWork的调用方继续运行着。在doWork调用方的后续语句中,在某个时刻,会调用其他函数,而至少会有一个函数可能会使用一部分或者全部doWork栈帧占用过的内存,假设这个函数称为f。当f运行时,doWork发起的lambda式依然在异步执行。该lambda式在原先的栈上对goodVals调用push_back,不过那已是在f的栈帧中了。这样的调用会修改过去属于goodVals的内存,而那意味着从f的视角看,栈帧上的内存内容会莫名其妙的被改变!想想看那样的问题,会多么酸爽。

综上所述,标准委员会意识到,销毁一个可联结的线程是在太过可怕,所以实际上已经封印了这件事(通过规定可联结的现成的析构函数导致程序异常终止)。

既然标准委员会把抉择权利给了你,如果你使用了std::thread型别对象,就必须确保从它定义的作用域出去的任何路径,使它成为不可联结状态。但是覆盖所有路径是复杂的,这包括正常走完作用域,还有经由return,continue,break,goto或异常跳出作用域。路径何其多。

任何时候,只要想在每条出向路径上都执行某动作,最常用的方法就是在局部对象的析构函数中执行该动作。这样的对象称为RAII对象,它们来自RAII类(RAII本身代表Resource Acquistion Is Initialzation,资源获取即初始化,即使该技术的关键其实在于析构而非初始化)。然而,没有和std::thread型别对象对应的标准RAII类。

自己写一个RAII的std::thread

幸运的是,自己写一个也并不难。例如,下面这个类就允许调用者指定ThreadRAII型别对象(它是个std::thread对应的RAII对象)销毁时调用join还是detach:

class ThreadRAII {
public:
    enum class DtorAction {join, detach};       //关于枚举类,参见Item 10

    ThreadRAII(std::thread&& t, DtorAction a)   //在析构函数中
    : action(a), t(std::move(t)) {}             //在 t 上采取行动a

    ~ThreadRAII()
    {
        if (t.joinable()) {                     //可联结性测试见下
            if (action == DtorAction::join) {
                t.join();
            } else {
                t.detach();
            }
        }
    }
    std::thread& get() {return t;}                  //见下

private:
    DtorAction      action;
    std::thread     t;
};

对于自己构建的类注意一下几点,我希望这段代码基本上不言自明,但指出以下几点可能会有帮助:

1. std::thread是只移型别
构造函数只接受右值型别的std::thread,因为我们想要把传入的std::thread型别对象移入ThreadRAII对象(提醒一下,std::thread是不可复制的)。
2. 类中成员的声明顺序有讲究
读/写哪个线程的thread_local变量并无影响。或者可以给出保证在std::async返回的期值之上调用get或wait,或者可以接受任务可能永不执行。使用wait_for或wait_until的代码会将任务被推迟的可能性纳入考量。构造函数的形参顺序的设计对于调用者而言是符合直觉的(指定std::thread作为第一个形参,而销毁行动作为第二个参数,比相反顺序更直观),但是,成员初始化列表的设计要求它匹配成员变量声明的顺序,而后者是把std::thread的顺序放到靠后的。在本类中,顺序不会导致不同,但作为一般讨论,一个成员变量的初始化有可能会依赖另一个成员变量,又因为std::thread型别对象初始化后可能会马上用来运行函数,所以把它们声明在类的最后是个好习惯。这保证了当std::thread型别对象在构造之时,所有在它之前的成员变量都已经完成了初始化,因而std::thread成员变量对应的底层异步执行线程可以安全地访问它们了。
3. 提供一个get避免复写接口
ThreadRAII提供了一个get函数,用以访问底层的std::thread型别对象。这和标准智能指针提供的get函数一样(后者用以访问底层裸指针)。提供get可以避免让ThreadRAII去重复std::thread的所有接口,也意味着ThreadRAII型别对象可以用于需要直接使用std::thread型别对象的语境。
4. 析构的时候判断可联结性很重要
ThreadRAII的析构函数在调用std::thread型别对象t的成员函数之前,会先实施校验,以确保t可联结。这是必要的,因为针对一个不可联结的线程调用join或者detach会产生未定义行为。用户有可能会构建了一个std::thread型别对象,然后从它出发创建一个ThreadRAII型别对象,再使用get访问t,接着针对t实施移动或是对t调用join或者detach,而这样的行为会使t变的不可联结。
如果你担心下面的代码会有竞态风险:

if (t.joinable()){
    if (action == DtorAction::join) {
        t.join();
    } else {
        t.detach();
    }
}

理由是,在t.joinable()的执行和join或detach的调用之间,另一个线程可能让t变得不可联结。你的直觉可圈可点,但你的担忧却是庸人自扰。

一个std::thread型别对象只能通过调用成员函数以从可联结状态转换为不可联结状态,例如join、detach或者移动操作。当ThreadRAII对象的析构函数被调用时,不应该有其他线程调用该对象的成员函数。如果同时发生多个调用,那的确会有竞态风险,但这竞态风险不是发生在析构函数内,而是发生在试图同时调用两个成员函数(一个是析构函数,一个是其他成员函数)的用户代码内。一般地,在一个对象之上同时调用多个成员函数,只有当所有这些函数都是const成员函数时才安全。

在我们的doWork一例中运用ThreadRAII,代码会长成这样:

bool doWork(std::function<bool(int)> filter,        //同前
            int maxVal = tenMillion)
{
    std::vector<int>  goodVals;

    ThreadRAII t(
    std::thread([&filter, maxVal, &goodVals]
            {
                for (auto i = 0; i <= maxVal; ++i)
                { if (filter(i)) goodVals.push_back(i); }
            }),
            ThreadRAII::DtorAction::join
    );
    auto nh = t.get().native_handle();
    ...
    if (conditionsAreSatisfied()) {
        t.get().join();
        performComputation(goodVals);
        return true;
    }
    return false;
}

在该例子中,我们选择在ThreadRAII析构函数中对异步执行线程调用join。因为我们之前已经看到了,调用detach函数会导致噩梦般的调试。我们之前也看过join会导致性能异常(实话实说,join的调试也绝不令人愉悦),但未定义行为(detach导致的)、程序终止(使用裸std::thread产生的)和性能异常之间做出选择,性能异常也是权衡之下的弊端最小的一个。

可遗憾的是,使用ThreadRAII在std::thread析构中实施join不是仅仅会导致性能异常那么简单,而是会导致程序失去响应。这种问题的“合适的”解决方案是和异步执行的lambda式通信,当我们已经不再需要它运行,它应该提前返回,但C++11中并不支持这种可中断线程。

因为ThreadRAII声明了析构函数,所以不会有编译器生成的 移动操作,但这里ThreadRAII对象没有理由实现为不可移动的。如果编译器会生成这些函数,这些函数的行为就是正确的,所以显示地请求创建它们是适当的:

class ThreadRAII {
public:
    enum class DtorAction {join, detach};       //同前

    ThreadRAII(std::thread&& t, DtorAction a)   //同前
    : action(a), t(std::move(t)) {}             //同前

    ~ThreadRAII()
    {
        if (t.joinable()) {                     //同前
            if (action == DtorAction::join) {
                t.join();
            } else {
                t.detach();
            }
        }
    }
    ThreadRAII(ThreadRAII&& ) = default;                //支持移动构造
    ThreadRAII& operator=(ThreadRAII&&) = default;      //支持移动赋值

    std::thread& get() {return t;}                  //同前

private:
    DtorAction      action;
    std::thread     t;
};

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Chiang木

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值