☰
StackExchange.Redis 同步阻塞陷阱:Sync over Async 与线程池饥饿(SER307/SER308 完全指南)
2026/10/10 2:30:39 网站建设 项目流程
  • 后端
  • 缓存
  • 数据库客户端
  • 消息队列

【免费下载链接】StackExchange.Redis

The Redis client for .NET

项目地址:https://gitcode.com/gh_mirrors/st/StackExchange.Redis
点击查看免费下载

本文是一份围绕 StackExchange.Redis 客户端“同步等待异步调用”(sync over async)问题的完整技术指南。它解释为什么在 Redis 调用上使用.Result、.Wait()或.GetAwaiter().GetResult()会比想象中严重得多,为什么故障症状常常出现在完全无关的位置(看起来像网络或服务器超时),以及正确的修复路径是什么。读完本文,你将能够识别线程池饥饿的典型超时报文、区分该问题与 Timeouts 与 Thread Theft 等其他相似问题,并掌握从改造调用链到DedicatedThreads缓解措施、再到构建期分析器规则(SER306/SER307/SER308)配置的完整处理方案。

如果你是从SER307或SER308警告,或是从一次技术支持对话来到这里的:本文解释为什么阻塞一个异步 Redis 调用比表面看起来更糟、为什么症状会出现在完全别的地方,以及该怎么做。

短版本:问题代码与修复

在异步方法上调用.Result、.Wait()或.GetAwaiter().GetResult(),就是sync over async。它看起来只是一个小便利,实际做的事情是:持有一个线程作为人质去等待回复——而处理该回复同样需要一个线程。当并发地这样做足够多次时,线程池就会耗尽处理回复的线程,于是任何东西都无法完成,也就没有任何线程被释放。此时客户端不是慢,而是卡死。

// 问题代码 var value = db.StringGetAsync(key).Result; // 修复方式 var value = await db.StringGetAsync(key);

没有第二种选择。特别要注意:切换到同步 API并不是修复手段——见下文“同步 API 不是逃生舱”一节。

为什么它会彻底失败,而不仅仅是变慢

多路复用(multiplexed)客户端让这个问题比一般情况更糟,因为你等待的东西需要你在等待期间正在占用的同一个资源:

  1. 你的代码对一个 Redis 命令调用.Result,调用线程随即阻塞。
  2. Redis 回复了。字节很快到达 socket——这一步几乎从来不是问题所在。
  3. 要把这些字节转成一个已完成的Task,客户端需要一个线程。
  4. 如果每个线程都阻塞在第 1 步,第 3 步就无法发生。
  5. 没有任何东西完成,所以第 1 步中的线程永远不会被释放。

结果就是看起来像网络或服务器问题的超时,而实际上两者都不是。一个标志性的信号是:超时报文报告数据已经坐在 socket 里——回复明明在那里,只是根本无法被处理。

源码佐证:回复处理确实依赖线程池

从源码结构看,这一机制的背后是物理连接上的读写路径。在 PhysicalConnection.Read.cs 中,读取模式通过IsSyncReader(_output is { IsSync: true })区分“由库自持线程驱动”与“借用线程池”两种形态;而 PhysicalConnection.Write.cs 的注释明确写道:在线程池饱和时,回复无法被处理——这正是DedicatedThreads特性标志想要解决的场景。也就是说,socket 上的字节要变成已完成的Task,必须有一个可用的线程来执行读取与完成回调,而这个线程恰恰可能正被你阻塞在某处等待同一个回复。

为什么增加线程也救不了你

.NET 线程池会按需创建线程,最多到它的最小值(min),然后开始强力限流:超过该点之后,它只会缓慢而谨慎地增加线程,大约每秒一到两个,使用的是一种试图寻找吞吐量最优值的 hill-climbing 启发式算法。该启发式假设线程是在工作的。而这里线程是被阻塞的,所以更多线程只是意味着更多被阻塞的线程。

这就是为什么调高MinThreads不是修复手段:

  • 它只是把墙推得更远,而不是把墙移除,负载上来之后你终究会撞上它;
  • 被阻塞的线程堆会随之增长,所以故障爆发时规模更大;
  • 底层的调用模式并没有改变。

调高最小值可以作为你修复调用点期间的合理临时措施(stopgap),它也确实能帮到真正的短时突发工作。但它帮不了一个系统性阻塞在 I/O 上的应用程序。

确认问题就是它

查看一条来自客户端的超时报文——如何完整解读超时报文参见 Timeouts。指向本问题的组合特征是:

  • Busy达到或超过WORKER或IOCP的Min,意味着线程池正处于限流、慢速增长的阶段;
  • 连接上有字节等待读取,意味着服务器其实已经应答;
  • 以及超时在负载下变得更糟,而不是随着缓存预热而变好。

一条把所有这些同时展示出来的报文大致长这样:

Timeout performing GET MyKey (5000ms), inst: 0, qs: 84, in: 487312, IOCP: (Busy=0,Free=1000,Min=8,Max=1000), WORKER: (Busy=73,Free=32694,Min=64,Max=32767), POOL: (Threads=73,QueuedItems=612,CompletedItems=418327,Timers=14)

这样解读:84 条命令正在等待回复(qs);476KiB 数据已经到达并坐在缓冲区里无人读取(in);工作线程池的忙碌线程数已经超过其最小值,所以它现在最多以每秒一两个的速度注入新线程;还有 612 个工作项排在它们后面(QueuedItems)。

in这个数字就是关键线索。服务器已经应答,字节就在缓冲区里,所以网络、服务器、连接本身都没有问题——只是没有可用线程去把回复接起来。QueuedItems从另一个方向说明了同一件事:工作到达的速度超过了线程池能够启动它们的速度。

(POOL统计只在 .NET 5 及以上版本上报;在 .NET Framework 上它显示为n/a。)

如果线程池是健康的而你仍然看到超时,那这就不是你的问题——请转而查看 Timeouts 和 Thread Theft。

修复它

按优先级排序:

  1. 让调用路径异步化。一路await上去。这是唯一能移除问题而不是迁就问题的改动,而且值得把异步向上推进到比你觉得方便的位置更深——路径上任何一帧的阻塞都会为它下面的所有东西重新引入问题。
  2. 把客户端从线程池上拿下来,作为你做第 1 步期间的缓解措施(见下文)。

同步 API 不是逃生舱

StackExchange.Redis 确实有同步 API,而且db.StringGet(key)并不是字面意义上的 sync-over-async:它没有包装一个异步调用再阻塞其结果。这个区别是关于机制的,而在这里它什么都买不到,因为后果完全相同。

调用线程仍然会阻塞直到 Redis 回复,而处理该回复仍然需要一个线程。如果调用线程来自线程池——在 ASP.NET 下、在托管服务(hosted services)中、在定时器回调里、在 continuation 里,几乎所有不是你亲手启动的东西都是这样——那么你依然占用了回复到达所需的那个资源。线程池以同样的方式、同样的原因挨饿。

所以同步 API 并不是安全地继续阻塞的办法,本文也不是在叫你去用它。如果一个调用方确实无法改成异步,诚实的立场是:你是在与线程池对赌,应当相应地调整容量并做好隔离——而不是说你已经避开了问题。

缓解措施:给客户端它自己的线程

ConnectionMultiplexer.SetFeatureFlag("DedicatedThreads", true); // 在应用程序启动早期调用

这会让库在它自己拥有的线程上读写,而不是借用线程池,因此即使线程池饱和,Redis 流量也依然可以流动。

要清楚这做什么、不做什么。它不能修复线程池——这个库里没有任何东西能做到,因为被阻塞的线程在你的代码里。它做的是:让 Redis 不再被卷入拥堵,这通常能把“一切都在超时”变成“应用程序很慢,而且其中一部分显然在阻塞”。这是一个好得多的调试起点,对许多应用来说也足以在真正修复完成前恢复服务。但它不是继续保留阻塞调用的许可证。

开启前值得了解的两条注意事项:

  • 它要为你连接的每个节点花费一个读线程和一个写线程(RESP2 的 pub/sub 连接除外,它们仍留在线程池上;RESP3 不使用独立的 pub/sub 连接),所以在非常宽的集群上开启前要三思,那里连接数随分片数扩展;
  • 它刻意做成 opt-in,并且是进程级、在启动时设置,而不是按连接设置。

源码佐证:DedicatedThreads 如何工作

该标志定义在 ConnectionMultiplexer.FeatureFlags.cs 中,是一个进程级静态标志:

  • SetFeatureFlag(string, bool)/GetFeatureFlag(string)通过Enum.TryParse<FeatureFlags>实现,内部位掩码保存在静态字段s_featureFlags中——这解释了它为什么是进程级、启动时设置,而不是按连接设置;
  • 源码注释明确指出:“它不能修复线程池,这里没有任何东西能做到:它只意味着在真正问题被找到期间,Redis 流量保持流动”,并直接引用了 docs/SyncOverAsync.md 这篇文档;
  • 每条连接的开销是“一个读线程加一个写线程”,与本文上文所述一致;
  • 诊断接口IInternalConnectionMultiplexer.IsSyncReader/IsSyncWriter(ConnectionMultiplexer.FeatureFlags.cs)可以按端点与连接类型查询“该连接是否由我们自持的线程读取/写入”,供测试与诊断使用——标志是请求,这两个查询才是实际发生了什么。

两条注意事项都不是永久的,第一条尤其如此。基于平台原生完成机制(Linux 上的io_uring、Windows 上的 IOCP)构建专用读线程的工作正在进行中——那将能用一个小而固定的线程集合服务许多连接,而不是每连接一对线程。那才是让这件事在任何宽度下都实用的东西。目前没有日期,这里也没有任何内容依赖它;但如果你读上面那条注意事项时心想“就我的分片数而言不行”,那么答案是“还不是现在”,而不是“不行”。

Wait系列辅助方法是一回事

Wait、WaitAll和TryWait——在IDatabase/IServer/ISubscriber上通过IRedisAsync提供,也提供在IConnectionMultiplexer上——是库自带的阻塞辅助方法,上文所有内容对它们同样适用:调用线程被持有整个往返过程,而回复需要一个属于它自己的线程。

它们并不比.Result更糟——它们应用多路复用器配置的超时,所以会失败而不是永远挂起——但那是更好的失败,而不是逃逸了问题。SER308会标记它们;请改为await那个任务。

源码佐证:在 ConnectionMultiplexer.cs 中,Wait(Task)、Wait<T>(Task<T>)与WaitAll(params Task[])的实现都是对task.Wait(TimeoutMilliseconds)/Task.WaitAll(tasks, TimeoutMilliseconds)的直接包装,超时后抛出TimeoutException(单个异常则剥壳后重新抛出)。这印证了文档的表述:它们不会永远挂起,但确实会把调用线程阻塞在等待上——而TimeoutMilliseconds正是多路复用器配置的超时(见 Configuration.md 中的ConnectTimeout/SyncTimeout等配置项体系)。

Fire-and-forget 是一个特例

CommandFlags.FireAndForget返回一个已经完成、携带默认值的任务,所以在它上面阻塞不会等待任何东西,也不可能饿死线程池。但它仍然不是它看起来的样子:该值在调用返回前就已固定,永远不是服务器的应答。要么丢弃它,要么如果你真的想要结果就去掉 fire-and-forget 标志——参见SER306。

源码与规则页佐证

诊断描述在 Diagnostics.cs 中明确定义SER306(AwaitFireAndForgetResult):“fire-and-forget 命令立即以默认值完成,永远不会携带来自服务器的结果,因此在它上面 await 或阻塞读取不到任何有意义的东西”。完整规则页见 SER306: waiting for a fire-and-forget result yields nothing,其中给出了推荐写法:

// 会被标记 - 无论何时读取,value 永远都是 RedisValue.Null var tran = db.CreateTransaction(); var value = await tran.StringGetAsync(key, CommandFlags.FireAndForget); await tran.ExecuteAsync(); // 建议 - 如果你想要结果,就不要要求 fire-and-forget var pending = tran.StringGetAsync(key); await tran.ExecuteAsync(); var value = await pending; // 或者,如果 fire-and-forget 就是你想要的,直接丢弃它 _ = tran.StringSetAsync(key, value, flags: CommandFlags.FireAndForget); await tran.ExecuteAsync();

注意它给出的唯一修复是丢弃已排队的任务——像 SER305 的修复那样“捕获任务并在Execute之后 await”在这里毫无收益,因为值不会随着等待而变好。CommandFlags.FireAndForget标志在|表达式中只会被添加,所以tran.StringGetAsync(key, flags | CommandFlags.FireAndForget)仍会被标记,而运行时不可知的tran.StringGetAsync(key, flags)不会被标记。

分析器:构建期捕获

包内自带一个 Roslyn 分析器,会在构建时标记这些代码:

  • SER307—— 阻塞在一个 Redis 调用上而不是 await 它。本文档就是它链接到的页面。
  • SER308—— 同一问题,但经由库自带的Wait/WaitAll/TryWait辅助方法。
  • SER306—— 读取一个 fire-and-forget 结果,那永远是默认值。SER307 会把这种情况交给这条规则,因为在已完成的 Task 上阻塞并不是饿死任何东西的原因。

完整的分析器规则族参见 Analyzer rules,包括事务规则(它们描述的是一个不同的问题)。三条规则(SER306/SER307/SER308)属于SER3xx范围内的“正确性(Correctness)”组——与SER300–SER304的“用法建议”组不同,它们描述的是看起来不像它实际行为的代码。

源码佐证:为什么是规则而不是[Obsolete]

SER307(BlockingOnRedisCall)与SER308(BlockingHelper)的诊断描述都定义在 Diagnostics.cs 中。其中对设计决策的注释很值得一读:

  • SER307 的帮助链接刻意指向本文档(解释线程池的故事)而不是某条规则页,因为人们在这里需要的是线程池原理,而不是对波浪线的描述;
  • SER307 的消息刻意只说“请改为 await”,不说“改用同步 API”——因为同步 API 不是答案:它虽然不是字面意义上的 sync-over-async,但被阻塞的线程就是被阻塞的线程,在线程池驱动的场景(ASP.NET 等)下饿死现象完全相同,一个把人指向同步 API 的分析器等于把用户送去同样糟糕的地方;
  • SER308 之所以做成规则而不是[Obsolete],是因为[Obsolete]上报的是CS0618——它与来自任何来源的所有过时警告共享同一个 ID,想只静默这一条就必须静默全部(包括你自己代码和其他包里的弃用)。ObsoleteAttribute.DiagnosticId可以解决这个问题,但那是 net5+ 的能力,而本库仍要支持 netstandard2.0,会在不同目标框架上产生不一致的 ID。自己的 ID 在所有平台上行为一致,且与规则族其余成员并列。

两条规则默认都是Warning(警告),与SER306相同;SER305则是整个集合中唯一的Error,因为它描述的代码根本无法工作(事务执行前等待永远不会完成)。

关闭这些规则

两条(SER307/SER308)都是警告,所以使用TreatWarningsAsErrors的构建在你就它们采取行动或把它们调低之前会失败。没有人能在一下午重写一个大代码库,而一条无法静默的规则就是一条会被整个拆除的规则,所以:

对于你已做出决定的单个调用点——比如一个遗留入口点、一个你无法控制的接口:

#pragma warning disable SER307 // blocking: called from <somewhere that cannot be async>

对于一个项目,在你逐步处理期间:

<NoWarn>$(NoWarn);SER307;SER308</NoWarn>

或者把它们调低而不是关闭,让它们在 IDE 中保持可见而不使构建失败——在.editorconfig中:

dotnet_diagnostic.SER307.severity = suggestion # or none, silent, warning, error dotnet_diagnostic.SER308.severity = suggestion

注意这些是我们自己的 ID 而不是CS0618,这正是重点:静默它们静默的是这个,而不是你依赖过的每一次弃用。也可以参照 Analyzer rules 中针对整个规则族的说明:它们默认是警告,是因为dotnet build不会打印 information 级别的诊断——在 IDE 之外不可见的建议不值得发布;而如果你用TreatWarningsAsErrors构建,升级后会让原本能编译的代码构建失败——什么都没有坏,你只是在“采取行动”和“调低它们”之间二选一。

说得直白一点:抑制它并不会让问题消失,如果你是因为超时来到这里的,那么这条规则正指着它们的成因。能用的地方优先用#pragma形式,因为它在现场记录了这个决定,并让代码库其余部分保持被覆盖。

参见

  • Timeouts —— 如何完整解读一条超时报文及其中的线程池统计信息
  • Thread Theft —— 一个气味相似但完全不同的问题:读线程被 continuation 劫持,而不是线程匮乏。很大程度上已成为历史:客户端默认请求RunContinuationsAsynchronously,因此在现代 .NET 上这已为你处理——那里的缓解措施针对的是无法信任其同步上下文遵守该承诺的主机,实践中指 .NET Framework 上的经典 ASP.NET
  • Pipelines and multiplexers —— 客户端为什么会塑造成这个样子(多路复用器架构的由来)
  • Analyzer rules —— SER3xx 规则族全览,包括事务规则(SER300–SER305)、fire-and-forget(SER306)与本主题(SER307/SER308)的配置与静默方式
  • 后端
  • 缓存
  • 数据库客户端
  • 消息队列

【免费下载链接】StackExchange.Redis

The Redis client for .NET

项目地址:https://gitcode.com/gh_mirrors/st/StackExchange.Redis
点击查看免费下载

相关推荐

上一篇:NCM转MP3轻松搞定:免费开源的ncmdump手把手使用指南
下一篇:0成本直链下载终极指南:六大网盘真实地址一键到手

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询