深入理解 Parallel.ForEachAsync:C#.NET 并行调度模型揭秘

简介

csharp 复制代码
┌──────────────┐
│ 数据源枚举器 │  IEnumerable / IAsyncEnumerable
└──────┬───────┘
       ↓
┌────────────────────┐
│ 并发调度器(Pump) │  ← 控制最多 N 个任务
└──────┬─────────────┘
       ↓
┌────────────────────┐
│ async Body(item)   │  ← 异步逻辑
└──────┬─────────────┘
       ↓
┌────────────────────┐
│ 完成 / 异常 / 取消 │
└────────────────────┘
  • 不是一次性启动所有任务

  • 是一个 "边消费、边执行、边补位" 的模型

核心设计目标

在异步场景下,维持固定并发度,持续消耗数据源,直到完成

痛点 ForEachAsync 的解法
Task.WhenAll 不限流 MaxDegreeOfParallelism
SemaphoreSlim 模板繁琐 内建
async foreach 调度复杂 自动处理

调度模型核心:滑动窗口(Sliding Window)

并发度不是"一次性分配"

假设:

csharp 复制代码
MaxDegreeOfParallelism = 3
items = [A, B, C, D, E, F]

执行顺序是这样的:

css 复制代码
启动 A  B  C   (占满 3 个槽位)
      │  │  │
      │  │  └─ C 完成 → 启动 D
      │  └──── B 完成 → 启动 E
      └─────── A 完成 → 启动 F

这就是滑动窗口

任何时刻:

  • 运行中的任务 ≤ MaxDegreeOfParallelism

  • 永远"有空位就补"

内部不是 Parallel.For,而是 Task 泵

关键认知

Parallel.ForEachAsync 并没有复用 Parallel.For 的线程切分模型

原因很简单:

  • Parallel.For → 同步代码 + 线程

  • ForEachAsync → 异步代码 + continuation

内部本质是一个 Task Pump(任务泵)

伪代码级理解(高度简化)

csharp 复制代码
async Task RunAsync()
{
    using var enumerator = source.GetEnumerator();
    var runningTasks = new List<Task>();

    while (true)
    {
        while (runningTasks.Count < maxDegree && enumerator.MoveNext())
        {
            var item = enumerator.Current;
            runningTasks.Add(ProcessAsync(item));
        }

        if (runningTasks.Count == 0)
            break;

        var finished = await Task.WhenAny(runningTasks);
        runningTasks.Remove(finished);
    }
}

真实实现更复杂(异常、取消、ValueTask、ExecutionContext),

为什么它天然适合 async,而 Parallel.For 不行?

对比一下两者的"调度单位"

API 调度单位
Parallel.For 线程 + 同步委托
ForEachAsync Task / ValueTask

async 的关键特性:

  • await 会 释放线程

  • 继续执行靠 Continuation

  • 不绑定固定线程

所以 ForEachAsync:

  • 不关心"用哪个线程"

  • 只关心"同时有多少个未完成任务"

枚举器访问是串行的

数据源的枚举(MoveNext)是串行的

也就是说:

csharp 复制代码
items.GetEnumerator().MoveNext()

只会在 一个调度上下文 中执行,不会并发访问枚举器。

为什么?

  • IEnumerable<T> 默认 不是线程安全的

  • 并发枚举会直接炸

所以 ForEachAsync 的并行点在:

  • Body 执行

  • 不是枚举阶段

异常与取消的调度策略

异常模型
  • 任意一个 Body 抛异常

  • 会:

    • 请求取消

    • 等待已启动任务结束

    • 最终聚合抛出异常

行为类似:

csharp 复制代码
await Task.WhenAll(...)
CancellationToken 不是"硬中断"

Token 被取消后:

  • 不再启动新任务

  • 已启动任务 需要自己响应 ct

csharp 复制代码
await Parallel.ForEachAsync(items, async (item, ct) =>
{
    ct.ThrowIfCancellationRequested();
    await DoAsync(item, ct);
});

为什么返回 ValueTask 而不是 Task?

原因只有一个:性能

  • Body 很可能:

    • 同步完成

    • 快速失败

  • ValueTask:

    • 避免不必要的 Task 分配

    • 降低 GC 压力

和 SemaphoreSlim 手写模型的本质对比

手写版本

csharp 复制代码
var sem = new SemaphoreSlim(5);

var tasks = items.Select(async item =>
{
    await sem.WaitAsync();
    try
    {
        await ProcessAsync(item);
    }
    finally
    {
        sem.Release();
    }
});

await Task.WhenAll(tasks);

ForEachAsync 内部其实就是:

  • SemaphoreSlim + Task.WhenAny

  • 加上:

    • 枚举安全

    • 异常聚合

    • 取消传播

    • ExecutionContext 管理

什么时候不该用 Parallel.ForEachAsync?

  • 强顺序依赖

  • 需要复杂生产者-消费者关系

  • 需要背压、缓冲区

  • 多阶段流水线

这些场景用:

  • Channel

  • TPL Dataflow

总结

Parallel.ForEachAsync = 一个为 async 设计的、滑动窗口式的并发任务调度器

它不是魔法,也不是线程并行,而是:

  • 控并发

  • 自动补位

  • 资源友好

  • 工程可控

相关推荐
AKULAKK5 分钟前
关于在Ghost版Win7上运行.net 2程序遇到的问题以及解决办法
.net·win7
波力海苔夹心脆6751 小时前
C# 序列化与反序列化详解:System.Text.Json、Newtonsoft.Json、XmlSerializer 用法、特性选项与安全实践
经验分享·后端·c#·json·.net
小羊没烦恼!6 小时前
在Scrum中实施敏捷建模
java·开发语言·windows·算法·c#
Lost of 程序猿7 小时前
建造者模式实战:告别“十参数构造函数“的数据导出任务
后端·设计模式·c#·asp.net
mudtools9 小时前
在.NET现有系统中快速集成飞书任务分配能力
后端·c#·.net
工具派12 小时前
Word文档太大怎么拆分?段落结构与两种拆法的边界实测
开发语言·c#·word
潜龙952713 小时前
需求文档一键变测试用例:手工用例智能生成平台
开发语言·c#
Lost of 程序猿13 小时前
命令模式实战:把“下单后的动作“打包成可执行的任务
后端·设计模式·c#·asp.net·命令模式
淡海水13 小时前
16-01-C#数据结构源码-附录A-术语表
unity·c#·游戏引擎·il2cpp
小羊没烦恼!13 小时前
【译】利用Asp.net MVC处理文件的上传下载
开发语言·缓存·c#·word·powerpoint·.net