在 Visual Studio 中,与 std::async 一起使用时未调用“thread_local”变量的析构函数,这是一个错误吗?

Posted

技术标签:

【中文标题】在 Visual Studio 中,与 std::async 一起使用时未调用“thread_local”变量的析构函数,这是一个错误吗?【英文标题】:In Visual Studio, `thread_local` variables' destructor not called when used with std::async, is this a bug? 【发布时间】:2018-11-26 14:37:05 【问题描述】:

以下代码

#include <iostream>
#include <future>
#include <thread>
#include <mutex>

std::mutex m;

struct Foo 
    Foo() 
        std::unique_lock<std::mutex> lockm;
        std::cout <<"Foo Created in thread " <<std::this_thread::get_id() <<"\n";
    

    ~Foo() 
        std::unique_lock<std::mutex> lockm;
        std::cout <<"Foo Deleted in thread " <<std::this_thread::get_id() <<"\n";
    

    void proveMyExistance() 
        std::unique_lock<std::mutex> lockm;
        std::cout <<"Foo this = " << this <<"\n";
    
;

int threadFunc() 
    static thread_local Foo some_thread_var;

    // Prove the variable initialized
    some_thread_var.proveMyExistance();

    // The thread runs for some time
    std::this_thread::sleep_for(std::chrono::milliseconds100); 

    return 1;


int main() 
    auto a1 = std::async(std::launch::async, threadFunc);
    auto a2 = std::async(std::launch::async, threadFunc);
    auto a3 = std::async(std::launch::async, threadFunc);

    a1.wait();
    a2.wait();
    a3.wait();

    std::this_thread::sleep_for(std::chrono::milliseconds1000);        

    return 0;

在 macOS 中编译和运行宽度 clang:

clang++ test.cpp -std=c++14 -pthread
./a.out

得到结果

Foo Created in thread 0x70000d9f2000
Foo Created in thread 0x70000daf8000
Foo Created in thread 0x70000da75000
Foo this = 0x7fd871d00000
Foo this = 0x7fd871c02af0
Foo this = 0x7fd871e00000
Foo Deleted in thread 0x70000daf8000
Foo Deleted in thread 0x70000da75000
Foo Deleted in thread 0x70000d9f2000

在 Visual Studio 2015 Update 3 中编译并运行:

Foo Created in thread 7180
Foo this = 00000223B3344120
Foo Created in thread 8712
Foo this = 00000223B3346750
Foo Created in thread 11220
Foo this = 00000223B3347E60

不调用析构函数。

这是一个错误还是一些未定义的灰色区域?

附言

如果最后的睡眠 std::this_thread::sleep_for(std::chrono::milliseconds1000); 不够长,有时您可能看不到所有 3 条“删除”消息。

当使用std::thread 而不是std::async 时,在两个平台上都会调用析构函数,并且始终会打印所有 3 个“删除”消息。

【问题讨论】:

IIRC MSVC 为std::async 使用线程池。这可能意味着在未来可用后,运行该函数的线程仍然存在。 (这里只是推测) The issue is discussed here @M.M 是的,谢谢,我昨天看到了,但没有时间正确阅读。我觉得现在可能有点过时了。 @PaulSanders 在我看来,那里提出的问题还没有得到解决(正如这个问题所证明的那样) @M.M 是的,我同意,这是一个复杂的问题,标准并没有真正正确地解决它(它做出了太多不适合线程池的相当无聊的承诺)。我计划在今天晚些时候更详细地研究 MSVC 的实现,并将我的发现发回给我的答案。我认为 Linux 完全通过包装 std::thread 来回避这个问题——也许 MS 在这里尝试了一点too 【参考方案1】:

介绍性说明:我现在已经对此了解了很多,因此重新编写了我的答案。感谢@super、@M.M 和(后来)@DavidHaim 和 @NoSenseEtAl 让我走上了正确的道路。

tl;dr Microsoft 对 std::async 的实现不符合标准,但它们有其原因,并且一旦您正确理解,它们所做的事情实际上是有用的。

对于那些不希望这样的人,编写一个直接替换 std::async 的替换代码并不难,它在所有平台上都以相同的方式工作。我已经发了一个here。

编辑:哇,MS 这些天开放,我喜欢它,请参阅:https://github.com/MicrosoftDocs/cpp-docs/issues/308


让我们从头开始。 cppreference 有这个说法(强调和删除我的):

模板函数async 异步运行函数f可能可选地在单独的线程中可能是线程池的一部分)。

但是,C++ standard 是这样说的:

如果launch::async 设置在policy 中,[std::async] 调用[函数 f] 就像在新的执行线程中一样 ...

那么哪个是正确的?正如 OP 所发现的,这两个语句具有非常不同的语义。好吧,标准当然是正确的,正如 clang 和 gcc 所示,那么为什么 Windows 实现会有所不同呢?就像很多事情一样,它归结为历史。

(老)link that M.M dredged up 有这样的说法,除其他外:

...微软以PPL(并行模式库)的形式实现了[std::async]...[并且]我可以理解那些公司急于打破规则并制作这些库可通过std::async 访问,尤其是如果它们可以显着提高性能...

...微软希望在使用launch_policy::async. 调用时更改std::async 的语义我认为这在随后的讨论中几乎被排除...(原理如下,如果您想了解更多信息,请阅读链接,非常值得)。

而且 PPL 是基于 Windows 对ThreadPools 的内置支持,所以@super 是对的。

那么 Windows 线程池有什么作用,它有什么用呢?嗯,它旨在以一种有效的方式管理频繁调度的、短期运行的任务,所以第 1 点是不要滥用它,但我的简单测试表明,如果这是你的用例,那么它可以提供显着的效率。本质上,它做了两件事

它会回收线程,而不必总是为您启动的每个异步任务启动一个新线程。 它限制了它使用的后台线程总数,之后对std::async 的调用将阻塞,直到线程空闲。在我的机器上,这个数字是 768。

知道了这一切,我们现在可以解释 OP 的观察结果了:

    main() 启动的三个任务中的每一个创建一个新线程(因为它们都不会立即终止)。

    这三个线程中的每一个都创建一个新的线程局部变量Foo some_thread_var

    这三个任务都运行完成,但它们运行的​​线程仍然存在(休眠)。

    然后程序会休眠一小会儿然后退出,保留 3 个线程局部变量未破坏。

我进行了许多测试,除此之外,我还发现了一些关键的东西:

当一个线程被回收时,线程局部变量被重新使用。具体来说,它们不会被销毁然后重新创建(已警告您!)。 如果所有异步任务都完成并且您等待的时间足够长,那么线程池将终止所有关联的线程,然后销毁线程局部变量。 (毫无疑问,实际规则比这更复杂,但这是我观察到的)。 在提交新的异步任务时,线程池会限制创建新线程的速率,希望在需要执行所有工作(创建新线程太贵了)。因此,对std::async 的调用可能需要一段时间才能返回(在我的测试中最多需要 300 毫秒)。与此同时,它只是四处游荡,希望它的船能进来。这种行为已记录在案,但我在这里指出它以防让你感到意外。

结论:

    Microsoft 对std::async 的实现不符合标准,但它显然是为特定目的而设计的,该目的是为了充分利用 Win32 ThreadPool API。你可以因为他们公然藐视标准而痛打他们,但这种做法已经存在很长时间了,他们可能有(重要的!)依赖它的客户。我会要求他们在他们的文档中指出这一点。不这样做是犯罪。

    在 Windows 上的 std::async 任务中使用 thread_local 变量是安全的。千万别做,会以眼泪收场。

【讨论】:

就我而言,我不能用局部变量替换thread_local。问题中的代码只是重现该问题的示例。嗯......现在,这个问题,如果线程在进程退出之前从未被破坏,那么 thread_local 变量永远不会破坏......没有析构函数,它就不再是 C++ 了。只要我瞄准 Windows,我可能不得不将std::async 扔进最深的深渊。 :( 好伤心。 @Rnmss 使用线程池进行异步在大多数其他语言中非常常见,因为它对于通常的场景来说是如此明显的胜利(由于相关成本高,Linux 下的异步无法在许多情况下使用) .你不应该依赖其他不做同样事情的实现——原因很简单,现在 Linux 下不存在基础设施。有解决方案,从显式传递状态到显式处理生命周期。 @RnMss 问题是,你不能同时拥有它。 要么,您接受并使用std::async 的语义来获得[我们希望] 它带来的效率您使用std::thread 来确保您的@987654350 @ 对象在您希望它们消失时消失。由于我给出的原因,thread_local 绝对打算与std::async 一起使用。如果你这样做,那么你就坐在定时炸弹上——在任何平台上。我认为这值得一票,就个人而言,至少你现在知道自己的立场了。 @voo 我看到 Linux 有 一些 类型的线程池支持,请参阅:linux.die.net/man/3/cp_thread_pool。好用吗,你知道吗?我看到手册页相当腼腆地简单地说“线程池实现”。可能意味着任何事情。 @Paul 我第一次听说它。上次我用 gcc 检查std::async 至少没有使用它,这使得std::async 的整个想法变得毫无意义——如果你没有线程池支持,std::async 基本上只是std::thread 和一些附加的功能(这也意味着我无法使用它)。【参考方案2】:

看起来只是 VC++ 中的许多错误中的另一个。 考虑一下 n4750 的这句话

所有使用 thread_local 关键字声明的变量都有线程 储存期限。这些实体的存储将持续到 创建它们的线程的持续时间。有一个与众不同的 每个线程的对象或引用,并且使用声明的名称是指 与当前线程关联的实体。 2 一个变量 线程存储持续时间应在其第一次使用 odr 之前初始化 (6.2) 并且,如果构造,应在线程退出时销毁。

+这个

如果实现选择 launch::async 策略,- (5.3) 调用 到异步返回对象上的等待函数,该对象共享 此异步调用创建的共享状态应阻塞,直到 关联线程已完成,好像已加入,否则超时 (33.3.2.5);

我可能是错的(“线程退出”与“线程完成”,但我觉得这意味着 thread_local 变量需要在 .wait() 调用解除阻塞之前被销毁。

【讨论】:

一旦你读到,我想你会明白,将其描述为“VC++ 中的许多 bug 中的另一个”是过于简单化并且有点侮辱 MS,除了有点“旋转”之外,是making a big effort 成为符合标准的(这不是一项小任务)。问题是,std::async 的当前定义实际上有点混乱,所以像我们这样的人误入歧途也就不足为奇了。 我知道那个链接。除了公然的公关谎言之外,我没有从中学到任何我不知道的东西。 MSVC 在标准一致性方面仍落后于 clang 和 gcc。 我到处打听,学到了很多东西。我更新了我的答案,以防你感兴趣。是的,基本上你是对的,但正如我的回答所解释的那样,有一些缓解因素。

以上是关于在 Visual Studio 中,与 std::async 一起使用时未调用“thread_local”变量的析构函数,这是一个错误吗?的主要内容,如果未能解决你的问题,请参考以下文章

当 Visual Studio 2017 与 std 标头不兼容时,如何使用最高警告级别(墙)?

在 Visual Studio 中,与 std::async 一起使用时未调用“thread_local”变量的析构函数,这是一个错误吗?

在 Visual Studio 中调用 std::swap 时的 std::bad_function_call

Visual Studio 调试器:轻松查看 std::list(和其他 std 容器)

std库中的Visual Studio编译错误

无法在 Visual Studio 2019 和 2022 中使用 std::counting_semaphore