`thread_local` 变量和协程

huangapple go评论61阅读模式
英文:

`thread_local` variables and coroutines

问题

在协程出现之前,我们使用回调函数来运行异步操作。回调函数是普通函数,可以包含thread_local变量。

让我们看一下这个例子:

void StartTcpConnection(void) 
{
    using namespace std;
    thread_local int my_thread_local = 1;
    cout << "my_thread_local = " << my_thread_local << endl;
    auto tcp_connection = tcp_connect("127.0.0.1", 8080);
    tcp_connection.async_wait(TcpConnected);
}

void TcpConnected(void)
{
    using namespace std;
    thread_local int my_thread_local = 2;
    cout << "my_thread_local = " << my_thread_local << endl;
}

从代码中可以看出,我有一个(这里未记录的)tcp_connect函数,它连接到TCP端点并返回tcp_connection对象。该对象可以等待TCP连接实际发生并调用TcpConnected函数。由于我们不知道tcp_connecttcp_connection的具体实现,我们无法确定它是否在相同线程上调用TcpConnected函数,或者在不同线程上调用,两种实现都可能。但我们可以确定my_thread_local对于两个不同的函数是不同的,因为每个函数都有自己的作用域。

如果我们需要这个变量在同一线程上是相同的,我们可以创建第三个函数,该函数将返回对thread_local变量的引用:

int& GetThreadLocalInt(void)
{
    thread_local int my_variable = 1;
    return my_variable;
}

因此,我们有完全的控制和可预测性:如果TcpConnectedStartTcpConnection在不同线程上运行,我们可以确定变量将是不同的,如果这些函数在同一线程上运行,我们知道它们可以是不同的或相同的,这取决于我们的选择。

现在让我们看看相同操作的协程版本:

void Tcp(void)
{
    thread_local int my_thread_local = 1;
    auto tcp_connection = co_await tcp_connect("127.0.0.1", 8080);
    cout << "my_thread_local = " << my_thread_local << endl;
}

这对我来说有点疑问。我仍然需要线程本地存储,这是一个重要的语言特性,我不想放弃它。然而,在这里,我们有两种情况:

  1. co_await之前的线程与co_await之后的线程相同。my_thread_local会发生什么变化?在co_await之前和之后,它是相同的变量吗,尤其如果我们使用GetThreadLocalInt函数获取其引用而不是值会怎样?
  2. co_await之后线程发生变化。C++运行时会重新初始化my_thread_local,将其设为新线程的值,还是复制上一个线程的值,或者可能使用对相同数据的引用?对于GetThreadLocalInt函数也有类似的问题,它返回对thread_local对象的引用,但引用本身的存储是auto的,协程会重新初始化它以适应新线程,还是会出现(危险的!!!)竞争条件,因为线程2将奇怪地获取对线程1线程本地数据的引用,并且在并行中潜在地使用它?

即使在任何特定编译器上调试和测试发生了什么是容易的,但重要的问题是标准是否对此有明确规定。否则,即使我们在VC++或gcc上进行测试并看到它在这两个流行的编译器上的行为,代码可能会在某些奇特的编译器上丧失可移植性,并编译出不同的结果。

英文:

Before coroutines we used callbacks to run asynchronous operations. Callbacks was normal functions and could have thread_local variables.

Let see this example:

void StartTcpConnection(void) 
{
    using namespace std;
    thread_local int my_thread_local = 1;
    cout &lt;&lt; &quot;my_thread_local = &quot; &lt;&lt; my_thread_local &lt;&lt; endl;
    auto tcp_connection = tcp_connect(&quot;127.0.0.1&quot;, 8080);
    tcp_connection.async_wait(TcpConnected);
}

void TcpConnected(void)
{
    using namespace std;
    thread_local int my_thread_local = 2;
    cout &lt;&lt; &quot;my_thread_local = &quot; &lt;&lt; my_thread_local &lt;&lt; endl;
}

As we see from code, I have some (undocumented here) tcp_connect function that connects to TCP endpoint and returns tcp_connection object. This object can wait until TCP connection will really occur and call TcpConnected function. Because we don't know specific implementation of tcp_connect and tcp_connection, we don't know will it call TcpConnected on the same or on different thread, both implementations are possible. But we know for sure that my_thread_local is different for 2 different functions, because each function has its own scope.

If we need this variable to be the same (as soon as thread is the same), we can create 3rd function that will return reference to thread_local variable:

int&amp; GetThreadLocalInt(void)
{
    thread_local int my_variable = 1;
    return my_variable;
}

So, we have full control and predictability: we know for sure that variables will be different if TcpConnected and StartTcpConnection will run on different threads, and we know that we can have them different or the same depending on our choice when these functions will run on the same thread.

Now let see coroutine version of the same operation:

void Tcp(void)
{
    thread_local int my_thread_local = 1;
    auto tcp_connection = co_await tcp_connect(&quot;127.0.0.1&quot;, 8080);
    cout &lt;&lt; &quot;my_thread_local = &quot; &lt;&lt; my_thread_local &lt;&lt; endl;
}

This situation is a bit questionable for me. I still need thread local storage, it is important language feature that I don't want to abandon. However, we here have 2 cases:

  1. Thread before co_await is the same one as after co_await. What will happen with my_thread_local? Will it be the same variable before and after co_await, especially if we'll use GetThreadLocalInt function to get its reference instead of value?
  2. Thread changes after co_await. Will C++ runtime reinitialize my_thread_local to value from new thread, or make a copy of previous thread value, or may be use reference to the same data? And similar question for GetThreadLocalInt function, it returns reference to thread_local object, but the reference storage itself is auto, will coroutine reinitialize it to new thread, or we'll get (dangerous!!!) race condition, because thread 2 will strangely get reference to thread 1 thread local data and potentially use it in parallel?

Even it is easy to debug and test what will happen on any specific compiler, the important question is whether standard says us something about that, otherwise even if we'll test it on VC++ or gcc an see that it behaves somehow on these 2 popular compilers, the code may loose portability and compile differently on some exotic compilers.

答案1

得分: 1

对于全局的 thread_local 变量,协程行为应该如预期(并且 MSVC 似乎存在一个与此相关的错误)。但在协程中的函数局部 thread_local 变量,规范中似乎存在一个漏洞。事实上,我甚至不确定规范的措辞在没有协程的情况下是否有意义。

[stmt.dcl]/3 中提到

> 具有静态存储期或线程存储期的块变量的动态初始化在控制流首次通过其声明时执行;在完成初始化后,将视为已初始化。

问题在于,按照 C++ 的规则,虽然只有一个 thread_local 变量,但由这个变量表示的多个对象。这些对象需要被初始化。那么... 这是如何发生的呢?

唯一合理的解释是特定线程的对象在控制流首次通过该线程的声明时被初始化。这就是问题所在。

如果使用 co_await,函数执行的线程会改变,那么如何通过新线程的声明呢?这意味着该线程的 thread_local 应该被零初始化。

最终,我会说你不应该在协程函数中使用 thread_local。很难理解 thread_local 应该具有什么值。新 thread_local 应该具有的唯一合理值是来自上一个线程的值。但这不是 thread_local 的预期工作方式。总的来说,这个想法本质上是荒谬的(我认为标准应该明确禁止在协程中声明 thread_local,就像他们为在 thread_local 的初始化器中使用 co_await 所做的那样)。

只需使用命名空间作用域的 thread_local 变量。除了前面提到的 MSVC 错误,它应该可以正常工作并有意义。

英文:

For global thread_local variables, the coroutine behavior ought to be as expected (and MSVC seems to have a bug with this). But function-local thread_local variables in coroutines, there seems to be a hole in the specification. Indeed, I'm not sure the wording makes sense even without coroutines.

[stmt.dcl]/3 says:

> Dynamic initialization of a block variable with static storage duration or thread storage duration is performed the first time control passes through its declaration; such a variable is considered initialized upon the completion of its initialization.

The problem is that, while by C++'s rules there is only one thread_local variable, there are multiple objects represented by that one variable. And those objects need to be initialized. So... how does that happen?

The only sane interpretation of this is that the object for a particular thread gets initialized the first time control flow passes through the declaration on that thread. And that's the problem.

If using co_await the thread a function executes on changes, then how could you pass through the declaration on the new thread? Which means that the thread_local for that thread should be zero-initialized.

Ultimately, I would say that you should never use thread_local in a coroutine function. It's just not clear what value the thread_local ought to have. And the only logical value for the new thread_local to have is the one from the previous thread. But that's not how a thread_local is supposed to work. Overall, the idea feels inherently nonsensical (and I would say that the standard should have explicitly forbid the declaration of thread_locals in a coroutine, just as they did for using co_await in a thread_local's initializer).

Just use a namespace-scoped thread_local variable. Outside of the aforementioned MSVC bug, it ought to work and make sense.

huangapple
  • 本文由 发表于 2023年2月14日 21:23:42
  • 转载请务必保留本文链接:https://go.coder-hub.com/75448478.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定