Parallel.ForEach 默认如何分区?
默认情况下,
Parallel.ForEach对
IEnumerable<t></t>使用的是「动态分区(dynamic partitioning)」策略:不是一次性把整个集合切分成固定几块,而是由线程在运行时按需从源中“拉取”小批量元素(比如 8–64 个),以减少争用和空闲等待。这种策略对大多数顺序可枚举场景够用,但对索引敏感、需局部缓存或 I/O 密集型操作,容易导致负载不均或重复开销。
什么时候必须显式传入 Partitioner.Create?
以下情况建议绕过默认行为,用
Partitioner.Create显式控制分区逻辑: 源是数组或
IList<t></t>,且每个分区需保持局部连续性(例如图像分块处理、矩阵行批处理) 需要固定大小的块(如每次处理 1000 条记录,避免某线程只拿到 3 条) 底层数据源本身支持高效范围访问(如数据库游标、内存映射文件),但
IEnumerable包装后丢失了随机访问能力 想禁用动态分区带来的内部锁和协调开销(尤其在超低延迟场景)
典型写法是:
Partitioner.Create(source, true)—— 第二个参数
true表示启用静态分区(对数组 / 列表自动按索引切分),比默认动态方式更可预测。
Partitioner.Create 的三个重载怎么选?
关键看数据源类型和是否需要自定义逻辑:
Partitioner.Create(TSource[] source, bool loadBalance):最常用。数组 +
loadBalance: false→ 每个线程分到连续大块;
true→ 类似默认动态,但基于索引调度
Partitioner.Create(IEnumerable<tsource> source)</tsource>:仅当源本身已实现高效枚举(如自定义
IEnumerator支持
Reset或分段)才考虑,否则可能引发重复枚举或线程不安全
Partitioner.Create(int fromInclusive, int toExclusive, int rangeSize):纯索引区间分区,适合配合外部数据结构(如
Span<byte></byte>或数组下标计算),不依赖具体集合实例
错误用法示例:对非数组的
List<t></t>直接传
Partitioner.Create(list, true)—— 虽然能编译,但
true参数在此无效,仍走动态路径;应先转成数组或用第三个重载。
分区器 + Parallel.ForEach 的实际性能陷阱
显式分区不等于性能提升,反而可能引入新问题:
分区粒度太粗(如 10 万条一个块):线程数少于 CPU 核心时严重浪费资源;某块耗时远超其他块时整体被拖慢 分区粒度太细(如每块 1 条):抵消并行收益,线程调度和锁开销反超计算收益 误用Partitioner.Create(source, false)处理非数组源:触发
NotSupportedException,因为只有数组和某些
IList实现支持静态索引分区 在分区器内部做重量级初始化(如打开文件、建连接):每个分区执行一次,而非每个线程一次
var data = Enumerable.Range(0, 100000).ToArray();
// ✅ 推荐:固定块大小,每块 1000 项,静态切分
var partitioner = Partitioner.Create(0, data.Length, 1000);
Parallel.ForEach(partitioner, range => {
for (int i = range.Item1; i < range.Item2; i++) {
Process(data[i]);
}
});
真正难的是平衡「局部性」「负载均衡」「初始化成本」三者——多数项目卡在这一步,不是不会写,而是没测过不同
rangeSize下的吞吐和 GC 行为。
