本文想做的事情很具体把“每请求 scope”的创建和释放落到两处能在源码里指认、在调试器里复现的点上——RequestServicesFeature的 getter以及Response.RegisterForDisposeAsync。很多 ASP.NET Core 开发者都听过 scoped 服务“每请求一个实例”。但你真把它拆成几个可验证的问题scope 是不是请求一进管道就建好CreateScope到底在第几行发生释放又是哪一行触发这时往往就说不清了。结论先摆出来在默认实现下ASP.NET Core 不会在请求开始时强制创建 scopescope 是RequestServicesFeature延迟创建的——第一次有人访问HttpContext.RequestServices时才会调用CreateScope。同一处还会把释放逻辑挂到响应结束阶段自动Dispose。接下来只做三件事1把CreateScope的发生点在源码/调试器里定位出来2把Dispose的发生点定位出来3确认它们针对的是同一个 scope且成对发生。总览一次请求中的 DI “第一跳”分层Context → Feature → Scope → Provider适用场景/背景你已经知道 scoped/singleton/transient 的语义但对“per-request scope 什么时候创建、什么时候释放”缺少能落地的定位点通常会卡在两类推断上每个请求都能解析 scoped于是觉得“请求一开始就创建了 scope”。scoped 会在请求结束回收于是觉得“框架肯定 somewhere 显式 Dispose 了它”但找不到位置。核心结构本文只讨论 DI 的“第一跳”把请求里与 DI 相关的入口链路拆成 4 层范围严格止步于“拿到 scope 内部的 ServiceProvider”为止1HttpContext对外暴露RequestServices业务代码最常碰到的入口。2IServiceProvidersFeatureRequestServices的真正承载者HttpContext本质上是转发。3RequestServicesFeature默认实现负责“要不要创建 scope”“创建后怎么缓存”“释放怎么挂到请求结束”。4IServiceScope / ServiceProviderscope 创建后_scope.ServiceProvider就是后续解析入口再往下才会进入GetService(...)、call-site 等容器内部细节本文不展开。一句可检验公式本文的验收口径用调试器能直接验证的口径把结论闭环起来没访问HttpContext.RequestServices⇒ 不会创建 scope第一次访问HttpContext.RequestServices⇒CreateScope 缓存 provider 注册请求结束释放请求结束 ⇒ 自动Dispose释放 scope 及其内部资源常见误解对比“每请求都有 scope”经常被听成“请求一开始就创建 scope”。本文后面只盯两件事创建点到底在哪个 getter/分支里发生CreateScope释放点释放动作怎么绑定到响应结束创建点RequestServicesFeature.RequestServices getter 的延迟 CreateScope 条件适用场景/背景当代码第一次读取HttpContext.RequestServices中间件、过滤器、控制器甚至某些框架组件内部时DI 才需要一个“请求级”的解析入口。scope 就是在这里真正出现的。核心原理逐行看 RequestServices getter 的关键分支RequestServicesFeature的RequestServices属性 getter 里有一个很明确的“延迟创建”分支。1触发条件(!_requestServicesSet _scopeFactory ! null)拆开看就是两层意思_requestServicesSet false之前从未计算/设置过RequestServices也就是“第一次访问”的判据。_scopeFactory ! null当前上下文确实有创建 scope 的工厂没有它就谈不上请求 scope。2首次访问时的创建动作只会执行一次_context.Response.RegisterForDisposeAsync(this)_scope _scopeFactory.CreateScope()_requestServices _scope.ServiceProvider_requestServicesSet true这里顺序值得注意先注册释放再创建 scope。这样只要 scope 一创建出来释放路径就已经挂上了第 4 节会把这件事说清。3结果缓存返回不重复创建getter 最后return _requestServices!;。当_requestServicesSet变成 true后续再访问RequestServices就直接返回缓存的_requestServices不会再CreateScope。可观察变量调试器里直接看在断点处建议盯这几个字段_requestServicesSet是否已经完成首次初始化。_scopescope 是否已创建首次访问后应非 null。_requestServices是否已经指向_scope.ServiceProvider。边界/对比setter 会改变默认行为RequestServices还有 setterset { _requestServices value; _requestServicesSet true; }。意思很直白如果外部代码手动 set 过RequestServices_requestServicesSet会提前变成 true此后 getter 就不会再走默认的CreateScope分支。这种情况多见于测试或自定义 hosting/管线注入不是框架“换规则”而是你跳过了默认 feature 的 scope 创建路径。回扣创建点不在“请求刚进管道”的某个固定位置而是落在RequestServicesFeature.RequestServicesgetter 的首次访问分支里。挂载点DefaultHttpContext 如何把 RequestServices 转发到 RequestServicesFeature适用场景/背景搞清楚“scope 在 getter 里延迟创建”之后下一步通常是把断点打到对的文件。但现实里你访问的是HttpContext.RequestServicesRequestServicesFeature又是从哪来的、什么时候塞进 context 的核心原理DefaultHttpContext 的 FeatureReferences 延迟挂载DefaultHttpContext并不是构造时就把所有 feature 都 new 一遍它通过FeatureReferences做按需创建和缓存。跟RequestServices相关的关键点主要是三条1默认实现的“工厂函数”DefaultHttpContext内部有一个静态委托_newServiceProvidersFeature context new RequestServicesFeature(context, context.ServiceScopeFactory)这句话回答了“谁 new 了RequestServicesFeature”默认就是DefaultHttpContext在需要时自己创建。2延迟创建与缓存Fetch(...)ServiceProvidersFeature属性会走_features.Fetch(ref _features.Cache.ServiceProviders, this, _newServiceProvidersFeature)含义是cache 里没有就用_newServiceProvidersFeature创建并缓存有了就直接复用。3HttpContext.RequestServices只是转发DefaultHttpContext的RequestServices属性本身就是get ServiceProvidersFeature.RequestServices;set ServiceProvidersFeature.RequestServices value;重要结论两层延迟把“第一次访问RequestServices才创建 scope”拆开其实是两层延迟第一次访问HttpContext.RequestServices可能先触发RequestServicesFeature的创建feature 层延迟。第一次访问RequestServicesFeature.RequestServices才触发CreateScope()scope 层延迟。回扣调试时别只盯“scope 什么时候创建”feature 本身也可能是第一次访问时才出现。两层延迟叠一起是很多人断点打了却“打空”的原因。释放点RegisterForDisposeAsync 如何把 scope 绑定到请求结束自动 Dispose适用场景/背景scoped 服务之所以能在“请求结束自动释放”根子在它们属于某个 scopescope 释放时会把它创建/跟踪的可释放实例一起处理掉。所以问题可以收敛成一句话请求结束时到底是谁来Dispose这个 scope核心原理创建 scope 时就把释放挂到 ResponseRequestServicesFeature的 getter 在首次创建分支里第一句就是1注册释放_context.Response.RegisterForDisposeAsync(this)意思是把“当前对象RequestServicesFeature实例”交给 Response 的生命周期管理响应结束时框架会回调已注册对象的 dispose。这里注册的是this不是_scope。原因也很简单RequestServicesFeature持有_scope字段它最清楚应该怎么释放_scope同步/异步分派也能在释放后把内部状态清掉。2RequestServicesFeature实现IAsyncDisposable/IDisposable它同时实现了IAsyncDisposable与IDisposableDispose()本质上是对DisposeAsync()的同步封装。3DisposeAsync内部对_scope做分派并清理字段DisposeAsync()的逻辑大致是_scope是IAsyncDisposable走DisposeAsync()。否则_scope是IDisposable走Dispose()。最后把字段清掉_scope null; _requestServices null;调试时这点非常好用请求结束触发 dispose 后你能在同一个 feature 实例上看到_scope从非 null 变成 null。边界没访问 RequestServices ⇒ 根本不用释放如果整个请求过程中从未访问过RequestServices不会进入首次访问分支不会CreateScope()也不会RegisterForDisposeAsync(this)。这时谈“释放 scope”其实没有对象可谈。回释放点不是业务代码手动Dispose也不是某个“后台统一清理”它就绑在首次访问RequestServices的那次调用里注册到 Response响应结束时触发RequestServicesFeature.DisposeAsync/Dispose。可复现调试路径断点、观察点、与“第一跳”确认清单适用场景/背景想把结论从“我知道”变成“我看见”最省事的方法就是用一条最短的请求空的 Minimal API 或一个 MVC action 都行按顺序打断点看变量怎么变。下面是一套可以重复跑的断点/观察点清单按执行顺序放就够了。断点与观察点按执行顺序1断点 ADefaultHttpContext.RequestServicesgetter目标确认HttpContext.RequestServices就是转发。观察点进入 getter 后是否直接转到ServiceProvidersFeature.RequestServices。如果之前没触发过ServiceProvidersFeature这次访问可能会先命中Fetch(...)创建 feature调用栈里能看到。2断点 BRequestServicesFeature.RequestServicesgetter 的 if 分支内部目标确认“首次访问触发CreateScope”。观察点进入前_requestServicesSet false_scope null通常如此。执行后_requestServicesSet true_scope ! null_requestServices _scope.ServiceProvider。第二次命中 getter不再进入 if 分支验证缓存。3断点 C_context.Response.RegisterForDisposeAsync(this)目标确认释放绑定与创建发生在同一处。观察点this就是当前RequestServicesFeature实例后续 dispose 时也是同一个对象。Response 内部“待释放队列”的结构本文不展开你只需要确认注册确实发生了。4断点 DRequestServicesFeature.DisposeAsync/Dispose目标确认请求结束自动释放 scope。观察点_scope的运行时类型是不是IAsyncDisposable走 async 分支或只有IDisposable。执行完成后_scope null_requestServices null。“第一跳”到此为止下一步才是容器内部解析在断点 B 里拿到_scope.ServiceProvider后继续跟进去就是下一跳ServiceProvider.GetService(...)、call-site、缓存等容器内部实现。本文的边界很明确把 per-request scope 的创建/释放闭环跑通容器解析链路留到后续再说。误区校验清单用断点直接证伪/证实误区 1scope 在请求开始就创建。校验让请求尽量不触达RequestServices或尽量晚触达看断点 B 什么时候第一次命中。误区 2scope 需要手动 Dispose。校验不写任何释放代码看断点 D 是否仍会在请求结束触发。回扣验收标准就一条在调试器里看到同一个 scope 的CreateScope和Dispose/DisposeAsync成对出现。把“每请求 scope”落到源码上其实就是两件事创建默认情况下不是请求一开始就创建而是首次访问HttpContext.RequestServices时经由RequestServicesFeature.RequestServicesgetter 进入CreateScope()。释放同一处会RegisterForDisposeAsync(this)从而在响应结束触发RequestServicesFeature.DisposeAsync/Dispose释放_scope并清空引用。如果你想把它变成可复述的结论按第 5 节断点清单跑一遍就够了。下一篇再从_scope.ServiceProvider往下跟进到ServiceProvider.GetService(...)的解析链路。