1. 程式人生 > >C# 窗體載入假死,非同步重新整理總結

C# 窗體載入假死,非同步重新整理總結

文章來源:http://blog.sina.com.cn/s/blog_621e24e201015r29.html

總結:1、control.Invoke 和 Control.BeginInvoke都是執行在UI執行緒下的,也就是主執行緒,與一般非同步不同

2、BeginInvoke的處理就是直接回調,Invoke卻在等待非同步函式執行完後,才繼續執行,也就是假如在迴圈中呼叫,BeginInvoke會提前返回,繼續非同步迴圈,而Invoke會等待一個迴圈完成才進行下一個迴圈,相當於是同步

3、文中的高重新整理的例子表明,介面從1到10000,用BeginInvoke執行飛快,但保證不了介面資料的連貫性,從1-10000不是連貫的,而且介面出現假死,而呼叫Invoke,從1-10000是連貫的,但是執行效率很慢,介面閃爍一直重新整理,但是不會假死,滾動條還是可以滾動的

4、解決介面閃爍

// 開啟控制元件的雙緩衝

SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint, true);

正文如下:

非同步呼叫是CLR為開發者提供的一種重要的程式設計手段,它也是構建高效能、可伸縮應用程式的關鍵。在多核CPU越來越普及的今天,非同步程式設計允許使用非常少的執行緒執行很多操作。我們通常使用非同步完成許多計算型、IO型的複雜、耗時操作,去取得我們的應用程式執行所需要的一部分資料。在取得這些資料後,我們需要將它們繫結在UI中呈現。當資料量偏大時,我們會發現窗體變成了空白麵板。此時如果用滑鼠點選,窗體標題將會出現”失去響應”的字樣,而實際上UI執行緒仍在工作著,這對使用者來說是一種極度糟糕的體驗。如果你希望瞭解其中的原因(並不複雜:)),並徹

底解決該問題,那麼花時間讀完此文也許是個不錯的選擇。

  一般來說,窗體阻塞分為兩種情況。一種是在UI執行緒上呼叫耗時較長的操作,例如訪問資料庫,這種阻塞是UI執行緒被佔用所導致,可以通過delegate.BeginInvoke的非同步程式設計解決;另一種是窗體載入大批量資料,例如向ListView、DataGridView等控制元件中新增大量的資料。本文主要探討後一種阻塞。

基礎理論

  這部分簡單介紹CLR對跨執行緒UI訪問的處理。作為基礎內容,相信大部分.NET開發者對它並不陌生,讀者可根據實際情況略過此處。

控制元件的執行緒安全檢測

  在傳統的窗體程式設計中,UI中的控制元件元素與其他工作執行緒互相隔離,每次我們訪問一個UI控制元件,實際上都是在UI執行緒中進行。如果嘗試在其他執行緒中訪問控制元件,CLR針對不同的.NET Framework版本,會有不同的處理。在Framework1.x中,CLR允許應用程式以跨執行緒的方式執行,而在Framework2.0及以後版本中,System.Windows.Form.Control新增了CheckForIllegalCrossThreadCalls屬性,它是一個可讀寫的bool常量,標記我們是否需要對非UI執行緒對控制元件的呼叫做出檢測。如果指定true,當以其他執行緒訪問UI,CLR會跑出一個”InvalidOperationException:執行緒間操作無效,從不是建立控制元件***的執行緒訪問它”;如果為false,則不對該錯誤執行緒的呼叫進行捕獲,應用程式依然執行。

  在Framework1.x版本中,這個值預設是false。問什麼之後的版本會加入這個屬性來約束我們的UI呢?實際上官方對此的解釋是當有多個併發執行緒嘗試對UI進行讀寫時,容易造成執行緒爭用資源帶來的死鎖。所以,CLR預設不允許以非UI執行緒訪問控制元件。

  然而,我們常常需要在窗體中使用非同步執行緒來處理一些操作,例如IO和Socket通訊等。這時跨執行緒的UI訪問又是必須的,對此,.NET給我們的補充方案就是Control的Invoke和BeginInvoke。

Control的Invoke和BeginInvoke

對於這兩個方法,首先我們要有以下的認識:

1.Control.Invoke,Control.BeginInvoke和delegate.Invoke,delegate.BeginInvoke是不同的。
2.Control.Invoke中的委託方法,執行在主執行緒,也就是我們的UI執行緒。而Control.BeginInvoke從命名上來看雖然具有非同步呼叫的特徵(Begin),但也仍然執行在UI執行緒。
3.如果在UI執行緒中直接呼叫Invoke和BeginInvoke,資料量偏大時,依然會造成UI的假死。

  有很多開發者在初次接觸這兩個函式時,很容易就將它們同異步聯絡起來、有些人會認為他們是獨立於UI執行緒之外的工作執行緒,實際上,他們都被這兩個函式的命名所矇蔽了。如果以傳統呼叫非同步的方式,直接呼叫Control.BeginInvoke,與同步函式的執行無異,UI執行緒還是會處理所有辛苦的操作,造成我們的應用程式阻塞。

  Control.Invoke的呼叫模型很明確:在UI執行緒中以程式碼順序同步執行,因此,拋開工作執行緒呼叫UI元素的干擾,我們可以將Control.Invoke視為同步,本文不做過多介紹。

  很多開發者在接觸非同步後,再來處理窗體假死的問題,很容易想當然的將Control.BeginInvoke視為WinForm封裝的非同步。所以我們重點關注這個方法。

體驗BeginInvoke

  前面說過,BeginInvoke除了命名上來看像非同步,其實很多時候我們呼叫起來根本沒有非同步的”非阻塞”特性,我用下面這個例子簡單的嘗試一次對BeginInvoke的呼叫。

  如你所見,我現在建立了一個簡陋的Form,其中放置了一個Lable控制元件lable1,一個Button控制元件btn_Start,下面,開始code:

private void btn_Start_Click(object sender, EventArgs e)
{
// 儲存UI執行緒的識別符號
int curThreadID = Thread.CurrentThread.ManagedThreadId;

new Thread((ThreadStart)delegate()
{
PrintThreadLog(curThreadID);
})
.Start();
}

private void PrintThreadLog(int mainThreadID)
{
// 當前執行緒的識別符號
// A程式碼塊
int asyncThreadID = Thread.CurrentThread.ManagedThreadId;

// 輸出當前執行緒的扼要資訊,及與UI執行緒的引用比對結果
// B程式碼塊
label1.BeginInvoke((MethodInvoker)delegate()
{
// 執行BeginInvoke內的方法的執行緒識別符號
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));
});

// 掛起當前執行緒3秒,模擬耗時操作
// C程式碼塊
Thread.Sleep(3000);
}

  這段程式碼在新的執行緒中訪問了UI,所以我們使用了label1.BeginInvoke函式。新的執行緒中,我們取得了當前工作執行緒的執行緒識別符號,也取得了BeginInvoke函式內的執行緒。然後,將它與UI執行緒的標誌符作比對,將結果輸出於Label1控制元件上。最後,我們掛起當前工作執行緒3秒,用於模擬一些常見的耗時操作。

  為了便於區分,我們將這段程式碼分為A、B、C三個程式碼塊。

執行結果:

我們能得到以下結論:

●PrintThreadLog函式主體(A、C程式碼塊)執行在新的執行緒,它執行了不被BeginInvoke所包含的其他程式碼。
●當我們呼叫了Control.BeginInvoke之後,執行緒排程權迴歸到了UI執行緒。也就是說,BeginInvoke內部的程式碼(B程式碼塊)均執行在UI執行緒。
●在UI執行緒執行BeginInvok中封裝的程式碼時,工作執行緒內的剩餘程式碼(C程式碼塊)同時進行。它與BeginInvoke中的UI執行緒並行執行,互不干擾。
●由於Thread.Sleep(3000)是隔離在UI執行緒外的工作執行緒,因此這行程式碼帶來的執行緒阻塞實際上阻塞了工作執行緒,不會給UI帶來任何影響。

Control.BeginInvoke的真正含義

  既然Control.BeginInvoke其中的委託函式仍執行在UI執行緒內,那這個”非同步”到底指的是什麼?話題回到本文最初:我們在上文已經提到了”控制元件的執行緒安全檢測”概念,相信大家對這種工作執行緒內呼叫Control.BeginInvoke的做法已經太熟悉了。我們也提到了”CLR不喜歡工作執行緒呼叫UI元素”。微軟的決心如此之大,以至於CLR團隊在.NET Framework2.0中添加了CheckForIllegalCrossThreadCalls和Control.Invoke、Control.BeginInvoke方法。這是一次相當重大的改革,CLR團隊希望達到這樣的效果:

  如果不申明CheckForIllegalCrossThreadCalls = false;這樣的”不安全”程式碼,你就只能使用Control.Invoke和Control.BeginInvoke;而只要使用後兩者,不論它們的上下文執行環境是其它工作執行緒還是UI執行緒,它們封裝的程式碼都會執行在UI執行緒內。所以,msdn對Control.BeginInvoke給出了這樣的解釋:在建立控制元件的基礎控制代碼所線上程上非同步執行指定委託。

  它的真正含義是:BeginInvoke所謂的非同步,是相對於呼叫執行緒的非同步,而不是相對於UI執行緒的非同步。

  CLR把Control.BeginInvoke(delegate method)中的非同步函式執行在UI內,如果你像我上文那樣用新執行緒呼叫BeginInvoke,那麼method相對於這個新執行緒內的其他函式是非同步的。畢竟method執行在了UI執行緒,新執行緒立即回撥,不必等待Control.BeginInvoke的完成。所以,這個後臺執行緒充分享受了”非同步”的好處,不再阻塞,只是我們看不到而已;當然,如果你在BeginInvoke內執行一段耗時的程式碼,無論是從遠端伺服器獲取資料庫資料、IO讀取,還是在控制元件內載入一大批資料,UI執行緒還是阻塞的。

  正如傳統的Delegate.BeginInvoke的非同步工作執行緒取自於.NET執行緒池,Control.BeginInvoke的非同步工作執行緒就是UI執行緒。

  現在您明白兩種BeginInvoke的區別了嗎?

Control.Invoke、BeginInvoke與Windows訊息

  實際上,Invoke和BeginInvoke的原理是將呼叫的方法Marshal成訊息,然後呼叫Win32Api的RegisterWindowMessage()向UI傳送訊息。我們使用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的不同之處,在於呼叫MarshaledInvoke時,Invoke向最後一個引數傳遞了false,而BeginInvoke則是true。

MarshaledInvoke的結構是這樣的:

private object MarshaledInvoke(Control caller, Delegate method, object[] args, bool synchronous)

  很明顯,最後一個引數synchronous表示是否按照同步處理。MarshaledInvoke內部這樣處理這個引數:

if (!synchronous)
{
return entry;
}
if (!entry.IsCompleted)
{
this.WaitForWaitHandle(entry.AsyncWaitHandle);
}

  所以,BeginInvoke的處理就是直接回調,Invoke卻在等待非同步函式執行完後,才繼續執行。

  到此為止,Invoke和BeginInvoke的工作就結束了,其餘的工作就是UI對訊息的處理,它由Control的WndProc(ref Message m)來執行。訊息處理到底會給我們的UI帶來什麼樣的影響?接著來看Application.DoEvents()函式。

Application.DoEvents

  Application.DoEvents()函式是WinForm程式設計中極為重要的函式,但實際程式設計中,大多數開發者極少呼叫它。如果您對這個函式缺乏瞭解,那很可能會在以後長期的程式設計中對“窗體假死”這樣的現象陷入迷惑。

  當執行 Windows 窗體時,它將建立新窗體,然後該窗體等待處理事件。該窗體在每次處理事件時,均將處理與該事件關聯的所有程式碼。所有其他事件在佇列中等待。當代碼處理事件時,應用程式不會響應。例如,如果將甲視窗拖到乙視窗之上,則乙視窗不會重新繪製。

  如果在程式碼中呼叫 DoEvents,則您的應用程式可以處理其他事件。 例如,如果您有向ListBox新增資料的窗體,並將 DoEvents 新增到程式碼中,那麼當將另一視窗拖到您的窗體上時,該窗體將重新繪製。如果從程式碼中移除 DoEvents,那麼在按鈕的單擊事件處理程式執行結束以前,您的窗體不會重新繪製。

  因此,如果我們在窗體執行事件時,不處理訊息佇列中的windows訊息,窗體必然會失去響應。而上文已經介紹過,Control.Invoke和BeginInvoke都會向UI傳送訊息,造成UI對訊息的處理,因此,這為我們解決窗體載入大量資料時的假死提供了思路。

解決方案

嘗試”無假死”

  這次我們使用開發中出現頻率極高的ListView控制元件,體驗一次理想的”非同步重新整理”,窗體中有一個ListView控制元件命名為listView1,並將View設定為Detail,新增兩個ColumnHeader;一個Button命名為btn_Start,設計檢視如下:

開始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列表,在載入的過程中,列表以令人眼花繚亂的速度新增資料,此時你嘗試拉動滾動條,或者移動窗體,都會發現這次的效果與以往的”白板”、”假死”截然不同!這是一個令人欣喜的變化。

執行過程:

  從我的截圖中可以看出,窗體在載入資料的過程中,依然繪製介面,並沒有出現”假死”。

  如果上述程式碼呼叫的是Control.BeginInvoke,程式會發生些奇怪的現象,想想是為什麼?

好吧,到了現在,我們終於可以鬆了一口氣了,介面響應的問題已經被解決,一切美好。但是,這樣的窗體還是暴漏出兩個大問題:
1. 比起傳統載入,”無假死窗體”載入速度明顯減慢。
2. 載入資料過程中,窗體發生劇烈閃爍現象。

問題分析

  我們在呼叫Control.Invoke時,強迫窗體處理訊息,從而使介面得到了響應,同時也產生了一些副作用。其中之一就是訊息處理使得窗體發生了在迴圈中發生了重繪,”閃爍”現象就是窗體重繪引發的,有過GDI+開發經驗的開發者應該比較熟悉。同時,每次呼叫Invoke都會使UI處理訊息,也直接增加了控制元件對資料處理的時間成本,導致了效能問題。

  對於”效能問題”,我並沒有什麼解決方案(有自己見解的朋友歡迎提出)。有些控制元件(ListView、ListBox)具有BeginUpdate和EndUpdate函式,可以臨時掛起重新整理,加快效能。但畢竟我們這裡建立了一個會滾動的介面,這種資料的”動態載入”方式是前者無法比擬的。

  對於”閃爍”,我先來解釋問題的原因。通常,控制元件的繪製包括兩個環節:擦出原物件與繪製新物件。首先windows傳送一個訊息,通知控制元件擦除原影象,然後進行繪製。如果要在控制元件面板上以SolidBrush繪製,控制元件就會在其面板上直接繪製內容。當用戶改變了控制元件尺寸,Windows將會呼叫很多繪製回收操作,當每次回收和繪製發生時,由於”繪製”較”擦除”更為延後,才會給使用者帶來”閃爍”的感覺。以往我們為解決此類問題,往往需要在Control.WndProc中作出複雜的處理。而.NET Framework為我們提供了更為優雅的一種方案,那就是雙緩衝,我們直接呼叫它即可。

最終方案

1.新建Windows元件DBListView.cs,讓它繼承自ListView。
2.在控制元件中新增如下程式碼: public DBListView()
{


// 開啟控制元件的雙緩衝
SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint, true);
}

將專案重新生成,然後從工具箱中拖出新增的組建DBListView到窗體上,命名為dbListView1,執行以下程式碼: 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();
} >

  現在”閃爍”的問題是不是已經得到了解決?

    對於DataGridView來說,也是每一行一行的新增,

for (int i = 0; i < Max_Item_Count; i++)
{

//建立行
DataGridViewRow dr = new DataGridViewRow();
foreach (DataGridViewColumn c in dataGridViewAllInfo.Columns)
{
dr.Cells.Add(c.CellTemplate.Clone() as DataGridViewCell);
}
//累加序號
dr.Cells[0].Value = i++;

try
{
dataGridViewAllInfo.Invoke((MethodInvoker)delegate()
{
dataGridViewAllInfo.Rows.Add(dr);
});
}
catch (Exception ex)
{
//如果插入出現異常,直接跳出
return;
}

}