2006年03月10日

COM クライアント実装の道程 for TaskScheduler その番外編2 〜 COM オブジェクトと GC とファイナライザ

ノートの方には MSDN の Platform SDK 部分は入れていなかったので、この記事を書くのにオンラインの MSDN を参照することもあります。で、気づいたんですが、なんか TaskScheduler の関連インターフェイスが増殖してる……。え嘘マジ? と大慌てで調べてみたら、その辺全部 Vista で追加されるインターフェイスのようです。…………。ふ、今やってるのも Vista までの寿命か……。いや後方互換性は残されるみたいですけど。Vista では ITaskScheduler の代わりに ITaskService を使って操作するスタイルになるようですね。増えたインターフェイスは ITaskScheduler では構造体として扱っていた部分みたいです。あるいはそもそも WinFX でサポートされるようになるのかしらん。

さて、今回のお題は COM オブジェクトの面倒くささについてです。知っている人は知っている、というか最近特にクローズアップされだしているような気がしますが(というか私がそう言う情報に敏感になっただけかも)、CLI の基礎でもあるガベージコレクタと COM オブジェクトとは、ひたすら食い合わせが悪いのです。

そもそも COM オブジェクトは、参照カウンタという機構を使用してインスタンスを管理します。新たにオブジェクトを参照するときにカウンタをインクリメントし、その参照を使わなくなったときにデクリメントします。デクリメントされたときにカウンタが 0 になっていればもうそのインスタンスを参照しているものがないと言うことなのでインスタンスを削除します。このインクリメント・デクリメントは COM オブジェクトを利用する側の責任で行う必要があります。

厳密には、.NET が扱う参照カウンタは COM ネイティブのものではなく、RCW が管理する値みたいです。

.NET で COM オブジェクトを扱う場合、今までの連載でやってたことや、あるいは Tlbimp.exe を使ったり(VS での COM の参照の追加も内部でこれをやってますね)でマネージドのクラス/インターフェイスにラップします。内部では、これらのクラスやインターフェイスは ランタイム呼び出し可能ラッパー ( RCW : Runtime Callable Wrapper )という仕組みを使います。インスタンスそのものが RCW インスタンスであるという方がわかりやすいかもしれません。これの最大の役割はデータのマーシャリングです。つまり .NET が渡した引数を COM が理解できる形に変換し、COM オブジェクトが返す値を .NET にふさわしい形に変換する作業ですね。

.NET/CLI ではメモリの管理手段としてガベージコレクタ(GC)を導入しています。オブジェクトの生成と消滅がメモリに直接影響するため、GC はオブジェクトの管理手段と捉えがちですが、あくまでメモリの管理のための機構です。そのため、そのほかの資源、例えばファイルハンドルを使用した場合、GC が使い終わった(参照がなくなった)からと言って解放してくれるわけではないと言うことになります。そのため .NET では IDisposable インターフェイスを用意してハンドルの利用者に明示的に解放させることを促すとともに、万が一解放忘れがあっても、オブジェクトが削除されるときに暗黙に呼び出される Finalize メソッドを利用して確実に解放されるようにしています。

要するに、GC は万能ではなく、メモリ以外の資源については無関心なわけです。それは COM の参照カウンタについても同じです。作成したときは(内部で CoCreateInstance などが呼ばれるため)自動的にカウンタがインクリメントされますが、インスタンスが不要になっても削除されても、カウンタのデクリメントは行いません。RCW もファイナライザなどによる管理をしてくれません。つまり、利用者が明示的に不要になった参照に対してデクリメントを行わなければならないわけです。しかも万が一例外など発生して解放忘れになったらまずいですから、必ず try-finally を挟まなければなりません。念のために null チェックも行って。面倒ですね。

この面倒臭さを多少なりと軽減するために IDisposable を実装したラッパクラスを作るというのは自然な発想でしょう。ですがこれもなかなかやっかいです。単純に考えれば、COM オブジェクトをフィールドとして持ち、Dispose で Marshal.ReleaseComObject を呼び出すという実装が思いつきます。しかしこれは実は不可なのです。

その前に、IDispossable について確認しておきましょう。一般的な IDisposable の実装は次の形です。

// C#
public class Disposable : IDisposable {
    public void Dispose() {
        this.Dispose(true);
        System.GC.SuppressFinalize(this);
    }
    protected virtual void Dispose(bool disposing) {
        if (disposing) {
            // マネージドオブジェクトの解放
        }
        // アンマネージドなリソースの解放
    }
    ~Disposable() {
        this.Dispose(false);
    }
}

' VB.NET
Public Class Disposable
    Implements IDisposable
    Public Sub Dispose() Implements IDisposable.Dispose
        Me.Dispose(True)
        System.GC.SuppressFinalize(Me)
    End Sub
    Protected Overridable Sub Dispose(ByVal disposing As Boolean)
        If (disposing) Then
            ' マネージドオブジェクトの解放
        End If
        ' アンマネージドなリソースの解放
    End Sub
    Protected Overrides Sub Finalize()
        Me.Dispose(False)
        MyBase.Finalize()
    End Sub
End Class

ここで問題です。フィールドに持っている COM オブジェクトはマネージドオブジェクトでしょうか、アンマネージドリソースでしょうか? 一見 COM オブジェクトというとアンマネージドですが、しかしそれは既にマネージドのクラスでラップされています。つまり、例えばファイルハンドルに対する FileStream インスタンスと同じ構造な訳です。

わかりやすいようにその FileStream をフィールドに持つクラスを考えましょう。

public class FileStreamWrapper : IDisposable {
    private System.IO.FileStream file;
    public FileStreamWrapper(string fileName) {
        this.file = new System.IO.FileStream(fileName,
                                             FileMode.Open);
    }
    public void Dispose() {
        this.Dispose(true);
        System.GC.SuppressFinalize(this);
    }
    protected virtual void Dispose(bool disposing) {
        if (disposing) {
            // マネージドオブジェクトの解放
            this.file.Close();
            // Disposeも呼びたいけど明示的実装なのでできない
            this.file = null;
        }
        // アンマネージドリソースは持っていない
    }
    ~Disposable() {
        this.Dispose(false);
    }
}

' VB.NET
Public Class FileStreamWrapper
    Implements IDisposable
    Private file As System.IO.FileStream
    Public Sub New(ByVal fileName As String)
        Me.file = New System.IO.FileStream(fileName,
                                           FileMode.Open)
    End Sub
    Public Sub Dispose() Implements IDisposable.Dispose
        Me.Dispose(True)
        System.GC.SuppressFinalize(Me)
    End Sub
    Protected Overridable Sub Dispose(ByVal disposing As Boolean)
        If disposing Then
            ' マネージドオブジェクトの解放
            Me.file.Close()
            ' Disposeも呼びたいけど Private なのでできない
            Me.file = Nothing
        End If
        ' アンマネージドリソースは持っていない
    End Sub
    Protected Overrides Sub Finalize()
        Me.Dispose(False)
        MyBase.Finalize()
    End Sub
End Class

FileStream のユーザ(FileStreamWrapper)から見れば、FileStream は、内部で何をやっていようが無関係にまぎれもなくマネージドオブジェクトです。ですからファイナライザで解放するものではありません。ユーザが明示的に Dispose を呼び出したときは Dispose(true) が呼ばれますから FileStream も Close されますが、ファイナライザが Dispose を呼び出したときは FileStream に対して何も行いません。

ではこの FileStreamWrapper に対して Dispose を明示的に呼ばなかった場合、ファイルハンドルが閉じられる機会は失われてしまうのでしょうか? いいえ、そんなことはありません。実際にファイルハンドル(=アンマネージドリソース)を持っている FileStream も当然ファイナライザが呼び出されます。このFileStream のファイナライザでは、直接アンマネージドリソースを持っているわけですから Dispose(false) が呼び出されたときにも問題なくファイルハンドルを解放します。

翻って今回の考察対象である COM ラッパーについて考えると、RCW の実装がどうあれ、TaskSchedulerClass はマネージドオブジェクトです。と言うことはファイナライザで解放するものではないということになります。上記のコードで言うところの if (disposing) { ... } の中で扱うオブジェクトですね。なら話は早い、ここに Marshal.ReleaseComObject を仕込んでおけば…………良くありません。COM オブジェクトは内部でアンマネージドリソースを扱いますが、そのファイナライザで解放処理を記述していません。これでは IDisposable を使えないことになります。なんて厄介なんでしょうか。

一体どうすればこの問題を解決できるのか? いやそもそも何故ファイナライザでマネージドオブジェクトを操作しないようになっているのでしょうか? この制約さえなければ普通に if (dispose) { ... } の外側で Marshal.ReleaseComObject を呼び出せば済む話なのに。

この話はなかなか根が深く語り出すと長くなりますが、問題の一つに「ファイナライザが実行される順番がわからない」と言うものがあります。すなわち、ファイナライザでマネージドオブジェクトを扱おうとするとき、すでにそのオブジェクトがファイナライズ済みである可能性があると言うことです。MSDN の Dispose メソッドの実装 には、他のオブジェクトを参照しないように注意した上で、実行中のファイナライザが、既に終了されている別のオブジェクトを参照した場合、そのファイナライザは失敗します。と書いています。ファイナライザの失敗とは穏やかではありませんが、少なくともそれがまともに動く保証はされないと言うことになります。

ここでふと気になるのが、ファイナライザでオブジェクトに触ったら駄目だと書かれているのに、じゃあ FileStream のファイルハンドルはどうするんだ? ということです。ファイナライザでオブジェクトに触れないのなら、このファイルハンドルだって触れないんではないだろうか?

もちろんそんなことはありません。この問題のポイントは、ハンドルは .NET では System.IntPtr で表現しますが、これが値型( C#/VB.NET における構造体)であるという事実です。

構造体はファイナライザを持てません。これは逆に言えば、他のファイナライザで操作する際に「既に終了している」である可能性を考慮する必要がないと言うことになります。ではフィールドに置かれた構造体はいつ削除されるのでしょうか? もちろんその構造体を持っているクラスのインスタンスが削除されるときです。つまり、クラスインスタンスが破棄されるまではこの構造体はそこに存在します。注意すべきは、これはあくまでその構造体インスタンスはそうであるというだけであり、その構造体が持つメンバについてはまた別問題であると言うことです。例えば以下のような構造を考えてみます。

public class Parent {
    public Child Child = new Child();
    public Parent() {
        this.Child.GrandChild = new GrandChild();
    }
    ~Parent() {
        Console.WriteLine(this.Child.GrandChild);
    }
}
public struct Child {
    public GrandChild GrandChild;
}
public class GrandChild {
    ~GrandChild() {}
}

Parent クラスのファイナライザで Child を扱うのが問題ないことは解説しました。しかしこれはその Child が持つ GrandChild に対して操作を行っています。GC がこの Parent インスタンスをゴミだと判断すると、同時にそのメンバである Child もゴミと言うことになります。さらに、そのChild が持つメンバも、他に参照されていなければゴミと言うことになります。このとき、Parent が実行するファイナライザでは既に GrandChild がファイナライズ済みである可能性があります。GC は GrandChild もゴミと見なしているため、ファイナライズを順不同に実行しようとするからです。

しかしまあ、System.IntPtr に対してファイナライザが処理を行うというのは、全く問題ありません。System.IntPtr は「既に終了している」状態にはなり得ないですし、別にフィールドに参照型オブジェクトを持っているわけでもありませんから。

ファイルハンドルについては分かりました。これをなんとか COM オブジェクトに活用できないものでしょうか?

単純に思いつくのは COM オブジェクトを構造体にラップするという手です。

public class ComWrapper {
    private struct ComWrapStructure {
        public object ComObject;
    }
    private ComWrapStructure comObj;
    public ComWrapper(object comObj) {
        this.comObj = new ComWrapStructure();
        this.comObj.ComObject = comObj;
    }
    ~ComWrapper() {
        // 本来は Dispose(false)で実行
        Marshal.ReleaseComObject(this.comObj.ComObject);
    }
}

……が、これは既に否定されました。直接構造体を持っていても、そのフィールドが参照型ならそれに手を出すわけにはいきません。当然 COM オブジェクトは参照型です。

では IntPtr で保持する案はどうでしょう? Marshal クラスには COM オブジェクトと IntPtr を相互に変換するいくつかのメソッドを持っています。今回は IUnknown ベースですから、Marshal.GetObjectForIUnknown と Marshal.GetIUnknownForObject メソッドをうまく使えばいいのではないでしょうか。

このアイデアには大きな欠点があります。上記の二つのメソッドを使用すると、それぞれ自動的に参照カウンタがインクリメントされるのです。さらにこの IntPtr とオブジェクトとは参照カウンタが別扱いされる模様。つまり内部で IntPtr を持っていて Release したとしても、ユーザに object 型で公開していたりしたら全く意味はなくなるわけです。ユーザに対して IntPtr で公開しても意味はないですしね。

公開された IntPtr に対して Marshal.GetObjectForIUnknown 、使い終わったらそのオブジェクトに対して Marshal.ReleaseComObject ……手間が変わってません。むしろ増えてる。

なかなかどん詰まりです。GC は極めて便利ですが、こういう場合極めて邪魔ですね。COM オブジェクトが GC されるのが原因で現状の袋小路に追い込まれています。どうせ COM オブジェクトに対すて GC は無力なんだから(言い過ぎ)、いっそ GC をしないでくれればいいのです。

実は、この「GC の対象にならないマネージドオブジェクト」を実現する手段が .NET には用意されています。ごく身近にも存在しています。System.Windows.Forms.Form なんかがそうです。どこかのメソッド内で new Form().Show(); とだけ書いておけば、ユーザが閉じるまでそのフォームは GC が発生しようが何しようが消えません。

某掲示板でその質問が出たときに調べたんですが、実は .NET 2.0 だと開いているフォームのコレクションが Application クラスに保存されているので(Application.OpenForms で取得できます)、わざわざこんなことをしなくても GC の対象にはならなかったりします。初めに .NET 2.0 の方を調べたので適当なクラスが参照を持ってるんだろうと思っていたら、.NET 1.x では実はこのコレクションが存在しないんですよねー。かなり調べ回る羽目になりました。で、私が分かった頃には既にきっちりした記事に書いてる人がいたり

これを実現する手段が GCHandle 構造体です。これは一つのオブジェクトをラップし、さらにそのラップしたオブジェクトを GC の対象から外します。それでどうなるか? まず、GCHandle は構造体ですから、ファイナライザで操作することができます。そのフィールドの参照型は一見使うことができなさげですが、しかし考えてみてください。ファイナライザで参照型フィールドを扱ってはいけない理由は、それが既にファイナライズされている可能性があるからです。GCHandle でラップした場合、Free メソッドを呼ぶまでは GC の対象になりません。つまり Free を呼ばない間は決してファイナライズされることがないと言うことであり、ここに破棄の順番が確定することになります。それならばファイナライザでこのラップオブジェクトを使用することも問題ではなくなります。

そんな便利な GCHandle ですが、多用は禁物です。GC の効率が悪くなりますから。

それでは実際に実装してみましょう。

// C#
using System;
using System.Runtime.InteropServices;
#if !V10 && !V11
using System.Runtime.ConstrainedExecution;
#endif
namespace HongliangSoft.Utilities.Unmanaged.ComHelpers {
  public class ComInterfaceWrapper
#if !V10 && !V11
    : CriticalFinalizerObject, IDisposable
#else
    : IDisposable
#endif
  {
    public void Dispose() {
      this.Dispose(true);
      GC.SuppressFinalize(this);
    }
    ~ComInterfaceWrapper() {
      this.Dispose(false);
    }
    public ComInterfaceWrapper(object comObj) {
      if (comObj == null) {
        throw new ArgumentNullException(
          "comObj",
          "null 参照からインスタンスを作ることはできません。");
      }
      if (! Marshal.IsComObject(comObj)) {
        throw new ArgumentException(
          "COM オブジェクトではありません。", "comObj");
      }
      this.handle = GCHandle.Alloc(comObj);
    }
    private GCHandle handle;
    public void CheckHR(int hResult) {
      if (hResult != 0) {
        Marshal.ThrowExceptionForHR(hResult);
      }
    }
    public object Instance {
      get {
        if (!(this.handle.IsAllocated)
           || this.handle.Target == null) {
          throw new ObjectDisposedException(
            "",
            "既にこのオブジェクトは破棄されています。");
        }
        return this.handle.Target;
      }
    }
    protected virtual void Dispose(bool disposing) {
      if (this.handle.IsAllocated) {
        if (this.handle.Target != null) {
          Marshal.ReleaseComObject(this.handle.Target);
          if (disposing)
            this.handle.Target = null;
        }
        this.handle.Free();
      }
    }
  }
}

' VB.NET
Imports System
Imports System.Runtime.InteropServices
#If Not(V = 10 AndAlso V = 11) Then
Imports System.Runtime.ConstrainedExecution
#End If
Namespace HongliangSoft.Utilities.Unmanaged.ComHelpers
  Public Class ComInterfaceWrapper
#If Not(V = 10 AndAlso V = 11) Then
    Inherits CriticalFinalizerObject
#End If
    Implements IDisposable
    Public Sub Dispose() Implements IDisposable.Dispose
      Me.Dispose(True)
      GC.SuppressFinalize(Me)
    End Sub
    Protected Overrides Sub Finalize()
      Me.Dispose(False)
      MyBase.Finalize()
    End Sub
    Public Sub New(ByVal comObj As Object)
      If comObj Is Nothing Then
        Throw New ArgumentNullException( _
          "comObj", _
          "null 参照からインスタンスを作ることはできません。")
      End If
      If Not(Marshal.IsComObject(comObj)) Then
        Throw New ArgumentException( _
          "COM オブジェクトではありません。", "comObj")
      End If
      Me._handle = GCHandle.Alloc(comObj)
    End Sub
    Private _handle As GCHandle
    Public Sub CheckHR(ByVal hResult As Integer)
      If Not(hResult = 0) Then
        Marshal.ThrowExceptionForHR(hResult)
      End If
    End Sub
    Public ReadOnly Property Instance() As Object
      Get
        If Not(Me._handle.IsAllocated) _
            OrElse Me._handle.Target Is Nothing Then
          Throw New ObjectDisposedException( _
            "", "既にこのオブジェクトは破棄されています。")
        End If
        Return Me._handle.Target
      End Get
    End Property
    Protected Overridable Sub Dispose(ByVal disposing As Boolean)
      If Me._handle.IsAllocated Then
        If Not(Me._handle.Target Is Nothing) Then
          Marshal.ReleaseComObject(Me._handle.Target)
          If disposing Then
            Me._handle.Target = Nothing
          End If
          Me._handle.Free()
        End If
      End If
    End Sub
  End Class
End Namespace

ArguemntException と ArgumentNullException で、string 二つ取るコンストラクタのパラメータの順番が逆なのは大変迷惑なので勘弁していただきたい。一体誰だ出てきやがれ。

ちなみに、Dispose は複数回呼んでも問題ないように設計する必要があります。今回は初めに GC.Free されているかどうかを確認し、既に解放済みの場合は何もしないようにすることで解決しています。またこのクラスはスレッドセーフではありません。まあ実際にスレッドセーフでないのは Dispose だけですけど。スレッドセーフにする場合、disposing が true の時に、this.handle.IsAllocated の前、まあつまりメソッド全体にロックする必要があります(false の場合=ファイナライザからの呼び出しの場合ロックしてはいけません)。this.handle.Target が null になりうるのでこれをロック対象にするわけにはいかないし、private な object インスタンスでも持たすことになるかな?

.NET 2.0 でコンパイルした場合に基底クラスとなる CriticalFinalizerObject 抽象クラスは、以前記事にした、CER に関するクラスです。このクラスから派生したオブジェクトのファイナライザは、AppDomain のアンロードと言った普通のファイナライザでは仕事を投げてしまう状況でも確実にファイナライズしてくれる、いわば超ファイナライザ(超とか言うな)になります。ただし、このファイナライザで記述されている内容は失敗しないこと、「不正な状態」にならないことが要求されます。

手元に Excel がないんで試せませんが、誰かこの CriticalFinalizerObject から派生しない普通のファイナライザで ReleaseComObject するようにして AppDomain ごとアンロードしてみてくれませんかね。

そのまま使うことも考えて abstract/MustInherits にはしていませんが、正直そんな使い方はほぼ意味がないと思います。これは ITaskScheduler のようなインターフェイスを自前で .NET に適合した形に書き直すための基底クラスとして扱うようにデザインしています。

ジェネリクスを使えばおいしいことができそうな匂いが漂ってきますが、微妙に不便です。最大の理由は、インターフェイスに対して explicit operator が使えないことです。このため using ComInterfceWrapper<ITaskScheduler> doc = new ITaskScheduler()) { ... } なんてコードが書けません。明示的に new ComInterfaceWrapper<ITaskScheduler> を作る(か明示的にキャストする)必要があります(この部分は C# 3.0 の var で、右側じゃなくて左側を省略できるようになるかな?)。またこれでラップした場合、各種メソッドを呼び出すには Instance プロパティを経由して呼び出すことになるため、結局のところ全然コードの簡略化にならないことになります。次善の策として Using<TCom>(TCom comObj, Action<TCom> action) なんて静的メソッドも考えましたが、匿名メソッドではフローの制御が面倒くさい…… 。大量に COM オブジェクトの参照を抱える場合は、ArrayList もった IDisposable な private ( or internal ) 構造体を定義して、それに突っ込んでいくという手をやったり。人様には見せられないですな、こんなの。

さて、それでは次回は今回定義した ComInterfaceWrapper を元に、ITaskScheduler を扱いやすいクラスにまとめようかと思います。解説が面倒な、というか TaskScheduler 全体に関わる TASKTRIGGER 構造体はできる限り先延ばし。

posted by Hongliang at 03:09| Comment(1) | TrackBack(1) | .NET | このブログの読者になる | 更新情報をチェックする
この記事へのコメント
> ArguemntException と ArgumentNullException で、string 二つ取るコンストラクタのパラメータの順番が逆なのは大変迷惑なので勘弁していただきたい。一体誰だ出てきやがれ。

ArgumentOutOfRangeException も含めて考えると、多数決的に、ArguemntException の実装ミスなのかな。

今更変更できないし、違っていても動作的には問題が無いのでしょうけれども、混乱のもとですね。(^^;

https://connect.microsoft.com/VisualStudio/feedback/ViewFeedback.aspx?FeedbackID=95436
Posted by 魔界の仮面弁士 at 2008年06月28日 08:27
コメントを書く
お名前:

メールアドレス:

ホームページアドレス:

コメント:

認証コード: [必須入力]


※画像の中の文字を半角で入力してください。
この記事へのトラックバックURL
http://blog.seesaa.jp/tb/14551716

この記事へのトラックバック

re: COMを.Netから使うとめんどくさい
Excerpt: re: COMを.Netから使うとめんどくさい
Weblog: 黒龍's Blog
Tracked: 2009-06-30 10:00

ここ(hongliang.seesaa.net)で公開しているものについて、利用は自由に行って頂いて構いません。改変、再頒布もお好きになさって下さい。利用に対しこちらが何かを要求することはありません。

ただし、公開するものを使用、または参考したことによって何らかの損害等が生じた場合でも、私はいかなる責任も負いません。

あ、こんなのに使ったってコメントを頂ければ嬉しいです。

×

この広告は1年以上新しい記事の投稿がないブログに表示されております。