CodeGym /课程 /C# SELF /取消订阅事件 ( -=) 与 内存泄漏

取消订阅事件 ( -=) 与 内存泄漏

C# SELF
第 53 级 , 课程 1
可用

1. 介绍

在某种意义上,C# 中订阅事件就像向好友订阅梗图推送:只要你不说“够了”并取消订阅,消息就会一直来。在编程里这尤其重要,因为忘记取消订阅不只是“又来一张梗图”,而是会造成内存泄漏

想象一下,你程序里有一个表单(比如额外的设置窗口)。它订阅了主窗口的事件以响应变化。用户关闭了表单,以为它被销毁了,但处理器仍然订阅着!因为发布者通过事件持有对它的引用,表单仍然存活在内存中。

结论: 如果订阅者对象订阅了发布者的事件并“忘记”取消订阅,只要发布者还活着,垃圾回收器就不会回收订阅者。

回顾一下操作符 += 并演示 -=

  • += — 订阅:把处理器加入事件调用列表。
  • -= — 取消订阅:从事件调用列表移除处理器。

看起来大概是这样:


worker.WorkCompleted += handler; // 订阅
worker.WorkCompleted -= handler; // 取消订阅

如果处理器被添加了两次,就需要删除两次,才能确保它从调用列表里真正消失。

一点内部实现细节

在背后,C# 的事件其实是个 delegate 字段(或者说 delegate 的列表),操作符 += 实际上调用了 Delegate.Combine,而 -= 调用了 Delegate.Remove。订阅事件的对象会成为引用图的一部分。这就是为什么忘记取消订阅会导致内存泄漏的原因。

2. 通过事件引起的内存泄漏:它是怎样工作的

经典情形


class Window
{
    public event EventHandler Updated;

    public void SimulateUpdate()
    {
        // 模拟:通知所有订阅者
        Updated?.Invoke(this, EventArgs.Empty);
    }
}

class SettingsForm
{
    public void OnWindowUpdated(object sender, EventArgs e)
    {
        Console.WriteLine("SettingsForm 响应窗口更新");
    }
}

逐步来看:


var window = new Window();
var settingsForm = new SettingsForm();

window.Updated += settingsForm.OnWindowUpdated;

window.SimulateUpdate(); // SettingsForm 响应

// 用户关闭了表单。我们丢掉了对它的所有引用:
settingsForm = null;

// 但只要 window 还活着,SettingsForm 对象就不会被回收,
// 因为 window.Updated 仍然持有对 OnWindowUpdated 方法的引用,
// 也就间接持有对 SettingsForm 实例的引用。

怎么做?
取消订阅:


// 为此我们需要保留对处理器或对象的引用:
window.Updated -= settingsForm.OnWindowUpdated;
settingsForm = null; // 现在对象可以被回收

表格:谁持有谁的引用

操作 谁持有引用 能否释放内存?
订阅事件 (+=) 发布者 指向 订阅者 不能,只要发布者活着
取消订阅 (-=) 没有 可以,在删除所有外部引用后
没有订阅 没有 可以

3. 如何正确组织取消订阅

显式移除处理器

比如在窗口或表单关闭时可以这样做:


class SettingsForm
{
    private readonly Window _window;

    public SettingsForm(Window window)
    {
        _window = window;
        _window.Updated += OnWindowUpdated;
    }

    public void Close()
    {
        _window.Updated -= OnWindowUpdated; // 取消订阅!
        // 这里放关闭逻辑(比如 Dispose、GC.SuppressFinalize 等)
    }

    public void OnWindowUpdated(object sender, EventArgs e)
    {
        // 事件处理
    }
}

如果 SettingsForm 是通过“关闭”按钮销毁的,重要的是别忘了调用包含取消订阅逻辑的方法(比如 Close())。

使用接口 IDisposable

对于那些订阅事件并且需要管理生命周期的复杂对象,实现 IDisposable 很方便。在 Dispose() 方法里做所有必要的取消订阅。


class SettingsForm : IDisposable
{
    private readonly Window _window;

    public SettingsForm(Window window)
    {
        _window = window;
        _window.Updated += OnWindowUpdated;
    }

    public void OnWindowUpdated(object sender, EventArgs e)
    {
        // ...
    }

    public void Dispose()
    {
        _window.Updated -= OnWindowUpdated;
        // 在这里释放其他资源
    }
}

现在可以在 using 语句块中使用 SettingsForm,或者显式调用 Dispose(),或者在需要的地方结合 GC.SuppressFinalize 做自动化释放(针对有终结器的类型)。

4. 与 lambda 表达式交互:风险与技巧

如果你用 lambda 表达式订阅事件,但没有把 lambda 保存到变量,你就无法取消订阅!


// 订阅 — 匿名 lambda
window.Updated += (s, e) => Console.WriteLine("Lambda 被调用!");

// 现在怎么取消?— 不行!
window.Updated -= (s, e) => Console.WriteLine("Lambda 被调用!"); // 这是另一个 delegate!

怎么办?
把 lambda 保存到 delegate 变量:


EventHandler handler = (s, e) => Console.WriteLine("Lambda 被调用!");
window.Updated += handler;

// ... 现在可以取消订阅了!
window.Updated -= handler;

5. 有用的细节

对象生命周期和事件的特性

另一个常见问题是两个“长寿命”对象之间通过事件形成交叉引用。比如,一个窗口订阅了另一个窗口的事件,两个对象都一直被使用且不被回收——内存会越来越多。

建议: 时刻注意谁订阅了谁以及什么时候需要取消订阅。如果订阅者和发布者的生命周期一致,那就没问题。如果订阅者可能比发布者活得更短,那就实现显式取消订阅。

通用规则:“订阅了就要取消订阅!”

  • 对于长寿命的发布者(比如全局的、单例、主窗口)——订阅者一定要实现取消订阅逻辑。
  • 对于临时对象(比如一次性的通知,或者订阅者比发布者活得久的情景)——可以放松一点,但仍然要注意上下文。
  • 如果不想手动管理取消订阅,可以使用像 WeakEvent(弱事件)这样的方案,或者借助特定框架。

6. 处理取消订阅时的典型错误

错误的取消订阅:处理器方法必须完全相同

在取消订阅时,你必须传入和订阅时完全相同的处理器。否则取消订阅不会生效。

错误示例:


window.Updated += settingsForm.OnWindowUpdated;
// ...
window.Updated -= new SettingsForm().OnWindowUpdated; // 不会生效!这是另一个实例和另一个 delegate!

正确示例:


window.Updated -= settingsForm.OnWindowUpdated;

如果订阅时使用了匿名 lambda 且没有保存对 delegate 的引用,就无法取消订阅,因为那将是另一个 delegate 实例:


// 订阅
window.Updated += (s, e) => Console.WriteLine("Lambda!");

// 尝试取消订阅 — 不会生效!
window.Updated -= (s, e) => Console.WriteLine("Lambda!");

“被忘记”的取消订阅

经常发生的情况是开发者忘了取消订阅,尤其当订阅者比发布者活得更久,或者开发者没完全理解事件的工作机制时。结果是订阅者在内存中比预期存在更久,导致内存泄漏和性能问题。

评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION