线程池饥饿排查:从 ThreadPool 指标追到 SQLite_BUSY

应用变慢时,第一反应很容易是“线程不够”或“CPU 不够”。但在高并发 Web 应用中,更常见的情况是线程池线程都被阻塞了:线程数量不断增加,工作项排队等待,新的请求迟迟得不到执行。 这就是线程池饥饿(Thread Pool Starvation)。本文用一次 SQLite 相关的现场问题说明如何观察它,以及为什么简单增加线程数往往会让问题更糟。 什么是线程池饥饿 当线程池没有可用线程处理新的工作项时,就发生了线程池饥饿。应用通常表现为响应变慢、请求超时,但 CPU 利用率未必很高。 常见诱因包括: 在异步方法上调用 .Result 或 .Wait(); 使用 Thread.Sleep; 长时间运行的定时任务或后台任务占用线程池线程; 同步阻塞 I/O; 多个线程竞争同一把锁; 数据库调用长时间等待锁释放。 尤其要注意,业务代码不需要“完全同步”才会产生饥饿。只要异步链路中夹杂了阻塞调用,线程池就可能在压力下逐渐耗尽可用线程。 用 dotnet-counters 观察现场 先对目标进程做压测,同时运行: dotnet-counters monitor -p 12256 重点观察 [System.Runtime] 下的几个指标: ThreadPool Queue Length 75 ThreadPool Thread Count 38 ThreadPool Completed Work Item Count 128 / sec 如果 ThreadPool Queue Length 持续增长,说明工作项已经开始排队;如果 ThreadPool Thread Count 长时间明显高于 CPU 核心数,同时队列仍然无法消化,这是线程池饥饿的强烈信号。现场案例中线程数长期达到 CPU 核心数约 3 倍,仍有大量工作项等待。 这个判断应该结合趋势和请求延迟一起看,不能把“线程数超过核心数”单独当成故障证明。线程池在遇到阻塞时主动注入线程本身就是一种正常的补救机制,问题在于注入的线程也被同样的阻塞点卡住了。 用 dotnet-stack 看谁在阻塞 线上通常无法附加 debugger,这时可以使用 dotnet-stack 获取托管线程的调用栈。现场看到的一类关键栈如下: ...

July 22, 2026

多进程配置文件读写的演化历程

第一阶段:共享一套读写逻辑,谁都可以写 一开始,多个进程共用一套读写逻辑: if (!Exists(configPath)) { WriteDefaultConfig(configPath); } var config = ReadConfig(configPath); 问题有两个: 多个进程可能同时发现“文件不存在”,然后一起创建 一个进程正在写,另一个进程正好在读 如果是直接覆盖写,读取方可能看到: 文件刚被创建,内容还是空的 文件已经存在,但只写了一半 配置结构还没写完整 目标文件在替换过程中短暂不可用 对应的现象就是: 偶发读取失败 配置解析异常 文件内容丢失 某些进程启动时报错 根因很简单:读者看到了写入中的中间状态。 第二阶段:加锁、重试、缓存,先把系统稳住 先说 FileShare。 它想解决的其实是两个很具体的问题: 读取正好撞上写入,结果读到一半内容 两次写入互相重叠,结果把文件写坏 最直接的做法,就是用共享模式限制文件如何被同时打开。 它的目标不是提供原子替换,而是尽量减少下面这些情况: 读取撞上写入中的半成品 两次写入同时落到同一个目标文件 文件直接被另一进程占用导致操作失败 FileShare 带来的直接结果是:冲突不会消失,只会变成可以观察、可以重试的失败。 所以后面又加了重试。 这能减少两类问题: 少报“文件被占用” 让偶发冲突尽量靠重试跨过去 再往后,问题变成了独占时间。 配置读取很频繁,导致写入一直在等待读取释放文件锁。 所以开始引入缓存: var config = ReadConfig(configPath); var lastWriteTime = GetLastWriteTime(configPath); cache = (config, lastWriteTime); 下次访问时先比较时间戳: var currentWriteTime = GetLastWriteTime(configPath); if (currentWriteTime == cache.LastWriteTime) { return cache.Config; } cache = ReadConfig(configPath); return cache.Config; 没变,缓存有效;变了,缓存失效,重新加载。 ...

July 17, 2026

本机使用相同端口启动多个程序

本地开发时,我们经常会遇到这样的需求: 项目 A 使用 3000 端口 项目 B 也使用 3000 端口 希望两个项目同时启动,互不影响 通常的答案是“改端口”。但在本机上,还有一种更整洁的办法:使用不同的 127.x.x.x 地址。 端口不是单独存在的 一个网络服务真正监听的不是“端口”,而是一个地址组合: IP 地址 + 端口 因此,下面两个监听实际上并不冲突: 127.0.0.2:3000 127.0.0.3:3000 它们的端口号相同,但 IP 地址不同。 127.x.x.x 是什么 127.0.0.0/8 整个网段都用于回环地址,也就是:发往这些地址的数据不会经过物理网卡,而是回到本机。 最常见的是: 127.0.0.1 但下面这些地址通常也可以使用: 127.0.0.2 127.0.0.3 127.10.10.10 它们都指向当前电脑,只是可以被操作系统区分为不同的本地地址。 一个可以直接运行的例子 准备两个目录,在两个终端分别运行: python -m http.server 3000 --bind 127.0.0.2 --directory project-a python -m http.server 3000 --bind 127.0.0.3 --directory project-b 现在两个程序都使用了 3000 端口,但访问地址不同: http://127.0.0.2:3000 → project-a http://127.0.0.3:3000 → project-b 这就是本地开发中“多个相同端口”的核心用法。 技巧:用域名代替 IP 直接记 127.0.0.2、127.0.0.3 并不困难,但开发时写域名更直观。 ...

July 16, 2026