C++11中的async(launch::async)是否使线程池在避免昂贵的线程创建方面变得过时?

143

这个问题与这个问题有些相关:C++11中std:: thread是否被池化? 尽管这两个问题不同,但意图相同:

问题1:仍然有必要使用自己的(或第三方库的)线程池来避免昂贵的线程创建吗?

其他问题的结论是,您不能依赖于std::thread被池化(可能会或可能不会)。 但是,std::async(launch::async)似乎更有可能被池化。

我认为这并不是标准规定的,但我认为所有良好的C++11实现都会在线程创建缓慢时使用线程池。只有在创建新线程不费时的平台上,我才希望它们总是生成新线程。

问题2:这只是我的想法,但我没有事实证明。 我很可能错了。 这是一种合理的猜测吗?

最后,我提供了一些示例代码,首先展示了我如何通过async(launch::async)表达线程创建:

示例1:

 thread t([]{ f(); });
 // ...
 t.join();

变成

 auto future = async(launch::async, []{ f(); });
 // ...
 future.wait();

示例2:火并忘线程
 thread([]{ f(); }).detach();

变成

 // a bit clumsy...
 auto dummy = async(launch::async, []{ f(); });

 // ... but I hope soon it can be simplified to
 async(launch::async, []{ f(); });

问题3:您更喜欢使用async版本还是thread版本?


以下内容仅供澄清:

为什么必须将返回值分配给虚拟变量?

不幸的是,当前的C++11标准强制要求您捕获std::async的返回值,否则析构函数会被执行,直到操作终止。这在某些情况下被认为是标准的错误(例如,Herb Sutter认为如此)。

来自cppreference.com的示例很好地说明了这一点:

{
  std::async(std::launch::async, []{ f(); });
  std::async(std::launch::async, []{ g(); });  // does not run until f() completes
}

进一步澄清:

我知道线程池可能有其他合法用途,但在这个问题中,我只关心避免昂贵的线程创建成本方面。

我认为还是有一些情况下线程池非常有用,特别是如果你需要更多的资源控制。例如,服务器可能决定仅同时处理固定数量的请求,以保证快速响应时间并增加内存使用的可预测性。在这种情况下,线程池应该是可以的。

线程局部变量也可能是自己的线程池的一个论点,但我不确定它是否在实践中相关:

  • 使用std::thread创建新线程时,线程局部变量未初始化而开始。也许这不是你想要的。
  • 对于由async生成的线程,对我来说有点不太清楚,因为线程可能已被重用。据我所知,线程局部变量不能保证被重置,但我可能错了。
  • 另一方面,使用自己的(固定大小)线程池,如果确实需要,可以完全控制。

10
然而,std::async(launch::async)似乎有更高的被池化的可能性。不,我认为是std::async(launch::async | launch::deferred)可能会被池化。只使用launch::async时,任务应该在新线程上启动,而不管其他任务正在运行什么。如果使用launch::async | launch::deferred策略,则实现可以选择哪个策略,但更重要的是它可以延迟选择哪个策略。也就是说,它可以等待直到线程池中的线程可用,然后选择异步策略。 - bames53
4
据我所知,只有VC++在使用std::async()时使用线程池。我仍然很好奇他们如何在线程池中支持非平凡的thread_local析构函数。 - bames53
3
我来翻译一下:@bames53,我仔细查看了gcc 4.7.2附带的libstdc++,发现如果启动策略不是确切的launch::async,则会将其视为仅launch::deferred,并且从未异步执行 - 因此,在实际效果上,该版本的libstdc++“选择”始终使用deferred,除非被强制执行其他策略。 - doug65536
3
我对thread_local析构函数的看法是,当使用线程池时,在线程退出时进行销毁并不完全正确。根据规范,异步运行的任务被视为在“新线程”上运行,这意味着每个异步任务都有自己的线程局部对象。基于线程池的实现必须特别小心,以确保共享相同后备线程的任务仍然表现得像它们有自己的线程局部对象。考虑这个程序:http://pastebin.com/9nWUT40h - bames53
3
在我的观点中,《规范》中使用“as if on a new thread”是一个非常大的错误。如果使用得当,std::async可以成为性能优化的利器 - 它可以成为标准的短期任务执行系统,并自然地由线程池支持。但现在,它只是一个std::thread,附带了一些垃圾代码以使线程函数能够返回一个值。还有,他们添加了冗余的“deferred”功能,完全重叠了std::function的作用。 - doug65536
显示剩余3条评论
1个回答

73

问题1::

我更改了原始内容,因为原始内容是错误的。我曾认为Linux线程创建非常便宜,但测试后发现在新线程与普通线程之间进行函数调用的开销巨大。处理函数调用的线程创建开销大约比普通函数调用慢10000倍或更多。因此,如果您需要发出许多小型函数调用,则线程池可能是个好主意。

很明显,随g++一起提供的标准C++库没有线程池。但我肯定可以看出使用线程池的情况。即使需要通过某种线程间队列传递调用的开销,它也可能比启动新线程更便宜。而且标准允许这样做。

在我看来,Linux内核人员应该努力使线程创建比当前更便宜。但是,标准C++库也应考虑使用池来实现launch::async | launch::deferred

OP是正确的,使用::std::thread来启动线程当然会强制创建一个新线程,而不是使用池中的线程。因此,::std::async(::std::launch::async, ...)更可取。

问题2::

是的,基本上这个操作'隐式'启动了一个线程。但实际上,正在发生的情况仍然非常明显。因此,我不认为“隐式”这个词是一个特别好的词。

我也不确定强制在销毁之前等待返回是否必须是错误。我不知道您是否应该使用async调用来创建不希望返回的“守护进程”线程。如果它们预计将返回,那么忽略异常是不可以的。

问题3:

就我个人而言,我喜欢线程启动是显式的。我非常重视可以保证串行访问的“孤岛”。否则,您最终会得到可变状态,您总是必须在某个地方包装互斥锁并记住使用它。

我更喜欢工作队列模型而不是“future”模型,因为有许多“串行的孤岛”,因此您可以更有效地处理可变状态。

但实际上,这取决于您正在做什么。

性能测试

因此,我测试了各种方法调用的性能,并在运行Fedora 29、使用clang版本7.0.1和libc++(而不是libstdc++)的8核(AMD Ryzen 7 2700X)系统上得出以下数字:

   Do nothing calls per second:   35365257                                      
        Empty calls per second:   35210682                                      
   New thread calls per second:      62356                                      
 Async launch calls per second:      68869                                      
Worker thread calls per second:     970415                                      

在我的 MacBook Pro 15 寸电脑上(Intel(R) Core(TM) i7-7820HQ CPU @ 2.90GHz),使用的是 Apple LLVM version 10.0.0 (clang-1000.10.44.4),操作系统为 OSX 10.13.6,我得到了以下结果:

   Do nothing calls per second:   22078079
        Empty calls per second:   21847547
   New thread calls per second:      43326
 Async launch calls per second:      58684
Worker thread calls per second:    2053775

对于工作线程,我启动了一个线程,然后使用了一个无锁队列将请求发送到另一个线程,并等待“完成”回复的发送。

“什么也不做”只是为了测试测试套件的开销。

很明显,启动线程的开销是巨大的。即使使用了线程间队列的工作线程,在Fedora 25虚拟机上会减慢约20倍左右,在本机OS X上会减慢约8倍。

我创建了一个OSDN代码库来保存我用于性能测试的代码。可以在这里找到:https://osdn.net/users/omnifarious/pf/launch_thread_performance/


3
我同意采用工作队列模型,但这需要使用“管道”模型,而这种模型可能并非适用于所有并发访问的场景。 - Matthieu M.
1
在我看来,表达式模板(用于运算符)可以用于组合结果,对于函数调用,我猜你需要一个call方法,但由于重载可能会稍微困难一些。 - Matthieu M.
4
"非常便宜"是相对于您的经验而言。我发现Linux线程创建的开销在我的使用中相当大。 - Jeff Hammond
7
在第一部分,你有点低估了创建威胁所需的工作量,以及调用函数所需的工作量有多少差异。函数调用和返回仅涉及几条CPU指令来操作栈顶上的几个字节。创建威胁意味着:1. 分配堆栈,2. 执行系统调用,3. 在内核中创建数据结构并将它们链接起来,在此过程中获取锁定,4. 等待调度程序执行线程,5. 切换上下文到该线程。这些步骤中的每一步本身所需的时间都比最复杂的函数调用要长得多。 - cmaster - reinstate monica
2
@Omnifarious 一个函数调用大约需要20个CPU周期(您测量的时间较短,因为一些开销被隐藏在测试工具之后)。内存分配很容易需要200个CPU周期。系统调用不少于约200纳秒。获取锁是线程间通信,在内核中执行,预计需要大约1微秒。我还没有开始讨论设置页表或刷新TLB的开销。如果专用硬件允许更快的线程创建,那是因为硬件针对此进行了优化,X86 CPU并没有这样做。 - cmaster - reinstate monica
显示剩余18条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接