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

Posted

技术标签:

【中文标题】异步代码、共享变量、线程池线程和线程安全【英文标题】:Asynchronous code, shared variables, thread-pool threads and thread safety [duplicate] 【发布时间】:2020-02-04 10:32:28 【问题描述】:

当我使用 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 值,从而导致超过 1,000,000 次迭代。虽然我无法在实践中产生这种情况。上面的代码总是在我的机器上打印 OK。这是否意味着代码是线程安全的?或者我应该使用lock 同步对i 变量的访问?

(* 根据我的测试,平均每 2 次迭代发生一次线程切换)

【问题讨论】:

你认为i为什么会缓存在每个线程中?请参阅this SharpLab IL 以深入了解。 @AndreasHassing 像这样的语句引起了我的担忧:编译器、CLR 或 CPU 可能会引入缓存优化,这样对变量的赋值不会立即对其他线程可见。 Part 4: Advanced Threading 【参考方案1】:

线程安全的问题在于读/写内存。即使这可以在不同的线程上继续,这里也不会并发执行。

【讨论】:

理论上,一个线程可以从本地缓存而不是主 RAM 读取和写入,这样会丢失由其他线程进行的更新。变量i既没有声明volatile也没有被锁保护,所以根据我的理解编译器,抖动和硬件(CPU)都可以进行这样的优化。 @TheodorZoulias 换出线程以恢复延续与并发访问不同。在上面链接的sharplab中,您可以看到将本地变量封装在私有字段中的整个状态机被传递给将执行延续的线程。在任何给定时间只有 1 个线程在访问 i @JohanP 状态机中的字段private int &lt;i&gt;5__2 未声明volatile。我担心的不是线程中断正在更新i 的另一个线程。在这种情况下这是不可能发生的。我担心一个线程使用过时的值i,缓存在CPU内核的本地缓存中,从前一个循环中离开,而不是从主RAM中获取i的新值。访问本地缓存比访问主 RAM 便宜,因此通过优化这些事情是可能的(根据我读过的内容)。 @TheodorZoulias 如果此循环中没有 async 代码,您是否也有同样的担忧? @TheodorZoulias 线程 A 运行,递增 i。代码命中await,线程 A 将所有状态传递给线程 B 并返回到池中。线程 B 递增 i。点击await。线程 B 然后将所有状态传递给线程 C,它返回到池中等等。在任何时候都没有对 i 的并发访问,不需要线程安全,发生线程切换并不重要,所有需要的状态都被传递到运行延续的新线程中。没有共享状态,这就是您不需要同步的原因。【参考方案2】:

我相信 Stephen Toub 的 this article 可以对此有所了解。特别是,这是一篇有关上下文切换期间发生的事情的相关文章:

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

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

【讨论】:

感谢丹尼尔的回答。老实说,如果ExecutionContext 从线程到线程的流动也用作使线程的本地缓存无效的机制,我会感到惊讶。但这也不是不可能的。 也许像@RaymondChen 这样的专家可以断言您的答案是对还是错。我相信世界上很少有人可以作为这个问题的可靠信息来源。 “使线程的本地缓存无效”意味着当线程执行上下文切换时,它还会以某种方式维护一个特定于该上下文的缓存。这意味着这个缓存的数据必须存储在类似于上下文的东西中......但是为什么当真正的上下文可供必须执行它的线程使用时呢?它还会带来确定哪两个上下文是“相同的”的问题,但只是代表稍后的执行点。当然,我并没有自称是专家,只是想把这个问题作为一种心理锻炼来推理。 另外,如果我错了,我可能会援引坎宁安定律:“在互联网上获得正确答案的最佳方式不是提问,而是发布错误的答案。” 但是硬件缓存不是线程特定的。事实上,即使是单线程代码也可以通过操作系统端的抢占式多任务来强制让步,并且它可以在不同的处理器上恢复执行(因此也可以在不同的 L1 和 L2 缓存)上恢复执行。此缓存失效并非特定于 asyncawait。上下文切换期间的缓存失效会以同样的方式影响单线程和多线程代码。

以上是关于异步代码、共享变量、线程池线程和线程安全的主要内容,如果未能解决你的问题,请参考以下文章

GIL 线程池 进程池 同步 异步

线程数据共享和安全 -ThreadLocal

GIL锁,线程池,同步异步

Java并发编程——常见的线程安全问题

Java+线程内部调用实例方法会多线程安全吗?

什么是线程安全?