TaskCompletionSource被多线程等待时SetResult行为随机性差异的原因探究
这是个非常有意思的观察,咱们一步步拆解背后的逻辑,就能明白为什么会出现这两种不同的输出~
首先先把你的测试代码和两种随机输出情况理清楚:
测试代码
using System.Diagnostics; var t = Start(); var t1 = Task.Run(async () => { await t; Thread.Sleep(TimeSpan.FromSeconds(2)); Print($"task1"); }); var t2 = Task.Run(async () => { await t; Thread.Sleep(TimeSpan.FromSeconds(4)); Print($"task2"); }); await Task.WhenAll(t1, t2); Print($"end"); Console.ReadLine(); static Task Start() { var taskCompletionSource = new TaskCompletionSource(); Process process = new() { StartInfo = new ProcessStartInfo(@"notepad"), EnableRaisingEvents = true }; process.Exited += (object? sender, EventArgs args) => { Print("one"); taskCompletionSource.SetResult(); Print("two"); }; process.Start(); return taskCompletionSource.Task; } static void Print(string msg) => Console.WriteLine($"[{Environment.CurrentManagedThreadId}] - {msg}");
两种随机输出
输出情况1:
[6] - one
[4] - task1
[6] - task2
[6] - end
(注:这里应该漏了[6] - two,这是代码中SetResult后的必然输出,大概率是粘贴时的失误)
输出情况2:
[4] - one
[4] - task1
[4] - two
[6] - task2
[6] - end
背后的核心逻辑:Task延续的内联执行规则
要理解这个随机性,得先搞懂.NET中TaskCompletionSource.SetResult处理延续任务(也就是await t之后的代码)的核心机制:
当你调用SetResult时,.NET会遍历所有等待该Task的延续任务,然后对每个延续做两个选择之一:
- 内联执行:在当前调用
SetResult的线程上同步执行延续代码,此时SetResult的后续代码(比如你的Print("two"))会被阻塞,直到这个延续执行完成。 - 排队执行:将延续任务放到线程池的等待队列中,由线程池调度空闲线程执行,此时
SetResult的后续代码会立刻继续运行,不用等延续完成。
而这个选择不是固定的,完全由线程池的动态状态和调度器的内联规则决定,这就是随机性的来源。
针对两种输出的具体分析
输出情况2(内联一个延续,另一个排队)
process.Exited在线程4触发,执行Print("one")后调用SetResult。- 调度器判断当前线程4可以安全内联第一个延续(t1的
await后续代码),于是线程4同步执行task1,完成后回到SetResult的后续逻辑,执行Print("two")。 - 第二个延续(t2的)因为线程池的动态状态(比如此时没有足够的内联条件,或者调度器优先排队),被放到线程池的线程6上执行,所以后续出现
[6]-task2。
输出情况1(部分内联,部分排队,SetResult阻塞到部分延续完成)
process.Exited在线程6触发,调用SetResult。- 调度器将第一个延续(t1的)排队到线程4的线程池线程,同时将第二个延续(t2的)内联到线程6执行。
- 线程6同步执行
task2,完成后才继续执行SetResult的后续代码Print("two")(也就是你漏写的那行输出),最后执行end。
为什么会有这种差异?
内联执行的判断涉及多个动态因素:
- 当前线程的状态:是否是线程池线程、是否有足够的栈空间执行延续代码。
- 线程池的负载:是否有空闲线程、线程池的饱和程度(如果线程池很忙,调度器可能更倾向于内联,避免排队延迟;如果线程池很闲,可能会排队让其他线程处理)。
- 延续任务的配置:你没有指定
TaskContinuationOptions.RunContinuationsAsynchronously,所以调度器会优先尝试内联;如果指定了这个选项,所有延续都会被强制排队,SetResult的后续代码会立刻执行,不会有随机性。
总结
这种随机行为是.NET线程池调度器的动态策略导致的——对于多个等待同一个Task的延续,调度器不会保证它们的执行顺序、是否被内联,也不会保证SetResult的后续代码与延续的执行顺序。
如果想消除这种随机性,你可以在创建延续时指定TaskContinuationOptions.RunContinuationsAsynchronously(或者在await时用ConfigureAwait(false)配合,但效果不同),强制所有延续都排队到线程池,这样SetResult的后续代码会立刻执行,不会被延续阻塞。
内容来源于stack exchange

