舊文重發:Windows 表單與多執行緒

本文原刊載於 .NET Magazine 中文版 2005 年 12 月號,現在將此舊文重發,只是為了方便參考。

摘要

在撰寫多執行緒的 Windows 表單應用程式時,有一項必須特別注意的規則,就是不可以在工作執行緒(worker thread)當中修改表單或控制項的屬性與方法。本文說明這項規則的由來,以及違反此規則將造成的後果,同時示範錯誤的以及正確的程式撰寫方式。

在撰寫多執行緒的 Windows 表單應用程式時,有一項必須特別注意的規則:只有在建立該表單(或控制項)的執行緒中,才能存取、修改表單(或控制項)的內容。如果讀者到MSDN Library查看Control.BeginInvoke方法的說明,也可以得到類似的訊息(雖然不是很明顯),其中有一段備註是這麼說的:「在任何執行緒裡面可以安全呼叫的函式只有四個:Invoke、BeginInvoke、EndInvoke、和 CreateGraphics。」進一步的解釋這句話,它的意思就是:除了上述四個方法,您在任何執行緒當中存取或修改 Control 類別的屬性或方法時,都有可能導致程式發生不預期的錯誤,例如:畫面沒有正常更新、程式當掉等等。而由於Form類別也是繼承自Control類別,因此,就如本文一開始所說的,如果您想要存取表單或控制項的屬性或方法,這些程式碼一定要執行於建立該表單或控制項的執行緒當中,通常這個執行緒就是應用程式預設的執行緒,又稱為主執行緒。

為什麼會有這項規則?簡單的說,是因為.NET控制項在設計時就是執行緒相依的(thread affnity),因此您只有在建立該表單或控制項的執行緒當中,才能安全地存取其屬性。但這樣的說明仍不足以讓我們了解背後真正的原因,在本文中,筆者將會詳細說明這項規則的來龍去脈,如果不遵守這個規則會造成什麼後果,並且示範正確的程式撰寫方式。讀者如果對執行緒的基本觀念還不熟悉,建議您參考 2002 年九月號由胡百敬老師所撰寫的文章。

Windows 表單與 Win32 視窗的關係

首先必須先了解的是 Windows 表單(即 Form 類別)與執行緒之間有什麼關係,以及它們的運作方式,由於 Windows 表單實際上是基於傳統的 Win32 視窗技術以及訊息傳遞的機制,因此這個小節筆者就先從表單與 Win32 視窗的關係談起,請讀者注意在本節當中的表單(form)跟視窗(window)這兩個名詞所代表的是不同的東西。

當您建立一個新的表單(form)時,只不過是建立了一個.NET物件,其實這時候並沒有建立任何視窗,這個表單物件的建構函式所執行的工作只是初始化物件的內部資料(欄位成員、事件處理常式),如此而已。我們所看到的視窗(Win32視窗)則是在表單第一次顯示時才會真正建立起來,整個過程是這樣的:在設定表單的 Visible 屬性為 True 時,該屬性的 set 方法會呼叫 SetVisibleCore 方法,此方法會讀取表單的 Handle 屬性,而 Handle 屬性的 get 方法會檢查視窗是否已經建立,若已建立,就傳回視窗handle,否則便呼叫 CreateHandle 方法,視窗便是在這個時候建立起來的。程式碼列表1是筆者利用 Reflector 工具反組譯 .NET Framework 的結果,讀者可以對照剛才的描述,應該能更清楚了解整個建立視窗的過程。

 程式碼列表 1:Windows 表單應用程式的 Main 方法
   1:  // Form 類別的 SetVisiblCore 方法.
   2:  public class Form : ContainerControl
   3:  {
   4:    protected override void SetVisibleCore(bool value)
   5:    {
   6:      // ....省略其他不相關的程式碼
   7:    
   8:      UnsafeNativeMethods.SendMessage(new HandleRef(this, base.Handle), 0x18, value ? 1 : 0, 0);
   9:      // 上面這行在讀取 base.Handle 時,就會呼叫 Control 類別的 Handle 屬性的 get 方法(見下方)。
  10:    }
  11:  }   
  12:  
  13:  // Control 類別的 Handle 屬性.
  14:  public class Control : Component
  15:  {
  16:    public IntPtr Handle
  17:    {
  18:      get
  19:      {
  20:        if (this.window.Handle == IntPtr.Zero)  // 若視窗 handle 尚未建立
  21:        {
  22:          this.CreateHandle();                // 建立視窗 handle
  23:        }
  24:        return this.window.Handle;
  25:      }
  26:    }
  27:    //...省略其他成員
  28:  }

因此,建立表單與建立Win32視窗的過程,在內部的運作上其實是分成兩個階段:(1) 建立表單物件;(2) 建立視窗,而建立視窗的動作則是經由設定表單的Visible屬性為True所引發。這種運作方式意味著表單的建構函式裡面不可以存取跟視窗handle有關的變數(因為當時視窗根本還沒建立起來),而且不只是表單而已,所有具有視窗handle的控制項都適用此規則。這對開發人員有什麼影響?當您在設計自訂的控制項時,可能要特別留意,那些跟視窗有關的資料必須以另外的資料結構(例如:陣列)儲存起來。以ListView控制項為例,當您要加入項目時,是透過Items屬性的Add方法,此方法會把加入項目的工作委派給ListView.InsertItem方法,InsertItem則會先檢查看看這個ListView控制項的視窗(注意:不是表單)是否已經建立了,若已建立,就送出LVM_INSERTITEM訊息給控制項,告訴它要加入一個新的LVITEM;若控制項尚未建立,那麼這些項目就會先儲存在一個ArrayList裡面,等到控制項的視窗建立時,便會觸發HandleCreated事件,在此事件中會把事先儲存的ArrayList內容全部加入到控制項裡面。當您在設計自己的控制項時,也很可能會需要用到這種技巧,即先把控制項所需的資料暫存到別處,等到控制項的視窗建立時,再把事先儲存的資料塞給控制項。聽起來挺麻煩的,不是嗎?還好我們通常不會需要從無到有設計一個新的控制項。

從以上的說明可以知道,表單的視窗只有在第一次設定Visible屬性為True時才會建立,可是我們平常在撰寫Windows表單應用程式時,並沒有去設定Form的Visible屬性,那這個屬性到底是什麼時候、在哪裡設定的?記得Windows表單應用程式的Main方法裡面都會類似程式碼列表2的程式碼。

 程式碼列表2:Windows表單應用程式的Main方法
  static void Main()
{
Application.Run(new Form1());
}
所有建立視窗的動作就是從程式碼列表1裡面的Application.Run() 開始。Application.Run() 會啟動一個訊息迴圈(message pump),在這個訊息迴圈裡面,會呼叫Win32 API的GetMessage函式,以便從執行緒的訊息佇列裡面取出下一個視窗訊息,接著再呼叫DispatchMessage函式將訊息傳送給目標視窗。簡言之,由主執行緒所建立的訊息迴圈會不斷從主執行緒的訊息佇列提取視窗訊息,並發送至應用程式中的各個視窗(或控制項)。請注意「執行緒的訊息佇列」這句話,如果視窗建立在另一條執行緒,那麼它的訊息就會被送到該執行緒的訊息佇列裡。Windows表單應用程式的視窗訊息處理的過程如圖1所示,當使用者按下「測試」按鈕時,系統會將這個按鈕事件轉換成一個滑鼠點擊的訊息存放到執行緒的訊息佇列中,應用程式則藉由訊息迴圈從訊息佇列中取出訊息,並且分派到對應的控制項(此例的「測試」按鈕),以便進行對應的處理(例如:將按鈕畫成凹陷的樣子)。


兩種訊息傳送機制

現在我們知道,當控制項的狀態改變時,系統會將它轉換成視窗訊息,放到訊息佇列裡面,再由程式取出並處理。而這個將訊息放到訊息佇列的動作,又有兩種不同的作法,一個是透過SendMessage函式,另一個是PostMessage函式。這兩種送訊息的方式有個主要的差異:SendMessage會令呼叫端停住,直到該訊息被處理完畢,程式的控制權才會回到呼叫端,而 PostMessage則是將訊息丟到訊息佇列後就立刻返回呼叫端。因此,應用程式實際上會有兩個訊息佇列,分別存放SendMessage和PostMessage送出的訊息,而訊息迴圈會優先處理SendMessage的訊息佇列,等這些訊息都處理完了,才會去處理PostMessage的訊息佇列。那麼,當 .NET控制項的屬性變更時,是採用哪一種傳送訊息的方法呢?
在.NET裡面,所有表單或控制項的屬性變更──不管此變更是由使用者操作時所觸動的,還是由程式去更改控制項的屬性或呼叫其方法──都會轉換成對應的視窗訊息,並且利用SendMessage送出該訊息。而文章前面也提到,在呼叫SendMessage時,必須等到送出的訊息被處理完畢,才會返回呼叫端繼續往下執行,這就是為什麼在撰寫多執行緒的Windows表單應用程式時,只能在主執行緒(也就是建立表單與控制項的執行緒)當中修改控制項屬性的主要原因。
舉個例子來說,假設現在應用程式有兩條執行緒,一條是主執行緒(main thread),另一條稱它為工作執行緒(worker thread),如果主執行緒正在等工作執行緒完成,而您又在工作執行緒裡面嘗試更新某個控制項,也就是呼叫了SendMessage來更新某個控制項的狀態,於是SendMessage會令工作執行緒停住(blocked),因為它必須等待主執行緒把SendMessage送出的訊息處理掉,可是這時候主執行緒卻因為在等待工作執行緒執行完畢,而沒有去處理訊息佇列裡面的訊息,結果便形成了兩條執行緒相互等待的情形,程式就當掉了。

範例實作

接著我們來撰寫一些簡單的測試程式,看看如果不按照之前建議的規則撰寫程式,會發生什麼情況。先在 Visual Studio .NET 2003 中建立一個新的 Windows 應用程式專案,然後在 Form1 上面各放一個 Button 和 Label 控制項,將按鈕名稱改為 btnTestThread。當按下按鈕時,建立一條工作執行緒,並且等待該執行緒執行完畢,而工作執行緒在執行完畢時會呼叫UpdateUI函式,此函式的用途是更新控制項的狀態,這裡筆者只是簡單的把執行該程式碼所在的執行緒名稱用 Label 控制項顯示出來,請參考程式碼列表3。

 程式碼列表3:範例一
   1:  using System.Threading;
   2:  //....
   3:  
   4:  private void btnTestThread_Click(object sender, System.EventArgs e)
   5:  {
   6:    // 為表單所在的執行緒取個名字,方便判斷程式執行於哪個執行緒。
   7:    Thread.CurrentThread.Name = "主執行緒";
   8:  
   9:    // 建立並且啟動工作執行緒。
  10:    Thread workerThread = new Thread(new ThreadStart(ThreadMethod));
  11:    workerThread.Name = "工作執行緒";
  12:    workerThread.Start();
  13:  
  14:    // 等待 workerThread 執行完畢。
  15:    workerThread.Join();
  16:  }
  17:  
  18:  private void ThreadMethod()
  19:  {
  20:    // 延遲一下,假裝執行了一項費時的工作。
  21:    Thread.Sleep(1000); 
  22:  
  23:    // 更新 UI。
  24:    UpdateUI();
  25:  }
  26:  
  27:  private void UpdateUI()
  28:  {
  29:    label1.Text = Thread.CurrentThread.Name + ": Work is done!" ;
  30:  }

在這個範例當中,筆者分別為主執行緒和工作執行緒設定了Name屬性,這可以讓我們很容易辨識某段程式碼是執行於哪個執行緒當中(以往撰寫Win32程式時,必須利用執行緒識別碼的一組數字來辨別,比較不方便),執行的結果如圖2所示。



從圖2當中可以看到,UpdateUI 函式是執行於工作執行緒的,而且,若讀者實際執行這個範例程式,會發現程式並沒有發生什麼怪異的現象,Label的Text有正常更新顯示,程式也沒有當掉。可是,前面不是說:不可以在其它執行緒當中修改控制項的屬性嗎?為什麼這樣寫程式還能運作無誤?這也是撰寫多執行緒應用程式的一個陷阱,如果一開始用簡單的程式去測試,發現違反這項規則其實沒有什麼問題,就這樣繼續寫下去的話,當程式發展到某種規模時,可能才會出現問題,到時候要除錯或修改程式就更麻煩了。因此,建議讀者在撰寫程式時還是應該遵守這項規則。

為了讓讀者實際體會一下,這裡筆者再利用一點簡單的技巧,讓範例一的程式發生兩條執行緒相互等待而導致當機的情形,只要將btnTestThread_Click函式的最後一行,也就是呼叫workerThread.Join() 的敘述改成如程式碼列表4那樣就行了,這是因為Thread的Join方法雖然會讓主執行緒等待工作執行緒執行完畢,但主執行緒仍然能夠處理訊息佇列中的訊息,而修改後的程式碼用一個空的迴圈等待工作執行緒結束,主執行緒就只能等待,而無法處理訊息佇列了。

 程式碼列表4:修改自程式碼列表3,會讓兩條執行緒相互等待造成當機
   1:  private void btnTestThread_Click(object sender, System.EventArgs e)
   2:  {
   3:    // 為表單所在的執行緒取個名字,方便判斷程式執行於哪個執行緒。
   4:    Thread.CurrentThread.Name = "主執行緒";
   5:  
   6:    Thread workerThread = new Thread(new ThreadStart(ThreadMethod));
   7:    workerThread.Name = "工作執行緒";
   8:    workerThread.Start();
   9:  
  10:    // 讓程式空轉,等待 workerThread 執行完畢。
  11:    while (workerThread.ThreadState != ThreadState.Stopped)
  12:    { }
  13:  }

程式碼照列表4修改之後,執行時按下按鈕就會讓程式當掉,原因前面已經解釋過了,讀者也可以試著把UpdateUI函式裡面的修改label1.Text屬性的程式碼註解掉,再執行看看,程式便不會當掉,這也可以證明如果在工作執行緒裡面沒有修改表單或控制項的屬性,是不會造成當機的。

最後,我們再把前面的範例改成正確的寫法,請參考程式碼列表5,跟前面的程式碼不一樣的地方,主要是ThreadMethod函式。

 程式碼列表5:正確的寫法
   1:  private void btnTestThread_Click(object sender, System.EventArgs e)
   2:  {
   3:    // 為表單所在的執行緒取個名字,方便判斷程式執行於哪個執行緒。
   4:    Thread.CurrentThread.Name = "UI thread";
   5:  
   6:    Thread workerThread = new Thread(new ThreadStart(ThreadMethod));
   7:    workerThread.Name = "工作執行緒";
   8:    workerThread.Start();
   9:  
  10:    while (workerThread.ThreadState != ThreadState.Stopped)
  11:    {
  12:      // 空轉等待 workerThread 執行完畢
  13:    }
  14:  }
  15:  
  16:  private void ThreadMethod()
  17:  {
  18:    // 延遲一下,假裝執行了一項費時的工作。
  19:    Thread.Sleep(1000); 
  20:  
  21:    // 安全地更新 UI
  22:    if (this.InvokeRequired) 
  23:    {
  24:      MethodInvoker mi = new MethodInvoker(this.UpdateUI);
  25:      this.BeginInvoke(mi, null);
  26:    }
  27:    else 
  28:    {
  29:      UpdateUI();
  30:    }
  31:  }
  32:  
  33:  private void UpdateUI()
  34:  {
  35:    label1.Text = Thread.CurrentThread.Name + ": Work is done!" ;
  36:  }

在程式碼列表4當中,關鍵的敘述在於ThreadMethod函式裡的 this.BeginInvoke() 敘述,這個BeginInvoke其實就是本文一開始引述MSDN Library文件時提到的,在任何執行緒當中都能安全地呼叫的那四個方法中的其中一個。這裡的this指的是Form物件,由於Form繼承了Control類別,因此也有BeginInvoke方法,此方法的作用是:將傳入此方法的委派方法執行於主執行緒中,也就是說,雖然ThreadMethod方法執行於工作執行緒,但是透過Control.BeginInvoke() 執行的委派方法,會切換到主執行緒當中執行。由於這裡的UpdateUI函式沒有傳回值也沒有傳入參數,因此就直接使用.NET 提供的MethodInvoker委派型別,讀者也可以視需要使用自訂的委派型別。


另外值得注意的是,在呼叫 this.BeginInvoke() 之前,程式先檢查了this.InvokeRequired屬性,當此屬性為true時,表示目前所在的執行緒不是主執行緒,因此才需要多費一番功夫去呼叫this.BeginInvoke();若InvokeRequired為false,就可以安全地更新控制項,而不用切換到主執行緒了。執行結果如圖3所示,讀者可以跟圖2比較一下,這回label1的訊息顯示的是「主執行緒」,可以看出更新控制項的動作確實被切換到主執行緒當中執行了。


結語

撰寫多執行緒的應用程式是一項挑戰,在寫程式時必須比撰寫單一執行緒的應用程式更加小心,要處理的問題包括資源爭用、同步化、以及本文所介紹的,Windows表單應用程式需要特別遵守的規則:不可以在主執行緒之外的執行緒當中修改控制項的屬性。筆者在本文中說明了這項規則背後的原因,同時也說明了表單與Win32視窗之間的關係以及訊息傳遞的機制,最後以實際的範例展示正確的程式撰寫方式,希望本文對於要開發多執行緒的Windows表單應用程式的讀者有一些幫助。

5 則留言:

  1. 很棒的說明,沒有用到很艱深的詞彙, 但讓人可以初步認識使用多執行緒上要注意的風險

    回覆刪除
  2. 這篇文章讓我學到不少觀念,
    不過有個小地方不太確定,想向版主請教一下,
    在"兩種訊息傳送機制"那段最後幾行的例子中,
    當「工作執行緒」利用SendMessage將訊息放到「工作執行緒的訊息佇列」中後,接下來這則訊息是由「主執行緒建立的訊息迴圈」直接取得並做處理嗎? 「工作執行緒並沒有自己的訊息迴圈和視窗處理程序」對嗎?

    回覆刪除
  3. 是的,您的理解沒錯,工作執行緒並沒有自己的訊息迴圈和視窗處理程序。(抱歉晚了點回覆)

    回覆刪除
  4. 恩,那我了解了,謝謝您的解說。
    (抱歉,我晚了更久才回覆,
    記得當初有加到我的最愛,
    但事後卻一直找不到這篇文)

    回覆刪除

技術提供:Blogger.
回頂端⬆️