Item 35:Prefer task-based programming to thread- based.

如果你想异步运行一个函数doAsyncWork,你有两个基本选择:基于线程的方法(thread-based)和基于任务的方法(task-based)。

int doAsyncWork();

std::thread t(doAsyncWork);             // thread-based 

auto fut = std::async(doAsyncWork);     // "fut" for "future", task-based

基于任务的方法通常优于基于线程的方法。在基于任务的方法中,doAsyncWork产生一个返回值,而这个返回值可能是我们感兴趣的。但是在基于线程的调用中,并没有直接的方法来访问返回值。从std::async返回的future提供了get函数。如果doAsyncWork抛出了异常,那么get函数就有用武之地了。遗憾的是,在基于线程的方法中,如果doAsyncWork抛出异常,程序就直接挂了(通过调用std::terminate)。

“线程”在C++ 软件中有三层含义:

  • 硬件线程,是实际执行计算的线程。现代机器架构为每个CPU核提供一个或多个硬件线程;
  • 软件线程,也被称为OS线程或系统线程,是操作系统在所有进程和硬件线程上执行调度的线程。通常,可以创建多于硬件线程的软件线程,因为当一个软件线程被阻塞(例如,执行I/O操作或等待互斥或条件变量)时,可以通过执行其他未阻塞的线程来提高吞吐量;
  • std::threads,C++ 进程中的对象,扮演着底层软件线程的handle角色。某些
    std::thread对象可以表示“null”handle,即,不对应任何软件线程。比如,默认构造时,没有提供要执行的函数;或者执行函数被移动给了其他std::thread;亦或已经join的(要运行的函数已经执行完);再或是被detach的(和底层软件线程之间的连接已经断开);

软件线程是有限的资源。如果尝试创建超出系统所能提供的线程,则会抛出std::system_error异常。即使使用noexcept来修饰,也不行:

int doAsyncWork() noexcept;         // see Item 14 for noexcept
        
std::thread t(doAsyncWork);         // throws if no more
                                    // threads are available

这种情况怎么处理呢?一种办法是,在当前线程运行doAsyncWork,但如果当前线程是界面线程,可能会导致响应问题。另一种方法是,等待某个已存在的线程完成工作,然后再重新创建一个新的std::thread,但被等待的线程有可能正在等待doAsyncWork的结果。总之,这两种方法都不太好处理这种情况。

即使没有耗尽线程,也会遇到超额订阅的问题。当就绪状态(即非阻塞)的软件线程多于硬件线程,线程调度器(通常是操作系统的一部分)会为硬件上的软件线程分配时间片。如果一个线程的时间片结束了,而另一个线程的时间片开始了,就会发生上下文切换。这样的上下文切换增加了系统的整体线程管理开销,尤其在一个软件线程的这一次和下一次的执行被调度器切换到不同的CPU内核上的硬件线程时,系统开销会更大。这种情况下,(1) 被调度的软件线程通常不会命中CPU缓存(也就是说,CPU缓存中没有包含对这个线程有用的任何数据和指令);(2) 这个被调度过来的软件线程还会“污染”刚刚为“旧”线程缓存的东西,因为“旧”线程有可能被再次调用到这个CPU核心上。

想避免超额订阅是很困难的,因为软件线程和硬件线程的最佳比例依赖于软件线程变成可运行状态的频率,而这个频率却是动态变化的(比如,当一个线程从IO密集型转换为计算密集型时)。最佳比例还依赖于上下文切换的成本和软件线程使用CPU缓存的效率(即命中率)。硬件线程的数量和CPU缓存的细节(比如缓存的大小及相对速度)又取决于计算机的体系结构,所以即使你在一个平台上优化好了你的应用,避免了超额订阅(同时保持硬件满载工作),也无法保证你的优化方案在其他机器上也能高效工作。

那么,让我们把这些问题都抛给std::async吧:

auto fut = std::async(doAsyncWork);     // onus of thread mgmt is
                                        // on implementer of
                                        // the Standard Library

上面的调用将线程管理的任务交给了C++ 标准库的实现者。这种用法还大幅降低了异常的产生。因为以上面的形式(见Item 36, 默认启动策略)调用std::async时,不会保证创建一个新的软件线程。相反,它允许调度器安排要执行的函数运行在请求这个函数结果的线程上(即,在调用get或wait的线程上)。

你可能想要自己实现std::async做的事情,但别太自信。std::async和运行时调度器绝对比你牛逼,因为它管理的是所有进程的线程。

另外,std::async也有可能阻塞GUI线程的,因为调度器并不知道哪个线程是需要及时响应的。这种情况下,你可以把std::launch::async启动策略传给std::async。

上面讲了这么多,并不意味着std::thread就应该被我们弃用,以下情况就比较适合使用std::thread:

  • 需要访问底层线程实现的API。 C++ 并发API通常采用特定平台的底层API来实现,比如常用的pthread或windows线程库。它们提供的API比C++ 提供的更丰富(比如,优先级和亲和性,这些是C++ 没有的)。为了访问底层线程实现的API,std::thread通常会提供native_handle成员函数,而std::future(std::async的返回值)则没有对应的方法;
  • 需要优化线程的使用。 比如,你正在开发一个服务软件,而这个软件是这台机器上执行的唯一有意义的进程,并且你清楚这台机器的硬件配置。
  • 需要实现超越C++ 并发API的线程技术。 比如,线程池技术,而 C++ 标准库没有提供。

但是,上面的情况并不常见。大多数时候,还是建议选择基于任务的设计。

Things to Remember

  • std::thread的API并未提供直接获取一部运行函数返回值的途径,而且如果那些函数抛出异常,程序就会终止;
  • 基于线程的程序升级要求手动管理线程耗尽、超额订阅、负载均衡,以及新平台适配;
  • 基于任务的编程通过std::async和默认的启动策略为你解决了大部分这些问题;
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值