与 TaskCompletionSource 和 AutoResetEvent 同步时出现奇怪的死锁

Strange deadlock while sync with TaskCompletionSource and AutoResetEvent

提问人:Sky Nano 提问时间:11/10/2023 最后编辑:Theodor ZouliasSky Nano 更新时间:11/10/2023 访问量:67

问:

在运行以下 C# 程序时,我随机得到了两个不同的结果。 结果 1(经常发生)实际上是死锁的。请向我解释为什么会这样。 我不希望在AutoResetEvent上使用WaitOne时出现死锁。

   class Program
    {
        static async Task Main()
        {
            TaskCompletionSource<bool> tcs = new();
            AutoResetEvent evtFinished = new AutoResetEvent(false);
            Func<Task> func = async Task () => 
                { await Task.Delay(10); tcs.SetResult(true); evtFinished.Set(); };
            var t = func.Invoke();
            //await func.Invoke();
            await tcs.Task;
            if (evtFinished.WaitOne(1000))
                Console.WriteLine("First Wait OK");
            else
            {
                Console.WriteLine("First Wait Timeout");
                for (int i = 0; i < 10 && !evtFinished.WaitOne(100); i++)
                    Console.WriteLine($" {i+1} Wait Timeout"); 
                await t;
                evtFinished.WaitOne();
                Console.WriteLine("Wait OK");
            }
        }
    }

<<<<<Result 1: >>>>>>
# First Wait Timeout
#  1 Wait Timeout
#  2 Wait Timeout
#  3 Wait Timeout
#  4 Wait Timeout
#  5 Wait Timeout
#  6 Wait Timeout
#  7 Wait Timeout
#  8 Wait Timeout
#  9 Wait Timeout
#  10 Wait Timeout
# Wait OK

<<<<<Result2 >>>>>
# First Wait OK
C# 任务 死锁 AutoResetEvent TaskCompletionSource

评论


答:

1赞 Yarik 11/10/2023 #1

默认情况下,同步运行其延续(在大多数情况下)。TaskCompletionSource.SetResultTask

这意味着在您的情况下,之后和直到下一个的所有内容都将在到达之前运行。await tcs.TaskawaitevtFinished.Set()

有多种方法可以解决这个问题,但最简单的方法是将标志传递给构造函数。TaskContinuationOptions.RunContinuationsAsynchronouslyTaskCompletionSource

评论

0赞 Sky Nano 11/10/2023
谢谢。解决方案有效。
0赞 user16606026 11/10/2023
如果解决方案有效,请将答案标记为已接受~
1赞 Stephen Cleary 11/10/2023 #2

发生这种情况有几个综合原因。首先,await 的基本行为(如我的博客所述)是它捕获一个“上下文”,并且 - 默认情况下 - 在该“上下文”中恢复。在这种情况下,没有上下文,因此方法在线程池线程上恢复。其次,当 awaitable 完成时,如果完成 awaitable 的线程与 捕获的上下文兼容,它将同步执行该延续。更具体地说,使用 .asyncawaitawaitTaskContinuationOptions.ExecuteSynchronously

在我的博客文章 不要阻塞异步代码 中更深入地探讨了此方案。

因此,通过它:

  1. Main在主线程上启动并调用 .func.Invoke
  2. func.Invoke命中它并返回一个未完成的任务,存储在 中。awaitt
  3. Main继续执行(仍在主线程上)并执行 .await tcs.Task
  4. 假设 尚未完成(这是争用条件),则返回未完成的任务。tcsMain
  5. 完成后,线程池线程将用于恢复执行。Task.Delayfunc
  6. 此线程池线程调用 ,它现在附加了一个延续(其余部分)。tcs.SetResultMain
  7. 由于线程池线程与捕获的上下文(它是线程池线程)兼容,因此它只是同步执行延续(其余部分)。Main
  8. 然后,此线程会同步阻止等待设置,但不会。请注意,此时您的调用堆栈是反转的:实际上调用了延续。您可以通过使延迟更大并在调试器中运行来验证这一点。evtFinishedfuncMain
  9. 最终,同一个线程池线程将命中,它最终返回给其调用方,允许完成运行,设置 .await tfuncevtFinished

评论

0赞 Sky Nano 11/10/2023
非常感谢您的回答。解释得很好。
1赞 JonasH 11/10/2023
在顶部,您提到“没有上下文”,但在步骤 7 中,您说“线程池上下文”。我认为这可以说得更清楚一点。
0赞 Guru Stron 11/10/2023
@SkyNano答案是否适合您 - 不要忘记将其标记为已接受。
0赞 Theodor Zoulias 11/10/2023
后面的线程是完成 awaitable 的线程。这可以很容易地通过实验来证明。缺省同步上下文是 ,而不是 。awaitnullThreadPool
1赞 Stephen Cleary 11/11/2023
我会说有一个兼容性检查,但你是对的,当没有捕获上下文时,检查总是通过。