10-03-高级-LINQ源码剖析-下-GroupBy-Join-Aggregate的底层原理 LINQ 源码剖析下GroupBy、Join 与 Aggregate系列C# 与常用数据结构源码剖析 · 高级特性篇阅读时间约 80 分钟源码基线.NET 8.0.0dotnet/runtime的System.LinqGrouping.cs、Join.cs、Aggregate.cs及相关 Iterator 实现版本边界本文只讨论 LINQ to Objects 的Enumerable。Queryable、PLINQ 与第三方 provider 有不同执行模型后续 .NET 的专用优化可能改变内部路径。代码约定代码均明确为业务示例、教学伪代码或结构化节选不冒充逐字源码。一、先区分三种“延迟”LINQ 查询常被笼统称为延迟执行但至少要拆成三件事调用方法时是否遍历源GroupBy(...)、Join(...)通常只返回查询对象调用瞬间不枚举。第一次 MoveNext 前是否必须缓冲GroupBy 在产出第一个分组前要完整读取源Join 在产出第一个结果前要完整建立 inner 的索引。结果是否流式产生Join 建完 inner Lookup 后可逐个读取 outer 并产出结果GroupBy 则已把全部元素放入分组。Aggregate不返回IEnumerable。调用它就立即枚举源并返回一个最终值属于终结操作。把这三者都归为“延迟”或“立即”会掩盖首项延迟、无限序列和内存峰值。本篇固定.NET 8源码解释 Lookup/Grouping而不是用DictionaryTKey,ListT冒充真实结构简化代码仅用于展现语义。二、Lookup 与 Grouping 的实际职责2.1 Lookup 不是一层 Dictionary 包装基线LookupTKey,TElement管理一个 Grouping 桶数组、比较器、分组数量以及连接所有分组的循环链。概念结构如下// 结构化概念不是逐字源码。 sealed class LookupTKey, TElement { IEqualityComparerTKey _comparer; GroupingTKey, TElement?[] _groupings; // 哈希桶头 GroupingTKey, TElement? _lastGrouping; // 插入顺序循环链尾 int _count; } sealed class GroupingTKey, TElement : IGroupingTKey, TElement, IListTElement { TKey _key; int _hashCode; TElement[] _elements; int _count; GroupingTKey, TElement? _hashNext; // 同桶冲突链 GroupingTKey, TElement? _next; // 分组插入顺序链 }字段可见性和辅助接口按 tag 有更多细节。关键点是 Grouping 本身既是某个键的动态元素数组又是哈希桶冲突链节点并参与分组顺序链。它不是额外DictionaryTKey,ListT中的一对对象。2.2 哈希与分组等价类每个源元素经 keySelector 得到 keyLookup 使用传入的IEqualityComparerTKey或默认比较器计算哈希和相等性。比较器认为相等的 key 进入同一 Grouping第一个创建该组的 key 通常成为IGrouping.Key对外值。相等键必须产生相同哈希且键参与相等/哈希的状态在分组建立期间必须稳定。可变引用 key 若在 keySelector 返回后被其他线程改动会破坏 Lookup 查询GroupBy 本身也不赋予源或元素并发安全。.NET 8Lookup 对 null key 有实现支持路径具体由内部哈希/比较逻辑处理。自定义 comparer 必须也能按它声明的契约处理可能输入不要把 Dictionary 的notnull约束或某一数据库 provider 的 null 语义直接套到 Enumerable.GroupBy。2.3 两种顺序在稳定、未并行的 LINQ to Objects 基线中外部分组按各键首次出现的顺序枚举每个组内元素按源中出现顺序保存。因此// 业务示例。 string[] source { b1, a1, b2, c1, a2 }; var groups source.GroupBy(x x[0]); // 组顺序b, a, c // b 组b1, b2a 组a1, a2这是 Enumerable GroupBy 的可观察行为不应误归因于哈希桶物理顺序Lookup 用独立的_next链维护分组创建顺序。换到 PLINQ 或远程 provider 时顺序规则不同除非明确排序。Grouping 的_elements按需扩容。某个热点键包含绝大多数元素时会形成一个很大的数组多个小组则产生许多 Grouping 和小数组。总元素量相同分布不同也会有不同对象数量、扩容复制和局部性。三、GroupBy返回延迟对象首枚举全量缓冲3.1 执行时序// 业务示例。 IEnumerableIGroupingchar, string query source.GroupBy(x x[0]); // 到这里通常没有读取 source。 using IEnumeratorIGroupingchar, string e query.GetEnumerator(); bool hasFirstGroup e.MoveNext(); // 第一次 MoveNext 为产出第一个组需要先把 source 完整读入 Lookup。所以最准确的说法是GroupBy 具有延迟调用语义但在枚举时是全缓冲操作。若源在构建 query 与枚举之间变化枚举看到的是枚举时的源状态再次枚举 query 通常会再次读取源并新建 Lookup而不是自动缓存上次结果。如果需要复用同一分组快照可显式ToLookup。ToLookup是立即执行调用时遍历源并返回可多次查询的 Lookup。它仍只是元素引用/值的浅快照元素对象内部可继续变化。3.2 elementSelector 与 resultSelectorGroupBy(source,keySelector,elementSelector)在缓冲时就对每个源项运行 elementSelector把结果放入组。带 resultSelector 的重载在分组完成后把 key 与组元素枚举交给结果转换。用户委托异常会终止枚举已创建的内部缓冲随后等待回收。resultSelector 若对同一 group 多次枚举通常会多次遍历已缓冲数组但不会重新枚举原 source。若内部又调用ToArray、OrderBy等会产生额外缓冲。3.3 复杂度和无限源对 n 个源项在哈希分布正常、比较器成本合理时构建期望 O(n)额外存储 O(n 分组数)。最坏哈希碰撞可让比较成本显著上升不能只写绝对 O(n)。每个元素至少进入某个分组数组GroupBy 不适合真正无限且不结束的源首个组永远无法产出。若需求是流式处理连续相同键可自己做相邻分段类似 chunk by但它只在输入已按键聚集时等价全局 GroupBy 必须知道后面是否还会出现旧键。无限事件流通常需要窗口、时间桶、容量上限与过期策略而不是裸 GroupBy。四、Join先完整索引 inner再流式扫描 outer4.1 构建哪一侧.NET 8的Enumerable.Join(outer, inner, ...)在枚举时首先从inner构建LookupTKey,TInner随后遍历 outer// 教学伪代码表达执行方向和结果顺序。 LookupTKey, TInner lookup BuildLookupForJoin(inner, innerKeySelector, comparer); foreach (TOuter outerItem in outer) { TKey key outerKeySelector(outerItem); if (lookup.TryGetGrouping(key, out GroupingTKey, TInner matches)) { foreach (TInner innerItem in matches) yield return resultSelector(outerItem, innerItem); } }这意味着参数顺序影响缓冲侧无论哪边更小标准 Join 的 inner 都被全量索引。若两边角色可交换且 resultSelector 能调整把更适合缓冲的一侧放 inner 可能降低内存但必须同时维护期望结果顺序与重复项笛卡尔语义。实现有针对空 inner 的快速结果路径具体 iterator 代码按 tag 阅读。普通语义仍是调用 Join 时不枚举第一次 MoveNext 先消费 inner再按需消费 outer。4.2 null 键边界Join 使用面向连接的 Lookup 创建路径在.NET 8LINQ to Objects 实现中innerKeySelector 产生 null 的项不会建立可匹配分组因此 outer 的 null 键也不会与之生成结果。这个边界与普通ToLookup可存 null 组、以及 SQL provider 的 null 语义都可能不同必须按基线测试不凭 GroupBy 经验推断。4.3 结果顺序和重复项Join 结果首先按 outer 枚举顺序对某个 outer匹配 inner 按它们在 inner 中的出现顺序。若 outer 某键出现 a 次、inner 同键出现 b 次就产生 a×b 个结果。高重复键可能造成输出爆炸即使 Lookup 本身只有 O(inner.Count) 存储。// 业务示例每个玩家与其同 ID 的所有记录匹配。 var result players.Join( records, p p.Id, r r.PlayerId, (p, r) new { p.Name, r.Score });若业务期望 inner 每键唯一应在建索引前验证或使用 Dictionary 并对重复 Add 失败不要让 Join 静默把重复数据扩成多结果。4.4 空间和生命周期Join 的 Lookup 保持所有 inner 元素直到该 Join 枚举器释放或不可达。即使调用.Take(1)也必须先完整读取 inner 才能产出第一个结果。若 inner 很大、outer 很小这个首项成本尤其重要。outer 是无限序列时Join 在有限 inner 建表后可以持续输出inner 是无限序列时永远无法开始 outer。若两边都超出内存需要数据库/外部排序合并/分区哈希连接或流式窗口连接不能期待 Enumerable.Join 自动溢写磁盘。五、GroupJoin每个 outer 获得一个匹配序列GroupJoin同样先为 inner 建 Lookup但对每个 outer 只调用一次 resultSelector并传入该键的匹配IEnumerableTInner无匹配时传入空序列。它返回的是IEnumerableTResult不是固定的IEnumerableIGrouping...。// 业务示例每个部门得到成员序列包括零成员部门。 var departmentsWithMembers departments.GroupJoin( employees, d d.Id, e e.DepartmentId, (department, members) new { department.Name, Count members.Count(), Members members });结果按 outer 顺序每个匹配序列按 inner 顺序。members 指向已建立 Lookup 中的组或空序列不会为每个 outer 重新扫描 inner。多个具有同键的 outer 可能接收同一底层组视图调用者应把它当只读 IEnumerable不依赖内部引用身份。常见“左外连接”查询语法会在 GroupJoin 后SelectMany(group.DefaultIfEmpty(), ...)。这会把无匹配 outer 展开为一条带默认 inner 的结果默认值可能是 null/零值领域上应显式处理不把缺失与合法默认对象混淆。GroupJoin 仍会完整缓冲 inner且 resultSelector 中保存 members 到长期对象会让整个 Lookup/相关元素的生命周期延长。需要物化小快照时可明确 ToArray但会增加复制。六、Aggregate立即、单遍、按严格顺序折叠6.1 无 seed 重载source.Aggregate(func)取第一个元素作为 accumulator再从第二个开始调用 func。空序列没有初始值会抛InvalidOperationException// 教学伪代码。 using var e source.GetEnumerator(); if (!e.MoveNext()) throw new InvalidOperationException(Sequence contains no elements); T acc e.Current; while (e.MoveNext()) acc func(acc, e.Current); return acc;它与数学上需要单位元的操作不同。若空序列有合理结果使用 seed 重载并选择正确单位元例如加法 0、乘法 1不要捕获异常充当正常空分支。6.2 seed 重载Aggregate(seed, func)从 seed 开始对每个源元素按枚举顺序更新累加器。空序列直接返回 seed。TAccumulate 可与 TSource 不同适合构建统计状态// 业务示例值元组作为小型累加器。 var summary values.Aggregate( seed: (sum: 0L, count: 0), func: (acc, value) (acc.sum value, acc.count 1));这段仍可能发生溢出取决于数据与 checked 上下文Aggregate 不自动提供数值稳定性。浮点加法非结合顺序改变会改变舍入结果。6.3 resultSelector 重载第三类重载在完整折叠后调用一次 resultSelector把内部累加状态转换为最终结果double average values.Aggregate( seed: (sum: 0.0, count: 0), func: (acc, value) (acc.sum value, acc.count 1), resultSelector: acc acc.count 0 ? double.NaN : acc.sum / acc.count);resultSelector 只在正常完成折叠后调用。source、func 或 resultSelector 抛异常会传播枚举器由实现按协议释放。6.4 “无中间集合”不等于“无分配”Aggregate 本身不需要像 GroupBy 一样建立 O(n) Lookup但 func 可以每步创建新字符串、List、不可变集合或闭包对象。用Aggregate(, (s,x) sx)拼接大量字符串会反复创建中间字符串常用string.Join、StringBuilder 或专用 API 更合适。若 accumulator 是大值类型每次acc func(acc,item)可能复制它引用类型 accumulator 可原地修改但异常后可能留下部分状态且不再是纯函数。复杂度应包含用户委托。七、比较器、键稳定性和可变元素GroupBy、Join、GroupJoin 均允许IEqualityComparerTKey。契约是相等键哈希相同、Equals 稳定且为等价关系。比较器选择直接定义组与匹配大小写不敏感 comparer 会把A与a归为同组文化 comparer 的语义也要显式记录。keySelector 返回大型结构体会产生哈希、比较和复制成本。返回可变引用对象更危险Lookup 建好后修改参与哈希的状态随后按该对象查组可能失败。最稳妥的是不可变、紧凑键或在 keySelector 中提取稳定 ID。分组元素是浅保存。源中 class 对象进入 Grouping 后对象字段仍可被修改GroupBy 不产生深快照。若要求历史一致性应在 elementSelector 投影成不可变值。用户委托还可能有副作用。多次枚举 GroupBy/Join 查询会重新枚举源并再次调用 selector不能把扣款、发消息、随机 ID 等副作用藏在 selector 中。即便当前只枚举一次调试器、Count/ToList 和日志也可能触发额外枚举。八、内存、GC 与大组/高基数模型GroupBy 的内存不仅是 n 个元素引用还有 Lookup 桶数组、每个不同键的 Grouping 对象、每组动态数组的未使用容量、碰撞/顺序链接。值类型 TElement 内联在组数组中大值会放大扩容复制引用类型保存引用并延长对象生命周期。两种极端分布单个大组Grouping 数少但一个_elements数组持续扩容可能形成大连续分配。每项不同键大量 Grouping 和小数组对象数与哈希开销高。Join/GroupJoin 只缓冲 inner但重复匹配会使下游结果数量远大于输入若紧接ToList输出物化可能成为真正内存峰值。使用流式下游可以避免一次保存全部结果却不能消除 inner Lookup。.NET大对象策略、对象头与阈值依运行时/版本变化不能把 CoreCLR 数字直接套 Unity。用分配追踪、存活堆、键基数和最大组大小测量不编造“int 键固定比 string 快几倍”。缓存ToLookup可避免重复建表但也让所有源元素持续可达。明确缓存失效与生命周期不要为节省 CPU 造成无界内存缓存。九、无限序列和资源型枚举器操作有限 source/inner 要求无限输入结果GroupBy / ToLookupsource 必须结束才能产出完整组永不产出第一个分组Join / GroupJoininner 必须结束outer 可按需继续inner 无限则卡在建表outer 无限可持续输出Aggregatesource 必须结束才返回最终值永不返回除非取消/异常终止IEnumerableT自身没有统一取消参数。无限/慢速源应把 CancellationToken 纳入枚举器实现或 selector 外层协议或改用IAsyncEnumerableT对应操作库。标准 Enumerable GroupBy 也不自动实现时间窗口。文件、数据库游标等资源型源会在查询枚举器生命周期内保持资源。GroupBy 读取完整源后才开始返回组Join 则先读完 inner再逐步持有 outer 枚举器。调用方应及时 Dispose 查询枚举器避免只取部分结果后长期持有资源。十、PLINQ 不是同一个顺序与聚合模型source.AsParallel()转入 PLINQ。它会分区、并行构建/合并状态内存、调度和顺序与 Enumerable 不同。默认 unordered 查询不承诺原始顺序AsOrdered()增加顺序约束和合并成本。并行 Aggregate 通常需要分区 accumulator、分区内更新函数、分区结果合并函数和最终 selector。合并函数应具备适当结合性普通左折叠中依赖严格顺序的减法、字符串拼接或浮点结果平行重组可能产生不同答案。带副作用的 accumulator 更危险。PLINQ 也不是自动解决大 Lookup 内存或哈希攻击。小输入、廉价 selector 和高合并成本时并行调度可能得不偿失。结论必须以目标 CPU、输入规模、键分布和顺序要求测量。不要把本文.NET 8 Enumerable的分组首现顺序、inner Lookup 字段和单线程迭代细节直接作为 PLINQ 契约。十一、Unity 热路径边界Unity 支持的 LINQ 类库与运行时实现随版本、API Compatibility Level、Mono/IL2CPP 后端变化。本文内部字段只能解释桌面.NET 8基线目标 Player 应重新 profile。GroupBy/Join 在每帧热路径会创建 Lookup、Grouping 和数组并保持输入元素到枚举结束。典型替代策略不是“永远禁用 LINQ”而是配置加载/关卡初始化时预建 Dictionary 或 Lookup并明确失效每帧计数只需 DictionaryTKey,int无需保存所有组元素已有稳定 ID 的实体直接维护索引避免每帧 Join短生命周期临时数据使用可复用缓冲时定义清理和容量上限先在 IL2CPP 真机用代表数据测 GC Alloc、CPU 和尾帧而非只测编辑器。缓存 Lookup 的元素若是UnityEngine.Object包装原生对象销毁后包装引用仍在组内并有 Unity 特殊 null 语义。用稳定 ID 和生命周期事件更新索引比周期性 GroupBy 更可控。Aggregate 用值累加器可能不分配 Lookup但 lambda 捕获、接口枚举、字符串构建和 async/协程边界仍可产生分配。检查具体调用形态。十二、典型失败反例认为调用 GroupBy 就立即读取源或反过来认为首组能流式产出。对无限事件流直接 GroupBy等待永远不会出现的第一组。把 Lookup 写成 DictionaryList 并依赖 Dictionary 枚举顺序解释组顺序。认为 GroupJoin 返回固定 IGrouping而忽略 resultSelector。让 Join 的 inner 是巨大/无限源却只准备 Take(1)。假定 Join 自动选择较小一侧建表。忽略重复键的 a×b 输出ToList 后内存爆增。在 selector 中执行副作用多次枚举时重复发生。修改作为 key 的对象状态破坏 Lookup 后续查询。对空序列调用无 seed Aggregate拿异常当正常流程。用字符串 Aggregate 反复连接误称“无中间分配”。把顺序敏感 Aggregate 直接并行化结果因重组改变。在 Unity 每帧 GroupBy/Join却只看平均 FPS 不看分配和尾帧。引用脱离环境的键类型固定倍率或伪基准。十三、差分、执行时序和基准实验13.1 可观察枚举源实现记录GetEnumerator、每次 MoveNext、Current 与 Dispose 的源。分别只创建 GroupBy/Join 查询、调用 GetEnumerator、第一次 MoveNext、Take(1)、完整枚举断言调用时序。对 Join 分别记录 outer/inner证明第一次结果前 inner 完整枚举而 outer 随结果推进。13.2 GroupBy 差分用参考模型按同一 comparer 保存“键首次出现列表 每键元素列表”。随机生成 null若 TKey 允许、重复、高基数和恒定哈希键与 GroupBy 比较组 Key、组顺序、组内顺序和 resultSelector。query 枚举两次验证源也读取两次ToLookup 则创建时读取一次。13.3 Join/GroupJoin 差分用双重循环建立语义参考按 outer 顺序对每个 outer 按 inner 顺序输出所有相等匹配。随机输入重复键、无匹配、空侧和 null key分别与 Join/GroupJoin 比较null 预期固定到.NET 8 Enumerable实测。验证结果数量包含重复项乘积。13.4 Aggregate 属性无 seed空序列应抛单元素不调用 func 并返回该元素有 seed空序列返回 seedresultSelector 正常时只调用一次。用非结合运算记录严格左折叠顺序让 func 在第 k 项抛错确认后续源不再读取且枚举器被释放。13.5 内存分布实验固定 n比较单大组、均匀少数组和每项一组记录总分配、对象数、最大数组、首项延迟、完整吞吐和枚举后存活。Join 固定输入量改变 inner/outer 参数位置与重复率分开记录 Lookup 和结果物化。测试代码、运行时、CPU、比较器、预热和 GC 配置一并发布不给普适倍数。13.6 Unity Player 实验同一数据在编辑器 Mono、目标平台 Mono/IL2CPP实际支持项与 Development/Release Player 测量。Profiler 标记查询构建、首 MoveNext、完整消费和 ToList记录 GC Alloc、主线程时间、帧分位数和存活引用。避免日志和随机数据生成污染测量区。十四、选型与源码阅读清单需要一次遍历按键计数时手写 Dictionary 计数器比 GroupBy 保存全部组更节省需要重复查询多个键时ToLookup 可表达只读一对多索引需要 inner 每键唯一时 Dictionary 更能暴露重复需要两侧超大时考虑让数据库执行、分区或排序合并需要无限流分组时设计窗口和过期只需总和/最值优先专用 Sum/Min/Max复杂状态才使用 Aggregate。阅读.NET 8源码建议按顺序GroupBy重载如何返回 IteratorIterator 的 MoveNext 何时Lookup.CreateLookup 的 GetGrouping、Resize 和 Grouping.Add分组_next与_hashNext的不同用途JoinIterator 如何CreateForJoin(inner)GroupJoin 如何把组传给 resultSelectorAggregate 三类重载如何处理空、seed 与 resultSelector。审查代码则问源何时枚举、会枚举几次哪侧全量缓冲首项延迟是否可接受键 comparer 和 null 语义是什么最大组和重复笛卡尔积有上界吗selector 是否纯且可重放历史对象会不会因 Lookup 缓存长期存活Unity/PLINQ/provider 是否改变执行模型十五、总结把查询写法还原为消费与缓冲.NET 8GroupBy 调用时返回延迟对象但第一次枚举会完整消费 source构建 Lookup。Grouping 同时保存键、动态元素数组、哈希冲突链和分组插入顺序链因此外组按键首次出现顺序、组内按源顺序输出。ToLookup 则在调用时立即完成同类缓冲。Join 和 GroupJoin 固定先缓冲 inner。Join 随 outer 流式产生每对匹配顺序为 outer 后 innerGroupJoin 对每个 outer 把完整匹配序列交给 resultSelector。重复键会放大输出inner 无限则永远无法开始。Aggregate 是立即单遍左折叠无 seed 空序列抛异常有 seed 空序列返回 seedresultSelector 在成功折叠后调用一次。它不建立 Lookup却不能约束用户 func 的分配、复制和副作用。理解 LINQ 性能不需要背虚构数字只需把每个算子还原成何时消费、缓冲哪一侧、保存多少对象、调用几次委托、结果按何种顺序产生。再用真实键分布、组大小和目标运行时验证才能决定查询表达式是否适合当前路径。下一篇async/await 的编译器重写状态机、上下文与资源边界