是否使用符合标准的线程池进行 std::async 的 Visual C++ 实现



Visual C++ 使用 Windows 线程池(如果可用,则使用 Vista 的CreateThreadpoolWork,如果不可用,则使用QueueUserWorkItem),使用std::launch::async调用std::async

池中的线程数是有限的。如果我们创建多个长时间运行而不休眠的任务(包括执行 I/O),则队列中即将执行的任务将没有机会工作。

标准(我使用的是 N4140)说将std::asyncstd::launch::async

一起使用

。调用INVOKE(DECAY_COPY(std::forward<F>(f)), DECAY_COPY(std::forward<Args>(args))...)(20.9.2, 30.3.1.2)就像在由线程对象表示的新执行线程中一样,DECAY_COPY()调用在调用async的线程中进行评估。

(§30.6.8p3,强调我的。

std::thread的构造函数创建一个新线程等。

关于一般的线程,它说(§1.10p3):

实现应确保所有未阻塞的线程最终取得进展。[注意:标准库函数可能会在 I/O 或锁上静默阻塞。执行环境中的因素(包括外部强加的线程优先级)可能会阻止实现对前进进度做出某些保证。—尾注]

如果我创建一堆操作系统线程或std::thread,所有线程都执行一些非常长(也许是无限)的任务,它们都将被安排(至少在Windows上;不会弄乱优先级,亲和力等)。如果我们将相同的任务调度到 Windows 线程池(或使用执行此操作的std::async(std::launch::async, ...)),则在较早的任务完成之前,后面的计划任务不会运行。

严格来说,这是否符合标准?"最终"是什么意思?


问题是,如果首先计划的任务实际上是无限的,则其余任务将不会运行。因此,其他线程(不是操作系统线程,而是根据 as-if 规则的"C++线程")不会取得进展。

有人可能会争辩说,如果代码有无限循环,则行为是未定义的,因此它是合规的。

但我认为,我们不需要标准所说的导致UB实现这一目标的那种有问题的无限循环。访问易失性对象、执行原子操作和同步操作都是"禁用"循环终止假设的副作用。

(我有一堆异步调用执行以下 lambda

auto lambda = [&] {
while (m.try_lock() == false) {
for (size_t i = 0; i < (2 << 24); i++) {
vi++;
}
vi = 0;
}
};

并且仅在用户输入时释放锁。但是还有其他有效的合法无限循环类型。

如果我计划了几个这样的任务,我计划在它们之后的任务将无法运行。

一个非常邪恶的例子是启动太多任务,这些任务运行直到释放锁/引发标志,然后使用std::async(std::launch::async, ...)引发标志的任务进行计划。除非"最终"这个词意味着非常令人惊讶的事情,否则该程序必须终止。但是在VC++实现下,它不会!

对我来说,这似乎违反了标准。让我感到疑惑的是笔记中的第二句话。各种因素可能会阻止实施对前进进展做出某些保证。那么这些实现是如何符合的呢?

这就像说可能存在阻止实现提供内存排序、原子性甚至存在多个执行线程的某些方面的因素。很好,但符合要求的托管实现必须支持多个线程。对他们和他们的因素来说太糟糕了。如果他们不能提供他们,那就不C++了。

这是放宽要求吗?如果这样解释,那就是完全撤回要求,因为它没有指定哪些因素,更重要的是,实现可能不提供哪些保证。

如果不是 - 那张纸条到底是什么意思?

我记得根据 ISO/IEC 指令,脚注是非规范性的,但我不确定注释。我确实在ISO/IEC指令中发现了以下内容:

24 笔记

24.1 目的或理由

注释用于提供旨在帮助理解或使用文档文本的其他信息。该文件应在没有注释的情况下使用。

强调我的。如果我考虑没有那个不清楚注释的文档,在我看来,线程必须取得进展,std::async(std::launch::async, ...)具有效果,好像函子在新线程上执行一样,就好像它是使用std::thread创建的,因此使用std::async(std::launch::async, ...)调度的函子必须取得进展。在带有线程池的 VC++ 实现中,他们没有。所以VC++在这方面违反了标准。


完整示例,在 i5-6440HQ 上的 Windows 10 企业版 1607 上使用 VS 2015U3 进行测试:

#include <iostream>
#include <future>
#include <atomic>
int main() {
volatile int vi{};
std::mutex m{};
m.lock();
auto lambda = [&] {
while (m.try_lock() == false) {
for (size_t i = 0; i < (2 << 10); i++) {
vi++;
}
vi = 0;
}
m.unlock();
};
std::vector<decltype(std::async(std::launch::async, lambda))> v;
int threadCount{};
std::cin >> threadCount;
for (int i = 0; i < threadCount; i++) {
v.emplace_back(std::move(std::async(std::launch::async, lambda)));
}
auto release = std::async(std::launch::async, [&] {
__asm int 3;
std::cout << "foo" << std::endl;
vi = 123;
m.unlock();
});

return 0;
}

如果为 4 个或更少,则终止。超过 4 个则不会。


类似问题:

  • 是否有使用线程池的 std::async 实现? - 但它不质疑合规性,无论如何都没有答案。

  • std::async - 依赖于实现的用法? - 提到"线程池并不真正受支持",但专注于thread_local变量(即使"不简单"或像答案和评论所说的那样不平凡,这也是可以解决的),并且没有解决接近取得进展要求的注释。

P0296R2 在 C++17 中,情况得到了一些澄清。 除非 Visual C++ 实现记录其线程不提供并发向前进度保证(这通常是不希望的),否则有界线程池不符合要求(在 C++17 中)。

关于"外部强加的线程优先级"的说明已被删除,也许是因为环境已经总是有可能阻止C++程序的进度(如果不按优先级,则通过挂起,如果不是这样,则由电源或硬件故障)。

该部分中还有一个规范性的"应该",但它仅与无锁操作有关(如 conio 所述),可以通过其他线程对同一缓存行(而不仅仅是同一原子变量)的频繁并发访问来无限期延迟。 (我认为在某些实现中,即使其他线程仅在读取,也会发生这种情况。

最新更新