C#10中Task集合并发/顺序执行的安全规范实现方案
现有Task集合需要支持并发执行、顺序执行两种运行模式,运行环境为C# 10 + ASP.NET Core,执行频率约每秒1次,单批次处理约10个任务,性能非首要优先级但需避免无意义开销。
现有并发执行参考实现如下:
public Task Foo(CancellationToken token) { // ... IEnumerable<Task> tasks = GetTasks(token); // ... return Task.WhenAll(tasks); }
目前待确认顺序执行的正确实现,现有3种候选写法如下:
public Task Bar(CancellationToken token) // 设计上有意不添加`async`关键字 { // ... IEnumerable<Task> tasks = GetTasks(token); // ... // (1) return Task.Run(async () => { foreach (var task in tasks) await task.WaitAsync(token); }, token); // (2) return Task.Run(async () => { foreach (var task in tasks) await Task.Run(() => task, token); }, token); // (3) foreach (var task in tasks) yield return Task.Run(() => task, token); // (?) }
3种候选写法均存在明显问题,不符合安全、符合现代C#范式的要求:
- 方案(1):外层
Task.Run属于完全不必要的线程池调度——ASP.NET Core本身就使用线程池线程处理请求,额外把逻辑丢到线程池只会平白增加线程切换开销,没有任何收益。更核心的问题是:如果GetTasks()返回的是提前创建好的“热任务”,在遍历到对应任务之前这些任务就已经启动并发运行了,根本达不到顺序执行的效果。 - 方案(2):嵌套的
Task.Run(() => task)是无意义的包装,既不会改变原有任务的启动时机,还额外增加了线程池工作项排队开销,同样存在热任务提前并发运行的核心问题。 - 方案(3):首先写法本身存在编译错误——方法签名返回值为
Task,yield return要求方法返回IEnumerable/IAsyncEnumerable类型,无法通过编译。即使修正返回值类型,依然没有解决热任务提前启动的问题,无法保证真正的顺序执行。
另外补充:刻意不给Bar方法添加async关键字属于无意义的微优化,C# 10中async/await的异步状态机生成开销极低,在你当前的业务量级下完全不会产生可感知的性能影响,刻意避开async反而会大幅提升代码复杂度、提高出错概率。
要实现真正的严格顺序执行,核心前提是不能让任务在被调度前就提前启动,这也是GetTasks()实现的核心要求。
GetTasks()的正确实现
GetTasks()不应该直接返回已经实例化的Task集合——Task创建后默认就是已启动的热任务,只要提前创建就会自动并发运行,后续再按顺序await只是按顺序等待结果,无法控制执行顺序。
推荐将GetTasks()改为返回任务工厂委托集合,只有调用委托时才会创建并启动对应任务,从根源上避免任务提前启动:
// 推荐签名:返回延迟执行的任务工厂,而非已启动的Task private IEnumerable<Func<CancellationToken, Task>> GetTaskFactories() { // 每个元素是未执行的异步委托,调用时才会启动对应任务 yield return async (ct) => await DoStep1WorkAsync(ct); yield return async (ct) => await DoStep2WorkAsync(ct); yield return async (ct) => await DoStep3WorkAsync(ct); // 其余任务按顺序返回 }
如果因为历史兼容原因无法修改返回类型,必须返回IEnumerable<Task>,则GetTasks()必须使用yield return做延迟生成,绝对不能提前用ToList()/ToArray()等方法把所有任务实例化:
// 兼容旧逻辑的GetTasks实现 private IEnumerable<Task> GetTasks(CancellationToken token) { // 遍历到对应元素时才会创建启动任务,禁止提前集中实例化 yield return DoStep1WorkAsync(token); yield return DoStep2WorkAsync(token); yield return DoStep3WorkAsync(token); }
顺序执行逻辑实现
基于任务工厂的实现最为简洁安全,不需要任何多余的Task.Run包装:
public async Task Bar(CancellationToken token) { var taskFactories = GetTaskFactories(); foreach (var factory in taskFactories) { // 上一个任务await完成后,才会调用下一个工厂启动新任务,实现严格顺序执行 await factory(token).WaitAsync(token); } }
如果使用兼容版IEnumerable<Task>返回值的GetTasks,顺序实现如下:
public async Task Bar(CancellationToken token) { var tasks = GetTasks(token); foreach (var task in tasks) { await task.WaitAsync(token); } }
对应调整后的并发执行实现也很简单,只需要先把所有工厂调用一遍启动所有任务,再统一等待即可:
public async Task Foo(CancellationToken token) { var taskFactories = GetTaskFactories(); var runningTasks = taskFactories.Select(f => f(token)).ToList(); await Task.WhenAll(runningTasks); }
注意:ASP.NET Core没有传统ASP.NET的同步上下文,直接await不会产生死锁问题,完全不需要用
Task.Run包装执行逻辑。
内容的提问来源于stack exchange,提问作者lonix

