返回资讯中心
外部精选
管理/文化
#开发者文化#职业成长#cc-by-sa#contributed#building-software#net

.NET 并行循环在单 CPU 容器中会退化为串行处理

来源标题:Your .NET parallel loop can become serial in a one-CPU container

在单 CPU 容器中,.NET 的 Parallel.ForEachAsync 默认按 CPU 数量限制并发,导致 I/O 密集型循环退化为单条处理。实测处理 200 条记录的中位耗时从双 CPU 的 42.1 秒升至单 CPU 的 83.6 秒。提高并发上限有帮助,但每个下游服务仍需设定可承受的并发预算。

Stack Overflow BlogGuilherme Appel2026/11/4 · 今天28 分钟阅读

以下正文由 AI 翻译自 Stack Overflow Blog(原文版权归原站所有),可能存在偏差,重要内容请以原文为准。

2026年11月4日你的 .NET 并行循环在单 CPU 容器里可能变成串行

基于 CPU 的默认值可能让一个 I/O 密集的循环一次只处理一条记录。提高上限会有帮助,但每个下游服务仍然需要一个它能承受的并发预算。

在我的实验环境里,把容器的 CPU 限制从两个 CPU 改成一个,处理 200 条记录所需的时间几乎翻倍:从 42.1 秒变成 83.6 秒。这些是每种配置各运行五次所得的中位数。双 CPU 版本一次处理两条记录;单 CPU 版本一次处理一条。两者都写入了全部 200 条记录。

我认为这类变慢很容易被忽略:批次仍然会完成,每条记录也都会被写入。如果没有一个关于它应该花多长时间的基线,多出来的等待看起来就像普通的服务延迟。

每条记录需要三次 enrichment 调用,然后向记录系统写入一次。我用了这个常见的模式: await Parallel.ForEachAsync(records, cancellationToken, async (record, ct) => await ProcessAsync(record, ct); }); 为什么默认值会变成一

不带 ParallelOptions 的 Parallel.ForEachAsync 最多允许 Environment.ProcessorCount 个并发操作。传入 new ParallelOptions { CancellationToken = ct } 会让 MaxDegreeOfParallelism 保持其初始值 -1。对 ForEachAsync 来说,这意味着在使用默认调度器时仍然是 ProcessorCount。显式设置 -1 并不会移除这个限制。

只带取消令牌的选项是我最容易忽略的。一个显式的 options 对象看起来让人放心,但在这里它仍然把并发交给基于 CPU 的默认值。

在 Linux 和 macOS 上,以及从 .NET 6 起的 Windows 上,ProcessorCount 会返回机器逻辑处理器数量、进程的 CPU 亲和性数量,以及向上取整为整数的 CPU 利用率限制这三者中最小的适用值。因此一个 CPU 的限制会得到 1;半个 CPU 的限制也会得到 1。这个值在运行时启动时固定下来,并在进程的整个生命周期内保持不变。

Go 1.25 在选择默认的 GOMAXPROCS 时使用的是 cgroup 的 CPU 限制,而不是 CPU 请求;JVM 在受支持的 Linux 平台上已默认启用容器检测。这并不意味着 Go 或 Java 会把 I/O 循环串行化。问题在于把由 CPU 推导出的值变成 I/O 并发限制的代码。

一条记录在其生命周期的大部分时间里可能都在等待其他服务。在异步等待期间,它并不需要独占一个 CPU。你需要多少条记录同时在途,取决于你想多快地处理它们,以及每条记录要等待多久。利特尔法则把这些平均值联系起来:在途项目数等于吞吐量乘以每项所需时间。

举例说明,假设三次 enrichment 调用总共耗时 360 毫秒,写入再耗时 30 毫秒。按每秒 100 条记录计算,0.39 秒的记录生命周期大约需要 39 条记录在途。这是一个说明性的计算,不是实验测量。单 CPU 的默认值只允许一条。

设置更大的 MaxDegreeOfParallelism 可以让更多记录在其他记录等待时取得进展。但它也允许对每个依赖项发出更多调用。一个 enrichment API 可能容忍几十个并发请求,而记录系统——通常是链条中较旧的组件——只能处理几个。

单一的限制无法表达这种差异。如果写入服务变慢,记录会在其写入阶段停留更久。更多写入会重叠,服务可能开始排队或拒绝请求,即使 enrichment API 仍有容量。

用一个 SemaphoreSlim 包住所有出站调用会限制它们的总并发数。但它仍然让分布不受控制。一个慢的依赖项会持有许可更久,可能占用共享池的大部分。全部调用共三十个许可并不能保证一个容量为六的服务看到的请求不超过六个。

并发和速率也需要分开控制。信号量限制的是在途调用;它不强制执行 API 的每分钟配额,包括 LLM 提供商的配额。利特尔法则有助于估算达到目标平均速率所需的并发数,但强制执行基于时间的配额需要限流器。

我希望 enrichment API 和记录系统有各自独立的限制。这样外层循环就可以允许足够多的记录在途,让两个服务都保持忙碌。这个辅助类为每个依赖项提供自己的信号量: public sealed class DependencyBudgets(int enrichmentLimit, int recordsLimit) private readonly SemaphoreSlim _enrichment = new(enrichmentLimit, enrichmentLimit); private readonly SemaphoreSlim _records = new(recordsLimit, recordsLimit); public Task<T> CallEnrichmentAsync<T>(Func<CancellationToken, Task<T>> call, CancellationToken ct) => GateAsync(_enrichment, call, ct); public Task<T> CallRecordsAsync<T>(Func<CancellationToken, Task<T>> call, CancellationToken ct) => GateAsync(_records, call, ct); private static async Task<T> GateAsync<T>(SemaphoreSlim gate, Func<CancellationToken, Task<T>> call, CancellationToken ct) await gate.WaitAsync(ct); try { return await call(ct); } finally { gate.Release(); } 这是实验环境 src/Lab.Runner/ConcurrencyStrategy.cs 中 PerDependencyStrategy(策略 C4)的精简版本,它也负责运行外层循环。

每个包装器会获取相关的许可,调用传入的 call,并在 finally 中释放许可。让一组预算由它本应约束的工作共享。为每条记录创建一组新预算,会让每条记录都有自己的额度。

根据服务的容量来选择限制。向它们的负责人询问它们能处理多少个同时请求。如果未知,就在测试环境中逐步提高并发,并观察延迟和拒绝情况。在某个特定负载下延迟开始攀升的服务,需要一个尊重该容量的预算。

(原文较长,此处译出前 6000 字,完整内容请点「打开原文」。)

正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。

打开原文
去社区讨论 →