06-03-源码-时间分片与调度策略
发布时间:2026/10/23 9:11:11 锦皓数字建站

源码深度时间分片与调度策略篇章06-源码深度-Unity增量式GC |阅读时间约 45 分钟 |前置知识增量标记原理一、引言与前提纠正增量式 GC 的宣传口径通常是把一次长暂停摊到多帧。这句话说起来只有一行工程上却对应一整套调度系统每帧给 GC 多少时间、一轮标记怎么被切成若干小步、切不完时怎么办、应用把堆改得太快时怎么兜底。本章就在源码层面把这套系统拆开。拆之前必须先纠正一个流传很广的错误模型。不少文章把 Unity 的增量 GC 描述成基于 SGen 的 PID 调度器甚至画出带IncrementalMarkContext、反馈控制器结构体的伪代码看起来精密实际上并不存在SGen 从来没有随 Unity 产品发布过Unity 的增量 GC 就是 Boehm-Demers-Weiser 收集器的 incremental modeMono 与 IL2CPP 两个后端皆是如此第 03-04 章已经划清了这条界线。如果你在别处看到GCScheduler、target_budget_ms这类Unity 源码可以默认那是作者的想象。这个虚构模型的危害不只是名字不对。PID 控制器的核心假设是存在一个可以连续调节的控制量预算毫秒数系统根据输出误差上一片实际耗时与目标的偏差反向修正它。按这个假设做调优你会去改预算系数、去查平滑因子而这些参数在 Unity 里根本不存在。真实的系统连控制量都不是同一类东西它不调节每帧给几毫秒的连续变量而是维护一本工作量账本账本欠多少就干多少时间上限只是防止干过头的保险丝。把两类模型混为一谈调优方向从一开始就偏了。那么真实的调度是什么样它由两层协作构成A 层bdwgc 核心的切片引擎。GC_collect_a_little_inner(n)是推进增量标记的唯一入口。它不按毫秒硬切而是按工作亏空work deficit循环推进用时间上限做兜底。这一层在开源的 Boehm 源码里可以逐行验证。B 层引擎与托管侧的泵。玩家循环每帧调用一次CollectALittle()IL2CPP 落到GC_collect_a_littleMono 经由 boehm-gc.c 的绑定把 A 层的切片引擎泵起来C# 侧的GarbageCollector.incrementalTimeSliceNanoseconds与System.GC.incrementalTimeSliceNanoseconds则是配置这个切片时间上限的旋钮。两层的分工可以这样概括A 层回答这一小步该干多少活、干到什么时候撒手B 层回答什么时候开始干、每次允许干多久。A 层是开源库的通用机制脱离 Unity 也成立B 层是 Unity 的集成方式它决定了这套机制在游戏帧循环里的具体节奏。真正自适应的东西确实存在但形态和 PID 控制器无关GC_rate决定每一步推进要记多少工作量GC_deficit做亏空记账GC_n_attempts一族计数器统计切片超时的重试次数。这是一套闭环控制只是它的执行器是亏空账本而不是预算计算器。反馈量不是上一片耗时而是已经欠了多少工作、连续超时了几次控制手段不是加减预算而是多跑几步或直接收摊。本文接下来九节就沿着这两层协作把机制讲透每一条结论都以 Boehm 源码与 Unity 托管绑定里能查到的符号为准。二、为什么标记必须切片而清扫不必分片不是均匀的。Boehm 的一轮收集里只有标记被切成跨帧的小步初始标记、最终标记和清扫都不切。原因回到两类工作各自的性质。标记的工作量与堆大小 × 存活对象图规模成正比。一轮完整标记要遍历从根出发能到达的整个对象图堆涨到上百 MB 时这一步就是几十毫秒的量级示例量级实际取决于堆布局与存活率。更麻烦的是它有状态标记栈、灰色对象队列、每个块的标记位这些都必须跨帧保存任意一帧中断后下一帧能从断点精确续上。03-04 章把这称为标记状态跨帧保留本章要补的是这份状态存在 GC 管理的持久区域里而推进它的函数就是GC_collect_a_little_inner。清扫则完全是另一回事。Boehm 不移动对象对象没有被搬走地址不变所以清扫不需要修补任何引用也不需要和应用线程协商。它要做的只是走一遍堆块把本轮没有标记的块挂回空闲链表。这活儿不改对象图、没有跨帧一致性问题、速度也远快于标记为它引入跨帧状态纯属亏本买卖。所以一轮增量周期的形态永远是[短暂 STW 初始标记] [帧 k 切片] [帧 k1 切片] ... [帧 kn 切片] [短暂 STW 最终标记] [一次性清扫] 根扫描 └──────── 标记被切片跨帧推进 ────────┘ 脏区补扫 不切片顺带说明本章的分工GC_mark_some内部如何遍历对象图、如何处理脏页属于 06-02 章的范围本文只把它当黑盒调用本文关心的是它外面那一圈谁调用它、按什么节奏调、超时了怎么办。把切标记、不切清扫这个不对称记住还有个实际用途读 Profiler 时你能据此判断一根长条是什么。分散在多帧的小段是增量标记在推进帧尾那根来自最终标记与清扫的短条是收尾动作而一根突兀的长条要么是非增量模式的全量收集要么是第六节说的回退。同样是GC.Collect形态不同对应的病根完全不同开错药方比不开药更糟。清扫保持一次性还意味着一件事增量 GC 对峰值暂停的改善是有上限的堆里存活块多到清扫本身都很慢时那部分暂停不会因为开了增量而消失只是相对标记来说它通常不是大头。三、bdwgc 的切片引擎GC_collect_a_little_inner现在进入 A 层的核心。Boehm 源码alloc.c里的GC_collect_a_little_inner(int n)是增量标记的推进函数整个调度策略浓缩在它十几行的亏空循环里。先给出示意代码再逐段解释// bdwgc alloc.c示意摘取主干逻辑 void GC_collect_a_little_inner(int n) { int i; for (i 0; i n; i) { if (GC_deficit 0) // 亏空已补平本轮切片完成 return; if (!GC_mark_some((ptr_t)0)) // 推进一步标记 break; // 标记已收尾 GC_deficit - GC_rate; // 完成一步冲销一份亏空 } // 亏空还没补平、但时间用尽按重试策略处理 if (GC_n_attempts GC_max_prior_attempts GC_time_limit ! GC_TIME_UNLIMITED) { GC_n_attempts; (void)GC_stopped_mark(GC_timeout_stop_func); // 放弃本切片等下帧再续 } else { (void)GC_stopped_mark(GC_never_stop_func); // 不再等了一次做完 GC_finish_collection(); } }三个变量撑起了整套机制。GC_rate每一步该记多少工作量。GC_rate是一个工作量单价每成功执行一步GC_mark_some就从亏空里冲销一份GC_rate。它在历史上与分配速率挂钩分配越多亏空积累越快同样的GC_rate下就要多跑几步才能补平标记自动提速去追分配。这就是开篇说的闭环控制系统不预测未来只对已经发生的分配记账然后让标记进度去消账。GC_deficit工作亏空账本。分配路径持续往这个账本里记债切片引擎持续还债。用亏空而不是毫秒做推进判据有一个关键好处工作量驱动保证进度单调。同一个对象图同样跑一步GC_mark_some完成的物理工作量是确定的亏空只会单调下降标记必然朝收敛方向走。如果反过来按毫秒硬切每帧固定跑 3ms那么机器快时切片过早结束、浪费帧预算机器慢时一步GC_mark_some都跑不完还会超帧进度既不可预测也不保证单调。时间预算兜底。毫秒预算并没有被丢掉它换了个位置GC_mark_some的推进过程中挂着一个停止判定timeout stop func不断检查是否已经超过GC_time_limit。超了就中断本步控制权回到GC_collect_a_little_inner的尾部由重试策略决定是放弃本切片、下帧再续还是强制做完。所以准确的说法是工作量决定推进多少时间上限决定何时撒手。两者职责分明谁也不越位。对比一下两种切法在真实机器上的表现这套分工的用意会更清楚。假设同一帧预算里给 GC 的额度是 2ms。按工作量驱动的现行设计切片先定好要消多少亏空跑到时间线了就停在自洽边界这一帧最多吃掉 2ms剩下的账下帧再还帧时间的上界被预算锁死。若改成按毫秒硬切比如每片恰好跑满 2ms问题出在步与步之间没有刚好 2ms 的落点机器快时一步GC_mark_some只花 0.5ms四步跑完还有 1.5ms 空转这帧的额度没用满标记进度白白落后机器慢时一步要 3ms超过预算却无法从半步里退出来帧时间被击穿。工作量驱动没有这两个极端时间只在步间检查粒度与步长天然匹配。n参数则决定这一次调用最多愿意跑几步循环上限是n步每步冲销GC_rate。引擎侧每帧的调用传入的就是这个步数因子实际跑几步取决于亏空有多大、时间是否先到。换句话说n是步数上限而不是时间预算真正限制单帧占用时长的始终是那根挂在GC_mark_some上的时间判定。再看GC_mark_some((ptr_t)0)这个调用本身。参数传空指针表示继续当前这轮标记函数内部沿着标记栈与灰色队列向前推进一段返回时会告诉你这轮标记是否已经收尾。循环里对返回值的处理很朴素标记收尾了就break没收尾就继续消账。值得注意的是这个函数就是 06-02 章深挖的那个遍历引擎本文不重复它的内部细节只强调一点接口语义它是可重入的任何一次调用返回后堆的标记状态都处于一个合法的中间态下一帧的下一次调用从这里接着走。这正是跨帧状态在函数签名层面的体现。时间兜底的位置也值得多说一句。它不是在循环外面掐表而是在GC_mark_some推进过程中反复检查。这样设计的好处是撒手的粒度足够细时间到的瞬间正在处理的对象不会被拦腰截断标记总能停在一个自洽的边界上。如果改成跑完整个GC_mark_some再看表一步遍历本身可能就是好几毫秒时间预算会被大幅击穿兜底就失去了意义。四、时间预算 API 链路亏空循环里的GC_time_limit从哪来这一节从 bdwgc 的全局变量一路追到 C# 属性把完整链路画出来。bdwgc 侧include/gc.h提供一族接口GC_time_limit全局时间上限变量GC_set_time_limit(ms)按毫秒设置GC_set_time_limit_ns(ns)按纳秒设置托管层用的就是它GC_TIME_UNLIMITED哨兵值表示不限时配合重试计数器决定是否允许强制完成。IL2CPP 侧的BoehmGC.cpp在初始化时就接上了这条线BoehmGC::Initialize在启用写屏障IL2CPP_ENABLE_WRITE_BARRIERS增量模式的前提的构建里调用GC_set_time_limit(IL2CPP_INCREMENTAL_TIME_SLICE)给增量模式垫一个默认时间片运行期SetMaxTimeSliceNs(ns)直接转发到GC_set_time_limit_ns(ns)。Mono 侧走的是boehm-gc.c的绑定层增量的开启靠GC_enable_incremental()推进入口则是mono_gc_start_incremental_collection()转发到GC_start_incremental_collection()。托管层的GarbageCollector.bindings.cs暴露三个成员isIncremental只读当前是否真跑在增量模式、incrementalTimeSliceNanoseconds读写每片纳秒预算、CollectIncremental(ulong nanoseconds 0)手动推进。System.GC.incrementalTimeSliceNanoseconds是同一旋钮的 System 命名空间入口。三者的关系是属性管预算给多大CollectIncremental管现在就推一把isIncremental管确认这套机制到底有没有在跑。整条链路如下C# 业务代码 GarbageCollector.incrementalTimeSliceNanoseconds 2_000_000; // 2ms │ ▼ 引擎托管绑定GarbageCollector.bindings.cs │ IL2CPPinternal calls → il2cpp-apiil2cpp_gc_* │ Mono icall → boehm-gc.c 绑定层 ▼ 运行时桥接层 IL2CPP: BoehmGC::SetMaxTimeSliceNs(ns) Mono: GC_enable_incremental() 一族 │ ▼ bdwgcinclude/gc.h GC_set_time_limit_ns(ns) → 写入全局 GC_time_limit │ ▼ 每帧的 GC_collect_a_little_inner timeout stop func 每步检查 GC_time_limit超时即中止本切片值得注意的是单位的落差托管 API 用纳秒bdwgc 老接口用毫秒桥接层负责换算SetMaxTimeSliceNs对应纳秒版接口正是为了避免毫秒精度不够。这也解释了为什么文档建议用纳秒表达预算毫秒粒度对每帧只给 GC 一两毫秒的场景太粗。五、每帧调度时序B 层的泵在什么时机打下去先说诚实的边界玩家循环内部具体在哪个阶段调用CollectALittle()属于 UnityPlayer 闭源部分公开资料没有给出确切位置。可以确定的是行为层面的事实启用增量后引擎每帧会调用一次切片推进对应il2cpp_gc_collect_a_little或 Mono 等价路径把 A 层的亏空循环跑起来而这一帧的 GC 时间从哪挤出来帧尾空闲、Update 之前的调度缝隙是引擎基于帧节奏做的安排外部只能推断。多帧推进的时序可以画成这样帧 k触发轮[初始标记 STW][玩家逻辑 Update][渲染提交] 切片 1 帧 k1 [切片 2][玩家逻辑 Update][渲染提交] 帧 k2 [切片 3][玩家逻辑 Update][渲染提交] ... 帧 kn [最终标记 STW][清扫][玩家逻辑 Update][渲染提交] ↑ 一轮标记结束切片停止切片在帧内的具体位置为推断示意可观测的事实是每帧至多一次切片调用以及 Profiler Timeline 上分散的小段 GC 开销。可观察面有三个。第一Profiler 的 CPU Timeline 上GC.Collect相关的段从非增量模式的一根长条变成分散在多帧的小段这直接验证每帧一次切片在工作。第二GC Allocated In Frame计数器的形态不变因为增量只重排暂停、不改变分配量。第三把incrementalTimeSliceNanoseconds临时调小一轮收集跨的帧数会明显变多这是从外部反推切片机制还在运转的最直接实验。关于帧尾空闲再补一句衔接 03-04 的话VSync 或Application.targetFrameRate至少要配一个帧与帧之间才有等待窗口可供切片安放。两者都不设、帧率跑满时每帧被逻辑和渲染塞满切片只能和主线程抢时间一轮标记拖长更容易滑向第六节的回退路径。还可以从调用次数的角度把两层的节奏对齐。B 层的泵是每帧一发A 层的引擎是每发最多n步。两层相乘决定了标记的名义推进速率而真正把它拉回现实的是两个衰减因素其一是时间预算步子跑到一半就被 timeout stop func 掐断名义步数跑不满其二是亏空账本的余额账本空了引擎提前收工这一帧本可以多干的活也没了干头。所以每帧推进多少从来不是一个配置项它是预算、账本余额、机器速度三者当场结算出来的结果。这也解释了为什么同样的项目在不同机型上一轮增量跨越的帧数会差出一截高端机每片消化的亏空多轮次自然更短低端机每片能干的活少同一本账要还更久掉进回退的风险也更高。把这层记在心里第九节调优时按机型分开验证就不显得多余了。顺带说明 Editor 与 Player 的差别编辑器自身的重绘、菜单、多窗口都会改变帧节奏切片落在帧内的位置和长度跟真机分布不同。本章所有关于时序的推断验收时都必须以 Development Build 连真机 Profiler 的数据为准这一点与 03-04 第 8.3 节的结论一致。六、超时、重试与回退切片引擎最微妙的分支在尾部那段重试逻辑。回顾第三节的示意当亏空还没补平、时间却已用尽时走哪条路取决于GC_n_attempts与GC_max_prior_attempts的比较。先解释为什么会出现切片总是超时。增量标记期间应用线程照常改写引用Boehm 用脏位VDB 或写屏障见 03-04 第 4.3 节记录被改写的区域最终标记阶段要补扫这些区域。如果应用改引用的速度太快本轮窗口内积累的脏区越来越大补扫工作随切片推进不降反升每片的物理工作量膨胀固定的纳秒预算就永远不够用。表现在账本上GC_deficit迟迟补不平每片都以超时告终GC_n_attempts一路累加。超过GC_max_prior_attempts之后引擎放弃等待走GC_stopped_mark(GC_never_stop_func)加GC_finish_collection()在一个 STW 里把整轮标记与后续工作强行做完。这就是官方文档说的 falls back to a full, non-incremental collection回退为一次全量非增量收集的源码落点。整理成状态流切片执行 → 时间到但亏空未平 │ ▼ GC_n_attempts max_prior_attempts ── 是 ──→ 放弃本切片下帧续切增量继续 │ 否 ▼ GC_stopped_mark(GC_never_stop_func) GC_finish_collection() ← 回退一个 STW 里做完等价非增量回退是安全兜底不会漏标也不会崩但经济账最难看前面若干帧已经付了切片开销和写屏障开销最后还要挨一记接近非增量水平的长暂停这一轮的增量收益归零。识别信号在 Profiler 上很典型大部分帧的 GC 都是毫秒级小段却周期性冒出一根长条通常就是回退在发生。这里可以顺手回答一个常见疑问既然回退这么亏为什么不把重试上限设得很大无限期地等下去答案藏在边跑程序边标记这个前提里。应用不会为了等 GC 而停下来改引用的速率也不会因为 GC 还没收敛就自动降下来。如果放任重试无限累积最坏情况是一轮标记拖过几十帧甚至上百帧这段时间里每帧都付着切片开销与写屏障开销堆还在持续膨胀浮动垃圾越积越多而收敛依然遥遥无期。重试上限的作用就是给这种越陷越深的状态画一条止损线宁可立刻挨一记大暂停也不允许收集器进入无界等待。换句话说回退不是调度失误而是设计者刻意保留的出口GC_never_stop_func这个名字本身就说明了它的语义这一次不再让时间打断我。治理方向不在调度器而在堆降低分配速率、减少大对象图的逐帧重建脏区生成速度降下来切片自然能收敛。另有一个相关的开启行为值得知道GC_enable_incremental()在堆上已有分配时会先强制做一次全量收集把增量周期从干净的堆开始这属于一次性的启动成本只在特性启用的那一刻发生不会在运行期反复出现。七、分配路径的切片泵一个容易被忽略的事实切片不只在帧回调里被泵起来。alloc.c的分配慢路径约 1564 至 1584 行区间在时间限制下同样会推进切片。含义是分配本身会触发标记推进。堆上每分配一批对象亏空账本就记一笔债慢路径顺手还一点标记进度和堆增长被耦合在同一处代码里。这个设计有两面。好的一面是标记进度天然追着分配速率走分配越猛泵打得越勤标记不会落后太远这让高分配负载下标记迟迟无法收敛比想象中稍难发生。坏的一面也来自同一条耦合分配增长的速度如果持续快过标记消债的速度亏空账本越积越厚一轮标记的总工作量随堆膨胀而膨胀切片数被拉长撞上时间预算与重试上限的概率同步上升最终仍可能落入第六节的回退。官方文档说 too many changes might overwhelm引用变动太多可能压垮增量在源码层面的机制正是分配记账追不上、脏区补扫膨胀这两条线的合流。对性能分析的推论看到一轮增量标记拖了很多帧才完成时先查的不是调度参数而是这一窗口内的分配速率与引用改写密度。切片引擎只会按既定规则消账它救不了一个每帧重建整棵对象图的项目。还可以把这条耦合讲得更具体些。分配慢路径推进切片意味着两件事同时为真。第一一个分配大户应用其实在帮 GC 干活每次触发慢路径都在给标记还债标记进度被分配推着走看起来像是一种良性互动。第二这种互动有上限分配带来亏空的同时也带来新的堆块与新的存活候选债越记越厚、单笔债务越来越大慢路径里那点顺手归还的量就逐渐变得杯水车薪。当记债速度稳定压过还债速度亏空账本不再围绕零轴波动而是单调上扬一轮标记的工作总量就被拖着不断变大切片数量跟着膨胀时间预算与重试上限被逐一触碰。用一句更工程化的话说切片引擎是一个以分配速率为扰动输入的负反馈系统扰动在系统承受范围内时它自会收敛扰动持续超过消化能力时它唯一的出路就是第六节那条回退支线。官方文档警告引用变动太多会压垮增量落在这一节的机制上就是扰动超出系统带宽的另一种说法。八、手动干预 API自动泵之外托管层留了两个手动入口。GarbageCollector.CollectIncremental(ulong nanoseconds 0)直接推进一次增量收集返回值指示这轮是否已经完成。nanoseconds参数允许为这一次调用单独指定预算传 0 则沿用全局incrementalTimeSliceNanoseconds。Manual模式下引擎不再自动触发每帧调用一次CollectIncremental()就相当于自己接管了 B 层的泵。System.GC.Collect()则是另一条路无视当前模式立刻做一次全量阻塞收集等于把整轮工作一次性付清。它和CollectIncremental的关系不是竞争而是分工。典型的协作剧本是场景加载点// 加载期协作示意先付清再恢复自动增量 void OnSceneLoadStart() { // 承受一次可预测的暂停把堆清到干净状态 System.GC.Collect(); } void OnSceneLoadDone() { // 恢复自动增量如需微调可在加载期临时放大预算 GarbageCollector.GCMode GarbageCollector.Mode.Incremental; GarbageCollector.incrementalTimeSliceNanoseconds savedSlice; }选择加载点做全量收集的理由很实际加载期间帧率本就不敏感一次可预测的长暂停远好过战斗帧里突如其来的回退。反过来说CollectIncremental的适用场景是我想精确控制这一轮何时开始、每片多大的框架层需求比如回合制或过场动画这类能预知空闲窗口的节奏点。两个入口都只是把预算喂给第三节那台亏空循环机器机器本身没有变。手动干预还有一个容易踩的坑值得点出CollectIncremental只推进、不保证完成。它返回一个布尔值告诉你这一轮是不是已经收尾返回 false 意味着标记还跨在半空你得在后续帧继续喂直到它返回 true。指望一次调用换回一次干净堆是不成立的真要现在就把堆清干净那该用的是GC.Collect。两个 API 的分工可以记成一句话CollectIncremental是踩离合控制节奏GC.Collect是直接挂挡到底一次付清。混用这两者写出来的加载流程常见症状就是加载结束时标记还没跑完进战斗后第一波分配立刻把残余的增量周期顶进回退。九、调优实践incrementalTimeSliceNanoseconds 取多少最后一节是工程旋钮。incrementalTimeSliceNanoseconds的取值是一场双向权衡取太小每片推进少一轮标记跨的帧数变多标记周期拉长增量窗口越长边扫边生的浮动垃圾窗口越长脏区积累时间也越长更容易顶到重试上限触发回退。取太大单片逼近甚至超过一帧预算增量退化成分几次做的非增量峰值暂停重新变大失去开增量的意义。一个常用的经验区间是把单片预算定在帧预算的 5% 到 15%。注意这是工程经验估算不是官方常数Unity 没有公布任何推荐毫秒数。按这个区间换算到常见目标帧率目标帧率帧预算5% 档典型估算15% 档典型估算常用落点典型估算30 FPS33.3ms1.7ms5.0ms2 ~ 4ms60 FPS16.7ms0.8ms2.5ms1 ~ 2ms90 FPS11.1ms0.6ms1.7ms0.8 ~ 1.5ms120 FPS8.3ms0.4ms1.2ms0.6 ~ 1ms表中全部为典型估算用于给出数量级直觉真实最优值取决于堆规模、分配速率与目标机型必须用 Development Build 连真机 Profiler 校准。调参的实际顺序建议是先按目标帧率选中间档落点然后在最重的战斗场景跑 A/B。如果 Timeline 上切片段稳定、长条罕见说明预算合适如果周期性出现长条回退优先查分配速率而不是先调大预算因为预算调大只是把回退推迟堆增长问题不会因此消失如果帧时间本身被切片顶破说明项目帧预算太紧先把帧内余量做出来再谈分片。另有一个反直觉的点低端机上机器慢同样的纳秒预算能完成的工作量少等效每片标记的对象数下降这天然是一种按机型的自适应不需要额外做分档配置除非低端机上回退频率明显升高那时再为低端档单独调小预算并同时压分配。十、本章小结把全章收拢成几句话。Unity 增量 GC 没有 PID 调度器有的是两层协作bdwgc 的GC_collect_a_little_inner按工作亏空循环推进标记GC_rate、GC_deficit、GC_n_attempts构成闭环记账引擎侧每帧一次CollectALittle()把这台机器泵起来托管层的incrementalTimeSliceNanoseconds与CollectIncremental提供配置与手动干预入口。工作量驱动保证进度单调纳秒预算通过 timeout stop func 兜底重试上限耗尽后回退为一次全量 STW。标记切片、清扫不切片是因为前者有跨帧状态而后者没有分配路径自己也泵切片所以堆增长和标记进度天然耦合高分配速率是回退的第一诱因。调优围绕帧预算的 5% 到 15% 起步最终以真机 Profiler 的分布尾部数据定案。本章总结真实机制可以用一条链路概括C# 的incrementalTimeSliceNanoseconds经绑定层落到 bdwgc 的GC_set_time_limit_ns写入GC_time_limit每帧引擎调用一次GC_collect_a_little_inner亏空循环调用GC_mark_some推进标记并按GC_rate冲销GC_deficit时间到则由 timeout stop func 中止切片反复超时超过重试上限时GC_stopped_mark(GC_never_stop_func)强制在一个 STW 里完成整轮这就是官方文档所述的回退为全量非增量收集。切片只作用于标记清扫因不移动对象而保持一次性。调优的杠杆是预算与分配速率两头预算按帧预算的 5% 到 15% 典型估算起步分配速率则是回退与否的决定因素任何调度参数都替代不了把堆增长压下来这件事。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。