C# 筆記:從 Lambda 表示式到 LINQ

1/30/2009

C# 筆記:擴充方法

1/29/2009
摘要:本文將簡單介紹 C# 3.0 的新語法:擴充方法(extension methods)。

C# 筆記:重訪委派-從 C# 1.0 到 2.0 到 3.0

1/28/2009
這篇文章主要是複習一下 C# 委派(delegate)的基本觀念,同時也示範從 C# 1.0、2.0、到 3.0 的委派寫法。 我們會看到更直覺的建立委派物件的語法、匿名方法、以及 Lambda 表示式。

C# 筆記:自動實作屬性與物件初始器

1/27/2009
C# 3.0 增加了自動實作屬性(auto-implementation property)與物件初始器(object initializer)的語法。這兩種語法都提供了某種程度的方便性,Scott Guthrie 很早就寫了一篇文章介紹它們,這裡就不寫太多重複的東西了。貼個簡單的範例上來:

C# 筆記:匿名型別

1/27/2009
時候,例如在某個函式裡面,我們會臨時需要一個簡單的類別來儲存一些簡單資料,但又不想為了這個簡單的需求另外定義一個類別,此時便可使用 C# 的匿名型別(anonymous type)。


C# 筆記:使用 var 宣告隱含型別

1/27/2009
C# 3.0 增加了 var 關鍵字,你可以用它來宣告隱含型別,例如:

int i = 10;
var j = 10;


ASP.NET 網站呼叫 Web service 時出現 SocketException (0x274c)

1/20/2009
這個問題不太好找,值得記錄一下。

問題描述

在 ASP.NET 2.0 網站中呼叫另一個網站的 Web service 時,「有些」用戶端瀏覽器會出現以下錯誤:

連線嘗試失敗,因為連線對象有一段時間並未正確回應,或是連線建立失敗,因為連線的主機無法回應。 111.xxx.xxx.xxx:80
[SocketException (0x274c): 連線嘗試失敗,因為連線對象有一段時間並未正確回應,或是連線建立失敗,因為連線的主機無法回應。 111.xxx.xxx.xxx:80]

問題難解的原因有兩個。首先,是這個問題只有在少數用戶端環境才會發生,開發團隊不易模擬出問題環境。其次,整個網站的運作架構有點複雜(load-balance、proxy、switch、firewall...blah blah blah),以至於碰到 80 port 被擋住時,花了很多時間在懷疑網路繞徑和防火牆等問題。畢竟,採用 web service 的原因之一,就是因為 80 port 一般都能通行無阻嘛!

發生原因

基本規則:當你在 .NET 程式中利用 Web service proxy 類別來呼叫 Web services,而且該 Web service 是位在 proxy/NAT router 背後,此呼叫動作可能就會失敗。

可是,我們碰到的狀況是只有少數用戶端會出錯,這點倒是令我想不透。

根據 Rick Strahl 的文章「Slow Http client calls from ASP.NET 2.0? Make sure you check your Proxy Settings!」, 裡頭說 .NET 2.0 的 HTTP client 類別預設會去抓取機器上的 IE proxy 設定,然而 ASP.NET 應用程式的執行帳戶(IIS 6 為 NETWORK SERVICE)權限很低,無法讀取那些儲存在 registry 裡面的 IE 設定。這條線索也挺有幫助。

解決方法

首先,開發團隊總算模擬出可以讓問題重現的環境和操作程序。經過測試發現,只要用戶端瀏覽器有指定透過公司內部的 proxy 伺服器連接 Internet,程式就能順利執行。

但是公司內部的 proxy 伺服器僅供企業內網的用戶端使用,故外網的使用者仍會有問題。因此,便從 Strahl 的文章得到這個靈感:何不試試為 Web service 物件指定 proxy 伺服器?

於是,為了讓 ASP.NET 程式在呼叫 Web service 時透過我們指定的 proxy 去連接,我在 web.config 裡面加了以下設定:

<system.net>
  <defaultProxy>
    <proxy
      bypassonlocal="true"
      usesystemdefault="false"
      proxyaddress="http://proxy.xxx.com:3128" />
  </defaultProxy>
</system.net>

如此一來,無論是內網還是外網的使用者,便都能夠順利執行程式了。

不過這種修改 web.config 的方式也會影響 .NET FtpWebRequest 類別,導致應用程式原本順利執行的 FTP 功能發生錯誤:The Requested FTP Command Is Not Supported When Using HTTP Proxy。此時便可以改用程式指定 proxy 的方式解決,參考以下範例:

MyService.Foo ws = new MyService.Foo();
WebProxy proxyObj = new System.Net.WebProxy("http://proxy.xxx.com:3128", True);
ws.Proxy = proxyObj;
ws.DoSomeThing();


這樣就不用去動 web.config 了。

當然,你也可以修改 FTP 傳輸處理的程式碼,將 FtpWebRequest 的 Proxy 設為 null。要用哪一種方式,就看實際情況而定了。

小結

在撰寫 Web service 應用程式時,須了解:
  • .NET 從 2.0 起,利用 wsdl.exe 或 Visual Studio 的 Add Web Reference 功能產生的 Web service proxy 類別預設會使用機器上的網際網路 proxy 連線設定來存取 Web service。此內定行為對某些網路環境會造成問題,須自行指定透過 proxy 連線的方式存取 Web service。
  • 透過 proxy 連線的方式有兩種:(1) 以程式建立 WebProxy 物件,然後指定給 Web service proxy 物件的 Proxy 屬性;(2) 在應用程式組態檔中設定 proxy 元素。
  • 應用程式組態檔的 proxy 元素可以控制 .NET 網路傳輸相關類別要不要使用 IE 的 proxy 設定,亦可明白指定 proxy server 的位址。

《OOAD with Applications 3rd edition》的翻譯進度(2009/1/16 更新)

1/16/2009

2009-02-24 更新

書籍已出版,詳情請看《物件導向分析設計與應用 第三版》書籍相關資訊


《Object-Oriented Analysis and Design with Applications 3rd edition》是由物件導向三巨頭之一的 Grady Booch 以及其他作者所共同撰寫。

中文版書名目前暫定為:《物件導向分析設計與應用 第三版》

這本書將近 700 頁,雖然附錄和參考文獻就占了 139 頁,但是對我來說還是屬於重量級的挑戰(對讀者來說恐怕也是吧)。

目前的翻譯進度:

第 12 章
> 附錄 > 索引 > 校稿 > 等出版


2009/1/16 更新:

今天(2009/1/16)的版本應該是最後的修訂了,最後一次校稿結果是由出版社的編輯做最終修訂,我自己也沒有看到最後的排版結果。不過,碁峰出版社的編輯 Novia 小姐相當細心,還抓到幾條二校的「漏網之魚」。相信最終的成品會有不錯的品質。

這帖不會再更新了,本文的回應也就此鎖住。有關本書的出版訊息、簡介、試讀章節等參考資源會另起新文。

相關文章:

複雜的資料查詢:手工打造 SQL 指令 vs. 撰寫程式碼

1/11/2009
對於複雜的資料查詢,我們是該用手工打造的 SQL 指令呢,還是該寫程式碼來處理?在甚麼情況下該選擇甚麼方法呢?

我的看法是應該盡量以撰寫程式的方式來處理。

在解釋原因之前,要先聲明一件事:我不是 SQL 高手,所以在一開始打算寫這篇文章時,我就在想我的結論可能過於偏頗。不過,若能拋磚引玉,得到其他前輩、高手的意見,因而修正自己的想法,倒也是好事一樁。

其實在多年以前,我便覺得撰寫 SQL 對我來說是一件「寫時容易讀時難」的工作。我不是說兩三兩個資料表 join 這類簡單的 SQL 查詢,我說的是包含七八層、甚至更多層子查詢的 SQL。因此在我自己寫的程式裡面,我都盡量不寫太複雜的 SQL,而我的 SQL 功力自然也就一直停留在小學生的階段(好吧,也許是幼稚園階段)。

兩種解法

會想把這個議題寫下來,其中一個原因是不久前又碰到了複雜 SQL 導致嚴重效能問題的情形。另一個原因,是正好一個朋友(T 先生)最近 msn 給我,提到他們公司裡面有一位 SA 寫了一個超級 SQL。這個超級 SQL 指令多達 8463 bytes,據他描述,若以每一列都填滿 80 個字元來計算,也有一百多列。

對一個 SQL 功力只有小學程度的我來說,這道一百多列的 SQL 指令自然讓我覺得不可思議,便半開玩笑的回他:「快請他回火星吧!」然而他說這個 SQL 不僅解救了他們的難題,執行速度還很快哩。

我比較擔心的,是萬一將來 business rule 有變,原作者又離開團隊了,當初也沒寫文件,那麼這些 SQL 誰有辦法改?朋友回答的大意是:在時程壓力下,能解決問題的就是好方法。言下之意是顧不了那麼多了。我不禁想到《軟體工程與 VSTS》裡面提到的「時程膽小雞」(schedule chicken)。

架構問題效能 vs. 可維護性

對我來說,要用手寫的複雜 SQL,還是撰寫程式來解決,是屬於架構面的議題。在決定採用何種解法時,必須同時考慮系統的整體效能和往後的維護問題。我不喜歡為了執行效能而捨棄可維護性,更何況我覺得兩者並非魚與熊掌。很多時候,程式開發人員並沒有仔細分析問題,寫出來的程式跑得慢,就想用 SQL 來一次解決。其實加快程式執行效能的技巧何其多(例如:資料快取、預先產生暫時資料、建立索引等),如果未經深思熟慮和多方測試,便認定哪一種解法就一定有更佳的執行效能,對此結論我會持相當保留的態度。

工作方式與習慣問題

有句諺語說,只會用鐵槌的木匠,看到甚麼東西都覺得是釘子,會想敲它一下。的確,每個人都會盡量用自己熟悉的工具來解決問題(我也是)。這便產生了另一個問題。

以我自己碰到的情況來說,開發團隊經過效能調校和檢視 log 之後,發現有一位 SA 負責的子系統都跑得特別慢,只要有使用者上線執行他負責的子系統,整個系統效能就 down 下來,變成龜速,甚至導致應用程式伺服器完全無法回應。進一步查看其程式碼,發現該子系統的程式很多都是超過 10 個 subquery 的 SQL,也就是說,那位 SA 開的規格書大都如此,所有資料查詢的處理都完全用複雜 SQL 指令一次解決,而未曾想過有些情況可能用程式碼處理比較適當。更令人驚訝的是,有些複雜 SQL 就只是取一兩個 count 值而已(select count(*) from many, many, many subqueries),而且還用到了許多會造成 full table scan 的條件判斷式。不禁納悶:這當中的 subquery 難道是必要的嗎?會不會有在迴圈裡面反覆執行此 SQL 的情況(這會是多大的效能負擔與資源浪費啊!)?

我並不是對 SQL 專家有意見,也並非全然反對寫複雜 SQL。為了提升效能,且實在別無他法時,自然還是得用。但前提是必須把 SQL 指令的 business rule 寫成文件,以便日後維護時可對照著 SQL 拆解、調整。這是為了避免 SA/SD 直接在規格書裡面寫好 SQL,程式設計師不明就裡直接照著 coding 而埋下的炸彈。少了說明文件,日後他人接手維護程式時,有多少人有把握完全理解那些複雜 SQL 的用意並修改它呢?我想這是我對超級 SQL 退避三舍的最主要原因。

小結

或許有人會認為,那我一定不敢用 regular expression,因為用它寫出來的東西更像天書,比 SQL 還難懂。恰好相反,我喜歡用 regular expression。但除非必要,我都盡量找別人寫好的字串樣板來用,而不自己動手寫。因為網路上已經有許多現成的了,而且這種東西不像 SQL 指令那樣對系統的整體效能和資料的正確性有那麼大的影響,如果發現目前使用的字串樣板有缺點,大不了再找一個,或者自己想辦法稍微改一下就行了。

最後,容我耍點引用他人論述來支持自己觀點的小伎倆。在《Release It! Design and Deploy Production Ready Software》這本書裡,作者進一步建議應盡量使用 O-R mapping(物件-關聯對應)框架,而不要手工打造 SQL,其理由與前面談的問題有點關係。且摘錄部分內容如下:

The problem is that developer-crafted SQL tends to be so idiomatic and unpredictable. All the database tuning for the rest of the application won’t help for these beastly one-offs.
開發人員手工打造的 SQL 往往帶有濃厚的個人色彩,而且無法預測。就算對應用程式的其餘部分進行資料庫調校,也無法減輕這類怪獸級 SQL 帶來的傷害。

What makes these handcrafted SQL queries so bad? They usually suffer from a handful of common mistakes. First, they often join on nonindexed columns. Second, they usually join too many tables.Third, developers .... will issue some query that joins five tables to select exactly the one row they're looking for, and then they'll issue that query 100 more times to find other individual rows.
手工打造的 SQL 容易犯幾個毛病:第一,它們常常 join 非索引欄位;第二,它們通常都 join 太多資料表了;第三,開發人員可能會單單為了取得一筆資料列而 join 五個資料表,而且可能還重複執行該 SQL 指令一百次以上,以便取得其他特定的資料列。

《I. M. Wright's "Hard Code"》第二章閱讀札記

1/08/2009
延續上一篇,這裡寫一點第 2 章<流程改善,沒有魔法>的筆記。

精實(Lean)製程定義了七種會破壞客戶價值流的浪費:
  • 過度生產(overproduction):開發太多不重要、甚至無用的功能。
  • 傳遞成本(transportation):專案建置(build)、版本分支(branch)、和團隊成員之間透過 e-mail 溝通等傳遞成本。
  • 多餘動作(motion):花在找資料、找解法、搞清楚要做甚麼等動作的時間太多。
  • 等待時間(waiting):系統功能的優先順序沒訂好、開發流程不當、品質不良等因素,導致各開發小組之間彼此等待。
  • 過度處理(overprocessing):把軟體功能設計得太複雜、對已經跑夠快的程式不斷做效能最佳化的調整、增加不必要的擴充等等。
  • 庫存過剩(inventory):矇著頭開發了一堆功能,後來發現有很多都白做了,在系統上線前就被拿掉,所以客戶也看不到這些功能--未實現的價值是種浪費。
  • 產品瑕疵(defects):太多 bugs 將令客戶失去信心,也會造成重工(rework)。
深度 vs. 廣度

作者認為,花太多時間專注在開發底層架構和元件等「基礎設施」上,可能會讓客戶久候,苦等不到他們想要的東西。為了經常獲得客戶的回饋意見,快速反覆(iteration)有個基本前提:開發應該「深度優先,而非廣度優先(depth first, not breadth first)。

廣度優先意味著包羅萬象,要求納入所有能想到的功能,並對每一項功能進行設計,設計階段完成後再全部一起實作,然後一起測試。深度優先則著重於把單一功能做好,完成了某項功能的設計、實作、測試之後,才繼續處理下一個功能。兩種極端都不好,但是對大部分的開發團隊來說,最好是先做一次高階的廣度設計,之後就立刻改以深度優先的方式進行底層的設計與實作。

的確,我覺得偏好深度優先的另一層意義是對品質的堅持,而不是以量取勝、上(線)了再說的心態。因為開發人力和時間都很寶貴,如果想要全都顧到,很可能結果就是每項功能的品質都差了點。一次交付預定的那些功能,專案進度表自然是交代得過去,可是當客戶開始使用時,可能會發現怎麼跟當初期望的不太一樣:好像每一項功能都做了,但又沒幾個讓人覺得滿意的。開發團隊並沒有真正實現當初對品質的承諾。

如果專案經理光是盯著進度表,只求達到表面上的實現承諾,卻從不認真思考軟體品質與維護成本的問題,我覺得是相當短視、而且不負責任的作法。這種領導風格可能一開始會讓客戶和老闆很高興,但是胡亂承諾、扮好人的結果,往往會讓後面維護的人疲於奔命:接客戶的抱怨電話、除 bug、改規格、加需求;每做一個專案就在團隊腳上綁一塊大石,到最後整個團隊精疲力竭,只能選擇抽身離去。

喔,又忍不住寫太多感想了,就此打住。

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

1/01/2009

本文原刊載於 .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表單應用程式的讀者有一些幫助。
技術提供:Blogger.
回頂端⬆️