在平時使用軟體或是.NET程式開發的過程中,我們有時會遇到程式關閉後但進程卻沒有退出的情況,這往往預示著代碼中有問題存在,不能正確的在程式退出時停止代碼執行和銷毀資源。這個現象有時並不容易被察覺,但在另一些情況下卻會產生影響軟體功能的Bug。本文列舉可能影響.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這一步驟。