閱讀405 返回首頁    go 技術社區[雲棲]


事件(Event),絕大多數內存泄漏(Memory Leak)的元凶[上篇]

最近這兩天一直在忙著為一個項目檢查內存泄漏(Memory Leak)的問題,對相關的知識進行了一下簡單的學習和探索,其間也有了一些粗淺的經驗積累,今天特意寫一篇相關的文章與大家分享。那些對內存泄漏稍微有點了解的人,對於本篇文章的標題,相信不會覺得是在危言聳聽。就我查閱的資料,已經這兩天的發現也證實了這一點:覺得部分的內存泄漏問題與事件(Event)有關。本篇文章將會介紹其原理,以及如何發現和解決由事件導致的內存泄漏問題。

image 為了讓讀者首先對這個主題有一個感官的印象,讓大家覺得內存泄漏問題離我們並不遙遠,我特意寫了一個簡單的應用程序。我們這個應用程序叫做TodoListManager,因為通過它可以實時查看屬於用戶的“待辦事宜(Todolist)”。這是一個GUI的應用,有兩個Windows Form組成:左側的窗體是一個程序的主界麵(為了簡單起見,我甚至沒有將其做成MDI窗體),點擊Todo List菜單項,右麵的Form被顯示出來:所有的代碼事宜將會全部列出,為了保證記錄的實時顯示,每隔5秒鍾數據自動刷新一次。

首先定義表示每一項TotoList Item定義了一個相應的類型:Event(不是我們談到的導致內存泄漏的事件)。Event僅僅包含簡單的屬性:主題(Subject),截至日期(DueDate)和相應的描述性文字(Description),Event定義如下:

   1: using System;
   2: namespace Artech.MemLeakByEvents
   3: {
   4:     public class Event
   5:     {
   6:         public string Subject { get; set; }
   7:         public DateTime DueDate { get; set; }
   8:         public string Description { get; set; }
   9:         public Event(string subject, DateTime dueDate, string desc)
  10:         {
  11:             if (string.IsNullOrEmpty(subject))
  12:             {
  13:                 throw new ArgumentNullException("subject");
  14:             }
  15:             this.Subject = subject;
  16:             this.DueDate = dueDate;
  17:             this.Description = desc ?? string.Empty;
  18:         }
  19:     }
  20: }

然後我將所有邏輯(實際上僅僅是定期獲取TodoList列表而已)定義在下麵一個叫做TodoListManager的類型中。為了演示的需要,我特意將其定義成Singleton的形式,並采用System.Threading.Timer實現定時地獲取Todo List的操作。

   1: using System;
   2: using System.Collections.Generic;
   3: using System.Threading;
   4: namespace Artech.MemLeakByEvents
   5: {
   6:     public  class TodoListManager
   7:     {
   8:         private static readonly TodoListManager instance = new TodoListManager();
   9:         public event EventHandler<TodoListEventArgs> TodoListChanged;
  10:         private Timer todoListRefreshSchedler;
  11:         private TodoListManager()
  12:         {
  13:             todoListRefreshSchedler = new Timer
  14:             (
  15:                 state =>
  16:                 {
  17:                     if (null == TodoListChanged)
  18:                     {
  19:                         return;
  20:                     }
  21:  
  22:                     TodoListChanged(null, new TodoListEventArgs(GetTodolist()));
  23:                 }
  24:            , null, 0, 5000);
  25:         }
  26:         public static TodoListManager Instance
  27:         {
  28:             get
  29:             {
  30:                 return instance;
  31:             }
  32:         }
  33:         private List<Event> GetTodolist()
  34:         {
  35:             var list = new List<Event>();
  36:             list.Add(new Event("Meeting with Testing Team", DateTime.Today.AddDays(2),"NIL"));
  37:             list.Add(new Event("Deliver progress report to manager ", DateTime.Today.AddDays(7), "NIL"));
  38:             return list;
  39:         }
  40:     }
  41: }

對於Timer的每一個輪詢,都會處觸發一個類型為EventHandler<TodoListEventArgs>的事件,通過注冊這個事件,可以通過類型為TodoListEventArgs的事件參數得到最新的TodoList的列表,TodoListEventArgs定義如下:

   1: using System;
   2: using System.Collections.Generic;
   3: namespace Artech.MemLeakByEvents
   4: {
   5:     public class TodoListEventArgs : EventArgs
   6:     {
   7:         public IEnumerable<Event> TodoList
   8:         { get; private set; }
   9:         public TodoListEventArgs(IEnumerable<Event> todoList)
  10:         {
  11:             if (null == todoList)
  12:             {
  13:                 throw new ArgumentNullException("todoList");
  14:             }
  15:  
  16:             this.TodoList = todoList;
  17:         }
  18:     }
  19: }

然後我們來看看我們的應用通過怎樣的形式將每一個刷新的列表顯示在TodolList窗體中。其實很簡單,我僅僅是在窗體Load的時候注冊TodoListManager的TodoListChanged事件,並將獲取到的TodoList列表綁定到DataGridView上麵。由於TodoListManager異步工作的原因,我借助了SynchronizationContext這麼一個對象實現對數據的綁定。

   1: using System;
   2: using System.Threading;
   3: using System.Windows.Forms;
   4:  
   5: namespace Artech.MemLeakByEvents
   6: {
   7:     public partial class TodoListForm : Form
   8:     {
   9:         public static SynchronizationContext SynchronizationContext
  10:         { get; private set; }
  11:  
  12:         public TodoListForm()
  13:         {
  14:             InitializeComponent();
  15:         }
  16:  
  17:         private void TodoListForm_Load(object sender, EventArgs e)
  18:         {
  19:             SynchronizationContext = SynchronizationContext.Current;
  20:             TodoListManager.Instance.TodoListChanged += TodoListManager_TodoListChanged;            
  21:         }
  22:  
  23:         private void TodoListManager_TodoListChanged(object sender, TodoListEventArgs e)
  24:         {
  25:             SynchronizationContext.Post(
  26:                 state =>
  27:                 {
  28:                     BindingSource bindingSource = new BindingSource();
  29:                     bindingSource.DataSource = e.TodoList;
  30:                     this.dataGridViewTodoList.DataSource = bindingSource;
  31:                 }, null);
  32:         }       
  33:     }
  34: }

整個應用就這麼簡單,但是為了確定是否真的出現內存泄漏,我們需要在查看內存狀態的時候,確保GC把所有垃圾對象全部回收完畢。為此,我在整個應用級別定義了一個靜態的System.Threading.Timer,讓它每隔半秒調用一次GC.Collect()。

   1: using System;
   2: using System.Windows.Forms;
   3: namespace Artech.MemLeakByEvents
   4: {
   5:     static class Program
   6:     {
   7:         static System.Threading.Timer gcScheduler = new System.Threading.Timer
   8:             (state => GC.Collect(), null, 0, 500);       
   9:         [STAThread]
  10:         static void Main()
  11:         {
  12:             Application.EnableVisualStyles();
  13:             Application.SetCompatibleTextRenderingDefault(false);
  14:             Application.Run(new MainForm());
  15:         }
  16:     }
  17: }

接下來我查看我們的應用程序是否會有內存泄漏的問題了。查看內存泄漏,當然不能通過我們的肉眼去捕捉,需要借助響應的Memory Profiling工具。我們有很多這樣的工具,有免費的,也有需要付錢購買的。在這裏我推薦兩個Memory Profiling工具,一個是JetBrains的dotTrace,另一個是RedGate的ANTS Memory Profiler,前者是免費的,後者不是。在這裏我通過後者來查看本應用的內存泄漏問題。

ANTS Memory Profiler通過這樣的原理來確定你的應用程序是否有泄漏問題:如果你懷疑某個操作會導致應該被GC回收的對象沒有被回收,那麼你在之前對內存分配情況拍一張快照(Snapshot),然後執行該操作,在操作完成並確定GC完成相應的回收操作後,在拍一張快照。通過對比,找出多餘的對象,並根據具體的情況分析該對象是否應該被GC回收,如果是的,怎意味著你的程序存在著內存泄漏問題。關於ANTS Memory Profiler的具體操作,這裏就不再細說了,隻要大家了解基本的原理,不影響對後麵內容的理解就可以了。

image 通過ANTS Memory Profiler啟動我們的應用程序後,在一開始的時候我們拍攝一張反映程序初始狀態的內存快照,然後選擇File\Todo List打開TodoListForm,等待一定的時間,再將TodoListForm關閉。為了讓GC有充分的時間進行垃圾回收,不妨再作相應的等待,然後拍下第二張快照。在Class List視圖中,你會發現原本應該被垃圾回收的TodoListForm窗體對象還存在於內存之中。

image 那麼是什麼導致TodoListForm不能GC正常回收呢?熟悉GC原理的人應該知道,原因隻有一個,那就是被某些正在使用或者會被使用,或者GC認為正在正在使用或者會被使用的對象引用著(Jeffrey Richiter將這些對象成為所謂的根)。ANTS Memory Profiler的強大之處就是可以讓你可以很清楚地看到這個對象正在被那些其他的對象引用著。

左圖就是TodoListForm對象在內存中的引用鏈,我們可以很清楚地看到:該對象被TodoListManager的一個類型為EventHandler<TodoListEventArgs>的事件引用,這個對象實際上是一個Delegate對象,而TodoListForm作為這個Delegate對象的Target。通過上麵給出的代碼,我們不難想出是由於在TodoListForm實現了對TodoListManager的TotoListChanged事件注冊導致了TodoListManager不能被垃圾回收。

上麵的實力說明了這麼一種情況:對於GUI應用可視化樹形結構來說,一個窗體被關閉,照例說它應該成為垃圾對象,GC在執行垃圾回收的時候就可以將其清楚的。但是,由於該對象注冊了一個事件到一個生命周期很長的對象(在本例中,TodoManager是一個Singletone對象,具有和整個應用程序一樣的生命周期),它就是被這麼一個對象長期引用,進而阻止 GC對其的回收工作。

所以,在這種情況:短暫生命周期注冊事件到長期生命周期對象上,在該對象被Dispose的時候,應該解除事件的注冊。你可以通過實現System.IDisposable接口,將解除事件注冊的操作放在Dispose方法中。對於本裏來說,你可以將相應的操作注冊到Form的Closing、Closed或者Disposed事件中。比如在下麵代碼中,我為TodoListForm添加了如下一個Closing事件處理程序:

   1: using System;
   2: using System.Threading;
   3: using System.Windows.Forms;
   4:  
   5: namespace Artech.MemLeakByEvents
   6: {
   7:     public partial class TodoListForm : Form
   8:     {
   9:         //省略其他成員
  10:         private void TodoListManager_TodoListChanged(object sender, TodoListEventArgs e)
  11:         {
  12:             SynchronizationContext.Post(
  13:                 state =>
  14:                 {
  15:                     BindingSource bindingSource = new BindingSource();
  16:                     bindingSource.DataSource = e.TodoList;
  17:                     this.dataGridViewTodoList.DataSource = bindingSource;
  18:                 }, null);
  19:         }
  20:  
  21:         private void TodoListForm_FormClosing(object sender, FormClosingEventArgs e)
  22:         {
  23:             TodoListManager.Instance.TodoListChanged -= TodoListManager_TodoListChanged;
  24:         }
  25:     }
  26: }

 

那麼,在此按照上麵的流程利用ANTS Memory Profiler查看內存泄漏,在第二個快照中,你將再也看不到TodoListForm的身影(如下圖)。本篇主要介紹如何重現事件注冊導致內存泄露,已及最直接的解決方案。下一篇我將進一步對其背後的原理進行剖析,並提出另一種更加“優雅而可靠”解決方案。

image


作者:蔣金楠
微信公眾賬號:大內老A
微博:www.weibo.com/artech
如果你想及時得到個人撰寫文章以及著作的消息推送,或者想看看個人推薦的技術資料,可以掃描左邊二維碼(或者長按識別二維碼)關注個人公眾號(原來公眾帳號蔣金楠的自媒體將會停用)。
本文版權歸作者和博客園共有,歡迎轉載,但未經作者同意必須保留此段聲明,且在文章頁麵明顯位置給出原文連接,否則保留追究法律責任的權利。
原文鏈接

最後更新:2017-10-30 11:04:22

  上一篇:go  [轉]如何解決事件導致的Memory Leak問題:Weak Event Handlers
  下一篇:go  擴展UltraGrid控件實現對所有數據行的全選功能[Source Code下載]