事件订阅没退订:C# 内存缓慢泄漏避坑复盘

有个长期运行的 C# 服务跑着跑着内存就缓慢不可逆地往上涨,几天就逼近上限不得不重启,不像一下子爆掉而是温水煮青蛙式一点一点涨极难察觉,直到监控曲线连成一条只升不降的斜线。抓内存快照分析发现堆里积压了海量本该回收的对象——一类视图模型对象明明业务早结束、外部也不再引用,却像幽灵赖在内存里死活不被 GC 回收。顺引用链往上追真凶浮出水面:它们都被一个事件牵住了。原来这些短命视图模型创建时订阅了某个长生命周期对象的事件,写法不过 dataService.DataChanged += this.OnDataChanged 一行,可用完本该销毁时它们从来没退订过,而事件的本质是发布者持有一个对所有订阅者的引用列表,只要不退订这个长命发布者就一直攥着对短命订阅者的引用,GC 一看还有人引用着就死活不回收。这篇文章从这次事件泄漏拖垮内存的事故出发,讲透 C# 内存泄漏:事件订阅本质是发布者持有订阅者引用、有 += 必有配对 -= 退订、退订时委托必须是同一个否则 lambda 退订静默失败、用弱事件模式兜底、分清 GC 管内存与 Dispose 管资源、用 using、抓 gcdump 看 GC Root 引用链定位,以及 GC 语言里泄漏形式从忘了释放变成无意中一直引用。

有个长期运行的 C# 服务,跑着跑着内存就缓慢地、不可逆地往上涨,几天下来就逼近上限、不得不重启。它不像那种"一下子爆掉"的泄漏,而是温水煮青蛙式的,一点一点地涨,极难察觉,直到内存监控的曲线连成一条刺眼的、只升不降的斜线。我抓了内存快照(dump)来分析,发现堆里积压了海量本该早就被回收的对象——一类处理业务的"视图模型"对象,明明对应的业务早就结束了、外部也不再引用它们,却像幽灵一样赖在内存里,死活不肯被垃圾回收(GC)。

我顺着这些"僵尸对象"的引用链往上追,真凶浮出水面,而且经典得让每个 C# 开发者都该警醒:它们都被一个'事件(event)'给牵住了。原来,这些短命的视图模型对象,在创建时订阅了某个长生命周期对象(比如一个全局的数据服务、或一个常驻的管理器)的事件——写法不过是 dataService.DataChanged += this.OnDataChanged; 这么一行。问题是,当这些视图模型用完、本该被销毁时,它们从来没有"退订"过那个事件。而事件的本质,是发布者(长生命周期对象)持有一个对所有订阅者(短命对象)的引用列表——只要不退订,这个长命的发布者就一直攥着对短命订阅者的引用,GC 一看"还有人引用着它呢",就死活不肯回收它。于是,一个个本该速朽的对象,被一个长命的事件源死死拴住,越积越多。

这就是 C#(以及很多带 GC 的语言)里极其经典、又极其隐蔽的内存泄漏:事件订阅了却不退订,导致订阅者无法被回收。它平时毫无征兆,却会在长期运行的进程里,让内存悄悄地、持续地泄漏,直到拖垮服务。这篇文章,就从这次"事件泄漏拖垮内存"的事故出发,把它的原理、各种规避手段、以及相关的 C# 资源管理坑,一次讲透。

先摆几个关于事件和 GC 的想当然

动手复盘前,先把我自己曾经深信、后来被这次泄漏教育的几个念头摆出来。

想当然的念头 残酷的真相
"有 GC, 不用管对象什么时候被回收" 只要还有引用, GC 就不回收, 事件订阅就是一种隐藏引用
"+= 订阅个事件而已, 没什么副作用" 它让发布者持有了订阅者的引用, 不退订就泄漏
"对象用完了自然会被回收" 被长命对象的事件牵着, 它永远回收不了
"内存涨一点没事, GC 会清理" GC 清不掉"还被引用"的对象, 涨上去就下不来
"订阅了不退订, 顶多多占一点内存" 长期运行下持续累积, 最终拖垮整个进程

这些念头的共同病根,是对"GC 凭什么回收一个对象"这件事理解不清——以为"逻辑上用完了"对象就该被回收,却忽略了 GC 的唯一判据是"还有没有可达的引用";而事件订阅,恰恰是一种极其容易被忽略的"隐藏引用"。要看清这次事故,得先理解事件订阅在内存里到底建立了什么。

第一件事:事件订阅,本质是发布者持有了订阅者的引用

理解这个泄漏的关键,是搞懂 publisher.SomeEvent += subscriber.Handler; 这行代码,在内存里到底干了什么。一个事件(event),底层是一个委托(delegate)的列表——发布者内部维护着一个"订阅者处理方法"的列表。当你用 += 订阅时,你其实是把 subscriber.Handler 这个委托加进了发布者的列表里。而一个指向实例方法的委托,会同时持有对那个实例(subscriber)的引用(因为它要在那个实例上调用方法)。

所以,+= 之后,引用关系是这样的:发布者 → 持有委托 → 委托持有订阅者实例的引用。这意味着,只要发布者还活着、还持有这个委托,订阅者就一直被引用着,GC 就绝不会回收它——哪怕你外部所有其它对它的引用都已经没了。在我的场景里,发布者是个长生命周期的数据服务(活得和进程一样久),订阅者是短命的视图模型,这条"长命 → 牵着 → 短命"的引用链,就成了泄漏的根源。下面这张图,把这个机制画出来:

看懂这张图,事故的根就清楚了:GC 不是不想回收那些视图模型,而是不能——因为那个长命的数据服务,通过事件这条隐藏的引用链,死死地牵着它们。事件订阅,在你不经意间,建立了一条'发布者指向订阅者'的引用;而当发布者比订阅者活得久时,这条引用就成了订阅者无法被回收的枷锁。接下来,我们就看怎么解开这把枷锁。

第二件事:铁律——有订阅(+=)就必有退订(-=)

根治这个泄漏的核心,就一条铁律:每一个 += 订阅,都要有一个配对的 -= 退订;在订阅者的生命周期结束时,主动把自己从发布者的事件里"摘"出来。退订(-=)做的事,正是把你的委托从发布者的列表里移除,从而断开"发布者 → 订阅者"那条引用链——引用一断,GC 就能正常回收订阅者了。在 C# 里,承载这种"清理"逻辑的标准位置,是实现 IDisposable 接口的 Dispose 方法。

// 反例:只订阅, 不退订, 订阅者被长命发布者牵住, 泄漏
public class ViewModel
{
    public ViewModel(DataService service)
    {
        service.DataChanged += OnDataChanged;  // 订阅了...
    }
    void OnDataChanged(object s, EventArgs e) { /* ... */ }
    // ...却从来没退订! ViewModel 永远被 service 牵着, 回收不了
}

// 正解:实现 IDisposable, 在 Dispose 里退订, 配对地解开引用
public class ViewModel : IDisposable
{
    private readonly DataService _service;
    public ViewModel(DataService service)
    {
        _service = service;
        _service.DataChanged += OnDataChanged;   // += 订阅
    }
    void OnDataChanged(object s, EventArgs e) { /* ... */ }

    public void Dispose()
    {
        _service.DataChanged -= OnDataChanged;   // -= 退订, 断开引用!
    }
}
// 使用方在用完 ViewModel 时调用 Dispose(), 它就能被正常回收了

这条 "+=-= 必须配对"的纪律,和我们之前聊数据库连接的 "获取归还 必须配对"、聊 ThreadLocal 的 "setremove 必须配对",是完全一样的工程心法——凡是会建立某种"持有关系"的操作,都必须有一个配对的、保证会被执行的"解除"操作。订阅一个事件,就等于建立了一份持有关系;不退订,就是没有履行解除的责任。养成"写下 += 的同时,就想好它的 -= 写在哪"的习惯,是杜绝这类泄漏的根本。

第三件事:退订有个隐蔽陷阱——委托必须"是同一个"

退订时有一个极其隐蔽、又极其常见的坑:-= 要想成功移除一个订阅,它移除的委托,必须和当初 += 添加的委托"是同一个"。如果你订阅时用的是一个匿名方法或 lambda 表达式,那么退订时你无法再写出"同一个"lambda——每次写的 lambda 都是一个新的、不同的委托对象,-= 一个新 lambda,根本匹配不到当初那个,退订会悄无声息地失败(不报错,但啥也没退掉)。

// 反例:用 lambda 订阅, 退订时无法引用"同一个" lambda, 退订失败!
service.DataChanged += (s, e) => HandleData(e);   // 匿名 lambda 订阅
// ...想退订时:
service.DataChanged -= (s, e) => HandleData(e);   // 这是另一个新 lambda!
// -= 匹配不到当初那个, 退订静默失败, 照样泄漏!

// 正解一:把 lambda/委托存进一个字段, 退订时用同一个引用
private EventHandler _handler;
public void Subscribe()
{
    _handler = (s, e) => HandleData(e);   // 存下这个委托
    service.DataChanged += _handler;
}
public void Unsubscribe()
{
    service.DataChanged -= _handler;      // 用同一个引用退订, 成功!
}

// 正解二(更简单):直接用具名的实例方法订阅/退订, 天然是同一个
service.DataChanged += OnDataChanged;     // 具名方法
service.DataChanged -= OnDataChanged;     // 能正确匹配移除

这个坑的教训是:如果你订阅了一个事件、且将来需要退订它,就不要用"用完即弃"的匿名 lambda 去订阅——要么用具名的实例方法(最简单、最不易错),要么把那个 lambda 委托存进一个字段、退订时用同一个引用。否则,你以为自己写了退订,实际上那行 -= 根本没起作用,泄漏依旧。这是无数人"明明写了退订却还是泄漏"的真正原因,务必警惕。退订的前提,是你能精确地引用到当初订阅的那个委托。

第四件事:实在管不好生命周期?用"弱事件模式"

"有 += 就配 -=" 是治本,但现实中,订阅者的生命周期有时很难精确把控,总会有疏漏的退订。对于这种"订阅者该走的时候,即便没主动退订,也别让它被事件牵住"的需求,C# 提供了一种巧妙的方案:弱事件模式(Weak Event Pattern)。它的核心思想,是让发布者对订阅者的引用,变成一个弱引用(WeakReference)——弱引用不会阻止 GC 回收对象。这样,即便订阅者忘了退订,只要外部不再引用它,GC 依然可以正常回收它。

// 弱事件:发布者用弱引用持有订阅者, 订阅者可被正常 GC, 即便忘了退订
// WPF 内置了 WeakEventManager
WeakEventManager<DataService, DataChangedEventArgs>
    .AddHandler(service, nameof(service.DataChanged), OnDataChanged);
// 这样 service 对 this 是弱引用, this 不会被它牵住, 忘退订也不泄漏

// 也可以自己用 WeakReference 实现弱引用持有
public class WeakSubscriber
{
    private readonly WeakReference<ViewModel> _ref;
    public WeakSubscriber(ViewModel vm) => _ref = new WeakReference<ViewModel>(vm);
    public void OnEvent(object s, EventArgs e)
    {
        // 用的时候才尝试取出, 取不到(已被回收)就跳过
        if (_ref.TryGetTarget(out var vm)) vm.Handle(e);
    }
}
// 核心:弱引用让"被引用"不再等于"不能回收", 给忘退订上了一道保险

弱事件模式是一道"防御性"的保险:它承认"人总会忘记退订"这个现实,于是从机制上让"忘记退订"不再导致泄漏。它在 WPF/UI 框架里用得很多(因为 UI 控件的事件订阅极容易泄漏)。但要清醒:弱事件是"减害"而非"免责"——它能兜住忘退订的泄漏,但弱引用本身有开销、逻辑也更绕,而且它不该成为你"懒得管生命周期"的借口。能用清晰的 "+=/-= 配对 + Dispose" 管好的,优先用它;只有在生命周期实在复杂难控的场景,才请出弱事件这道保险。

第五件事:理解 GC 与 IDisposable——托管与非托管的分工

这次事故也促使我把 C# 的资源管理模型重新捋清。关键是分清两件事:GC 自动管理"托管内存"(普通对象),而 IDisposable/Dispose 用来手动、及时地释放"非托管资源"和"需要主动解除的持有关系"。事件订阅(它建立的引用)、文件句柄、数据库连接、网络连接、非托管内存——这些 GC 要么管不了、要么不能及时管,都该靠 Dispose 来确定性地清理。而 using 语句,是确保 Dispose 一定被调用的最佳方式。

// using 确保 Dispose 一定被调用(无论正常还是异常), 及时释放资源
using (var vm = new ViewModel(service))   // ViewModel : IDisposable
{
    vm.DoWork();
}   // 出了这个块, vm.Dispose() 自动被调用 → 退订事件, 可被回收

// C# 8 起更简洁的 using 声明, 作用域结束时自动 Dispose
using var conn = new SqlConnection(connStr);
conn.Open();
// ... 方法结束时 conn 自动 Dispose

// 误区澄清:
// - 不要依赖"终结器(析构函数/finalizer)"来释放, 它执行时机不确定、还拖慢 GC
// - GC 只管托管内存, "退订事件/关句柄"这类清理, 它不会替你做
// - 所以:实现 IDisposable + 用 using/Dispose, 才是确定性清理的正道

这里的核心认知是:有了 GC,不等于可以完全不管资源。GC 帮你免去了管理普通对象内存的负担,但它有两个管不到的地方:一是非托管资源(句柄、连接等,它不知道怎么释放);二是需要主动解除的持有关系(比如事件订阅,它看到引用还在就不回收)。这两类,都要靠你实现 IDisposable、并用 using/Dispose 来确定性地、及时地处理。"GC 管内存,Dispose 管资源与解除持有"——把这条分工记牢,是写出无泄漏 C# 代码的基础。

第六件事:怎么定位这类内存泄漏——抓 dump 看引用链

最后说排查。这类"对象该回收却没回收"的泄漏,最有力的定位手段,是抓内存快照(dump),然后分析"是谁还在引用着这些本该死掉的对象"。这正是我定位真凶的方法。工具上,可以用 Visual Studio 的诊断工具、dotMemory、或命令行的 dotnet-dump + dotnet-gcdump

# 用 dotnet 工具抓内存快照, 分析泄漏
dotnet-gcdump collect -p <pid>       # 抓一份 GC 堆快照
# 在 Visual Studio / PerfView / dotMemory 里打开它, 重点看:

# 1. 哪类对象的实例数异常多(比如几万个 ViewModel, 明显不正常)
# 2. 选中这类对象, 看它的"引用根(GC Root)路径"——
#    谁还在引用它? 顺着引用链往上, 往往就看到一个事件的委托列表
# 3. 对比两次快照(隔一段时间各抓一份), 看哪类对象在持续增长
#    持续增长且回收不掉的, 就是泄漏的对象

# 关键技巧:盯住"实例数只增不减"的类型, 再看它的 GC Root 引用链,
#          引用链尽头那个"长命的持有者", 就是泄漏的根源(常是事件源)

这套"抓快照 → 看哪类对象异常多 → 顺引用链找到谁牵着它"的方法,是定位托管内存泄漏的通用套路。对于事件泄漏,引用链的尽头,往往就是一个长生命周期对象的事件委托列表——顺着它,就能精确找到"哪个事件订阅忘了退订"。内存泄漏不是玄学:对象为什么没被回收,答案就明明白白地写在它的 GC Root 引用链里,抓个 dump 看一眼,真凶就无所遁形。到这儿,这次事故的方方面面就齐了。我把排查思路收成一张决策图:

把这套理解建立起来,事件泄漏这类隐蔽的内存问题就能被预防和定位。最后,拧成几条可直接照做的铁律:

  1. 事件订阅会让发布者引用订阅者,发布者比订阅者活得久时, 不退订就泄漏。
  2. += 就必有配对的 -=,在订阅者销毁时(通常 Dispose 里)主动退订。
  3. 要退订的事件别用匿名 lambda 订阅,用具名方法或把委托存字段, 否则退订静默失败。
  4. 生命周期难控时用弱事件模式,让忘退订也不至于泄漏, 但它是减害非免责。
  5. 分清 GC 管内存、Dispose 管资源与解除持有,实现 IDisposable + 用 using。
  6. 别依赖终结器释放资源,它时机不定、拖慢 GC, 确定性清理靠 Dispose。
  7. 泄漏靠抓 dump 定位,看哪类对象只增不减、顺 GC Root 引用链找到牵着它的人。

一张 C# 内存泄漏速查表

把 C# 里常见的"明明有 GC 却泄漏"的场景汇成一张表,排查时对照着查。

泄漏场景 谁在牵着对象 对策
事件订阅没退订(本文) 长命发布者的事件委托列表 配对 -= / Dispose / 弱事件
用 lambda 订阅却退不掉 退订的是不同的新 lambda 具名方法 / 委托存字段
静态集合/缓存只加不删 静态字段(根永远可达) 设上限/过期, 及时移除
未释放的 IDisposable 非托管资源/句柄没释放 using / 调 Dispose
定时器(Timer)没停 Timer 持有回调目标 用完 Dispose/Stop 定时器
闭包意外捕获大对象 委托捕获了外层变量 只捕获必要的最小数据

更广的视角:GC 语言里,泄漏的"形式变了"

这次事故让我对"带 GC 的语言会不会内存泄漏"这个问题有了更深的理解。很多人以为有了 GC 就不会内存泄漏了——这是个流传很广的误解。准确的说法是:GC 消灭了 C/C++ 里那种"忘了 free、对象彻底失联"的经典泄漏;但它催生了一种新形式的泄漏——"对象逻辑上已经没用了,却因为还存在某条可达的引用链,而无法被回收"。事件订阅没退订、静态集合只加不删、定时器没停……这些都是同一类:不是"忘了释放",而是"无意中一直引用着"。

这个认知很重要,它改变了你在 GC 语言里排查内存问题的思路:你要找的,不再是"哪里忘了 free",而是"这个本该死掉的对象,到底还被谁引用着、是哪条引用链让 GC 没法回收它"。顺着这个思路,事件、静态字段、缓存、闭包、长生命周期容器,就成了你重点排查的"嫌疑人"——它们都是那种"容易在不经意间长期持有对象引用"的地方。在 GC 的世界里,管理内存的关键,从'记得释放'变成了'管好引用的生命周期'——别让短命的对象,被长命的东西无意中牵住。

这也再次和这个系列里那些"资源借还"的主题遥相呼应:无论是数据库连接的归还、ThreadLocal 的清理、还是这里事件的退订,本质上都是同一件事——你建立的每一份"持有关系",都要在恰当的时机被解除。只不过在 GC 语言里,这份"持有"常常是隐式的、藏在事件和引用背后的,需要你更敏锐地去察觉它、管理它。

写在最后

这次"事件泄漏拖垮内存"的事故,给我最深的体会,是它击碎了我对垃圾回收(GC)的一种盲目信任。曾几何时,我把 GC 当成一个无所不能的"内存管家",以为有了它,我就再也不用操心对象什么时候被回收——"反正用完了它会帮我清理"。可这次泄漏让我清醒地认识到:GC 是一个忠实但'死板'的管家,它的唯一准则是"只要还有人引用,我就绝不丢弃"。它不懂业务逻辑,不知道你心里那个对象"其实早就没用了";它只看引用。所以,如果你在不经意间,通过一个事件、一个静态集合,留下了一条指向它的引用,GC 就会忠实地、固执地,永远把它留在内存里。GC 解放了我们,但没有、也不可能,免除我们"管好引用关系"的责任。

这件事让我对"自动化"这件事有了更辩证的看法。自动垃圾回收,无疑是编程史上一项伟大的发明,它把我们从手动 malloc/free 那种极易出错的苦役中解放了出来。但任何自动化,都有它的边界和前提——它替你处理了"绝大多数常规情况",却也可能让你在那"少数例外"面前,因为过度依赖、丧失警觉而栽跟头。GC 替你回收了所有"真正没人要"的对象,但它无法替你判断"哪些引用其实是多余的、该被解除的"——这恰恰是它的边界,也正是泄漏滋生的地方。所以,真正会用一项自动化工具的人,不是那个完全不懂、全盘托付的人,而是那个既享受它的便利、又清楚它的边界、并在边界之外保持着一份清醒的人。这次事故于我,正是这样一堂课:它教会我,即便站在 GC 这样强大的自动化肩膀上,也要始终对"我的对象之间,到底建立了哪些引用、这些引用该在何时解除"这件事,保有一份不曾松懈的觉察。愿你我都能既感激自动化带来的轻盈,又不被这份轻盈蒙蔽,在它照拂不到的角落里,依然亲手把每一份该解除的持有,稳稳地解除——因为正是这份在自动化边界处的清醒与担当,区分了"能用工具"和"真正驾驭工具"。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

慢下游拖垮核心下单:服务雪崩与熔断避坑复盘

2026-5-31 2:06:36

技术教程

忘了一个 await:TS 浮动 Promise 乱序吞错避坑

2026-5-31 2:25:55

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索