条款37:使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之间的值实施计算。如果筛选是费时的,而条件检验也是费时的,那么并发地做这两件事就是合理的。

我们会优先选用使用基于任务的设计(参见条款35),但是让我们假定会去设置实施筛选的那个线程的优先级。条款35解释过,这要求使用线程的低级句柄,从而只能通过std::thread的API来访问。基于任务的API(如期值等)没有提供这个功能。因此,我们唯有采用基于线程一途,基于任务在此不可行。

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

constexpr auto tenMillion = 10000000;  //关于constexpr,参见条款15

bool doWork(std::function<bool(int)> filter,  //返回值代表
            int maxVal = tenMillion)          //计算是否执行了
{                                             //关于std::function
                                              //参见条款2
    std::vector<int> goodVals;                //筛出的值
    
    std::thread t([&filters, maxVal, &goodVals])   //遍历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(goodVas);
        return true;              //计算已实施
    }

    return false;                 //计算未实施
}

在我解释这个代码为何有毛病之前,先说明tenMillon的初始值在C++14中可以更具可读性,这利用C++14能把单引号作为数字分隔符的能力:

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

我还想提一下在线程t开始执行之后才去设置它的优先级,这有点像老话说的,烈马已经脱缰跑走后才关上马厩的门。更好设计是以暂停状态启动线程t(因为可以在它执行计算之前调整其优先级),但我不想在这里展示那部分代码来分散你的注意力。如果你感觉没看到这段代码更会分散注意力的话,请翻到条款39,因为那里展示了如何启动处于暂停状态的线程。

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

你可能想知道,std::thread的析构函数为何这样运作。那是因为,另外两种明显的选项可以说是会更糟糕。它们是:

  • 隐式join。在这种情况下,std::thread的析构函数会等待底层异步执行线程完成。这听上去合理,但却可能导致难以追踪的性能异常。例如,如果conditionAreSatisfied()早已返回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类在标准库中很常见,例如STL容器(各个容器在析构函数都会析构容器内容并释放其内存)、标准智能指针、std::fstream型别对象(其析构函数会关闭对应的文件),还有很多。然而,没有和std::thread型别对象对应的标准RAII类,可能是因为标准委员会把join或detach用作默认选项的途径都堵死了,这么一来也就不知道真有这样的类的话该如何运作。

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

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

    ThreadRAII(std::thread&& thread, DtorAction action) 
        : m_action(action), m_thread(std::move(thread))
    {}

    ~ThreadRAII()
    {
        if (m_thread.joinable())
        {
            if (m_action == DtorAction::join)
            {
                m_thread.join();
            }
            else
            {
                m_thread.detach();
            }
        }
    }

    std::thread& getThread() 
    {
        return m_thread;
    }

private:
    DtorAction m_action;
    std::thread m_thread;
};

我希望这段代码基本上不言自明,但指出以下几点可能会有帮助:

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

如果你担心下面的代码会有竞险:

        if (m_thread.joinable())
        {
            if (m_action == DtorAction::join)
            {
                m_thread.join();
            }
            else
            {
                m_thread.detach();
            }
        }

理由是,在m_thread.joinable()的执行和join或detach的调用之间,另一个线程可能让m_therad变得不可联结。你的直觉可圈可点,但你的担忧却是庸人自扰。一个std::thread型别对象只能通过调用成员函数以从可联结状态转换为不可联结状态,例如join、detach或移动操作。当ThreadRAII对象的析构函数被调用时,不应该有其他线程调用该对象的成员函数。如果同时发生多个调用,那的确会有竞险,但这个竞险不是发生在析构函数内,而是发生在试图同时调用两个成员函数(一个是析构函数,一个是其他成员函数)的用户代码内。一般地,在一个对象之上同时调用多个成员函数,只有当所有这些函数都是const成员函数时才安全(参见条款16)。

bool doWork(std::function<bool(int)> filter,  //同前
            int maxVal = tenMillion)          
{                                                                                        
    std::vector<int> goodVals;                //同前
    
    ThreadRAII(std::thread([&filters, maxVal, &goodVals])   //使用RAII对象
                 {
                    for(auto i = 0; i < maxVal; ++i)
                    {
                        if (filter(i)) goodVals.push_back(i);
                    }
                 }),
                ThreadRAII::DtorAction::join           //RAII action
    );
    
    auto nh = t.get().native_handle();   //使用t的低值句柄设定t的优先级
    ...
    if (conditionsAreSatisfied()){
        t.get().join();                 //让t结束执行
        performComputation(goodVas);
        return true;              //计算已实施
    }

    return false;                 //计算未实施
}

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

哎呀,条款39会展示,使用ThreadRAII在std::thread析构中实施join不是仅仅会导致性能异常那么简答,而是会导致程序失去响应。这种问题的“合适的”解决方案是和异步执行的lambda式通信,当我们已经不再需要它运行,它应该提前返回,但C++11中并不支持这种可中断线程。我们可以手动实现它,但这个话题超出了本书的范围。

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

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

    ThreadRAII(std::thread&& thread, DtorAction action) 
        : m_action(action), m_thread(std::move(thread))
    {}

    ~ThreadRAII()
    {
        if (m_thread.joinable())
        {
            if (m_action == DtorAction::join)
            {
                m_thread.join();
            }
            else
            {
                m_thread.detach();
            }
        }
    }

    ThreadRAII(ThreadRAII&&) = default; //支持
    ThreadRAII& operator=(ThreadRAII&&) = default;  //移动操作  

    std::thread& getThread() 
    {
        return m_thread;
    }

private:
    DtorAction m_action;
    std::thread m_thread;
};

要点速记

  • 使std::thread型别对象在所有路径皆不可联结
  • 在析构调用join可能导致难以调试的性能异常
  • 在析构时调用detch可能导致难以调试的未定义行为
  • 在成员列表的最后声明std::thread型别对象
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值