系列主題:基於消息的軟件架構模型演變
在Winform和Asp.net時代,事件被大量的應用在UI和后台交互的代碼中。看下面的代碼:
private void BindEvent()
{
var btn = new Button();
btn.Click += btn_Click;
}
void btn_Click(object sender, EventArgs e)
{
MessageBox.Show("click");
}
這樣的用法可以引起內存泄漏嗎?為什么我們平時一直寫這樣的代碼從來沒關注過內存泄漏?等分析完原因后再來回答這個問題。
為了測試原因,我們先寫一個EventPublisher類用來發布事件:
public class EventPublisher
{
public static int Count;
public event EventHandler<PublisherEventArgs> OnSomething;
public EventPublisher()
{
Interlocked.Increment(ref Count);
}
public void TriggerSomething()
{
RaiseOnSomething(new PublisherEventArgs(Count));
}
protected void RaiseOnSomething(PublisherEventArgs e)
{
EventHandler<PublisherEventArgs> handler = OnSomething;
if (handler != null) handler(this, e);
}
~EventPublisher()
{
Interlocked.Decrement(ref Count);
}
}
這個類提供了一個事件OnSomething,另外在構造函數和析構函數中分別會對變量Count進行累加和遞減。Count的數量反應了EventPublisher的實例在內存中的數量。
寫一個Subscriber用來訂閱這個事件:
public class Subscriber
{
public string Text { get; set; }
public List<StringBuilder> List = new List<StringBuilder>();
public static int Count;
public Subscriber()
{
Interlocked.Increment(ref Count);
for (int i = 0; i < 1000; i++)
{
List.Add(new StringBuilder(1024));
}
}
public void ShowMessage(object sender, PublisherEventArgs e)
{
Text = string.Format("There are {0} publisher in memory",e.PublisherReferenceCount);
}
~Subscriber()
{
Interlocked.Decrement(ref Count);
}
}
Subscriber同樣用Count來反映內存中的實例數量,另外我們在構造函數中使用StringBuilder開辟1000*1024Size的大小以方便我們觀察內存使用量。
最后一步,寫一個簡單的winform程序,然后在一個Button的Click事件中寫入測試代碼:
private void btnStartShortTimePublisherTest_Click(object sender, EventArgs e)
{
for (int i = 0; i < 100; i++)
{
var publisher = new EventPublisher();
publisher.OnSomething += new Subscriber().ShowMessage;
publisher.TriggerSomething();
}
MessageBox.Show(string.Format("There are {0} publishers in memory, {1} subscribers in memory", EventPublisher.Count, Subscriber.Count));
}
for循環中的代碼是一個很普通的事件調用代碼,我們將Subscriber實例中的ShowMessage方法綁定到了publisher對象的OnSomething事件上,為了觀察內存的變化我們循環100次。
執行結果如下:
publisher和subscriber的數量都為3,這並不代表發生了內存泄漏,只不過是沒有完全回收完畢而已。每個publisher在出了for循環后就會被認為沒有任何用處,從而被正確回收。而注冊在上面的觀察者subscriber也能被正確回收。
再放一個Button,並在Click中寫以下測試代碼:
private void BtnStartLongTimePublisher_Click(object sender, EventArgs e)
{
for (int i = 0; i < 100; i++)
{
var publisher = new EventPublisher();
publisher.OnSomething += new Subscriber().ShowMessage;
publisher.TriggerSomething();
LongLivedEventPublishers.Add(publisher);
}
MessageBox.Show(string.Format("There are {0} publishers in memory, {1} subscribers in memory", EventPublisher.Count,Subscriber.Count));
}
這次for循環中不同之處在於我們將publisher保存在了一個list容器當中,從而保證100個publisher不能垃圾回收。這次的執行結果如下:
我們看到100個subscribers全部保存在內存中。如果觀察資源管理器中的內存使用率,你也能發現內存突然漲了幾百兆並且再不會減少。
想一下下面的場景:
public class Runner
{
private LongTimeService _service;
public Runner()
{
_service = new LongTimeService();
}
public void Run()
{
_service.SomeThingUpdated += (o, e) => { /*do some thing*/};
_service.SomeThingUpdated += (o, e) => { /*do some thing*/};
_service.SomeThingUpdated += (o, e) => { /*do some thing*/};
_service.SomeThingUpdated += (o, e) => { /*do some thing*/};
}
}
LongTimeService是一個長期運行的服務,從來不被銷毀,這將導致所有注冊在SomeThingUpdated 事件上的觀察者也不會能回收。當有大量的觀察者不停的注冊在SomeThingUpdated 上時,就會發生內存泄漏。
這三個測試說明了引起事件內存泄漏的場景:當觀察者注冊在了一個生命周期長於自己的事件主題上,觀察者不能被內存回收。
解決辦法是在事件上顯示調用-=符號。
再回過頭來看開始提出來的問題:當使用了Button的Click事件的時候,會發生內存泄漏嗎?
btn.Click += btn_Click;
觀察者是誰?btn_Click方法的擁有者,也就是Form實例。
主題是誰?Button的實例btn
主題btn什么時候銷毀?當Form實例被銷毀的時候。
當Form被銷毀的時候,btn及其觀察者都會被銷毀。除非Form從來不銷毀,並且大量的觀察者持續注冊在了btn.Click上才能發生內存泄漏,當然這種場景是很少見的。所以我們開發winform或者asp.net的時候一般來說並不會關心內存泄漏的問題。


