プログラム系統備忘録ブログ

記事中のコードは自己責任の下でご自由にどうぞ。

LibraryImportAttribute経由で取得したCOMオブジェクトのReleaseはほぼGC頼りになる

2024/01/19 23時頃追記: ComObject.FinalReleaseの諸問題は.NET 9で解決されます: Make multiple calls to FinalRelease safe by jkoritzinsky · Pull Request #97059 · dotnet/runtime

2024/01/13 11時頃追記: レビュー時のコメントが守られていないのは流石に変に思ったのでissueを立てました。

2024/01/11 23時頃追記: ComObject.FinalRelease後のメソッド呼び出しも未定義動作となる話と、APIレビュー時のコメントが守られていないらしい話を追記しました。その他細かい表現を修正しました。

.NET 8になって、LibraryImportAttributeGeneratedComInterfaceAttributeを使って、COMオブジェクトをマーシャリングできるようになりました。より具体的には、ネイティブのCOMオブジェクトを保持する、マネージドなComObject型へマーシャリングされます。さて、COMオブジェクトを扱う場合、参照カウンタを管理すること、すなわち適切にReleaseすることが必須です。しかしドキュメントを探しても、ComObjectが保持するネイティブCOMオブジェクトをどのようにReleaseすればいいのか分かりませんでした。実装確認や実験をすると、ComObject型が持つネイティブCOMオブジェクトは、基本的にGCタイミングでのみReleaseされるということが分かりました。本記事は結果や実験方法について記述します。

まとめ

検証バージョン

  • Microsoft Visual Studio Community 2022 (64-bit) - Current Version 17.8.3
  • .NET 8.0.0 ( System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription で確認)
  • Microsoft Windows [Version 10.0.19045.3803] ( ver コマンドで確認)
  • Windows 11 SDK 10.0.22621.0 (関係あるかは不明ですが念のため記載)
  • 動作検証用ソリューションTan90909090/ComInteropTestの64-bitビルド版

関連する型などの紹介

本節は本題とはあまり関係しません。次の節までスキップしても問題ありません。

runtime/docs/design/libraries/LibraryImportGenerator/Compatibility.md at v8.0.0 · dotnet/runtimeに記載がある通り、LibraryImportAttributeを使用したP/InvokeでCOMオブジェクトをマーシャリングできるようになりました。対応するPull RequestはAdd marshallers for GeneratedComInterface-based types by jkoritzinsky · Pull Request #86177 · dotnet/runtimeだと思います。

その際、C#側で再定義するCOMインターフェース型にはSystem.Runtime.InteropServices.Marshalling.GeneratedComInterfaceAttribute Classの属性を付与する必要があります。System.Runtime.InteropServices.ComImportAttribute Classは非対応です(SYSLIB1099 Casting between a 'ComImport' type and a source-generated COM type is not supported警告で教えてくれます)。

ネイティブ側のCOMオブジェクトを保持する型として、System.Runtime.InteropServices.Marshalling.ComObject Classが使われます。ComObject型へマーシャリングする方法は、System.Runtime.InteropServices.Marshalling.ComInterfaceMarshaller<T> Classを使う方法と、System.Runtime.InteropServices.Marshalling.UniqueComInterfaceMarshaller<T> Classを使う方法の2つがあります。このマーシャリングの方法も本記事の主題の1つです。詳細は後述します。

内部的には、ComObject型インスタンスはSystem.Runtime.InteropServices.Marshalling.StrategyBasedComWrappers Classが生成します。StrategyBasedComWrappers型は.NET 8で追加されました。一方でStrategyBasedComWrappers型の抽象基底クラスであるSystem.Runtime.InteropServices.ComWrappers Classは.NET 5からあります。.NET 7までのComWrappers型は、Using the ComWrappers API - .NET | Microsoft Learnに記述があるように、自分で頑張って派生型を作って運用する方式だったようです。

ComObject型から各種COMインターフェースへのキャストは、System.Runtime.InteropServices.IDynamicInterfaceCastable Interfaceを使って実現しているようです。IDynamicInterfaceCastableインターフェースはAdd IDynamicInterfaceCastable interface by elinor-fung · Pull Request #37042 · dotnet/runtimeのPRで追加されました。変更内容を見ると、コンパイル時ではなく、実行時のランタイムレベルでキャスト時の動作を特別扱いしているようです。

なお、DllImportAtributeComImportAttributeでCOMオブジェクトを取得すると、マネージド側ではRuntime Callable Wrapper(略称RCW)にラップされます。LibraryImportAttributeGeneratedComInterfaceAttribute経由で取得したComObjectのことは、おそらくRCWとは呼ばないと思います(自信なし)。

問題の現象の説明

LibraryImportAttribute適用結果のソースからComObjectが生成されるまでの流れ

  1. LibraryImportAttributeを適用したCoCreateInstance等のソース生成結果は、マーシャリングにComInterfaceMarshaller<T>を使用する場合と、UniqueComInterfaceMarshaller<T>を使用する場合とで異なります。
    • ComInterfaceMarshaller<T>を使用する場合は、ppv = global::System.Runtime.InteropServices.Marshalling.ComInterfaceMarshaller<object>.ConvertToManaged(__ppv_native)を含むソースが生成されます。
    • UniqueComInterfaceMarshaller<T>を使用する場合は、ppv = global::System.Runtime.InteropServices.Marshalling.UniqueComInterfaceMarshaller<object>.ConvertToManaged(__ppv_native)等を含むソースが生成されます
  2. ComInterfaceMarshaller<T>.ConvertToManagedの実装ではStrategyBasedComWrappers.DefaultMarshallingInstance.GetOrCreateObjectForComInstance((nint)unmanaged, CreateObjectFlags.Unwrap)の結果を、UniqueComInterfaceMarshaller<T>.ConvertToManagedの実装ではStrategyBasedComWrappers.DefaultMarshallingInstance.GetOrCreateObjectForComInstance((nint)unmanaged, CreateObjectFlags.Unwrap | CreateObjectFlags.UniqueInstance)の結果を返します。この2つの呼び出しは、第2引数のCreateObjectFlags内容のみが異なります。
  3. StrategyBasedComWrappers.DefaultMarshallingInstanceプロパティは単なるinternalシングルトンです。StrategyBasedComWrappers自身はGetOrCreateObjectForComInstanceをオーバーライドしていないため、基底クラス側のComWrappers.GetOrCreateObjectForComInstanceを呼び出すことになります。
  4. ComWrappers.GetOrCreateObjectForComInstanceの実装は環境によって異なります。
  5. StrategyBasedComWrappers.CreateObjectの実装でようやくComObjectインスタンスを生成しています。その際にオブジェクト初期化子でUniqueInstance = flags.HasFlag(CreateObjectFlags.UniqueInstance)を設定しています。すなわちComObject.UniqueInstanceプロパティは、ComInterfaceMarshaller<T>でマーシャリングした場合はfalseUniqueComInterfaceMarshaller<T>でマーシャリングした場合はtrueになります。

なお、「COMオブジェクトをマーシャリングするコードをSource Generatorが生成する際、指定がない場合のデフォルトとしてComInterfaceMarshaller<T>を使用する」の判定がどこにあるのかは探し当てられませんでした……。

ComObjectの解放処理

System.Runtime.InteropServices.Marshalling.ComObject Classのドキュメントを見ると、保持するネイティブCOMオブジェクトを以下のメソッドでReleaseできそうです:

ComObject.Finalizeの実装を見ると、以下の解放処理を行っています:

~ComObject()
{
    CacheStrategy.Clear(IUnknownStrategy);
    IUnknownStrategy.Release(_instancePointer);
}

一方で、FinalReleaseのドキュメントにはif it is a unique instance.との条件が書かれていますが詳細が不明です。そこでComObject.FinalReleaseの実装の実装を見ると、internalUniqueInstanceプロパティがtrueである場合にのみ、Finalize同様の解放処理を行っています:

public void FinalRelease()
{
    if (UniqueInstance)
    {
        CacheStrategy.Clear(IUnknownStrategy);
        IUnknownStrategy.Release(_instancePointer);
    }
}

前述した通り、UniqueInstanceプロパティの値は、ComInterfaceMarshaller<T>でマーシャリングした場合はfalseなのでFinalReleaseメソッドを呼び出しても無意味です。UniqueComInterfaceMarshaller<T>でマーシャリングした場合はtrueになるため、FinalReleaseメソッドを呼び出すと解放できそうです。

ComObject解放処理中の2つの内容

解放処理中の2つの処理はインターフェース経由で呼び出しています。実際に使用される具象型は、StrategyBasedComWrappers.CreateObjectでComObjectを生成する際に一緒に生成しています。

解放処理1つ目: CacheStrategy.Clear(IUnknownStrategy)

  1. ComObject.CacheStrategy用の実引数は、publicStrategyBasedComWrappers.DefaultIUnknownStrategy Propertyで生成されます。
  2. StrategyBasedComWrappers.DefaultIUnknownStrategyの初期値internalDefaultCaching型インスタンスです。
  3. DefaultCaching.Clearの実装は以下のものです:
void IIUnknownCacheStrategy.Clear(IIUnknownStrategy unknownStrategy)
{
    foreach (var info in _cache.Values)
    {
        _ = unknownStrategy.Release(info.ThisPtr);
    }
    _cache.Clear();
}

保持している各種キャッシュをReleaseし、キャッシュのコレクションであるConcurrentDictionary _cacheフィールドそのものもClearしています。複数回呼び出しても安全です。

解放処理2つ目: IUnknownStrategy.Release(_instancePointer)

  1. ComObject.IUnknownStrategy用の実引数は、protectedStrategyBasedComWrappers.GetOrCreateIUnknownStrategy Methodで生成されます。
  2. StrategyBasedComWrappers.GetOrCreateIUnknownStrategyのデフォルト実装publicStrategyBasedComWrappers.DefaultIUnknownInterfaceDetailsStrategy Propertyを返します。
  3. StrategyBasedComWrappers.DefaultIUnknownInterfaceDetailsStrategyの初期値internalFreeThreadedStrategy型インスタンスです。
  4. FreeThreadedStrategy.Releaseの実装は以下のものです:
unsafe int IIUnknownStrategy.Release(void* thisPtr)
    => Marshal.Release((nint)thisPtr);

こちらはMarshal.Release(IntPtr) Methodへ全てを任せています。すなわち問答無用でネイティブCOMオブジェクトのReleaseメソッドを呼び出します。複数回呼び出すと、呼び出した回数だけReleaseされます。

UniqueComInterfaceMarshaller<T>使用時にComObject.FinalRelease()する際の重大な注意点

上述したように、UniqueComInterfaceMarshaller<T>でマーシャリングしたComObjectの場合はComObject.FinalRelease()で明示的に解放できます。しかし、絶対に考えるべき点があります:

  • ComObject.ReleaseObject呼び出し後でも、当該ComObjectや、各種COMインターフェースを経由したメソッド呼び出しを行えてしまいます。すなわちUse-After-Freeであり、未定義動作です。
    • Use-After-Freeの影響は、もしかしたら環境や場合によって偶然問題がないかもしれませんし、System.AccessViolationException Class型の例外などが送出されるかもしれません。
    • ComImportAttribute使用時のRCWでは、Marshal.ReleaseComObject等で解放した後にアクセスすると、System.Runtime.InteropServices.InvalidComObjectException class型の例外が送出されるという、「安全な」挙動です。もちろん、未定義動作など起こしません。
  • のちのGCタイミングでComObject.Finalizeが呼ばれ、その際に改めてReleaseしようとします。またもやUse-After-Freeを引き起こすものであり、未定義動作です。これを避けるためにはGC.SupressFinalizeによるGC抑制が必須に思います。
    • GCタイミングのUser-After-Free時に例外が発生する場合、どのオブジェクトが原因であるのかを探すことは困難でしょう。
    • GC.SupressFinalizeを使う箇所でCA1816 ClassName.MethodName() calls GC.SuppressFinalize(object) on something other than itself. Change the call site to pass 'this' ('Me' in Visual Basic) instead.CA1816 ClassName.MethodName() calls GC.SuppressFinalize(object), a method that is typically only called within an implementation of 'IDisposable.Dispose'. Refer to the IDisposable pattern for more information.の警告が発生すると思います。しかしおそらく他に回避策はありません。それらの警告を抑制して良いと思います。
    • ComImportAttribute使用時のRCWでは、Marshal.ReleaseComObject等で解放したら後は何も気にする必要はありません。もちろん、GC発生時に未定義動作など起こしません。

そういうわけで、ComObject.FinalRelease呼び出しはGC.SupressFinalize呼び出しと併用するか、どちらも呼び出さないか、が必要なります。当然ながら、どちらも呼び出さない場合は、ComInterfaceMarshaller<T>と同じくReleaseタイミングはGC任せになります。

ComObjectがUniqueであるか判断できない以上、ComObject.FinalReleaseは実質的に使えないのでは?

ここまででComObject.FinalReleaseを扱う際の注意点を述べてきましたが、改めて全体を振り返ると、まともに扱うことは極めて困難であるように思います:

  • UniqueComInterfaceMarshaller<T>でマーシャリングしたComObjectでのみ、ComObject.FinalReleaseする意味があります。かつGC.SupressFinalizeとの併用が必須です。
  • 一方で、ComInterfaceMarshaller<T>でマーシャリングしたComObjectでは、ComObject.FinalReleaseしても無意味であるため、GCによるComObject.Finalize待ちになります。もし誤ってGC.SupressFinalizeしてしまうとRelease不足となり、メモリリークします。
  • そうなると、対象とするComObjectがどちらの方法でマーシャリングされたのかを知りたくなりますが、残念ながらComObject.UniqueInstanceプロパティはinternalであるため、判別不能です(リフレクションを使えば取得できますが、将来的にプロパティ名等が変更される可能性があります)。
  • つまり、ComObjectをマーシャリングする2種類の方法が混ざってしまうと、ComObject.FinalReleaseおよびGC.SupressFinalizeを呼び出すべきかどうかが判断不能になります。結局は安全策としてComObject.FinalReleaseを全く使わずに、GCでそのうち回収されるのを待つことになりそうです。

もしも、「すべてのマーシャリングをUniqueComInterfaceMarshaller<T>で行わせるように、COMオブジェクトを取得するすべてのP/Invoke箇所でMarshalUsingAttributeを指定する」ことを徹底できれば、ComObject.FinalReleaseを使えるでしょう。しかしうっかりミスなどで、デフォルトのマーシャラーであるComInterfaceMarshaller<T>が混入してしまうことは簡単に起こり得ると思います。

動作検証コード

本節では、動作検証に使用したコードを抜粋して解説します。コード全体はGitHubのTan90909090/ComInteropTestへアップロードしています。64-bit設定でビルドして確認しました。

動作検証用のために、以下の性質を持つ組み込みのCOMインターフェースを探しました:

  1. コールバック等で別のCOMインターフェースを引数に取るCOMインターフェースであること
  2. インターフェースの継承を行っているCOMインターフェースであること

MSDNを適当に探していると、INamespaceWalk, INamespaceWalkCB, INamespaceWalkCB2が見つかったので、それらを独自実装して使いました。それらの本来の用途は全く知りません。

また、WindowsTerminalでは制御文字による文字色の変更に対応しているため、せっかくなので使ってみました。制御文字の非対応のターミナルで実行したい場合は、ComInteropTest/ServerDll/ImplementedClasses.hppUSE_ESCAPE_CHARACTER_TO_COLOR_OUTPUTマクロ定義内容を変更してください。

ログ出力やレジストリ登録を行うC++製COMサーバー用DLL

ComInteropTest/ServerDllプロジェクトです。ビルドしてできたDLLを、管理者権限のコマンドプロンプトregsvr32.exe ServerDll.dllを実行してレジストリへ登録してください。ここで管理者権限が必須である理由は、DllInstallエクスポート関数でのコマンドライン引数解釈をサボって省略したからですすみません。

実験が終わったら、管理者権限のコマンドプロンプトregsvr32.exe /u ServerDll.dllを実行して、レジストリ登録を解除してください。

動作確認用クラスでは、各種COMインターフェースの呼び出し時に標準出力へログを出力します。特にAddRef, Release時では参照カウンタをログ出力します。また、ComInteropTest/ServerDll/ClsIds.hppで動作確認用クラスのCLSIDを定義しています。

C++製COMクライアントから通常通り使う

ComInteropTest/ClientCpp/ClientCpp.cppです。動作確認用クラスのCLSID経由でCoCreateInstanceを呼び出してCOMオブジェクトを生成します。適当にCOMインターフェース経由で呼び出して、最後にReleaseします。問題なくCOMオブジェクトを解放できることが分かります。

C#製COMクライアントからComImportAttribute経由で使う

ComInteropTest/ClientCsComImport/ClientCsComImport.csです。DllImportAttributeCoCreateInstanceをP/Invokeして、動作確認用クラスのCLSID経由でCOMオブジェクトを生成してRCWを取得します。その後、ComImportAttibute属性を適用しつつ各種COMインターフェースを再定義して使用します。

最後に、標準入力の値に従って、以下のどちらかを行います。

  1. RCWに対してMarshal.ReleaseComObjectを呼び出さない。GCによるRCWの解放を待つ。
  2. RCWに対してMarshal.ReleaseComObjectを呼び出す。

どちらの場合でも、COMオブジェクトがReleaseされて解放されることが分かります。

C#製COMクライアントからGeneratedComInterfaceAttribtuteかつComInterfaceMarshaller<T>経由で使う

ComInteropTest/ClientCsGeneratedComInterface/ClientCsGeneratedComInterface.csです。LibraryImportAttributeCoCreateInstanceをP/Invokeして、動作確認用のクラスのCLSID経由でCOMオブジェクトを生成します。その際、マーシャラーを指定していないため、デフォルトのマーシャラーであるComInterfaceMarshaller<T>を使用してComObjectインスタンスを取得します。その後、GeneratedComInterfaceAttribute属性を使用しつつ各種COMオブジェクトを再定義して使用します。

最後にComObject.FinalReleaseを呼び出していますが、無意味な呼び出しであるため、COMオブジェクトの参照カウンタが変化しないことが分かります。最後のGC.CollectGC.WaitForPendingFinalizersを使用したGC強制実行時に、COMオブジェクトがReleaseされて解放されることが分かります。

C#製COMクライアントからGeneratedComInterfaceAttribtuteかつUniqueComInterfaceMarshaller<T>経由で使う

ComInteropTest/ClientCsGeneratedComInterfaceUnique/ClientCsGeneratedComInterfaceUnique.csです。LibraryImportAttributeCoCreateInstanceをP/Invokeして、動作確認用のクラスのCLSID経由でCOMオブジェクトを生成します。その際、マーシャラーにUniqueComInterfaceMarshaller<T>を指定してiるため、当該マーシャラーを使用してComObjectインスタンスを取得します。その後、GeneratedComInterfaceAttribute`属性を使用しつつ各種COMオブジェクトを再定義して使用します。

最後に、標準入力の値に従って、以下のいずれかを行います。

  1. ComObject.FinalReleaseGC.SupressFinalizeも呼び出さない。GCによるComObject.Finalize呼び出しを待つ。
  2. ComObject.FinalReleaseを呼び出しますが、GC.SupressFinalizeは呼び出さない。
  3. ComObject.FinalReleaseGC.SupressFinalizeも呼び出す。

1つ目か3つ目の場合は問題ありません。COMオブジェクトがReleaseされて解放されることや、プロセスが正常に終了コード0で終了することが分かります。

問題の2つ目の場合は、ComObject.FinalReleaseでCOMオブジェクトがReleaseされて解放された後に、改めてComObject.Finalizeで解放しようとします。動作確認した結果、System.AccessViolationException: 'Attempted to read or write protected memory. This is often an indication that other memory is corrupt.'な例外が発生しました。

FinalRelease後のFinalize中に「System.AccessViolationException: 'Attempted to read or write protected memory. This is often an indication that other memory is corrupt.'」が発生

スタックトレースを見ても「どれかのComObjectが原因らしい」が分かるぐらいです。どのComObjectが、どこでFinalReleaseした結果のAccess Violationなのかが全く分かりません。もし複雑なプロジェクトでこの現象に遭遇してしまったら、非常に辛い状況になると思います。

また別の確認として、ComObject.FinalRelease呼び出し後に当該ComObject経由でメソッド呼び出しを行おうとしました。その場合でも、AccessViolationExceptionが発生しました。

感想

  • ComImportAttributeをそのまま使うほうが、未定動作など起こりませんし、Marshal.ReleaseComObjectで明示的に解放できます。GeneratedComInterfaceAttributeへ移行しなくてもいいのでは、と思ってしまいます。
    • 本記事の本題ではないで省略していますが、移行作業そのものが大変だと思います。
      • そもそも、GeneratedComInterfaceAttribute関係のドキュメントが少なすぎます。ComWrappers source generation - .NET | Microsoft Learnくらいだけは見つかりました。
      • ComObjectに対してはMarshal.ReleaseComObjectMarshal.FinalReleaseComObjectは使えなくなりますし、Type.IsCOMObjectで何かを判断している箇所はis ComObjectかどうかの判断へ変更する必要があるでしょう。
      • GeneratedComInterfaceAttributeが対応しているのはIUnknown系統のみであり、IDispatch系統は非対応です。もしIDispatch派生インターフェースを使用している場合は、自分でIDispatchを再定義する必要があるでしょう。
      • 関連するCOMインターフェースの再定義をすべてComImportAttributeからGeneratedComInterfaceAttributeへ変更する必要があります。もし、System.Runtime.InteropServices.ComTypes Namespace以下のインターフェースを使ってしまっているなら、それらのインターフェースはGeneratedComInterfaceAttribute属性がないため、自分でGeneratedComInterfaceAttribute版を再定義する必要があります。
    • 擁護すると、GeneratedComInterfaceAttributeは悪いことばかりではないです。COMインターフェースの継承時に基底インターフェースのメソッドのコピペが不要になる利点は大きいと思います。
  • 正直なところ、なぜComObjectがこのような設計になってしまったのか分かりません。そもそも、なぜUniqueComInterfaceMarshaller<T>Uniqueという単語が使われているのかも分かりません。Uniqueでない場合はどのような挙動なのか(1つのマネージドComObjectが複数のネイティブCOMオブジェクトを持つのか、1つのネイティブCOMオブジェクトを複数のマネージドComObjectが共有するのか)も分かりません。そして、開発者はどのような場合にUniqueComInterfaceMarshaller<T>を指定するべきなのかも分かりません。分かる方がおられたらぜひコメントください。
Make the second call a no-op
Throw InvalidOperationException rather than ObjectDisposedException when the COM object is used after calling FinalRelease
  • 考えれば考えるほど「現状の実装はなにかバグっているのでは?」という思いに駆られたのでissueを立てました。コメントは付いていないもののマイルスートンが.NET 9へ設定されました。本記事の現象が将来的に改善されることを祈っています。 → .NET 9では修正されていそうです!
  • .NET 8時点でもComImportAttributeを使っている箇所で、SYSLIB1096 Mark the type 'IShellItem' with 'GeneratedComInterfaceAttribute' instead of 'ComImportAttribute' to generate COM marshalling code at compile timeなどのMessageがエラー一覧に表示されます。ただ本記事に記載している理由や、QuickAction一発とはいかない移行作業の大変さから、#pragma warning disable SYSLIB1096で抑制しまくってもいいと思います。