高品質軟體之路(二)──管理與重用寫好的程式


寫程式有件事很忌諱──reinventing the wheel,也就是說,程式應該具有重覆使用性,同一套邏輯在不同場合下使用,不應該重寫,而是想辦法套用,畢竟以前寫好的程式是測試過的,具有一定的可信度。重寫的話,測試程式也是要重寫,花時間在這些無用功上是不值得的。可是問題是以前寫好的東西不見得百分之百適用,硬要用還是要加減一些東西。結構型設計模式(Structural Design Pattern)就是要處理這類的問題。從一開始如果能使用結構型設計模式寫程式,之後的維護管理會比較輕鬆,要重覆使用也比較簡單。其實很多程式設計師天天都在不自覺地使用這些模式,只是沒有人會很明確地指出來某部份程式使用某種模式罷了。GOF的書把這類設計模式分成七種:

1. Adapter:用別人寫好的程式庫的時候,通常會遇到引數轉換的問題,也就是說,要把原始的資料轉換成程式庫可以接受的格式。引數轉換是所有程式都會遇到的狀況,所以要想辦法寫成能重用的程式庫,然後用adapter做資料變換後送到程式庫裡。Adapter把寫好的程式庫重新封裝,引數的類別改為原始資料,這樣轉換的部份就被包在新的類別裡,使用者不用知道原來類別怎麼使用,引數資料怎麼轉換,就可以讓程式很方便地執行想要的工作。

  1. class MyClass
  2. {
  3.     public void Method1(int arg1);
  4. }
  5.  
  6. class Adapter
  7. {
  8.     private MyClass m_class;
  9.  
  10.     public Adapter(MyClass obj)
  11.     {
  12.         m_class = obj;
  13.     }
  14.  
  15.     public bool MyMethod1(string arg1)
  16.     {
  17.         try
  18.         {
  19.             int arg = Convert.ToInt32(arg1);
  20.             m_class.Method1(arg);
  21.             return true;
  22.         }
  23.         catch (Exception)
  24.         {
  25.             return false;
  26.         }
  27.     }
  28. }

光用Adapter有個問題:新類別和原來類別是無法分割的(高耦合度),尤其是原來類別很複雜的時候,單元測試變得很麻煩,這時候就要配合下面要談的Bridge一起使用。

2. Bridge:Bridge提供去耦合設計,使得二個不同模組之間的相關性降低,抽換一個模組(例如,單元測試)變得很容易,這個模式也是實作Dependency Injection最方便的方法。在C#和Java的世界裡,Bridge就是interface;C++則是利用純抽象類別和多種繼承實作。Bridge常常和其他的模式一起使用,以達到去耦合的目的。

  1. class MyClass: IBridge
  2. {
  3.     public void Method1(int arg1);
  4. }
  5.  
  6. interface IBridge
  7. {
  8.     void Method1(int arg1);
  9. }
  10.  
  11. class Adapter
  12. {
  13.     private IBridge m_class;
  14.  
  15.     public Adapter(IBridge obj)
  16.     {
  17.         m_class = obj;
  18.     }
  19.  
  20.     public bool MyMethod1(string arg1)
  21.     {
  22.         try
  23.         {
  24.             int arg = Convert.ToInt32(arg1);
  25.             m_class.Method1(arg);
  26.             return true;
  27.         }
  28.         catch (Exception)
  29.         {
  30.             return false;
  31.         }
  32.     }
  33. }

 

3. Composite:Composite是把類別建成樹狀結構以處理比較複雜的資料,也就是說,類別裡面有一個成員是同一個類別的物件。最簡單的Composite pattern是linked list。

4. Decorator:Decorator也是在用別人寫好的程式庫時會用到,和Adapter不一樣,Decorator所產生的新類別和原始類別會有一樣的方法介面,不過介面的實作經過修正,和原來類別的實作已經不同。最直覺的方式是繼承,不過繼承最大的問題還是耦合。一般的Decorator會用這種方式實作:

  1. class Decorator
  2. {
  3.     private IBridge m_class;
  4.  
  5.     public Decorator(IBridge obj)
  6.     {
  7.         m_class = obj;
  8.     }
  9.  
  10.     public void Method1(int arg1)
  11.     {
  12.         m_class.Method1(arg1);
  13.         Console.WriteLine("new Method1");
  14.     }
  15. }

稍微麻煩一點,不過彈性比較大,父類別也不需要定義虛擬方法。

5. Facade:這個模式用來包裝大系統。一個大系統通常可以拆解成數個模組,模組之間透過介面(方法或變數)溝通,不過很多時候模組之間只需要內部溝通,當外部程式把整個系統當做整體時,內部溝通使用的介面不應該被外部程式看到。Facade把這些模組封裝起來,並且重新定義外部程式使用的介面,使得整個系統可以比較方便地使用。從外面看來,Facade就是一個整體不可分割,至於內部怎麼樣,就不是外部程式可置喙的了。

6. Flyweight:這個模式比較接近data pool的概念。例如顯示英文字母需要26張圖片,如果一段文章有3000個字母,那就要3000張圖片,但是大部份是重覆的,這樣會浪費不少記憶體。更嚴重的問題是換字型時,顯示資料要全部重做。比較好的辦法是把圖片編號,然後把3000個字母和圖片編號對應,這樣不但節省記憶體,換字型的時候只要換成另一組圖片就行了,文章內容和圖片編號的對應不會動到。Flyweight的使用時機是在要處理的物件很大但是只有有限個,透過編號或指標的方式這些物件存取會有效率地多。

7. Proxy:直譯是代理的意思,proxy出現的場合通常是I/O,像輸出到螢幕、讀取和寫入檔案或網路。輸出入裝置相對來說是比較慢的,所以proxy裡通常會有個記憶體緩衝區,程式把資料寫到這個緩衝區,另外一個執行緒會把緩衝區的資料送到真正的I/O裝置去,這個做法的最大好處是程式的執行緒不會卡在I/O,缺點是如果程式不正常結束,可能有些資料來不及送到檔案去,造成檔案毀損。這種用二個執行緒處理資料的方式一般稱為生產者─消費者模式(Producer-Consumer pattern),在多緒環境相當常見。

結構型模式的重點是在讓程式的可重用性提高,並且用易懂易維護的方式處理大型資料。第三類的行為型模式最複雜也變化最多,之後再談吧。

張貼在 Computers and Internet | 發表留言

高品質軟體之路


上一篇提到軟體品質管制,最基礎的是單元測試。單元測試完成之後,整合性測試當然也不可少,單元測試做得好,整合測試就會比較像是最後的確認,因為理論上蟲都會在單元測試就抓出來了。如果每個模組都符合合約,組起來問題應該不大,這是合約式程式設計(Contract Programming)的最大好處,程式品管相對起來也會輕鬆些。

但單元測試要做得好,得大量使用Dependency Injection(DI)把各個物件實作用介面分開,才能用Mock Object把不用測的物件隔開。這之中有個大問題,DI把mock object傳到待測類別的建構子裡來控制mock object的行為,如果待測類別有很多個物件,每個都要mock,那它的建構子就會長得很可怕:

  1. interface IClass1 { }
  2. interface IClass2 { }
  3. interface IClass3 { }
  4. interface IClass4 { }
  5. interface IClass5 { }
  6. interface IClass6 { }
  7.  
  8. class Class1 : IClass1 { }
  9. class Class2 : IClass2 { }
  10. class Class3 : IClass3 { }
  11. class Class4 : IClass4 { }
  12. class Class5 : IClass5 { }
  13. class Class6 : IClass6 { }
  14.  
  15. class TestClass
  16. {
  17.     IClass1 m_class1;
  18.     IClass2 m_class2;
  19.     IClass3 m_class3;
  20.     IClass4 m_class4;
  21.     IClass5 m_class5;
  22.     IClass6 m_class6;
  23.     public TestClass(IClass1 c1, IClass2 c2, IClass3 c3, IClass4 c4,
  24.         IClass5 c5, IClass6 c6)
  25.     {
  26.         m_class1 = c1;
  27.         m_class2 = c2;
  28.         m_class3 = c3;
  29.         m_class4 = c4;
  30.         m_class5 = c5;
  31.         m_class6 = c6;
  32.     }
  33. }
  34.  
  35. class Program
  36. {
  37.     static void Main(string[] args)
  38.     {
  39.         TestClass obj = new TestClass(new Class1(), new Class2(),
  40.             new Class3(), new Class4(),
  41.             new Class5(), new Class6());
  42.         //……
  43.     }
  44. }

比較好的做法是用一些設計模式(Design Pattern)把起始物件的部份包裝起來,程式會比較簡潔。

設計模式最經典的書籍是四人幫(Gang of Four, GoF)的Design Patterns: Elements of Reusable Object-Oriented Software,裡面收錄了23種常用的設計模式。設計模式不是程式,而是針對常見的程式設計問題提出的解決方案,並且分析這個解決方案的優缺點,提供程式設計師做選擇。不同的設計模式可能可以解決類似的問題,但在不同的情況之下,程式師可能會選擇不一樣的答案。這本書把設計模式分成三大類:創建型(Creational Patterns)、結構型(Structural Patterns)和行為型(Behavioral Patterns)。創建型是DI使用最多而且也最容易懂的模式,從這邊切入會比較容易感覺到設計模式的妙用。

1. Builder:這大概是最簡單的模式之一。既然每個物件內含好幾個部份,那就把這些建構子的呼叫放到TestClass的外面,再用property setter把TestClass物件組出來。這個做法有個很大的缺點,TestClass裡面包含的物件統統被看光光,這違反物件封裝的精神,不過如果只有一二個,這倒不失為一個好方法,觀念上簡單,程式也不會太難讀。

  1. class TestClass
  2. {
  3.     IClass1 m_class1;
  4.     IClass2 m_class2;
  5.     IClass3 m_class3;
  6.     IClass4 m_class4;
  7.     IClass5 m_class5;
  8.     IClass6 m_class6;
  9.     public TestClass() { }
  10.     public IClass1 Class1
  11.     {
  12.         get { return m_class1; }
  13.         set { m_class1 = value; }
  14.     }
  15.     public IClass2 Class2
  16.     {
  17.         get { return m_class2; }
  18.         set { m_class2 = value; }
  19.     }
  20.     public IClass3 Class3
  21.     {
  22.         get { return m_class3; }
  23.         set { m_class3 = value; }
  24.     }
  25.     public IClass4 Class4
  26.     {
  27.         get { return m_class4; }
  28.         set { m_class4 = value; }
  29.     }
  30.     public IClass5 Class5
  31.     {
  32.         get { return m_class5; }
  33.         set { m_class5 = value; }
  34.     }
  35.     public IClass6 Class6
  36.     {
  37.         get { return m_class6; }
  38.         set { m_class6 = value; }
  39.     }
  40. }
  41.  
  42. class Program
  43. {
  44.     static void Main(string[] args)
  45.     {
  46.         TestClass obj = new TestClass();
  47.         obj.Class1 = new Class1();
  48.         obj.Class2 = new Class2();
  49.         obj.Class3 = new Class3();
  50.         obj.Class4 = new Class4();
  51.         obj.Class5 = new Class5();
  52.         obj.Class6 = new Class6();
  53.         //……
  54.     }
  55. }

 

2. Factory Method:顧名思義,程式提供一個工廠把物件生出來。觀念上只是把建構子裝到一個虛擬方法裡面去,利用引數來判斷產生那一類的物件。

  1. interface IClass {}
  2. interface IClass1: IClass { }
  3. interface IClass2: IClass { }
  4. interface IClass3: IClass { }
  5. interface IClass4: IClass { }
  6. interface IClass5: IClass { }
  7. interface IClass6: IClass { }
  8.  
  9. class TestClass
  10. {
  11.     IClass1 m_class1;
  12.     IClass2 m_class2;
  13.     IClass3 m_class3;
  14.     IClass4 m_class4;
  15.     IClass5 m_class5;
  16.     IClass6 m_class6;
  17.     public TestClass()
  18.     {
  19.         m_class1 = Create(ObjectType.Class1) as Class1;
  20.         m_class2 = Create(ObjectType.Class2) as Class2;
  21.         m_class3 = Create(ObjectType.Class3) as Class3;
  22.         m_class4 = Create(ObjectType.Class4) as Class4;
  23.         m_class5 = Create(ObjectType.Class5) as Class5;
  24.         m_class6 = Create(ObjectType.Class6) as Class6;
  25.     }
  26.  
  27.     enum ObjectType
  28.     {
  29.         Class1,
  30.         Class2,
  31.         Class3,
  32.         Class4,
  33.         Class5,
  34.         Class6,
  35.     }
  36.     public virtual IClass Create(ObjectType typeCode)
  37.     {
  38.         switch(typeCode)
  39.         {
  40.             case ObjectType.Class1: return new Class1();
  41.             case ObjectType.Class2: return new Class2();
  42.             case ObjectType.Class3: return new Class3();
  43.             case ObjectType.Class4: return new Class4();
  44.             case ObjectType.Class5: return new Class5();
  45.             case ObjectType.Class6: return new Class6();
  46.             default: throw new Exception();
  47.         }
  48.     }
  49. }
  50.  
  51. class Program
  52. {
  53.     static void Main(string[] args)
  54.     {
  55.         TestClass obj = new TestClass();
  56.         //……
  57.     }
  58. }

好處是如果今天我要的是Class1的子類別物件,我只要覆寫Create方法就可以了,以前的寫法沒辦法做到這個,非得回到TestClass的建構子修改不可。缺點是所有的物件都要來自於同一個源頭,否則每個物件起始化都要用一個虛擬方法包裝,另外就是如果要新增一個物件,要改動的部份有二個:ObjectType enumeration和Create方法。大部份物件起始化的方法都是共用的,不如把這些方法放到一個類別裡面去,這就是底下要講的Abstract Factory。

3. Abstract Factory

這大概是我最常用的Creational Pattern,觀念上還是把建構子封到虛擬方法裡面,不一樣的地方是一個新的介面和類別:IFactory和Factory。所有的虛擬方法都放到Factory裡面,在起始化TestClass的時候把Factory放進去。

  1. interface IFactory
  2. {
  3.     IClass1 CreateClass1();
  4.     IClass2 CreateClass2();
  5.     IClass3 CreateClass3();
  6.     IClass4 CreateClass4();
  7.     IClass5 CreateClass5();
  8.     IClass6 CreateClass6();
  9. }
  10.  
  11. class Factory: IFactory
  12. {
  13.     public IClass1 CreateClass1()
  14.     {
  15.         return new Class1();
  16.     }
  17.  
  18.     public IClass2 CreateClass2()
  19.     {
  20.         return new Class2();
  21.     }
  22.  
  23.     public IClass3 CreateClass3()
  24.     {
  25.         return new Class3();
  26.     }
  27.  
  28.     public IClass4 CreateClass4()
  29.     {
  30.         return new Class4();
  31.     }
  32.  
  33.     public IClass5 CreateClass5()
  34.     {
  35.         return new Class5();
  36.     }
  37.  
  38.     public IClass6 CreateClass6()
  39.     {
  40.         return new Class6();
  41.     }
  42. }
  43.  
  44. class TestClass
  45. {
  46.     IClass1 m_class1;
  47.     IClass2 m_class2;
  48.     IClass3 m_class3;
  49.     IClass4 m_class4;
  50.     IClass5 m_class5;
  51.     IClass6 m_class6;
  52.     public TestClass(IFactory factory)
  53.     {
  54.         m_class1 = factory.CreateClass1();
  55.         m_class2 = factory.CreateClass2();
  56.         m_class3 = factory.CreateClass3();
  57.         m_class4 = factory.CreateClass4();
  58.         m_class5 = factory.CreateClass5();
  59.         m_class6 = factory.CreateClass6();
  60.     }
  61.  
  62. }
  63.  
  64. class Program
  65. {
  66.     static void Main(string[] args)
  67.     {
  68.         TestClass obj = new TestClass(new Factory());
  69.         //……
  70.     }
  71. }

和Factory method比起來,downcasting不見了,所有的switch case都變成了獨立的方法,這樣就不用定義ObjectType這個enumeration,要加新的建構子的時候也不用修改原本的Create方法,只要在介面加上一個新方法。要把某個物件換掉,再寫一個Factory class就行了。這個寫法讓程式讀起來比較簡潔。這個pattern在單元測試非常好用,mock object可以透過abstract factory放進待測的類別。用這種方法,這些虛擬建構子管理起來也會比Factory Method方便。

4. Prototype:這個pattern的想法也很單純,對於複雜的物件先做一個放好,要用的時候複製一個一樣的,然後做少許修改。其實我會避免用這個pattern(個人偏見),我覺得仔細設計建構子比較實際。

5. Singleton:這個pattern也很常用,直譯是單值型,也就是說整個程式裡面只有一個這個類別的物件。Abstract Factory通常是一個Singleton(也沒必要搞很多個工廠出來,一個就夠用了),另外像ThreadPool、Print pool也會用到,一般來說,如果要透過唯一管道存取資源,Singleton就會派上用場。Singleton實作通常有二種寫法:

1)程式一開始就起始化這個物件

  1. class Factory: IFactory
  2. {
  3.     private static IFactory s_factory = new Factory();
  4.     public static IFactory Instance { get { return s_factory; } }
  5.  
  6.     public IClass1 CreateClass1() { return new Class1(); }
  7.  
  8.     public IClass2 CreateClass2() { return new Class2(); }
  9.  
  10.     public IClass3 CreateClass3() { return new Class3(); }
  11.  
  12.     public IClass4 CreateClass4() { return new Class4(); }
  13.  
  14.     public IClass5 CreateClass5() { return new Class5(); }
  15.  
  16.     public IClass6 CreateClass6() { return new Class6(); }
  17. }

 

2)當要用的時候才起始化這個物件

  1. class Factory: IFactory
  2. {
  3.     private static IFactory s_factory;
  4.     public static IFactory Instance
  5.     {
  6.         get
  7.         {
  8.             if (s_factory == null)
  9.                 s_factory = new Factory();
  10.             return s_factory;
  11.         }
  12.     }
  13.  
  14.     public IClass1 CreateClass1() { return new Class1(); }
  15.  
  16.     public IClass2 CreateClass2() { return new Class2(); }
  17.  
  18.     public IClass3 CreateClass3() { return new Class3(); }
  19.  
  20.     public IClass4 CreateClass4() { return new Class4(); }
  21.  
  22.     public IClass5 CreateClass5() { return new Class5(); }
  23.  
  24.     public IClass6 CreateClass6() { return new Class6(); }
  25. }

提取物件都是用一樣的方法:Factory.Instance,差別只在物件起始化的時間而已。我比較喜歡第二種寫法就是了。有一派程式師認為Singleton對單元測試實際上是不利的,Singleton在某種程度上是global variable,所以能不要用就不要用。這個想法沒有問題,不過像abstract factory這類的物件,用Singleton無傷大雅。其他的單值型物件也可以用類似TestClass的建構子的方法傳進去,也就是說,在類別內部不直接使用單值型物件,而是使用該物件的成員參考,這樣進一步又可以去耦合。就算那一天該物件不再是單值型,TestClass也不用修改。不過程式當中Singleton不要太多是真的,而且Singleton設成唯讀或唯寫物件比較合適,雙向讀取會造成Singleton不好維護。

這些模式是四人幫從大量程式當中歸納出來的,雖然是十幾年前的書,到今天仍然受用無窮。活用它們可以快速提高軟體的品質。下一篇再來談第二類的模式:Structural Patterns。

張貼在 Computers and Internet | 發表留言

軟體的品質管制


寫程式寫了十幾年,其實也從來沒仔細想自己的程式品質如何。畢竟以前是學生,研究或作業交得出來就了事。現在不同了,雖然對自己程式的重用性很有把握,但是發現一些以前沒遇到的事:以前的寫法程式耦合度(Coupling)太高,造成單元測試(Unit test)相當大的困擾。單元測試是近十年來測試的顯學,主要的精神是把程式切割成最小的單元,對每個單元寫測試程式,好處是每一個測試程式都很小,要除錯很方便。甚至目前比較新的程式開發方式是把單元測試先寫出來,再去寫程式碼滿足這些測試程式(Test-driven software development)。單元測試是程式碼和使用者的合約,保證程式的功能不會在開發的過程當中受到破壞。程式師也應該在開發的過程當中常執行單元測試,以確認開發過程當中的更動不會改變程式的行為。要修改合約,得有個很好的理由,也就是說單元測試是不輕易更動的。

那耦合度高的程式有什麼問題?耦合度高就代表切割不易,也就是說要單元測試要測的單元變大了,要牽扯到的部份變多,出錯的時候要找兇手會變得很麻煩,這樣單元測試的精神就完全喪失了。好,扯了半天,耦合度是什麼?白話一點說,就是物件之間相互依賴的程度,一般來說,物件之間相互依賴的方式就這二種:使用和繼承,它們都是物件導向設計的核心。耦合度高的程式要修改不容易,一修改等於程式大部份都有更動到,這些部份的測試要重做,如果耦合度低,程式更改的部份少,要重做的測試也相對少很多。所以,耦合度高代表程式在設計上有些問題,增修和維護上會比較麻煩。以前讀過一條物件導向程式設計準則:如果使用和繼承都是設計的選項,「使用」優先。我一直沒搞懂為什麼,繼承可是物件導向的三大特性之一呢。現在知道了,在物件類別裡使用其他物件的耦合度會比繼承來得低,去耦合也容易得多。耦合不可避免,降低物件之間的依賴程度成為去耦合化的最主要任務。下面有三個物件類別,Word_Impl1和Word_Impl2有相同的行為,Application和Word_Impl1是繼承的關係,Application和Word_Impl2是使用的關係。

  1. class Application
  2. {
  3.     public virtual void Run(object[] args)
  4.     {
  5.         // do something
  6.     }
  7. }
  8.  
  9. class Word_Impl1 : Application
  10. {
  11.     public override void Run(object[] args)
  12.     {
  13.         base.Run(args);
  14.         // do something else
  15.     }
  16. }
  17.  
  18. class Word_Impl2
  19. {
  20.     private Application m_app = new Application();
  21.     public void Run(object[] args)
  22.     {
  23.         m_app.Run(args);
  24.         // do something else
  25.     }
  26. }

假設Application.Run的主要工作是遠端連線,在單元測試Word_Impl1.Run和Word_Impl2.Run時就要有一台遠端伺服器,不然沒辦法做測試。這就有點討厭了,連線的程式本來就有一定的複雜度,出錯的時候比較難斷定是Application.Run還是Word_Impl有問題。Word_Impl1.Run和Word_Impl2.Run變成也在測試Application.Run的正確性,這違反了單元測試的精神。

目前去耦合最常用的技術應該是Dependency Injection (DI)。這又是一個很難翻好的術語,Wikipedia翻成「依賴注入」,一般人看得懂才有鬼。觀念上來說是指物件本身對其他物件的依賴來自外界的指定。還是用例子解釋會比較清楚:

  1. interface IApplication
  2. {
  3.     void Run(object[] args);
  4. }
  5.  
  6. class Application: IApplication
  7. {
  8.     public void Run(object[] args)
  9.     {
  10.         // do something
  11.     }
  12. }
  13.  
  14. class Word_Impl3
  15. {
  16.     private IApplication m_app;
  17.     public Word_Impl3(IApplication app)
  18.     {
  19.         m_app = app;
  20.     }
  21.     public void Run(object[] args)
  22.     {
  23.         m_app.Run(args);
  24.         // do something else
  25.     }
  26. }

Word_Impl3比較像Word_Impl2,唯一的差別是m_app的型別從class Application改成了interface IApplication和加上一個建構函式。interface最早是Java提出來的概念,由於Java只允許單一類別繼承,如果要使用到多重繼承,其他的父母親全都得是interface,.NET沿用了這個概念。interface簡單想就是C++的全抽象類別,裡面定義的所有成員都只有定義而無實作,當然Java和.NET對interface有特別最佳化過,效率會比全抽象類別高很多(而且可以多重繼承許多interface,全抽象類別還是個類別,只能單一繼承)。幾年前開始接觸Java的時候一直沒搞清楚interface的妙用,只想到是多重繼承的變形(其實我也沒搞懂多重繼承使用的時機,使用的機會真的很少),Depedency injection把interface變成了程式設計的重點。在Word_Impl3裡,m_app物件由外部指定,既然IApplication可以有不同的實作,Word_Impl3就會因為不同的IApplication實作產生不一樣的行為。IApplication成為程式物件之間的合約,規範Run這個方法的行為,只要Run的行為符合事先程式架構裡的要求,Word_Impl3根本不會管Run是怎麼寫出來的,所以我們也不用對這部份做測試。更棒的是,Word_Impl3和Application可以同時開發,平行測試,因為二者是無關的,只透過合約綁在一起,二者之間的耦合度大大的降低。

既然二者是同時開發,如果Application還沒有寫出來,要怎麼測Word_Impl3?這裡牽扯到單元測試裡最重要的一個概念:mock object。由於合約有規定Run的行為,通常是回傳值和可能擲出的例外,我們可以用一個簡單的IApplication實作來控制Run的行為:

  1. class MockApplication : IApplication
  2. {
  3.     public Action<object[]> Run { get; set; }
  4.     void IApplication.Run(object[] args)
  5.     {
  6.         if (Run != null)
  7.             Run(args);
  8.     }
  9. }

這樣在單元測試裡就可以透過指定Run這個delegate執行合約裡提到的行為來測試Word_Impl3的正確性。這種Mock類別的建立,現在有好幾種現成工具可以幫忙,像MoqMoles。其實DI並不只是用在單元測試,在一開始的設計階段,DI大量降低程式間的耦合度,每個模組可以獨立開發,分開測試,維護上也方便許多。

張貼在 Computers and Internet | 2 則迴響

再談程式多工(III)─執行緒安全與資料集合


.NET提供了很多標準的資料結構,不過MSDN在這些資料結構的文件當中幾乎都會提到:

Public static (Shared in Visual Basic) members of this type are thread safe. Any instance members are not guaranteed to be thread safe.

也就是說,如果多個執行緒同時進行讀寫同一個資料集合,.NET不保證結果正確,所有執行緒同步處理的工作必須自己來。我遇過的狀況是這樣的:一個執行緒用foreach要對集合裡的每一個元素做一些運算,但是另一個執行緒有時候會修改集合增減元素。結果是通常第一個執行緒會丟出InvalidOperationException說集合被修改。MSDN建議的方式是在想辦法鎖住foreach,修改集合時要等這個鎖打開才行。原文如下:

A List<T> can support multiple readers concurrently, as long as the collection is not modified. Enumerating through a collection is intrinsically not a thread-safe procedure. In the rare case where an enumeration contends with one or more write accesses, the only way to ensure thread safety is to lock the collection during the entire enumeration. To allow the collection to be accessed by multiple threads for reading and writing, you must implement your own synchronization.

這時候,ReaderWriterLockSlim就有用了。底下是用generic寫出來的保證執行緒安全的資料集合:

 

  1. class SafeCollection<T, TCollection>: ICollection<T>, IDisposable
  2.     where TCollection: ICollection<T>, new()
  3. {
  4.     private TCollection m_collection = new TCollection();
  5.     private ReaderWriterLockSlim m_lock = new ReaderWriterLockSlim();
  6.     private bool m_disposed = false;
  7.  
  8.     private class Enumerator : IEnumerator<T>
  9.     {
  10.         private SafeCollection<T, TCollection> m_collection;
  11.         private IEnumerator<T> m_enumerator;
  12.         private bool m_locked = false;
  13.  
  14.         public Enumerator(SafeCollection<T, TCollection> collection)
  15.         {
  16.             m_collection = collection;
  17.             m_enumerator = collection.GetEnumerator();
  18.         }
  19.  
  20.         public T Current
  21.         {
  22.             get { return m_enumerator.Current; }
  23.         }
  24.  
  25.         public void Dispose()
  26.         {
  27.             if (m_locked)
  28.                 m_collection.m_lock.ExitReadLock();
  29.             m_enumerator.Dispose();
  30.         }
  31.  
  32.         object System.Collections.IEnumerator.Current
  33.         {
  34.             get { return this.Current; }
  35.         }
  36.  
  37.         public bool MoveNext()
  38.         {
  39.             if (!m_locked)
  40.             {
  41.                 m_locked = true;
  42.                 m_collection.m_lock.EnterReadLock();
  43.             }
  44.  
  45.             if (!m_enumerator.MoveNext())
  46.             {
  47.                 m_locked = false;
  48.                 m_collection.m_lock.ExitReadLock();
  49.             }
  50.             return m_locked;
  51.         }
  52.  
  53.         public void Reset()
  54.         {
  55.             m_enumerator.Reset();
  56.             m_locked = false;
  57.             m_collection.m_lock.ExitReadLock();
  58.         }
  59.     }
  60.  
  61.     public void Add(T item)
  62.     {
  63.         try
  64.         {
  65.             m_lock.EnterWriteLock();
  66.             m_collection.Add(item);
  67.         }
  68.         finally
  69.         {
  70.             m_lock.ExitWriteLock();
  71.         }
  72.     }
  73.  
  74.     public void Clear()
  75.     {
  76.         try
  77.         {
  78.             m_lock.EnterWriteLock();
  79.             m_collection.Clear();
  80.         }
  81.         finally
  82.         {
  83.             m_lock.ExitWriteLock();
  84.         }
  85.     }
  86.  
  87.     public bool Contains(T item)
  88.     {
  89.         try
  90.         {
  91.             m_lock.EnterReadLock();
  92.             return m_collection.Contains(item);
  93.         }
  94.         finally
  95.         {
  96.             m_lock.ExitReadLock();
  97.         }
  98.     }
  99.  
  100.     public void CopyTo(T[] array, int arrayIndex)
  101.     {
  102.         try
  103.         {
  104.             m_lock.EnterReadLock();
  105.             m_collection.CopyTo(array, arrayIndex);
  106.         }
  107.         finally
  108.         {
  109.             m_lock.ExitReadLock();
  110.         }
  111.     }
  112.  
  113.     public int Count
  114.     {
  115.         get
  116.         {
  117.             try
  118.             {
  119.                 m_lock.EnterReadLock();
  120.                 return m_collection.Count;
  121.             }
  122.             finally
  123.             {
  124.                 m_lock.ExitReadLock();
  125.             }
  126.         }
  127.     }
  128.  
  129.     public bool IsReadOnly
  130.     {
  131.         get
  132.         {
  133.             try
  134.             {
  135.                 m_lock.EnterReadLock();
  136.                 return m_collection.IsReadOnly;
  137.             }
  138.             finally
  139.             {
  140.                 m_lock.ExitReadLock();
  141.             }
  142.         }
  143.     }
  144.  
  145.     public bool Remove(T item)
  146.     {
  147.         try
  148.         {
  149.             m_lock.EnterWriteLock();
  150.             return m_collection.Remove(item);
  151.         }
  152.         finally
  153.         {
  154.             m_lock.ExitWriteLock();
  155.         }
  156.     }
  157.  
  158.     public IEnumerator<T> GetEnumerator()
  159.     {
  160.         return new Enumerator(this);
  161.     }
  162.  
  163.     IEnumerator IEnumerable.GetEnumerator()
  164.     {
  165.         return this.GetEnumerator();
  166.     }
  167.  
  168.     ~SafeCollection() { Dispose(false); }
  169.  
  170.     public void Dispose()
  171.     {
  172.         Dispose(true);
  173.         GC.SuppressFinalize(this);
  174.     }
  175.  
  176.     protected virtual void Dispose(bool disposing)
  177.     {
  178.         if (m_disposed)
  179.             return;
  180.         if (disposing)
  181.         {
  182.             m_lock.Dispose();
  183.             IDisposable disposable = m_collection as IDisposable;
  184.             if (disposable != null)
  185.                 disposable.Dispose();
  186.             m_disposed = true;
  187.         }
  188.     }
  189. }

有點長,但是不會很難懂,主要就是利用ReaderWriterLockSlim的特性把ICollection和IEnumerator重新包裝過,要用那種ICollection的實作類別都沒關係。之前提過foreach實際上是IEnumerator的MoveNext和Current的合體,所以把整個Enumerator鎖起來就行了,就因為這樣,我定義了一個新的Enumerator,只要這個新的Enumerator物件的MoveNext第一次被呼叫,就加上ReaderLock,移到集合尾巴的時候,就把鎖解開。這樣集合增減元素的時候就一定要等到所有的Enumerator的ReaderLock都解開才能進行(因為增減元素都是WriterLock)。當然用這篇寫的ReaderWriterLockSlimWithCountdownEvent更理想。

NET 4.0提供了另一個Namespace:System.Collections.Concurrent,顧名思義裡面有一些支援多執行緒的資料集合(也就是說它們是執行緒安全的),不過都和生產者─消費者設計模式(Producer-Consumer design pattern)有關,對於一般的資料結構,執行緒同步和鎖定還是要自己來。生產者─消費者設計模式其實就是我在這篇提到的把程式變成生產線的概念,細節等有時間再慢慢寫吧。

張貼在 Computers and Internet | 3 則迴響

再談程式多工(II)─.NET的非同步處理程式設計模式


非同步處理(Asynchronous)是多執行緒程式最能發揮的舞台。每一個函式(.NET稱作delegate)都可以用非同步處理的方式呼叫。所謂的非同步是指函式呼叫後在另一個執行緒執行,這樣就下一行就不用等到函式結束後才進行,所以如果這個函式要花很多時間執行,非同步處理就可以加快程式的進行。當然最大的問題是,執行緒間如何協調把事情做對,總不能別的執行緒執行到一半的結果就拿來用,到時候程式怎麼死的都不知道。在.NET 4.0之前,一般會有二種寫法:

1. Thread pool thread+ManualResetEvent

  1. void RunTestMethod()
  2. {
  3.     ManualResetEvent waitHandle = new ManualResetEvent(false);
  4.     List<int> result = new List<int>();
  5.     ThreadPool.QueueUserWorkItem(state =>
  6.     {
  7.         for (int i = 0; i < 1000000; i++)
  8.             result.Add(i);
  9.         waitHandle.Set();
  10.     });
  11.     //…..
  12.     waitHandle.WaitOne();
  13.     result.ForEach(i => Console.WriteLine("{0}", i));
  14. }

可以看出來ManualResetEvent是最重要的元件,透過觸發這個元件,主執行緒就知道放出去的工作做完了,主執行緒拿到的結果會是正確的。如果沒有waitHandle.WaitOne()這一行,很難說result裡會有多少個元素。用ThreadPool.QueueUserWorkItem有個小麻煩,例外處理要在Queue進去的函式裡全部做好,主執行緒這邊拿不到例外的訊息。

2. BeginInvoke+EndInvoke

  1. void RunTestMethod2()
  2. {
  3.     Func<List<int>> func = new Func<List<int>>(() =>
  4.         {
  5.             List<int> result = new List<int>();
  6.             for (int i = 0; i < 1000000; i++)
  7.                 result.Add(i);
  8.             return result;
  9.         });
  10.     IAsyncResult asyncResult = func.BeginInvoke(null, null);
  11.     //…..
  12.     func.EndInvoke(asyncResult).ForEach(i => Console.WriteLine("{0}", i));
  13. }

BeginInvoke開啟另一個工作執行緒,相當於ThreadPool.QueueUserWorkItem,EndInvoke把工作執行緒收回來,相當於第一個寫法裡的waitHandle.WaitOne(),同樣地,例外要在func裡面處理掉,EndInvoke並不會有例外的訊息。這個寫法的好處是變數封起來不會讓主執行緒看到。喜歡那一種是見人見智啦,不過據說BeginInvoke/EndInvoke這個組合會快一點點。(註:UI裡的BeginInvoke/EndInvoke又是另一回事,它們並沒有新生一個工作執行緒出來)

.NET 4.0加入了另一種寫法,除了原有的功能之外,還加上二個東西:中途打斷的機制和傳回工作執行緒發生的例外。

  1. void RunTestMethod3()
  2. {
  3.     CancellationTokenSource cancel = new CancellationTokenSource();
  4.     Func<List<int>> func = new Func<List<int>>(() =>
  5.         {
  6.             List<int> result = new List<int>();
  7.             for (int i = 0; i < 1000000; i++)
  8.             {
  9.                 if (cancel.Token.IsCancellationRequested)
  10.                     cancel.Token.ThrowIfCancellationRequested();
  11.                 result.Add(i);
  12.             }
  13.             return result;
  14.         });
  15.     Task<List<int>> task = Task<List<int>>.Factory.StartNew(func);
  16.     // …
  17.     try
  18.     {
  19.         List<int> result = task.Result;
  20.         result.ForEach(i => Console.WriteLine("{0}", i));
  21.     }
  22.     catch (AggregateException ex)
  23.     {
  24.         ex.InnerExceptions.ToList().ForEach(innerEx =>
  25.             Console.WriteLine("{0}", innerEx));
  26.     }
  27.     finally
  28.     {
  29.         task.Dispose();
  30.         cancel.Dispose();
  31.     }
  32. }

Task是.NET 4.0提供的非同步處理元件,相當於ThreadPool.QueueUserWorkItem,也是把一個delegate放到新的工作執行緒下面跑(Task是最佳化過的執行緒,效率上比ThreadPool好些)。重點是所有在delegate裡發生的例外會用Task.Result或Task.Wait()用AggregateException丟回來,主執行緒再做例外處理。另外就是CancellationTokenSource,如果在程式當中呼叫CancellationTokenSource.Cancel(),工作執行緒就會感應到(cancel.Token.IsCancellationRequested被設成true),丟出例外結束工作執行緒。我自己是覺得CancellationTokenSource不算太好用,還得用一個迴路不斷檢查作業是否被取消。

非同步處理還有另一個重要的概念:回呼(Callback)。簡單說,就是工作執行緒結束時主執行緒要求工作執行緒做的事情,所以Callback是一個函式,BeginInvoke可以把Callback傳進去。回呼的概念衍生出來的就是事件處理機制(Event Handler)和Observable-Observer設計模式。事件處理實際上是一種特殊的Observable-Observer模式,.NET外加了一個函式庫Reactive Framework (Rx)支援這種設計(要另外下載),也把Event統合了進來。本來還搞不清楚為什麼.NET 4把IObservable和IObserver加進來,看了Rx之後就再清楚也不過了。大約來說,Rx之於IObservable就像Linq之於IEnumerable,所以C# Event可能會退休讓Rx接手做event handling。細節部份等我玩得差不多再寫吧。

張貼在 Computers and Internet | 發表留言

再談程式多工(I)──升級版的資料鎖定和等待機制


最近把專案裡面的執行緒重新整理一遍,也把.NET Framework 4.0對多工的基本支援讀完,高級班的部份要有點實作經驗才能確定搞懂。照理說,這些材料都是作業系統裡最基本的東西,對不是科班出身的我得花點時間,不是太難,只是有點雜。好佳在的是.NET Framework 4.0總算把多執行緒的管理機制和元件補得差不多了,不然有些程式寫起來還是滿麻煩的。一年前寫這篇的時候,.NET 4.0(支援C# 4.0)還在beta,現在已經聽到C# 5.0的規格書要把平行處理這部份的語法再簡化,不過什麼時候會出來就不知道了。除了前一篇寫到執行緒管理最基本的同步鎖定(locking)和事件等待(event wait handle)之外,還有一些常用的基本變形(資料可以在這裡找到):

1. Mutex:Process間使用的locking,比lock慢50倍。

2. Semaphore:locking的延伸版,簡單說,就是允許多個執行緒執行這個程式區段,但是有個上限,當執行該區段的執行緒個數抵達這個上限時,多出來的執行緒進入要求會放到一個等待區,這些執行緒也會停下來,直到有執行緒離開這個區段為止。走多少個,再放多少個進去。感覺很像是去餐廳吃飯排隊,客滿的話,就得等到有人吃完結帳離開才會有新的客人進去。locking可以看成是Semaphore的上限等於1。不過,在.NET來說,Semaphore的效能比簡單的locking差滿多,程式內的Semaphore(用SemaphoreSlim類別)大概是lock的十倍慢,Process間的Semaphore(用Semaphore類別)則是約50倍慢,和Mutex差不多。

3. ReaderWriterLock:這個鎖有二個等級:讀取和寫入。簡單說,如果鎖定義成讀取等級,允許多執行緒進入;如果升級成寫入,則只有一個執行緒可以進入該區段,就算是讀取執行緒要進來,也是要等寫入執行緒離開之後,才能進入鎖定區段;反過來說,升級成寫入也必須要等全部讀取執行緒離開鎖定區段以後才會執行,不然資料可能會亂掉。.NET 3.5以後,Microsoft建議使用ReaderWriterLockSlim這個類別以增加效率,約是一般lock的二倍慢。舊的ReaderWriterLock則是約5倍慢。ReaderWriterLockSlim好用的時候是當資料需要被大量讀取卻鮮少更動的時候,這樣多執行緒的讀取就不需要浪費時間開鎖。

底下是個小小的測試:一堆整數,每二秒要列出所有的整數和計算總和,但是這二件事用不同的執行緒執行,在測試的過程當中,這堆整數會愈來愈多,不過每增加一個要三秒鐘。

  1. class Test: IDisposable
  2. {
  3.     Timer m_timer1;
  4.     Timer m_timer2;
  5.     ReaderWriterLockSlim m_lock = new ReaderWriterLockSlim();
  6.     List<int> m_intCollection = new List<int>();
  7.     object m_consoleLock = new object();
  8.  
  9.     public Test()
  10.     {
  11.         Console.WriteLine("Test starts @ {0}", DateTime.Now.TimeOfDay);
  12.         m_timer1 = new Timer(TimerCallback1, null, 0, 2000);
  13.         m_timer2 = new Timer(TimerCallback2, null, 0, 2000);
  14.     }
  15.  
  16.     void TimerCallback1(object state)
  17.     {
  18.         try
  19.         {
  20.             m_lock.EnterReadLock();
  21.             List<int> collection = new List<int>(m_intCollection);
  22.             lock (m_consoleLock)
  23.             {
  24.                 Console.Write("{0} TimerCallback1: ", DateTime.Now.TimeOfDay);
  25.                 foreach (int item in collection)
  26.                 {
  27.                     Console.Write("{0} ", item);
  28.                 }
  29.                 Console.WriteLine();
  30.             }
  31.         }
  32.         finally
  33.         {
  34.             m_lock.ExitReadLock();
  35.         }
  36.     }
  37.  
  38.     void TimerCallback2(object state)
  39.     {
  40.         try
  41.         {
  42.             m_lock.EnterReadLock();
  43.             int sum = 0;
  44.             foreach (int item in m_intCollection)
  45.             {
  46.                 sum += item;
  47.             }
  48.             lock (m_consoleLock)
  49.             {
  50.                 Console.Write("{0} TimerCallback2: ", DateTime.Now.TimeOfDay);
  51.                 Console.WriteLine("sum = {0}", sum);
  52.             }
  53.         }
  54.         finally
  55.         {
  56.             m_lock.ExitReadLock();
  57.         }
  58.     }
  59.  
  60.     public void AddItem(int item)
  61.     {
  62.         try
  63.         {
  64.             m_lock.EnterWriteLock();
  65.             Console.WriteLine("{0}, Add one item", DateTime.Now.TimeOfDay);
  66.             Thread.Sleep(3000);
  67.             m_intCollection.Add(item);
  68.         }
  69.         finally
  70.         {
  71.             m_lock.ExitWriteLock();
  72.         }
  73.     }
  74.  
  75.     public void Dispose()
  76.     {
  77.         m_timer1.Dispose();
  78.         m_timer2.Dispose();
  79.     }
  80. }
  81.  
  82. class Program
  83. {
  84.     static void Main(string[] args)
  85.     {
  86.         using (Test test = new Test())
  87.         {
  88.             for (int i = 1; i <= 2; i++)
  89.                 test.AddItem(i);
  90.             for (int i = 3; i <= 4; i++)
  91.             {
  92.                 test.AddItem(i);
  93.                 Thread.Sleep(1000);
  94.             }
  95.         }
  96.         Console.ReadKey();
  97.     }
  98. }

 

結果大概是這樣:

imageimage

二次的結果有點差別,這當然是多執行緒的關係,那一個執行緒搶到優先權進而拿到ReaderWriterLock是不一定的。不過相同的是二個Timer可以同時進入ReaderLock的鎖定區段做他們該做的事,但是一進入WriterLock,二個Timer都在等待,也造成有二三個Timer Event同時開始,本來該每二秒做的事情擠到同一個時間才開始做。

4. CountdownEvent:可以看作ManualResetEventSlim(.NET 4.0提供的ManualResetEvent精簡版)的延伸版,內含一個計數器,程式師可以隨意增減計數器,但要預設一個最大值,當計數器歸零,CountdownEvent會發出信號給等待的執行緒,之後計數器必須用Reset方法重置之後才能繼續使用。ManualResetEvent就是把最大值設做1。

5. Interlocked: 提供一些最佳化且保證執行緒安全的整數運算。主要是加減的部份。大部份執行緒管理元件多多少少都要用到這些運算。

上面的測試程式其實有點問題,ReaderWriterLockSlim有實作IDisposable,也就是說,在Test的Dispose方法裡面應該要呼叫ReaderWriterLockSlim.Dispose確保資源有被釋放掉(利用資源回收車的Finalizer做這件事是不太可靠的)。如果直接就把m_lock.Dispose()加到Test.Dispose()裡面會有個風險:如果某個執行緒還擁有m_lock的鎖定權的時候,m_lock.Dipose()會丟出例外說不可以把它丟掉。只有在ReaderWriterLockSlim沒有被任何執行緒抓住的時候,它的Dipose方法才不會出問題。那,怎麼辦?也就一定要等到所有執行緒把m_lock放出來了。我覺得很奇怪,Microsoft在設計這個類別的時候怎麼沒想到處理這件事,更糟的是,MSDN所有的有關ReaderWriterLockSlim的範例都沒提到怎麼用Dispose。網路上有些高手在吵這個(ReaderWriterLockSlim.Dispose()也沒完全照IDisposable.Dispose的合約規範,Microsoft客服部已經收到不少這樣的抱怨啦),不過還是要想個辦法解決。最簡單的方法是利用ReaderWriterLockSlim裡面內建的Property,在Test類別的Dipose方法裡加上一個迴圈檢查:

  1. public void Dispose()
  2. {
  3.     m_timer1.Dispose();
  4.     m_timer2.Dispose();
  5.     while (m_lock.IsReadLockHeld ||
  6.         m_lock.IsWriteLockHeld ||
  7.         m_lock.WaitingReadCount + m_lock.WaitingUpgradeCount + m_lock.WaitingWriteCount > 0) ;
  8.     m_lock.Dispose();
  9. }

 

這方法有個缺點:如果鎖要很久很久才會開,檢查就是個空迴圈,浪費CPU資源。比較好的寫法是用CountdownEvent包一下,在Dispose裡等CountdownEvent歸零,這樣就不用操CPU:

  1. class ReaderWriterLockSlimWithCountdownEvent: IDisposable
  2. {
  3.     ReaderWriterLockSlim m_lock = new ReaderWriterLockSlim();
  4.     CountdownEvent m_lockCount = new CountdownEvent(1);
  5.  
  6.     public ReaderWriterLockSlimWithCountdownEvent() { }
  7.     public void EnterReadLock()
  8.     {
  9.         m_lockCount.AddCount();
  10.         m_lock.EnterReadLock();
  11.     }
  12.     public void ExitReadLock()
  13.     {
  14.         m_lock.ExitReadLock();
  15.         m_lockCount.Signal();
  16.     }
  17.     public void EnterWriteLock()
  18.     {
  19.         m_lockCount.AddCount();
  20.         m_lock.EnterWriteLock();
  21.     }
  22.     public void ExitWriteLock()
  23.     {
  24.         m_lock.ExitWriteLock();
  25.         m_lockCount.Signal();
  26.     }
  27.     
  28.     public void Dispose()
  29.     {
  30.         m_lockCount.Signal();
  31.         m_lockCount.Wait();
  32.         m_lockCount.Dispose();
  33.         m_lock.Dispose();
  34.     }
  35. }

 

然後把原來Test裡面的ReaderWriterLockSlim換成ReaderWriterLockSlimWithCountdownEvent就可以了。這是我真正在用的ReaderWriterLock。

其實自己寫一個CountdownEvent也不難,就是個Interlock加上ManualResetEventSlim:

  1. class CountdownEvent: IDisposable
  2. {
  3.     ManualResetEventSlim m_zero;
  4.     int m_counter;
  5.     readonly int m_initCount;
  6.     public CountdownEvent(int initialCount)
  7.     {
  8.         m_initCount = initialCount;
  9.         m_counter = initialCount;
  10.         m_zero = new ManualResetEventSlim(initialCount == 0);
  11.     }
  12.     public void AddCount()
  13.     {
  14.         Interlocked.Increment(ref m_counter);
  15.     }
  16.     public void Signal()
  17.     {
  18.         if (Interlocked.Decrement(ref m_counter) == 0)
  19.             m_zero.Set();
  20.     }
  21.  
  22.     public void Reset()
  23.     {
  24.         Reset(m_initCount);
  25.     }
  26.  
  27.     public void Reset(int newCount)
  28.     {
  29.         m_counter = newCount;
  30.         if(m_counter > 0)
  31.             m_zero.Reset();
  32.     }
  33.  
  34.     public void Wait()
  35.     {
  36.         m_zero.Wait();
  37.     }
  38.     
  39.     public void Dispose()
  40.     {
  41.         m_zero.Dispose();
  42.     }
  43. }

當然這是我用ManualResetEventSlim做出來的,.NET裡是怎麼寫的就要看MSIL了,也有可能是先寫好CountdownEvent再做ManualResetEventSlim。反正Microsoft寫好這東西,用他們家的就行了。

其實還有很多主題,有空再往下寫……

張貼在 Computers and Internet | 1 則迴響

managed和unmanaged code的資料交換


最近老闆們都去放耶誕節和新年,留守也不會有什麼大事發生,正好可以玩一下想玩的程式概念。在做project的時候由於用到公司別組用C++寫的unmanged程式,免不了得做unmanaged和mamanged的資料交換。C#由於是在.NET架構之下(一般稱為managed code,不在.NET下的程式則稱為unmanged/native code),.NET的資料型別和C++並不相同,尤其是自訂和指標相關物件,如果不額外做點事情,二邊資料彼此是看不懂的,Compiler馬上會抱怨不知道該怎麼辦。要做資料交換,MSDN給三條路:P/Invoke,COM marshaling和C++ marshaling。

P/Invoke和COM marshaling基本上是把native code包成動態程式庫或是COM物件供managed code叫用。效率如何,微軟只說P/Invoke會比COM marshaling好點(additional 10 instructions vs. 50 instructions per data exchange),和全都是managed code比起來慢不少就是了。C++/CLI(早期叫managed C++)算是比較理想的一種做法,Visual Studio會把用C++/CLI寫的程式編成.NET讀得懂的東西(MSIL,C#也是編譯成這個intemediate language),但是在C++/CLI裡面可以直接使用native code寫的物件類別,這個設計提供一個漂亮的資料交換管道,效率也是最好的。只不過要多寫一些程式「騙」.NET說native的資料物件是managed。

假設我有一個寫好的MyArray物件。我想要1)用.NET處理一堆字串物件,但最後要用MyArray物件送到別人用native code寫好的函式裡面去;2)別人處理好的MyArray物件我要用.NET code要做後續的處理:

<template class T>
class MyArray
{
public:
    MyArray();
    ~MyArray();
    void add(const T& x);
    void remove(const T& x);
    bool find(const T& x);
    size_t size() const;
    const T& operator[](size_t i);
};

解法有很多種,我比較喜歡的是寫一個C++/CLI的集合類別把MyArray<T>包裝起來:

ref class MyDotNetStringCollection: ICollection<String^>
{
private:
    MyArray<string>* _array;
internal:

    property MyArray* MyArray { MyArray* get() { return _array; } }
public:
    MyDotNetStringCollection(): _array(new MyArray<string>) {}
    ~MyDotNetStringCollection() { this->!MyDotNetStringCollection(); }
    !MyDotNetStringCollection() { delete _array; }

    #pragma region ICollection<T> members
    virtual property int Count { int get() { return (int)_array->size(); } }
    virtual property bool IsReadOnly { bool get() { return false; } }
    virtual void Add(String^ item)
    {
        _array->add(marshal_as<string>(item));
    }
    virtual void Clear()
    {
        delete _array;
        _array = new MyArray<string>;
    }
    virtual bool Contains(String^ item)
    {    return _array->find(marshal_as<string>(item)); }
    virtual void CopyTo(array<String^>^ array, int arrayIndex)
    {
        if(array == nullptr) throw gcnew ArgumentNullException("array");
        if(arrayIndex < 0) throw gcnew ArgumentOutOfRangeException("arrayIndex");
        int arrayEndIndex = arrayIndex + Count;
        if(array->Rank > 1 || arrayEndIndex > array->Length)
            throw gcnew ArgumentException();
        for(int i = arrayIndex; i < arrayEndIndex; i++)
            array[i] = (*_array)[i - arrayIndex];
    }
    virtual bool Remove(String^ item)
    {
        string str = marshal_as<string>(item);
        if(_array->find(str))
        {
            _array->remove(str);
            return true;
        }
        return false;
    }
    #pragma endregion

    #pragma region IEnumerable<T> members
    virtual IEnumerator<String^>^ GetEnumerator();
    virtual IEnumerator IEnumerator_GetEnumerator() = IEnumerator::GetEnumerator
    {    return GetEnumerator(); }
    #pragma endregion
}

這樣下來,如果我要原來的native MyArray<string>物件就用MyArray Property提出來就可以了,其他的就和一般的.NET Collection完全一樣;如果要用native的函式也行,MyDotNetStringCollection也會隨之更新。裡面留下來沒寫的是GetEnumerator()這個方法,其他的都是很簡單的封裝,最長的CopyTo也只是遵守ICollection<T>::CopyTo的Contract把不合理的引數用例外丟回去而已(.NET 4.0用Code Contract應該會更好,不過還沒時間研究這部份)。marshal_as則是Microsoft提供的基本型別在managed和unmanaged之間的轉換函式。如果是unmanaged的自訂類別,managed code這邊也要定義一個相對應的物件類別做為container,並加上一個自訂的function作為轉換機制。

GetEnumerator()沒先寫下來當然有原因,上一篇有提到C#的yield return會自動提供一個GetEnumerator的實作,可惜C++/CLI沒有這東西,實作要自己生:

ref class MyDotNetStringEnumerator: IEnumerator<String^>
{
private:
    MyArray<string>* _array;
    int m_index;
    int m_count;
public:
    MyDotNetStringEnumerator(MyArray<string>* array):
        _array(array), m_index(-1), m_count((int)array->size()) {}
    ~MyDotNetStringEnumerator() { this->!MyDotNetStringEnumerator(); }
    !MyDotNetStringEnumerator() { _array = NULL; }

    #pragma region IEnumerator<T> members
    virtual property String^ Current
    {
        String^ get()
        {
            return m_index == -1 ? nullptr :
                marshal_as<String^>((*array)[m_index]);
        }
    }
    virtual property Object^ IEnumerator_Current = IEnumerator::Current
    { Object^ get() { return Current; } }
    virtual void Reset() { m_index = -1; }
    virtual bool MoveNext()
    {
        if((int)_array->size() != m_count)
            throw gcnew InvalidOperationException();
        if(m_index == (int)_array->size())
            return false;
        Interlocked::Increament(m_index);
        return true;
    }
    #pragma endregion
}

IEnumerator<String^>^ MyDotNetStringCollection::GetEnumerator()
{
    return gcnew MyDotNetStringEnumerator(_array);
}

很明顯C#的yield return真的幫程式師省了不少工,C++/CLI要寫這麼一大堆才能做同樣的事情。C#提供了foreach(C++/CLI則為for each)關鍵字作為存取IEnumerator<T>的主要方式,底下二個程式片段的功用是完全一樣的。

程式一(MSDN建議的作法):

IEnumerable<String^>^ stringCollection;
for each(String^ str in stringCollection)
{
    Console::WriteLine("{0}", str);
}

程式二(實際上內部的運作程式碼):

IEnumerator<String^>^ enumerator = stringCollection->GetEnumerator();
while(enumerator->MoveNext())
{
    Console::WriteLine("{0}", enumerator->Current);
}
enumerator->Dispose();

除了資料交換以外,方法或函數也會需要在managed和unmanaged code之間轉換,C++ marshaling在這部份的無痛轉換做到一半,在managed code裡面可以直接呼叫unmanaged function,不用額外做任何事。從unmanaged code叫用managed method就有點麻煩:

using namespace System::Runtime::InteropServices;
typedef void (*MyFuncPtr)(int arg1, int arg2);

ref class MyTest
{
    delegate void MyMethod(int arg1, int arg2);
    void TestMethod(int arg1, int arg2)
    {
        // ......
    }

    void Demo()
    {
        IntPtr funcPtr = Marshal::GetFunctionPointerForDelegate(
            gcnew MyMethod(this, &MyTest::TestMethod));
        MyFuncPtr unmanagedFuncPtr = (MyFuncPtr)(void*)funcPtr;
        // ......
    }
}

Demo這個方法裡面用了Marshal::GetFunctionPointerForDelegate這個方法(在System::Runtime::InteropServices namespace下)把managed delegate轉成函數指標unmanagedFuncPtr,這樣子就可以在unmanaged code裡面使用在managed code裡寫好的程式了。我遇到的情況是別人寫好的C++程式需要一個函數指標當做callback,但是我的程式需要存取managed的資料物件,方法必須要用managed code,所以只好用這種轉換方式把callback送進去。不過這種轉換不要常做,如果常常會用到這個unmanagedFuncPtr這個結果,不如就做一次轉換後把結果存起來,但是相對應的managed delegate物件必須要在unmanagedFuncPtr被叫用時還存在沒被資源回收車收走。得想另外的解決方法,原因是:delegate所佔有的記憶體會因garbage collector移動。資源回收車把不再使用的記憶體回收之後,會移動存活下來的變數在記憶體的位置(把變數和變數中間的位置補滿),否則會有memory fragment的問題,程式如果要大塊連續記憶體可能會要不到。瞭解garbage collector的行為之後,就知道這下子麻煩了,unmanagedFuncPtr是garbage collector看不到的東西,當delegate移動之後,unmanageFuncPtr還是指向原來的地方,執行時就會看到access violation。.NET的標準做法是用gcroot或GCHandle把delegate的記憶體鎖住不讓garbage collector移動,這樣轉成native function就不會有問題,只是會降低garbage collector的效能。我試了一下,在Visual Studio 2010 Debug mode下沒問題,Release mode會出一些狀況,callback在另一個native code執行緒下跑還是會有access denied, previlaged instruction這類OS level的問題,目前還找不到相關資料確定是什麼情況。

這個做法有二個限制:

1. 如果callback是另一個native code的執行緒,TestMethod裡面不能用到任何的managed物件(.NET馬上死掉,抓了好久才抓出來是這個問題,看別人的例子,managed thread沒有這個問題,我自己還沒試)。後來從managed和unmanaged的記憶體分配方式想,這個結果是正常的,unmanaged的程式本來就看不到managed的物件,不出錯才奇怪。那我真的要用managed物件怎麼辦?目前我能想到的是再找個中繼站,也就是說,把managed物件所需要的資訊先用一個unmanaged的data member存起來,轉換的部份用另一個方法做,不要寫在同一個方法裡。把managed物件送進unmanaged function也一樣,先用這個data member把managed物件的資料轉出來,這樣unmanaged function就可以用了(如果物件不大,效率上應該不會比COM差)。如果是這樣的話,那不如把delegate直接改寫成native function,這樣就不存在marshal delegate的問題。這很可能是讓unmanagedFuncPtr長時間存活最有效率的方法。如果delegate是用C#寫的,看起來GCHandle是唯一的做法,如果用C++/CLI,那就直接用native function寫callback吧。

2. 引數型別必須是managed和unmanaged二邊都有的基本型別:像int, float, double和指標void*(.NET是IntPtr)等。這樣就剩下一個問題我到現在還沒搞懂:函數指標的引數如果有自訂物件類別該怎麼辦?當然可以用COM解決(實際上System::Runtime::InteropServices這個namespace主要就是為了COM marshaling設計的),只是我不太喜歡這個解法(太慢太難看)。另外一個可能性是走後門透過void*做轉換。void*對C語言程式師來說應該不是什麼大問題,只是要很小心,void*可以cast成任何一種指標,如果cast成不對的型別,CPU不會馬上說有問題,之後會發生什麼事沒人知道。所以程式可以寫成類似這樣子:

#pragma managed(push, off)
class MyUnmanagedType
{
    // .....
};

typedef void (*MyActualCallbackFunc)(const MyUnmanagedType& obj, void* userData);

// function to run
void MyUnmanagedFunction(MyActualCallbackFunc callback, void* userData)
{
    MyUnmanagedType obj;
    // .......
    (*callback)(obj, userData);
}

typedef void (*MyUnmanagedCallbackFunc)(void* obj);
struct MyUserData{  MyUnmanagedCallbackFunc _func; };

// the unmanaged callback to be sent to unmanaged code
void MyCallback(const MyUnmanagedType& obj, void* userData)
{
    MyUserData* usrData = (MyUserData*)userData;
    (*(usrData->_func))(&obj);
}

#pragma managed(pop)

ref class MyManagedType
{
    delegate void MyManagedCallbackDelegate(IntPtr obj);
    MyManagedCallbackDelegate^ m_managedCallback;
    MyUserData* _usrData;

    void ManagedCallback(IntPtr obj)
    {
        MyUnmanagedType* myObj = (MyUnmanagedType*)(obj.ToPointer());
        // ......
    }
public:
    MyManagedType() 
   {
        m_managedCallback = gcnew MyManagedCallbackDelegate(this, &MyManagedType::ManagedCallback);
        _usrData = new MyUserData;
        usrData->_func = Marshal::GetFunctionPointerForDelegate(m_managedCallback);
    }

    void Run()
    {
        MyUnmanagedFunction(MyCallback, _usrData);
    }

    ~MyManagedType() { this->!MyManagedType(); }
    !MyManagedType() { delete _usrData; }
}

相當痛苦而且很容易弄錯,但是為了要用以前留下來的native程式也沒辦法。如果unmanaged callback設計得不好,這些轉換會更複雜,甚至得用到全域變數,在多緒環境之下就更容易有問題了。程式一開始設計爛掉,後面要救都救不回來。

張貼在 Computers and Internet | 1 則迴響

2010 Taipei International Flora Expo


IMG_1366

回到美國也好幾天了,可是時差還沒有調好,可能是年紀大了,愈來愈難調,固定早上二三點會起床一次(都是餓醒的,這個時間是台北的晚餐時間)。這次回台灣,唯一算是旅遊行程的只有花一天一夜逛花博(沒辦法,有些重要的事要辦)。說正格的,如果沒那些政治口水,花博真的很棒,值得花個二三天仔細逛完,主辦單位真的很聰明,三日券的設計是剛剛好的,不過很多台北人會買全期票吧,光看表演就值了。

第一次去是星期天晚上,只看了屏風和影舞者合作的歌舞劇和流行館。歌舞劇只能看站票,雖然是短短四十分鐘,也不得不佩服台灣還是有夠水準的劇團和舞台設計(我有點懷疑是對嘴的就是了),星光票才一百五台幣,這個價去那裡可以找到歌舞劇可以看(更何況我們內湖區那天還半價)。流行館也很特別,外牆是用PET瓶做的,配合方位和外牆水幕的設計,夏天可以完全不使用冷氣:

IMG_1365IMG_1450

隔天早上去新生區拚開門,雖然是上班日,人還是一大堆,九點進場,最熱門的夢想館預約券就沒了Crying face。其它重要場館大部份也要排隊排半個小時左右。下面這張是爭豔館外面,由中山足球場改建而成。雖然是中午吃飯時間,排了半個小時還沒進去。

IMG_1343

爭豔館一部份是花卉比賽場地,不過我去的時候主要是國內的蘭花比賽,每株都很漂亮,只是我不懂評比的標準是什麼@@。

IMG_1362IMG_1351IMG_1353IMG_1347IMG_1356

花茶殿──由林安泰古庴整修而成:

IMG_1341IMG_1342

未來館:依海拔把台灣的各種植物分類,放在這個大溫室裡。屋頂有步道可以上去,成龍好像就是在這個屋頂違規爬欄杆的。

IMG_1370IMG_1382IMG_1399IMG_1378IMG_1393IMG_1409IMG_1396IMG_1398IMG_1406

養生館──館內不准照相,這些是在館外的養生苑照的:

IMG_1436IMG_1437IMG_1438IMG_1439IMG_1440

其他有趣的:限定便當也要發放預約券。這張的照相時間是中午十二點多,已經有人在排晚上便當的票,台灣人真是愛排隊。

IMG_1363

張貼在 旅行 | 發表留言

.NET處理資料集合的技巧


寫程式不免都是在處理大串的資料,例如我要列1到100的平方,C++大都會這樣寫:

#include <iostream>
using namespace std;
for(int i = 1; i <= 100; i++)
{    
   cout << i * i << endl;
}

這個寫法有個缺點:輸出和計算混在一起,如果今天我要改成輸出到別的地方去,整個要重寫,不好。換個版本:

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

void output(int i) { cout << i << endl; }

int main()
{    
   vector<int> result;    
   for(int i = 1; i <= 100; i++)    
   {        
      result.push_back(i * i);    
   }
   for_each(result.begin(), result.end(), output);
}

程式碼變長了,不過好處是我把計算和輸出完全分開,輸出到別的地方只要動output這個函式就可以,輸出的部份也不限制只有100個,看vector有多長,我就列多少個數字。不過還是有一個討厭的地方,如果今天不是求平方數,而是一堆很複雜的計算,假設算一個要一分鐘,我得等一百分鐘以後才看得到結果。.NET給了一個好玩的東西:IEnumerable<T>,之前在寫LINQ的時候就提過。它像是一個array卻不完全是,傳統的array得事先知道所有的值(像上面的vector<int>),IEnumerable<T>卻擁有一個當場把值算出來的能力,再改版一次:

using System;
using System.Collections.Generic;

namespace Test
{    
   class Program
   {        
      static void Main(string[] args)
      {
          foreach(int i in Sqr())
             Console.WriteLine("{0}", i);        
      }

      private static IEnumerable<int> Sqr()
      {
         for(int i = 1; i <= 100; i++)                
            yield return i * i;        
      }
   }
}

看起來差別不大,我只是把計算丟到一個函式去而已,唯一特別是yield return這個關鍵字,yield return在C#裡是很神奇的,它的作法是傳回一個值,然後等caller要抓下一個值的時候再跑一個迴圈產生一個值傳回去,如果caller沒有要東西,它連算都懶得算。這就是我要的東西啦,就算計算很複雜,只要一個值算出來我就可以拿到它,不用等到全部算好。

LINQ就是靠IEnumerable<T>這個功能才能兼顧效率和功能性。這也是為什麼MSDN會特別警告LINQ的使用者,結果會到提取數值的時候才計算出來,如果中間傳進去的參數有任何改變,會以提取數值當時的參數為準。

其實用C++也是可以做到這個功能,不過要多花很多程式碼,C#的yield return展開來也就是這組程式碼。有點複雜,只能大概講一下概念。首先看二個介面的原型:

public interface IEnumerable<T>
{    
   IEnumerator<T> GetEnumerator(); 
}

public interface IEnumerator<T>
{    
   T Current { get; }    
   bool MoveNext();
   void Reset();
}

IEnumerable<T>只做一件事,抓一個IEnumerator<T>回來。IEnumerator<T>也很簡單,它像是一個單向存取的資料集合,我們只能看現在位置的值(Current),把游標往下移一個位置(MoveNext),或是把游標移回資料集合的起始位置(Reset)。也就是說,IEnumerator<T>根本不管資料在記憶體裡面是怎麼放的,只要有個函式找到下一個值在那裡就好。yield return一寫下去,.NET就會產生一個IEnumerator<T>的實作。foreach會呼叫GetEnumerator和MoveNext以及Current把值找回來給程式師,超省工Hot smile

.NET 4.0加入了另一組有趣的東西:IObserver<T>和IObservable<T>。我第一次看到有點傻掉,好像回到研究所唸Linear system,怎麼會有Observer!!微軟的官方說法是IObserver<T>是IEnumerator<T>的Mathematical Dual(這是linear programming嗎?),IEnumerator<T>是把資料從集合裡一個一個拉出來;IObserver<T>有點相反,它會去收集資料集合發生的變化,也就是說,資料集合用IObservable<T>表示,當它發生變化的時候會通知IObserver<T>,IObserver<T>就做相對應的處理,這種設計方式一般叫做Observer design pattern。

public interface IObserver<T>
{    
   void OnNext(T value);
   void OnError(Exception ex);
   void OnComplete();
}

public interface IObservable<T>
{    
   IDisposable Subscribe(IObserver<T> observer);
}

和IEnumerator<T>/IEnumerable<T>很類似,IObserver<T>有三個方法,IObservable<T>只有一個。IObservable<T>提供方法讓IObserver<T>訂閱更新通知,傳回的物件則用於取消訂閱(呼叫這個物件的Dispose方法)。IObserver<T>則要處理三種不同的事件:收到一個更新(OnNext),收到一個錯誤(OnError)和完成全部更新(OnComplete,從此之後IObservable<T>不會再送通知出來)。

Observer design pattern提供另一個角度來看資料集合。如果今天資料量很大,當一二筆資料發生變動,用IEnumerable<T>把資料一個一個拉出來檢查非常浪費時間。Observer design pattern則把資料集合當作生命體,當它發生變動的時候會主動告訴感興趣的人它有變化,其他人就可以做相對應的處理。我自己的看法是observer design pattern是event handler的一個變形,總覺得event handler更有彈性。要看.NET後續要怎麼玩這一對interface,如果有像yield return或LINQ這樣的好東西,那就更好了,不然還是空空的有點虛幻。

張貼在 Computers and Internet | 1 則迴響

記憶體管理


上一篇最後提到程式裡的大麻煩大抵是二類問題:記憶體管理和執行緒管理。先從記憶體的問題談起。

在Pascal之前的程式語言沒有記憶體洩漏的問題(但是有記憶體浪費的問題),作業系統會把程式所有變數所佔的記憶體全部先配置好,程式就只能在這些配置好的變數裡面玩。這種作法最大的問題是,如果事先不知道陣列的大小就慘了,作業系統沒法配置這個陣列給程式使用,一個變通的方法是配一塊很大的陣列,然後只用所需要的部份(浪費,e.g. Fortran 77)。Pascal引入了指標(pointer),指標提供了編譯器產生動態配置記憶體指令的能力,需要配置的記憶體大小可以在當下計算出來之後再和作業系統借,這解決了浪費的問題。當然,有借就要有還,忘了還,就變成了記憶體洩漏。指標到C以後發展到極致,沒有指標,複雜的程式寫不出來。Pascal和C的語法很容易造成記憶體洩漏,C++由於引入了物件的概念,利用destructor可以讓洩漏的問題稍稍小一點,Managed Environment因為有資源回收車(Garbage collector)不定時地到程式裡逛一逛把沒用的記憶體收回去,洩漏的問題更小(不是100%解決,後述)。

防止記憶體洩漏的方法只有一個──養成良好習慣,記得把記憶體還回去(廢話)。我的記性不太好,常常會忘,所以我只好勤勞點作memory bookkeeping,要記憶體的時候記一筆,還記憶體的時候把這筆消掉,如果程式結束的時候還有剩,就代表我的記憶體出問題。當然,這應該在debug mode的程式做,release的版本是不會有這部份的(完整的Memory Bookkeeper還要再加上執行緒管理的部份,這裡先不管):

#include <vector>
class MemoryBookkeeper
{
   struct MemoryInfo
   {
      void* _pointer;
      string _note;
   };
   std::vector<MemoryInfo> _memory;
public:
   MemoryBookkeeper() { }
   ~MemoryBookkeeper() { assert(_memory.size() == 0); }
   void Add(void* ptr, string note)
   {
      MemoryInfo info;
      info._pointer = ptr;
      info._note = note;
      _memory.push_back(info);
   }
   void Remove(void* ptr)
   {
      std::vector<MemoryInfo>::iterator it;
      for(it = _memory.begin(); it != _memory.end(); ++it)
         if(it->_pointer == ptr)
            break;
      assert(it != _memory.end());
      _memory.erase(it);
   }
};

當然還有更有效率的寫法,如用std::map加快搜尋速度,不過反正這個class只在debug mode用,效率不重要,用vector就行了。這裡的重點是在destructor,如果_memory的長度沒有歸零,用assert結束程式,向設計師抱怨。Add函式放在每一個new的後面,Remove則放在每一個delete前面,這樣就完成bookkeeping的手續。(這個class實際上是Writing Solid Code裡範例的C++簡易版)

Managed Environment輕鬆很多,大部份時間是不用管記憶體的,資源回收車會監控記憶體的使用情況,做memory bookkeeping,當程式記憶體不夠或是CPU很閒的時候回收車會來把沒有指標指到的記憶體收回去。但是有一個例外情況:當物件有一個特別的方法叫Dispose的時候,設計師在物件使用完後必須呼叫這個方法把資源放掉。為什麼會要這樣做,得先解釋Managed Environment和garbage collector的運作原理。Managed Environment是作業系統之上的虛擬機器(VM),在Managed Environment上面執行的程式並不是真的機器碼,而是一種中間產物(Java稱為bytecode,.NET Framework則稱為Microsoft Intermediate Language (MSIL)),真正的機器碼是在執行時期才由虛擬機器產生出來送給CPU執行。加這一層虛擬機器的主要目的就是加強記憶體和執行緒管理。在程式開始的時候,作業系統會給程式一塊Memory Heap(Heap很難翻,我不喜歡它的標準中譯:堆積,反正當作作業系統給的記憶體就對了,Heap只是資料結構而已),VM會在這上面再配置一個Heap(一般叫做Managed Heap),程式有的記憶體因此就分成二大塊(當然總計不能超過最大定址空間),一般的物件會用到的是Managed Heap,這部份VM管得很好,程式師完全不用操心,垃圾車會來把沒用到的收走。擁有Dispose方法的物件就不一樣了,它們使用到一部份的unmanaged heap,這部份VM是不管的,所以程式師得手動把它們清掉。通常有Dispose方法的class還有另一樣東西:Finalizer。Finalizer是Garbage collector在回收物件時所呼叫的函式,缺點是我們不知道什麼時候Finailizer會被呼叫(因為垃圾車會不定時出現),呼叫Dipose的好處是確定unmanaged heap裡的資源在當下就被釋放掉,這部份在多緒環境裡尤其重要,釋放執行緒同步物件的時間點常常必須是確定的,否則可能會有無法預測的錯誤。Garbage collector在回收記憶體的時候會先看物件是不是有Dispose方法(在.NET裡即實作IDisposable介面),如果有,Garbage collector會把物件先放到一個queue裡面(.NET把它叫作FReachable Queue),巡完全部程式之後,Finalizer執行緒會出動,把queue裡面的東西一個一個拿出來執行它的Finalizer,完成回收作業。也就是說,有實作IDisposable介面的物件要至少二輪才能清乾淨,所以如果能避免用到Unmanaged heap就避免,這樣可以提高程式效率。

下一個問題是Garbage collector什麼時候會出勤?答案很簡單:不知道,.NET內部有一套演算法決定資源回收車的班表,這就是人家的know how了。Garbage collector出勤的時候,會檢查每一塊在Managed heap上配置的記憶體,如果有被變數參考到,就會在記憶體做一個記號,如果沒有,就檢查是不是IDisposable,是就放到FReachable Queue,不是就直接回收。全部回收完之後把散在各地的記憶體集中,這樣要配置大塊的記憶體也方便許多。記憶體上的記號稱為Memory Generation,一開始是Gen 0,最多到Gen 2。Garbage collector一般會先只檢查Gen 0的記憶體,回收之後,如果閒置記憶體大小夠了,就不會再檢查Gen1或Gen2。如果Performance Counter裡的Gen2 Heap Size很大,通常代表程式的記憶體管理有問題。Garbage collector如果執行Gen2,那就很花時間了,程式執行效率會下降。

最後要提的是如何實作Dispose和Finalizer。要實作Dispose前,別忘了把IDisposable加到介面清單裡面,用C#大概長這樣:

class MyClass: IDisposable
{
    ~MyClass() { Dispose(false); }

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if(m_disposed)
            return true;
        m_disposed = true;
        if(disposing)
        {
            // release managed resources, e.g., calling Dispose()
            // of other objects
        }
        // release unmanaged resources if any
    }

    private bool m_disposed = false;
}

MSDN說Dispose可以在任何地方呼叫,如果多次呼叫也不能丟出例外(雖然我還是覺得很奇怪為什麼不能丟例外,但是這是IDisposable裡面規定的),所以就定義m_disposed避免重覆。這裡的重點在這個含有一個引數的Dispose,程式師手動呼叫的時候就會把所有資源清理掉,不管是managed或是unmanaged,子類別也可以覆載這個方法以釋放子類別物件的資源。Finalizer(~MyClass())則只負責unmanaged的部份(千萬別把managed resources放在這裡,當程式執行到這裡時,我們不知道這些資源是不是已經被釋放掉)。理論上finalizer是不用做任何事的,把unmanaged的部份放進去只是為了萬一程式師〞忘了〞呼叫Dispose,至少unmanaged的資源可以繳回去,managed的資源則讓garbage collector自己處理。這樣可以避免記憶體洩漏,不過結果對不對就不保證了。所以還是要養成習慣呼叫Dispose。Dispose()方法還多了一行:GC.SuppressFinalize(this),這行的目的是告訴Garbage collector說,我都清好了,Finalizer執行緒不用理我,跳過Finalizer直接回收。Managed C++的寫法稍有不同:

ref class MyClass
{
    !MyClass() // finalizer
    {
        // release unmanaged resources
    }
    ~MyClass() // destructor
    {
        // release managed resource
        this->!MyClass();
    }
}

編譯器會自動加上IDisposable介面。這個寫法相當於:

void Dispose(bool disposing)
{
    if(disposing)
        ~MyClass();
    else
        !MyClass();
}

所以在destructor裡的最後一行要加上finalizer。

張貼在 Computers and Internet | 發表留言