C#中async/await执行顺序不符合预期的原因探究
首先得明确你的核心疑惑:为什么三个Counter的Count方法没有按照“输出两行后切换”的预期执行,反而出现了输出顺序混乱、线程频繁切换的情况?下面从几个关键角度拆解原因:
1. 异步方法的「同步执行阶段」是关键
你要记住一个异步编程的核心规则:异步方法在遇到第一个未完成的await之前,都是同步运行的。
你的代码里,Task.WhenAll会同时启动三个Count()任务,但一开始它们都会在同一个线程(ThreadId=1)上同步执行:
- 先跑
counter1的Count方法,从i=0到i=2,直到遇到await Task.Yield()才暂停; - 接着线程回到
Task.WhenAll,立刻启动counter2的同步部分,同样执行到i=2的await; - 再轮到
counter3完成同步阶段的i=0到i=2。
这就是为什么你看到前几行是counter1连续输出3行,然后counter23行,再counter33行——这部分完全是同步执行,没有任务切换。
2. Task.Yield()的真实作用:只让权,不保证切换到指定任务
Task.Yield()的功能是让出当前线程的执行权,把当前异步方法的后续逻辑放到调度器的等待队列里。但它绝对不会保证调度器会选择另一个等待的任务来执行。
.NET线程池的调度器(控制台应用默认用ThreadPoolTaskScheduler)会根据线程池负载、任务优先级等因素随机选择下一个要执行的任务,没有“轮流执行”的强制规则。比如:
counter1的后续逻辑被调度到ThreadId=3执行,它会连续跑到下一个await(i=4时);- 此时调度器可能选择
counter3的任务而不是counter2,这就导致了输出顺序的混乱。
3. 线程池的线程复用与调度随机性
线程池的线程是可复用的,调度器会根据系统资源情况动态分配线程给等待的任务,这就是你看到ThreadId频繁变化的原因。而且线程池不会为了“公平性”特意轮流调度不同的任务,它的核心目标是高效利用CPU资源。
如何实现「每输出两行切换任务」的预期?
如果想要严格的轮流执行逻辑,不能依赖Task.Yield()的随机调度,必须手动加入同步协调机制,比如用SemaphoreSlim来控制任务的执行顺序:
class Program { static async Task Main(string[] args) { var semaphore = new SemaphoreSlim(1); var counter1 = new Counter("counter1", string.Empty, semaphore); var counter2 = new Counter("counter2", " ", semaphore); var counter3 = new Counter("counter3", " ", semaphore); await Task.WhenAll(counter1.Count(), counter2.Count(), counter3.Count()); } } public class Counter { private string _name; private string _prefix; private SemaphoreSlim _semaphore; public Counter(string name, string prefix, SemaphoreSlim semaphore) { _name = name; _prefix = prefix; _semaphore = semaphore; } public async Task Count() { for (int i = 0; i <= 10; i++) { await _semaphore.WaitAsync(); try { Console.Write($"{_prefix}{_name}: {i}. ThreadId: {Environment.CurrentManagedThreadId}"); if (i % 2 == 0 && i != 0) { Console.WriteLine($" is divisible by 2. Yielding"); } else { Console.WriteLine(); } } finally { _semaphore.Release(); } // 每完成两行输出后,主动让出执行权 if (i % 2 == 0 && i != 0) { await Task.Yield(); } } } }
这个例子里,SemaphoreSlim保证同一时刻只有一个任务能输出内容,每完成两行输出后用Task.Yield()让出执行权,就能实现你预期的轮流切换效果。
总结
- 异步方法并非全程异步,初始阶段会同步执行到第一个
await; Task.Yield()只是让出执行权,调度器的任务选择是随机的,不保证公平性;- 若需要严格的执行顺序,必须手动加入同步协调机制。
内容的提问来源于stack exchange,提问作者BodzioSamolot

