2023 年我给自己定的目标是「把 CLR 和 ASP.NET Core 的源码通读一遍」,读了大半年,能用上的不到一成。后来一次线上问题让我改了想法:真正管用的是知道去哪查、能多快定位,而不是记住多少细节。这篇写了两次想法之间的落差,以及现在给自己定的三个检查点。中间有一节我是硬着头皮写的,当时其实还没想清楚。

一、2023 年那个年度计划

2023 年 4 月,组里来了个新同事。有次聊到接口偶发超时,他说了句「大概率是连接池里 handler 到期了,重建的时候赶上并发」,又补一句「这段逻辑在 HttpConnectionPool 里」。我当时表面点头,其实是有点被压到的——同一个现象,他给了一个位置,我只能给一个猜测。

那周回家我就给自己定了个年度计划:把 CLR 和 ASP.NET Core 的源码通读一遍。做法是每天下班看一到两个小时,周末加一个下午。机器上 clone 了 dotnet/runtimedotnet/aspnetcore 两个仓库,加起来三个多 G。《CLR via C#》也买了,但只看到第 20 章左右就放下了,正好卡在程序集加载那块,读了两天读不进去。

到 2023 年 11 月,我在 Obsidian 里攒了 200 多条笔记,按模块分了文件夹:Kestrel 启动流程、EndpointRouting 匹配、ServiceProvider 的生命周期实现、Configuration 加载链、HttpClientFactory 的连接池管理、GC 的分代和标记压缩。看着挺有成就感。现在回头数,这 200 多条里后面两年真正被我翻出来用过的,不超过 15 条。

二、两次「读了一周、一点用不上」

第一次是 2023 年 9 月,刚开始读两个月的时候。一个内部服务跑两天内存涨到 2 G 多,重启之后从 300 MB 重新往上爬。我那时候刚看完 GC 那部分,觉得自己能查。结果 dump 拉下来完全不会看:WinDbg 装了但命令不熟,dotnet-dump analyze 进去之后不知道该跑什么。最后是另一位同事远程帮我看的,结论是我自己写的缓存字典没设上限,跟 GC 一点关系没有。

第二次更典型。2023 年 10 月,我花了一整个星期读 Kestrel 的启动和请求处理,从 WebApplicationBuilder 一路跟到 HttpProtocol,还画了一张图。画完挺得意,然后等机会用上它,等了两年,一次都没有——我们的服务前面是 IIS 做反向代理,Kestrel 那几个配置项上线之后没人动过,也没有要改的理由。那张图现在还在笔记里,我看着它只觉得浪费。

后来我大概想明白问题在哪:我读的时候没有带着问题。我读的是「这个东西是怎么实现的」,而能沉淀成能力的读法是「我遇到的这个现象是怎么产生的」。前者读完会有一种懂了的错觉,而且这种错觉很舒服,会推着你继续读下一个模块;后者读起来很痛苦,因为一上来就会撞在自己不知道的地方。我那大半年,大部分时间是在消费前一种舒服。

三、那次四十分钟的定位

真正让我改想法的是 2025 年 3 月的一个问题。有个内部接口偶发 500,一天十几次,日志里只有一行 System.Threading.Tasks.TaskCanceledException,没有堆栈,也没有 InnerException。我先怀疑数据库超时,加了 SQL 耗时日志,没有;又怀疑反向代理那一层的超时,找运维看了一眼,也不是。

最后是靠 dotnet-counters 看出来的。我在那台机器上挂着 monitor 跑了二十分钟,抓到出问题的那几分钟,看到请求队列突然堆起来,线程池的线程数同时在涨。这个现象加上 TaskCanceledException,指向的是出站请求超时,不是入站。然后我去代码里搜 new HttpClient,在 Startup 里找到一处——两年前写的、调外部短信通道的客户端,直接 new 的,每次请求都建连接,端口耗尽的时候就开始超时。从开始查到定位到那一行,四十分钟左右。

这件事让我不舒服的地方在于:我那二百多条源码笔记一条都没用上。真正用上的是三样——我知道 TaskCanceledException 在超时场景下会抛(这个是我早先踩过一次记住的)、我知道有 dotnet-counters 这么个工具、我知道去搜 new HttpClient。全是碎的,不成体系。而反过来看,如果 2023 年我不是通读,而是每次解决完问题回头读相关那一小段,这四十分钟大概能压到二十分钟。

四、我现在给自己定的三个检查点

那之后我把「深度」这个词在心里换了个定义。以前是「我知道多少」,现在是「我多快能找到」。具体落到三个检查点,我在解决完一个问题之后会对着问一遍:

  1. 能不能在半小时内定位到具体那一行代码,而不是停在「大概是网络问题」这一层。这里说的定位,是能说出文件名和行号、能动手改。
  2. 能不能说出这个东西大概在哪个 NuGet 包、哪个命名空间、哪个类里,几千行上下。不需要记得实现,需要知道去哪找。
  3. 能不能讲给别人听,讲到第三层问题答不上来就算及格。答不上来的那一层,就是下一次要补的地方。

第三个检查点是从那位新同事身上学的。他当时也不是背下来的,他只是知道连接池的管理在 HttpConnectionPool 那个类里,具体细节一样要现翻。区别是他知道翻哪儿,我连翻哪儿都不知道。

做法上我改了两件事。一件是读源码的时机:不再通读,改成每次解决完一个具体问题,回头把相关那一小块读一遍,一般三百到五百行,当天读完。那次 HttpClient 之后,我读了 HttpConnectionPool 和 HttpClientFactory 里 handler 生命周期那两部分,写了两页笔记,到现在还记得;比之前读三个月的东西记得牢得多,因为它挂在一个具体的事上。

另一件是给自己造问题。测试环境里我把连接池上限改成 1,看服务在什么时间点开始排队、日志长什么样、监控曲线是什么形状,然后改回来。这种自己造出来的现象,比读十遍源码好记。

五、有一节我是硬着头皮写的

前面那句「知道去哪找比记住实现重要」,我说得挺顺,但其实到现在也没完全想清楚。

我不确定的是,这套标准是不是在给自己偷懒找借口。我确实认识把整个 runtime 读得很透的人,他们看性能问题、写基础库的时候明显比我快,而且能看到我看不到的层次。我做的是业务系统,可能永远用不上那个深度——但「用不上」和「不需要」是两回事,我没法证明是后者,也许只是我碰到的场景还不够难。

还有一个我改了又改的地方:读源码到底要不要为读而读。我现在的答案是要,但别把它算进「提升技术」里。读的时候我是享受的,把一条线跟通的感觉很好,这个理由本身就够了。要说收获,我 2023 年读 ServiceProvider 那部分,最大的用处是两年后看别人的 DI 问题时反应快了半拍。这算收获吗?我觉得算,但它没法计划,也没法量化。

所以这篇到最后其实没有结论。我只知道 2023 年那个计划对我来说是错的,2026 年这套也未必对,可能再过三年又得改一遍。先记下来,等下次被现实打脸的时候回来看。