Windows Forms Invoke() 泄露句柄原因之调查

版权声明:所有博客文章除特殊声明外均为原创,允许转载,但要求注明出处。

有朋友问我关于 微软开源 WinForms, WPF 和 WinUI 的看法。说实话,我没什么看法。开源这个事,从来都是有兴趣的人才会 Awesome,对吃瓜群众只能是 So what。不过这则消息让我想起大概半年前维护产品客户端时遇到的一个问题,当时没有机会深入分析,现在既然 WinForm 已经开源,倒是值得深入分析一下了。

背景

问题的表现是这样的:据我们的一位客户报告,程序客户端长时间运行后会泄露 Windows 资源句柄。首先应当感谢这位心细如发的用户朋友,不然我们自己都想不到去注意一下资源句柄的数量(汗)。能观察到如此技术性的信息,想来多半是同行了,至少也应该有一定技术背景,可惜他在邮件里并未透露自己的身份。当然啦,尊重客户隐私的我们也不会多嘴什么的......

我们的客户端程序基本上是用 Windows Form 编写的,另有少量代码使用了 C++ 本地代码和 P/Invoke。接到问题后,首先花了一些时间定位发生问题的代码,最终锁定在 Control.Invoke() 方法的调用上。因为我们的程序需要定时更新界面内容,从而创建了一个后台工作线程专门收集当前状态信息,每当状态发生变化时,就会调用 Invoke() 通知界面刷新。经反复修改测试后我确认,只要调用 Invoke(),任务管理器中的句柄计数就会增加,而和 Invoke() 调用的委托方法是没有关系的————即便是 Invoke 一个空函数,句柄仍然在增加。可见,问题出在 WinForm 内部。

当然了,虽然彼时 WinForm 并未开源,但借助 ILSpy / dotPeek /JustDecompile 之类的工具,我们还是有机会研究一下 WinForm 的内部实现的。我也确实用 dotPeek 简单反编译了一下代码,可惜的是这样得到的代码还是丢失了部分信息,读起来比较费劲。由于计划给出的时间有限,最终我查到了 StackOverflow 上的这个问题:UI Thread .Invoke() causing handle leak? 按照下面有人给出的提示,只要用 BeginInvoke() 代替 Invoke(),就可以避免句柄泄露。按照这个思路修改代码,果然解决了问题,遂提交之。

复现问题

为了说明问题,我们可以写一个简单的 WinForm 程序来复现场景。

    delegate void StringDelegate(string param);

    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();
        }

        private BackgroundWorker _worker;

        private void Form1_Load(object sender, System.EventArgs e)
        {
            _worker = new BackgroundWorker();
            _worker.WorkerSupportsCancellation = true;
            _worker.DoWork += worker_DoWork;
            _worker.RunWorkerAsync();
        }

        private void worker_DoWork(object sender, DoWorkEventArgs e)
        {
            int counter = 0;
            while (!_worker.CancellationPending)
            {
                Thread.Sleep(100);
                string msg = string.Format("pid={0}, Worker report {1}", 
                    Process.GetCurrentProcess().Id, counter);
                StringDelegate callback = s => this.Text = s;
                Invoke(callback, msg);
                // BeginInvoke(callback, msg);
                counter++;
            }
        }
    }

基本上,这段程序就是启动了一个后台任务(BackgroundWorker),然后不停地向主线程调用 Invoke()。为了方便对比,我们还可以生成另一个版本的程序,只要简单地将 Invoke() 替换成 BeginInvoke(),然后通过任务管理器观察它们运行时的区别。

Tasks

从任务管理器可以观察到:两个版本启动时的各项指标几乎是完全一样的,但 Invoke() 版本的句柄数量在不停上涨,内存占用也有所增加,但幅度不大。BeginInvoke() 版本的句柄数量偶尔会跳动一下,但很快又恢复正常,基本上保持稳定。有意思的是,在我目前的操作系统上(Win10 X64),如果你等待足够长的时间,会观察到这样的现象:Invoke() 版本的句柄数量一直增长到大约 2200 左右,会猛然降低到原来的 300,然后继续上升,如此不停循环。其他操作系统或者补丁级别不同的话或许会观察到不同的数字,但大致表现应该是类似的。

这里还有一个小小的问题。我们知道 Invoke() 泄露了句柄,但到底是什么内容的句柄?任务管理器可以告诉我们线程、User 对象、GDI 对象的数量,但是从上图来看,这些都不是,那么到底是什么?我原本想用 SysInternals 工具箱中的 Process Explorer 来检查,但 PE 竟然也无法显示。好在工具箱中还有一个更加专业的工具: Handle.exe, 该它出场了:

handle -a -p 12732

上述命令表示返回所有句柄(-a),以及要检查的进程PID(-p xxx)。需要说明的一点是,该命令需要在管理员模式下执行,否则可能得不到正确的结果。

Handles

从输出可以确认,Invoke() 泄露的资源是核心对象 Event。

从现象结合对 CLR 的了解,我们大概可以这样猜想:Invoke() 应当是在内部创建了事件资源,但并未在调用结束后马上释放,而是等待 GC 时由 Finalizer 来释放。如果当真是这样的话,那问题也不算特别严重,毕竟资源最终还是会释放的,只是对系统资源会造成不必要的压力———— Invoke() 是同步的,那么调用完毕后资源应该是可以马上释放的,没有必要等待终结器来回收。

探索源码

现象已经清楚,现在我们可以研究源码来确认具体的原因了。WinForms 开源地址 托管在Github上。出乎意料的是,git clone 很快就执行完了,不过想想也对,毕竟是刚开源(事实上最早的提交是在 10 月份,应该是早就有了但一直未公开)。

好奇心胜的我忍不住又统计了一回代码:

WinForms cloc

虽然不知道 WinForms 代码库里有 Smalltalk 是什么鬼(想必是文件扩展名搞混了),不过和 C# 代码相比数量很小,影响可以忽略不计。可见 WinForms 的代码总体规模大概在 35W 行,并不是特别大的项目。尽管按官方消息说后续可能还会有其他代码陆续放出来,我大致浏览了一下,基本可以断定主体部分的代码都已经开放出来了,那些消息估计是针对 WPF 或者 WinUI 的。

我们知道在 WinForms 中, Invoke()Control 的方法。好在 WinForms 源代码结构还是很规矩的,Control 的代码还是很好找:src\System.Windows.Forms\src\System\Windows\Forms\Control.cs

我们先看一下 Invoke() 的实现。该方法有几个版本的重载,不外乎互相调用,值得注意的部分在这里:

    public Object Invoke(Delegate method, params Object[] args) {
        using (new MultithreadSafeCallScope()) {
            Control marshaler = FindMarshalingControl();
            return marshaler.MarshaledInvoke(this, method, args, true);
        }
    }

MarshaledInvoke() 是一个比较长的方法,其中注释比较多,值得花时间仔细看。忽略一些相对次要的部分和较长的注释,重点代码如下:

    private Object MarshaledInvoke(Control caller, Delegate method, Object[] args, bool synchronous) {
        ...
        ExecutionContext executionContext = null;
        if (!syncSameThread) {
            executionContext = ExecutionContext.Capture();
        }
        ThreadMethodEntry tme = new ThreadMethodEntry(caller, this, method, args, synchronous, executionContext);

        ...
        if (synchronous) {
            if (!tme.IsCompleted) {
                WaitForWaitHandle(tme.AsyncWaitHandle);
            }
            if (tme.exception != null) {
                throw tme.exception;
            }
            return tme.retVal;
        }
        else {
            return(IAsyncResult)tme;
        }

看到 WaitHandle 的字样敏感的同学应该和我一样在脑内触发信号了,这明显是分配核心对象的标志。不过 WaitForWaitHandle() 方法的实现并不出奇,尽管内容较多,但本质上就是调用核心对象的 WaitOne() 方法,并不涉及分配,没什么特别值得注意的。我们应当留心的是 ThreadMethodEntry.AsyncWaitHandle 属性,这才是真正分配事件对象的场所:

    public WaitHandle AsyncWaitHandle {
        get {
            if (this.resetEvent == null) {
                lock (invokeSyncObject) {
                    if (this.resetEvent == null) {
                        this.resetEvent = new ManualResetEvent(false);
                        if (this.isCompleted) {
                            this.resetEvent.Set();
                        }
                    }
                }
            }
            return(WaitHandle)this.resetEvent;
        }
    }

你得到它了!果然这里 new 了一个手工重置事件,相应的也会增加句柄计数。那么我们再看看该对象是如何释放的?

    ~ThreadMethodEntry()
    {
        if (this.resetEvent != null)
        {
            this.resetEvent.Close();
        }
    }

果然。ThreadMethodEntry 事实上实现了 IAsyncResult 接口,也就是异步返回的对象,但它并不是个 Disposable 的对象,释放资源的动作在终结器中才会调用。这和我们观察到的现象是一致的。

但且慢宣告成功,我们还有一点值得考虑,那就是:BeginInvoke() 为什么不会造成句柄泄露呢?

我们再看看 BeginInvoke() 的实现:

    public IAsyncResult BeginInvoke(Delegate method, params Object[] args) {
        using (new MultithreadSafeCallScope()) {
            Control marshaler = FindMarshalingControl();
            return(IAsyncResult)marshaler.MarshaledInvoke(this, method, args, false);
        }
    }

这个实现和 Invoke() 几乎是完全一样的,区别只在于最后一个参数 synchronous。从名字不难推想这个参数是用于控制同步或异步返回的。MarshaledInvoke() 代码前面已经看过了,现在再重点回忆一下 synchronous 参数是如何处理的:

    private Object MarshaledInvoke(Control caller, Delegate method, Object[] args, bool synchronous) {
        ...
        if (synchronous) {
            if (!tme.IsCompleted) {
                WaitForWaitHandle(tme.AsyncWaitHandle);
            }
            if (tme.exception != null) {
                throw tme.exception;
            }
            return tme.retVal;
        }
        else {
            return(IAsyncResult)tme;
        }

可见,只有在同步也就是 Invoke() 调用的情况下,才会获取 ThreadMethodEntry.AsyncWaitHandle 属性。而从前面的代码已经看到了, AsyncWaitHandle 的实现是懒加载的,只有在被调用的情况下才会真正创建内部事件,所以异步调用的 BeginInvoke() 不会泄露句柄。但同时需要注意的是,如果再调用 EndInvoke():

IAsyncResult ar = BeginInvoke(...);
EndInvoke(ar);

那么这种写法和直接调用 Invoke() 效果几乎是完全一样的。EndInvoke() 同样会在内部调用 WaitForWaitHandle(),于是句柄泄露再次出现。

结论:如果你不需要 Invoke() 的返回值,那么最好是调用 BeginInvoke() 并避免随后调用 EndInvoke(),可以避免资源泄露。但这种泄露在下次 GC 时会被终结器所释放,所以并不是特别严重的问题,如果对系统资源不是那么敏感的应用,那么调用 Invoke()BeginInvoke/EndInvoke() 也是可以接受的。

写在最后:一点杂感

有些朋友对于开源有点不以为然,认为那是几个核心开发者的事情,其他人只不过是瞧个热闹;又或者认为我们这些个人开发者再怎么折腾,也比不上大厂的软件有质量保证。我希望本文的例子能告诉你事实并非如此。开源并不意味着,其他人必须吃透整个项目才能有所贡献;也不代表大厂就能覆盖到所有 case,一般群众发现不了问题。对我们而言,即便不可能对开源代码做出什么了不起的贡献,能够跟踪问题、发现问题,甚至自己动手解决问题,就是多了一层保证,当采用开源软件的时候也更有信心。否则的话,你只能寄希望于厂商即使发现问题和释放补丁了。

值得称道的是,Windows Forms 的代码————仅就我自己观察到的范围而言————应该说写得相当不错,目录结构整齐、格式规范且注释清楚,这在我浏览代码时为我省下了不少力气。虽然我个人的兴趣主要还是在跨平台的 UI 框架,微软开放的框架都和 Windows 绑得太死,对我来说意义不大,但是对于以 Windows 为主要平台的程序员来说,还是值得看一下的。另外一个可能比较有价值的点:WinForms 内部封装的 NativeMethod 非常多且完整,对于需要大量调用 Win32 API 的程序员朋友来说,应该是个很有价值的资源,直接 clone 源码复制,也比搜索 pinvoke.net 要快多了,质量上想必也更有保证吧。