异步代码、共享变量、线程池线程和线程安全性。

5

当我使用async/await编写异步代码时,通常会使用ConfigureAwait(false)避免捕获上下文,每次await后我的代码会从一个线程池线程跳到下一个线程。这引起了关于线程安全的担忧。这段代码是否安全?

static async Task Main()
{
    int count = 0;
    for (int i = 0; i < 1_000_000; i++)
    {
        Interlocked.Increment(ref count);
        await Task.Yield();
    }
    Console.WriteLine(count == 1_000_000 ? "OK" : "Error");
}

变量i未受保护,并由多个线程池线程访问。尽管访问模式是非并发的,但理论上每个线程都可以增加本地缓存值i的值,导致超过100万次迭代。然而,在我的机器上无法实现这种情况。上面的代码总是打印“OK”。这是否意味着代码是线程安全的?还是我应该使用lock同步对i变量的访问?(*根据我的测试,平均每2次迭代发生一次线程切换)

1
你认为为什么在每个线程中都缓存了 i?请参考此SharpLab IL进行深入探究。 - AndreasHassing
1
@AndreasHassing 我对这样的陈述感到担忧:编译器、CLR或CPU可能会引入缓存优化,使得对变量的赋值不会立即对其他线程可见。第4部分:高级线程 - Theodor Zoulias
2个回答

2

线程安全的问题在于读写内存。即使这个过程在不同的线程上进行,但是这里并没有并发执行。


@TheodorZoulias 将线程替换为恢复一个继续不等于并发访问。在上面链接的 sharplab 中,您可以看到整个状态机,它封装了私有字段中的本地变量,并传递给将执行继续的线程。任何时候只有1个线程正在访问 i - JohanP
@TheodorZoulias 使用 await Task.Yield() 仍然是单线程的代码。 - JohanP
@JohanP await 会导致线程切换。在我的代码中,一次只有一个线程运行,但不是从开始到结束都是同一个线程。为了保证在这些条件下我的代码的线程安全性,目前我看不到其他选择,只能使用锁来保护共享变量(示例)。当然,在我学到更好的方法之前,这是我打开这个问题的原因。 - Theodor Zoulias
2
@TheodorZoulias 线程A运行,增加i。代码遇到await,线程A将所有状态传递给线程B并返回池中。线程B增加i。遇到await。然后线程B将所有状态传递给线程C,它返回池等等。在任何时候都没有对i进行并发访问,不需要线程安全,线程切换发生也无关紧要,所有所需状态都传递到运行继续的新线程中。没有共享状态,因此不需要同步。 - JohanP
@JohanP 是的,".....这里没有任何并发执行。" - Jeroen van Langen
显示剩余8条评论

0

我相信Stephen Toub的这篇文章可以为此提供一些启示。特别是,以下是关于上下文切换期间发生的事情的相关段落:

每当代码等待一个awaitable,而其awaiter表示它尚未完成(即awaiter的IsCompleted返回false)时,该方法需要暂停,并通过awaiter的继续执行恢复。这是我之前提到的异步点之一,因此ExecutionContext需要从发出await的代码流经到继续委托的执行。这由框架自动处理。当异步方法即将暂停时,基础结构会捕获ExecutionContext。传递给awaiter的委托引用了此ExecutionContext实例,并在恢复方法时使用它。这就是使ExecutionContext所代表的重要“环境”信息能够在等待中流动的原因。

值得注意的是,Task.Yield()返回的YieldAwaitable始终返回false


感谢Daniel的回答。老实说,如果从线程到线程的ExecutionContext流动也作为使线程本地缓存失效的机制,我会感到惊讶。但这也不是不可能的。 - Theodor Zoulias
也许像@RaymondChen这样的专家可以断言你的答案是否正确。我认为全世界很少有人能够成为这个问题可信信息的来源。 - Theodor Zoulias
我在谈论硬件缓存。如果你想看一下这些内容:C#中的Volatile关键字-内存模型解释 当你在C#中读取一个非volatile字段时,会发生一个非volatile读取,并且你可能会从线程的缓存中看到一个过期的值。C#中的常见多线程错误 当必须修改变量时,它首先从RAM加载到L3缓存,然后进入相关核心的L2和L1缓存,最后在核心本身中进行操作。 - Theodor Zoulias
1
但是硬件缓存不是线程特定的。实际上,即使是单线程代码也可能被操作系统的抢占式多任务强制让出CPU,并且它可能会在不同的处理器(因此是不同的L1和L2缓存)上恢复执行。这种缓存失效不仅适用于asyncawait。在上下文切换期间进行缓存失效将以相同的方式影响单线程和多线程代码。 - Daniel Crha
我真的不知道。这些文章中的信息非常复杂,让我感到困惑。我真正希望的是有一位专家能够简单地回答这个问题,比如说“别担心,一切都安全”,或者“你担心是对的,保护好你的变量”,这样我就可以继续我的生活,而不必深入了解硬件缓存等所有细节。 :-) - Theodor Zoulias
显示剩余2条评论

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