轉(zhuǎn)帖|其它|編輯:郝浩|2011-04-11 14:44:55.000|閱讀 2027 次
概述:在之前的《創(chuàng)建無阻塞的異步調(diào)用》中,已經(jīng)介紹過異步調(diào)用的編寫步驟和實施原理。異步調(diào)用是CLR為開發(fā)者提供的一種重要的編程手段,它也是構(gòu)建高性能、可伸縮應(yīng)用程序的關(guān)鍵。在多核CPU越來越普及的今天,異步編程允許使用非常少的線程執(zhí)行很多操作。
# 界面/圖表報表/文檔/IDE等千款熱門軟控件火熱銷售中 >>
引言
在之前的《創(chuàng)建無阻塞的異步調(diào)用》中,已經(jīng)介紹過異步調(diào)用的編寫步驟和實施原理。異步調(diào)用是CLR為開發(fā)者提供的一種重要的編程手段,它也是構(gòu)建高性能、可伸縮應(yīng)用程序的關(guān)鍵。在多核CPU越來越普及的今天,異步編程允許使用非常少的線程執(zhí)行很多操作。我們通常使用異步完成許多計算型、IO型的復(fù)雜、耗時操作,去取得我們的應(yīng)用程序運行所需要的一部分?jǐn)?shù)據(jù)。在取得這些數(shù)據(jù)后,我們需要將它們綁定在UI中呈現(xiàn)。當(dāng)數(shù)據(jù)量偏大時,我們會發(fā)現(xiàn)窗體變成了空白面板。此時如果用鼠標(biāo)點擊,窗體標(biāo)題將會出現(xiàn)”失去響應(yīng)”的字樣,而實際上UI線程仍在工作著,這對用戶來說是一種極度糟糕的體驗。如果你希望了解其中的原因(并不復(fù)雜:)),并徹底解決該問題,那么花時間讀完此文也許是個不錯的選擇。
一般來說,窗體阻塞分為兩種情況。一種是在UI線程上調(diào)用耗時較長的操作,例如訪問數(shù)據(jù)庫,這種阻塞是UI線程被占用所導(dǎo)致,可以通過delegate.BeginInvoke的異步編程解決;另一種是窗體加載大批量數(shù)據(jù),例如向ListView、DataGridView等控件中添加大量的數(shù)據(jù)。本文主要探討后一種阻塞。
基礎(chǔ)理論
這部分簡單介紹CLR對跨線程UI訪問的處理。作為基礎(chǔ)內(nèi)容,相信大部分.NET開發(fā)者對它并不陌生,讀者可根據(jù)實際情況略過此處。
控件的線程安全檢測
在傳統(tǒng)的窗體編程中,UI中的控件元素與其他工作線程互相隔離,每次我們訪問一個UI控件,實際上都是在UI線程中進(jìn)行。如果嘗試在其他線程中訪問控件,CLR針對不同的.NET Framework版本,會有不同的處理。在Framework1.x中,CLR允許應(yīng)用程序以跨線程的方式運行,而在Framework2.0及以后版本中,System.Windows.Form.Control新增了CheckForIllegalCrossThreadCalls屬性,它是一個可讀寫的bool常量,標(biāo)記我們是否需要對非UI線程對控件的調(diào)用做出檢測。如果指定true,當(dāng)以其他線程訪問UI,CLR會跑出一個”InvalidOperationException:線程間操作無效,從不是創(chuàng)建控件***的線程訪問它”;如果為false,則不對該錯誤線程的調(diào)用進(jìn)行捕獲,應(yīng)用程序依然運行。
在Framework1.x版本中,這個值默認(rèn)是false。問什么之后的版本會加入這個屬性來約束我們的UI呢?實際上官方對此的解釋是當(dāng)有多個并發(fā)線程嘗試對UI進(jìn)行讀寫時,容易造成線程爭用資源帶來的死鎖。所以,CLR默認(rèn)不允許以非UI線程訪問控件。
然而,我們常常需要在窗體中使用異步線程來處理一些操作,例如IO和Socket通訊等。這時跨線程的UI訪問又是必須的,對此,.NET給我們的補充方案就是Control的Invoke和BeginInvoke。
Control的Invoke和BeginInvoke
對于這兩個方法,首先我們要有以下的認(rèn)識:
1.Control.Invoke,Control.BeginInvoke和delegate.Invoke,delegate.BeginInvoke是不同的。
2.Control.Invoke中的委托方法,執(zhí)行在主線程,也就是我們的UI線程。而Control.BeginInvoke從命名上來看雖然具有異步調(diào)用的特征(Begin),但也仍然執(zhí)行在UI線程。
3.如果在UI線程中直接調(diào)用Invoke和BeginInvoke,數(shù)據(jù)量偏大時,依然會造成UI的假死。
有很多開發(fā)者在初次接觸這兩個函數(shù)時,很容易就將它們同異步聯(lián)系起來、有些人會認(rèn)為他們是獨立于UI線程之外的工作線程,實際上,他們都被這兩個函數(shù)的命名所蒙蔽了。如果以傳統(tǒng)調(diào)用異步的方式,直接調(diào)用Control.BeginInvoke,與同步函數(shù)的執(zhí)行無異,UI線程還是會處理所有辛苦的操作,造成我們的應(yīng)用程序阻塞。
Control.Invoke的調(diào)用模型很明確:在UI線程中以代碼順序同步執(zhí)行,因此,拋開工作線程調(diào)用UI元素的干擾,我們可以將Control.Invoke視為同步,本文不做過多介紹。
很多開發(fā)者在接觸異步后,再來處理窗體假死的問題,很容易想當(dāng)然的將Control.BeginInvoke視為WinForm封裝的異步。所以我們重點關(guān)注這個方法。
前面說過,BeginInvoke除了命名上來看像異步,其實很多時候我們調(diào)用起來根本沒有異步的”非阻塞”特性,我用下面這個例子簡單的嘗試一次對BeginInvoke的調(diào)用。
如你所見,我現(xiàn)在創(chuàng)建了一個簡陋的Form,其中放置了一個Lable控件lable1,一個Button控件btn_Start,下面,開始code:
private void btn_Start_Click(object sender, EventArgs e)
{
// 儲存UI線程的標(biāo)識符
int curThreadID = Thread.CurrentThread.ManagedThreadId;
new Thread((ThreadStart)delegate()
{
PrintThreadLog(curThreadID);
})
.Start();
}
private void PrintThreadLog(int mainThreadID)
{
// 當(dāng)前線程的標(biāo)識符
// A代碼塊
int asyncThreadID = Thread.CurrentThread.ManagedThreadId;
// 輸出當(dāng)前線程的扼要信息,及與UI線程的引用比對結(jié)果
// B代碼塊
label1.BeginInvoke((MethodInvoker)delegate()
{
// 執(zhí)行BeginInvoke內(nèi)的方法的線程標(biāo)識符
int curThreadID = Thread.CurrentThread.ManagedThreadId;
label1.Text = string.Format("Async Thread ID:{0},Current Thread ID:{1},Is UI Thread:{2}",
asyncThreadID, curThreadID, curThreadID.Equals(mainThreadID));
});
// 掛起當(dāng)前線程3秒,模擬耗時操作
// C代碼塊
Thread.Sleep(3000);
}
這段代碼在新的線程中訪問了UI,所以我們使用了label1.BeginInvoke函數(shù)。新的線程中,我們?nèi)〉昧水?dāng)前工作線程的線程標(biāo)識符,也取得了BeginInvoke函數(shù)內(nèi)的線程。然后,將它與UI線程的標(biāo)志符作比對,將結(jié)果輸出于Label1控件上。最后,我們掛起當(dāng)前工作線程3秒,用于模擬一些常見的耗時操作。
為了便于區(qū)分,我們將這段代碼分為A、B、C三個代碼塊。
運行結(jié)果:
我們能得到以下結(jié)論:
●PrintThreadLog函數(shù)主體(A、C代碼塊)執(zhí)行在新的線程,它執(zhí)行了不被BeginInvoke所包含的其他代碼。
●當(dāng)我們調(diào)用了Control.BeginInvoke之后,線程調(diào)度權(quán)回歸到了UI線程。也就是說,BeginInvoke內(nèi)部的代碼(B代碼塊)均執(zhí)行在UI線程。
●在UI線程執(zhí)行BeginInvok中封裝的代碼時,工作線程內(nèi)的剩余代碼(C代碼塊)同時進(jìn)行。它與BeginInvoke中的UI線程并行執(zhí)行,互不干擾。
●由于Thread.Sleep(3000)是隔離在UI線程外的工作線程,因此這行代碼帶來的線程阻塞實際上阻塞了工作線程,不會給UI帶來任何影響。
Control.BeginInvoke的真正含義
既然Control.BeginInvoke其中的委托函數(shù)仍執(zhí)行在UI線程內(nèi),那這個”異步”到底指的是什么?話題回到本文最初:我們在上文已經(jīng)提到了”控件的線程安全檢測”概念,相信大家對這種工作線程內(nèi)調(diào)用Control.BeginInvoke的做法已經(jīng)太熟悉了。我們也提到了”CLR不喜歡工作線程調(diào)用UI元素”。微軟的決心如此之大,以至于CLR團(tuán)隊在.NET Framework2.0中添加了CheckForIllegalCrossThreadCalls和Control.Invoke、Control.BeginInvoke方法。這是一次相當(dāng)重大的改革,CLR團(tuán)隊希望達(dá)到這樣的效果:
如果不申明CheckForIllegalCrossThreadCalls = false;這樣的”不安全”代碼,你就只能使用Control.Invoke和Control.BeginInvoke;而只要使用后兩者,不論它們的上下文運行環(huán)境是其它工作線程還是UI線程,它們封裝的代碼都會執(zhí)行在UI線程內(nèi)。所以,msdn對Control.BeginInvoke給出了這樣的解釋:在創(chuàng)建控件的基礎(chǔ)句柄所在線程上異步執(zhí)行指定委托。
它的真正含義是:BeginInvoke所謂的異步,是相對于調(diào)用線程的異步,而不是相對于UI線程的異步。
CLR把Control.BeginInvoke(delegate method)中的異步函數(shù)執(zhí)行在UI內(nèi),如果你像我上文那樣用新線程調(diào)用BeginInvoke,那么method相對于這個新線程內(nèi)的其他函數(shù)是異步的。畢竟method執(zhí)行在了UI線程,新線程立即回調(diào),不必等待Control.BeginInvoke的完成。所以,這個后臺線程充分享受了”異步”的好處,不再阻塞,只是我們看不到而已;當(dāng)然,如果你在BeginInvoke內(nèi)執(zhí)行一段耗時的代碼,無論是從遠(yuǎn)程服務(wù)器獲取數(shù)據(jù)庫資料、IO讀取,還是在控件內(nèi)加載一大批數(shù)據(jù),UI線程還是阻塞的。
正如傳統(tǒng)的Delegate.BeginInvoke的異步工作線程取自于.NET線程池,Control.BeginInvoke的異步工作線程就是UI線程。
現(xiàn)在您明白兩種BeginInvoke的區(qū)別了嗎?
Control.Invoke、BeginInvoke與Windows消息
實際上,Invoke和BeginInvoke的原理是將調(diào)用的方法Marshal成消息,然后調(diào)用Win32Api的RegisterWindowMessage()向UI發(fā)送消息。我們使用Reflector,可以看到以下代碼:
Control.Invoke:
public object Invoke(Delegate method, params object[] args)
{
using (new MultithreadSafeCallScope())
{
return this.FindMarshalingControl().MarshaledInvoke(this, method, args, true);
}
}
Control.BeginInvoke:
[EditorBrowsable(EditorBrowsableState.Advanced)]
public IAsyncResult BeginInvoke(Delegate method, params object[] args)
{
using (new MultithreadSafeCallScope())
{
return (IAsyncResult)this.FindMarshalingControl().MarshaledInvoke(this, method, args, false);
}
}
在以上代碼中我們看到Control.Invoke和BeginInvoke的不同之處,在于調(diào)用MarshaledInvoke時,Invoke向最后一個參數(shù)傳遞了false,而BeginInvoke則是true。
MarshaledInvoke的結(jié)構(gòu)是這樣的:
private object MarshaledInvoke(Control caller, Delegate method, object[] args, bool synchronous)
很明顯,最后一個參數(shù)synchronous表示是否按照同步處理。MarshaledInvoke內(nèi)部這樣處理這個參數(shù):
if (!synchronous)
{
return entry;
}
if (!entry.IsCompleted)
{
this.WaitForWaitHandle(entry.AsyncWaitHandle);
}
所以,BeginInvoke的處理就是直接回調(diào),Invoke卻在等待異步函數(shù)執(zhí)行完后,才繼續(xù)執(zhí)行。
到此為止,Invoke和BeginInvoke的工作就結(jié)束了,其余的工作就是UI對消息的處理,它由Control的WndProc(ref Message m)來執(zhí)行。消息處理到底會給我們的UI帶來什么樣的影響?接著來看Application.DoEvents()函數(shù)。
Application.DoEvents
Application.DoEvents()函數(shù)是WinForm編程中極為重要的函數(shù),但實際編程中,大多數(shù)開發(fā)者極少調(diào)用它。如果您對這個函數(shù)缺乏了解,那很可能會在以后長期的編程中對“窗體假死”這樣的現(xiàn)象陷入迷惑。
當(dāng)運行 Windows 窗體時,它將創(chuàng)建新窗體,然后該窗體等待處理事件。該窗體在每次處理事件時,均將處理與該事件關(guān)聯(lián)的所有代碼。所有其他事件在隊列中等待。當(dāng)代碼處理事件時,應(yīng)用程序不會響應(yīng)。例如,如果將甲窗口拖到乙窗口之上,則乙窗口不會重新繪制。
如果在代碼中調(diào)用 DoEvents,則您的應(yīng)用程序可以處理其他事件。 例如,如果您有向ListBox添加數(shù)據(jù)的窗體,并將 DoEvents 添加到代碼中,那么當(dāng)將另一窗口拖到您的窗體上時,該窗體將重新繪制。如果從代碼中移除 DoEvents,那么在按鈕的單擊事件處理程序執(zhí)行結(jié)束以前,您的窗體不會重新繪制。
因此,如果我們在窗體執(zhí)行事件時,不處理消息隊列中的windows消息,窗體必然會失去響應(yīng)。而上文已經(jīng)介紹過,Control.Invoke和BeginInvoke都會向UI發(fā)送消息,造成UI對消息的處理,因此,這為我們解決窗體加載大量數(shù)據(jù)時的假死提供了思路。
解決方案
嘗試”無假死”
這次我們使用開發(fā)中出現(xiàn)頻率極高的ListView控件,體驗一次理想的”異步刷新”,窗體中有一個ListView控件命名為listView1,并將View設(shè)置為Detail,添加兩個ColumnHeader;一個Button命名為btn_Start,設(shè)計視圖如下:
開始code:
private readonly int Max_Item_Count = 10000;
private void button1_Click(object sender, EventArgs e)
{
new Thread((ThreadStart)(delegate()
{
for (int i = 0; i < Max_Item_Count; i++)
{
// 此處警惕值類型裝箱造成的"性能陷阱"
listView1.Invoke((MethodInvoker)delegate()
{
listView1.Items.Add(new ListViewItem(new string[]
{ i.ToString(), string.Format("This is No.{0} item", i.ToString()) }));
});
};
}))
.Start();
}
代碼運行后,你將會看到一個飛速滾動的ListView列表,在加載的過程中,列表以令人眼花繚亂的速度添加數(shù)據(jù),此時你嘗試?yán)瓌訚L動條,或者移動窗體,都會發(fā)現(xiàn)這次的效果與以往的”白板”、”假死”截然不同!這是一個令人欣喜的變化。
運行過程:
從我的截圖中可以看出,窗體在加載數(shù)據(jù)的過程中,依然繪制界面,并沒有出現(xiàn)”假死”。
如果上述代碼調(diào)用的是Control.BeginInvoke,程序會發(fā)生些奇怪的現(xiàn)象,想想是為什么?
好吧,到了現(xiàn)在,我們終于可以松了一口氣了,界面響應(yīng)的問題已經(jīng)被解決,一切美好。但是,這樣的窗體還是暴漏出兩個大問題:
1. 比起傳統(tǒng)加載,”無假死窗體”加載速度明顯減慢。
2. 加載數(shù)據(jù)過程中,窗體發(fā)生劇烈閃爍現(xiàn)象。
問題分析
我們在調(diào)用Control.Invoke時,強迫窗體處理消息,從而使界面得到了響應(yīng),同時也產(chǎn)生了一些副作用。其中之一就是消息處理使得窗體發(fā)生了在循環(huán)中發(fā)生了重繪,”閃爍”現(xiàn)象就是窗體重繪引發(fā)的,有過GDI+開發(fā)經(jīng)驗的開發(fā)者應(yīng)該比較熟悉。同時,每次調(diào)用Invoke都會使UI處理消息,也直接增加了控件對數(shù)據(jù)處理的時間成本,導(dǎo)致了性能問題。
對于”性能問題”,我并沒有什么解決方案(有自己見解的朋友歡迎提出)。有些控件(ListView、ListBox)具有BeginUpdate和EndUpdate函數(shù),可以臨時掛起刷新,加快性能。但畢竟我們這里創(chuàng)建了一個會滾動的界面,這種數(shù)據(jù)的”動態(tài)加載”方式是前者無法比擬的。
對于”閃爍”,我先來解釋問題的原因。通常,控件的繪制包括兩個環(huán)節(jié):擦出原對象與繪制新對象。首先windows發(fā)送一個消息,通知控件擦除原圖像,然后進(jìn)行繪制。如果要在控件面板上以SolidBrush繪制,控件就會在其面板上直接繪制內(nèi)容。當(dāng)用戶改變了控件尺寸,Windows將會調(diào)用很多繪制回收操作,當(dāng)每次回收和繪制發(fā)生時,由于”繪制”較”擦除”更為延后,才會給用戶帶來”閃爍”的感覺。以往我們?yōu)榻鉀Q此類問題,往往需要在Control.WndProc中作出復(fù)雜的處理。而.NET Framework為我們提供了更為優(yōu)雅的一種方案,那就是雙緩沖,我們直接調(diào)用它即可。
最終方案
1.新建Windows組件DBListView.cs,讓它繼承自ListView。
2.在控件中添加如下代碼: public DBListView()
{
// 打開控件的雙緩沖
SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint, true);
}
將項目重新生成,然后從工具箱中拖出新增的組建DBListView到窗體上,命名為dbListView1,執(zhí)行以下代碼: private void button1_Click(object sender, EventArgs e)
{
new Thread((ThreadStart)(delegate()
{
for (int i = 0; i < Max_Item_Count; i++)
{
// 此處警惕值類型裝箱造成的"性能陷阱"
dbListView1.Invoke((MethodInvoker)delegate()
{
dbListView1.Items.Add(new ListViewItem(new string[]
{ i.ToString(), string.Format("This is No.{0} item", i.ToString()) }));
});
};
}))
.Start();
} >
現(xiàn)在”閃爍”的問題是不是已經(jīng)得到了解決?
在我們的實際應(yīng)用中,這種加載數(shù)據(jù)引起的阻塞是很常見的,在用戶對界面性能關(guān)注度不高的情況下,使用本文介紹的方式處理這種阻塞是一種不錯的選擇,如果以類似IE8、迅雷等軟件的載入動畫配合,效果會更理想。
本站文章除注明轉(zhuǎn)載外,均為本站原創(chuàng)或翻譯。歡迎任何形式的轉(zhuǎn)載,但請務(wù)必注明出處、不得修改原文相關(guān)鏈接,如果存在內(nèi)容上的異議請郵件反饋至chenjj@fc6vip.cn
文章轉(zhuǎn)載自:博客園