是什么在.NET程序关闭时阻碍进程的退出?
在平时使用软件或是.NET程序开发的过程中,我们有时会遇到程序关闭后但进程却没有退出的情况,这往往预示着代码中有问题存在,不能正确的在程序退出时停止代码执行和销毁资源。这个现象有时并不容易被察觉,但在另一些情况下却会产生影响软件功能的Bug。本文列举可能影响.NET程序进程退出的因素,并用几个小例子说明这些因素如何导致Form Application和Windows Service的Bug。
一、进程不能退出对于某些Windows Form程序的影响
在传统C/S结构的系统中,客户端会通过Socket或WCF服务利用特定的端口与服务端保持通信。因此在很多应用场景中,为避免端口冲突,单台计算机同一时刻只允许启动一个客户端,这也符合一个客户端代表单个用户角色的业务设计。这可以通过Mutex类,或者在客户端启动时检查是否已有同名的进程存在来实现。有些客户端启动逻辑被设计成当存在已有进程时,不初始化用户界面,而是自动切换到已经打开的客户端并关闭自身。
在这种情况下,如果前一次从客户端界面中退出,但是进程没有关闭,那随后再次启动客户端时就再也无法正常显示出用户界面,除非手动杀掉进程再次启动。
二、Foreground线程导致进程无法退出的例子
用如下代码来模拟进程无法退出的情况。简单起见,这个小窗口程序没有任何网络或数据库操作,仅仅是用一个线程定时刷新UI。设想是当程序界面构建完成后启动一个Thread,随后每隔1秒刷新当前时间,当点击窗体关闭按钮之后,程序退出,Thread和进程一同被销毁。
1 public partial class Form1 : Form 2 { 3 Thread worker = null; 4 5 public Form1() 6 { 7 InitializeComponent(); 8 Load += new EventHandler(Form1_Load); 9 } 10 11 void Form1_Load(object sender, EventArgs e) 12 { 13 worker = new Thread(new ThreadStart(DoWork)); 14 worker.Start(); 15 } 16 17 private void DoWork() 18 { 19 while (true) 20 { 21 Thread.Sleep(1000); 22 if (IsHandleCreated && !IsDisposed) 23 { 24 Invoke((MethodInvoker)(() => label1.Text = DateTime.Now.ToString())); 25 } 26 } 27 } 28 }
在关闭窗体之后,实际的运行结果却是,用户看不到任何界面,但进程一直停留在任务管理器中,Thread也没有停止工作。
本例中,进程无法退出的原因就在于worker线程的IsBackground属性。创建Thread时没有对它赋值,IsBackground就保留它的默认值false,这种方式启动的线程也叫前台线程。可以看出,从Thread类创建出来的线程默认为前台线程。按照MSDN的解释,前台线程与后台线程唯一的区别,就是前者在完成执行代码之前会阻止进程的终止。也即.NET进程在退出时,会先等待前台线程执行完所有的操作,而后直接终止正在运行中的后台线程。
三、什么情况下使用Foreground线程
由于Background线程在进程程退出时被立即中止可能导致处理中断或数据丢失,当线程处理的任务和数据比较重要时,需要考虑用Foreground线程。例如希望退出程序时仍然能完整保存数据,或者在退出时需要完成到服务器的数据上传工作,或者需要确保某些资源得以释放。而在另一些情况下,如果线程执行的任务在并不是非常重要,则可以考虑用Background线程,如监听网络通信或临时计算任务等。
.NET中有多种方式可以创建或使用一个新线程,除了Thread类之外,还有ThreadPool.QueueUserWorkItem方法、BackgroundWorker类、Task类、Parallel类以及各种Timer。在这之中,只有从Thread类创建出来的线程才会默认是Foreground,其它的类多数是使用线程池中的线程来执行任务,而线程池中全部是Background线程。
除了使用Thread类创建Foreground线程外,设置Thread.CurrentThread.IsBackground属性值可以让运行中的Background线程变为Foreground线程。但这种方式应该谨慎使用,主要原因在于执行该语句的线程可能由线程池进行管理,我们难以在应用程序中对该线程的行为和生命周期进行控制,也不应该这样做。假如该线程执行任务非关键任务,又耗时比较长,那将其IsBackground设置为false同样会阻碍进程的退出,也不符合使用线程池的原则。但如果有明确的意图需要这样做,唯一需要保证的是让线程的任务快速完成。使用完线程池中的线程后忘记重置IsBackground为true并不会导致任何问题,因为线程池会在重用线程时重置这个值。
四、控制线程正常退出
回到上面的示例代码,假如我们已经决定要使用Foreground线程,那需要做的就是给线程的执行代码一个退出条件,让它在恰当的时候优雅的停止,而非无休止的运行下去。可以设置一个变量指示主窗口是否正在退出,再由线程定期检查这个变量,决定是否结束。
1 public partial class Form1 : Form 2 { 3 Thread worker = null; 4 bool isClosing = false; 5 6 public Form1() 7 { 8 InitializeComponent(); 9 10 worker = new Thread(new ThreadStart(DoWork)); 11 worker.Start(); 12 } 13 14 private void DoWork() 15 { 16 while (!isClosing) 17 { 18 Thread.Sleep(1000); 19 if (IsHandleCreated && !IsDisposed) 20 Invoke((MethodInvoker)(() => label1.Text = DateTime.Now.ToString())); 21 } 22 } 23 24 protected override void OnClosing(CancelEventArgs e) 25 { 26 base.OnClosing(e); 27 isClosing = true; 28 } 29 }
五、Foreground导致Windows Service进程延迟退出
对于Windows Service程序来讲,Foreground线程仍然会阻止Service进程的退出,但是情况稍有不同。一段最简单的Service程序代码如下,服务启动代码写在OnStart方法中,创建了一个线程对象循环执行任务,OnStop方法会在服务停止时被调用,这里假设需要5秒钟时间运行资源清理代码。
1 public partial class Service1 : ServiceBase 2 { 3 Thread worker; 4 5 public Service1() 6 { 7 InitializeComponent(); 8 } 9 10 protected override void OnStart(string[] args) 11 { 12 worker = new Thread(new ThreadStart(DoWork)); 13 worker.Start(); 14 } 15 16 protected override void OnStop() 17 { 18 // Clean up resources. 19 Thread.Sleep(5000); 20 } 21 22 private void DoWork() 23 { 24 while (true) 25 { 26 // Time consuming work task. 27 Thread.Sleep(50); 28 } 29 } 30 }
在服务中停止这个名为“Windows Service Stop Test”的服务,带有进度条的服务控制对话框出现,并在5秒钟后关闭。对于服务控制器来说,OnStop方法执行完毕即意味着服务停止动作已经完成,服务控制器最多等待OnStop方法执行125秒,超过这个时间之后会弹出错误1053:“服务没有及时响应启动或控制请求”并返回,之后OnStop方法中的代码仍然会继续运行直到完成。这时由于Foreground线程还在运行,服务对应的进程也没有退出,仍然在任务管理器里面。然而与Windows Form程序不同的是,30秒后这个进程会被强制退出。这种情况下,没有正确退出的Foreground会导致的进程延迟时间是30秒。
六、Finalize方法导致的延迟
假定所有的线程都被妥善管理,Service停止之后进程退出的时间仍然可能由于Finalize方法的执行产生延迟。进程退出时会导致进程中的AppDomain被卸载和CLR被关闭,这一动作会触发对所有对象的垃圾回收,并调用它们的Finalize方法。Finalize方法被允许的最长执行时间是2秒,因此进程可能会在Service停止2秒之后才退出。
七、进程延迟退出可能暴露出来的问题
进程延迟2秒或30秒退出会有什么问题呢?下面这个示例在Service启动时监听本机某个端口,在停止时花5秒钟时间做了一些清理工作,但是由于种种原因没有关闭对端口的监听。在实际的项目中,这种情况时有发生。可能是某个程序员认为进程终止后对端口的监听自然消失,没有必要手动关闭;也可能是由于要释放的资源太多,漏掉了关闭端口代码。当然还有另外一种情况,设想关闭端口的代码位于某个类型的Finalize方法中,而Finalize方法还没有执行到这一行代码就因为超出2秒时间被终止……
1 public partial class Service1 : ServiceBase 2 { 3 TypeA objectA = null; 4 5 public Service1() 6 { 7 InitializeComponent(); 8 } 9 10 protected override void OnStart(string[] args) 11 { 12 objectA = new TypeA(); 13 14 TcpListener listener1 = new TcpListener(IPAddress.Parse("127.0.0.1"), 12345); 15 listener1.Start(); 16 } 17 18 protected override void OnStop() 19 { 20 // Clean up resources. 21 Thread.Sleep(5000); 22 } 23 } 24 25 public class TypeA 26 { 27 ~TypeA() 28 { 29 // Clean up resources. 30 Thread.Sleep(3000); 31 } 32 }
现在,启动这个服务,再停止这个服务,然后再次启动,虽然Finalize方法导致进程退出晚了两秒,但到目前为止并没有造成任何麻烦。然而当想要尝试“重新启动”这个服务的时却得到了“本地计算机上的服务启动后停止”的提示,服务无法启动成功。
检查事件查看器,我们可以很快发现问题出在对网络端口的争用上。在用户尝试“重新启动”时,服务控制器仅仅是简单的停止并启动服务。停止的时候,完成OnStop方法需要5秒钟,之后控制器认为服务停止过程已完成(实际上也确实如此),再次启动服务,并开始监听同一网络端口。但这时前一次停止的服务进程还没有完全退出,端口也没有释放,因此新的进程打开这一端口就产生了SocketException。
八、让进程更快退出的几个编程建议
严格来说,进程延迟退出并没有导致任何新问题的产生,只是暴露了代码里原本已经存在的缺陷,这些缺陷几乎都与资源的使用和释放不当有关。当代码中有完善且恰到好处的错误日志时,这些问题或许很快就能被定位和解决,而在另一些情况下可能要花费一些周折才能找到根源所在。因此在平时的编程中就遵循一些规则来避免这类问题的发生是有必要的,结合本文的小例子,有如下建议:
- 根据需要决定使用Foreground或者Background线程。Foreground线程可以保证重要的工作在进程退出前有机会完成,更重要的,需要为包括Foreground线程在内的所有线程设定退出条件。
- 当需要使用Foreground线程时,Thread类型是最好的选择,直接设置Thread.CurrentThread.IsBackground属性是不推荐的方式。
- 程序退出时,应该手动释放所有非托管资源,并且越关键的资源要越早释放,如示例中的网络端口。
- 相对于在Finalize方法中释放资源,Dispose模式是更好的方式。Dispose模式不依赖于垃圾回收,可以自主决定何时释放不用的对象,而不是把释放资源的压力都集中在Finalize这一步骤。