COMのアパートメント (5) CoInitializeExは初期化ではない

(追記:2020/5/26)

COMについて、細々とですがこのページに誘導されてくるのが分かっています。もっとわかりやすく包括的に書いたページがありますが、残念ながら、なぜか検索エンジンのレートから外されているため、ここで案内しておきます。「ChalkTalk CLR – COMのすべて」 を最初に見ることをお勧めします。


さて、そろそろ架空のコードでは現実とどう違うのかわからなくなってくるので、現実のコードがどうなるのかを見ていくことにする。
COMで最初にお世話になるのは、CoInitializeとCoInitializeEx、CoUninitialize APIだ。
前回までの解説からなんとなくイメージを持ってもらえたかも知れないが、これらのAPIは、現在のスレッドにアパートメント属性を設定・解除する。
CoInitializeはCoInitializeExのバリアントなので、CoInitializeExで説明する。
CoInitailizeEx API

HRESULT hr = CoInitializeEx(0, COINIT_MULTITHREADED);

// COMに関する様々な処理...

CoUninitialize();

CoinitializeExの第一引数は使われていないので0(ないしNULL)を指定する。
第二引数は、初期化フラグを指定し、COINIT列挙値の値を使用する。ほとんどの場合、ここにはCOINIT_MULTITHREADEDかCOINIT_APARTMENTTHREADEDのどちらかを指定する。
COINIT_MULTITHREADEDにした場合、現在のスレッドのアパートメント属性として、MTA(マルチスレッドアパートメント)に属するように設定する。
COINIT_APARTMENTTHREADEDにした場合、STA(シングルスレッドアパートメント)に属するように設定する(そして、CoInitializeを使用すると、STA固定となる)。

マルチスレッドアパートメントに設定すると、そのスレッドは、COMコンポーネントとのやり取りで発生するすべてのスレッド同期作業を、自分で行うと宣言したことになる。
シングルスレッドアパートメントに設定すると、そのスレッドは、COMコンポーネントとのやり取りで発生するすべてのスレッド同期作業を、COMが背後で面倒見てほしいと宣言したことになる。

これらの違いについては次回に譲る。

これでCOMに関する下地が出来た訳だが、巷のコードを見ているとアパートメント選択以前の勘違いが多いことに気が付く。つまり、このAPIの呼び出しは、呼び出し元のスレッド(カレントスレッド)に対してアパートメントの設定を行うのであるが、まるでプロセスワイドにCOMの初期化が行われているかのような使い方だ。

たとえば、DLLによるライブラリを作成していたとする。このDLLは外部に以下のような公開エントリポイントを持つとする。

__declspec(dllexport) HRESULT __stdcall InitializeLibrary();
__declspec(dllexport) HRESULT __stdcall DoSpecialMakeMoney(const ULONG multiply);
__declspec(dllexport) HRESULT __stdcall TerminateLibrary();

このライブラリを初期化するには、InitializeLibraryを呼び出す。内部の実装(DoSpecialMakeMoney)では別のCOMコンポーネントを呼び出す予定なので、InitializeLibrary内でCoInitializeExを呼び出している。しかし、InitializeLibraryを呼び出したスレッドと、DoSpecialMakeMoneyを呼び出したスレッドは別のスレッドかも知れない。同じく、TerminateLibraryもまた、別のスレッドである可能性がある。

すると、CoInitializeExやCoUninitializeを呼び出す事は何の意味をも持たなくなってしまう(CoInitializeExは、「現在のスレッド」にアパートメントを「設定」するのだから)。

それどころか、InitializeLibraryを呼び出した時点で、すでにCOMの初期化が行われているかもしれない。しかもその時点のスレッドは、STAであるべきか、MTAであるべきかは、呼び出し元のみぞ知るわけであり、ライブラリの中では知りようがない(無理やり変更を試みると、RPC_E_CHANGED_MODEが返される)。

仮に、InitializeLibrary内でCoInitializeExのエラーコードを適切にハンドリングしたとする(RPC_E_CHANGED_MODEのほかにS_FALSEが返される可能性がある。これはすでに同じアパートメント属性で初期化されていることを示す)。これでエラーは回避できるかもしれないが、そもそも呼び出し元のスレッドはCOMの初期化を行っておらず、InitializeLibrary呼び出しの「後」で、自力でCoInitializeExを呼び出していたとしたらどうだろうか。その実装には関与できないかもしれず、従ってここで示したようなエラーの回避が行われているかどうかわからず、結局のところ、必要とされる正しいアパートメント属性に設定できたのかどうかもあやふやだ。

つまり、このようなコードは完全な「お手付き」と言える。最も、COMの初期化を行ってあげようという親切心は分からなくもないが…

堅牢なライブラリに仕上げるためには、まず、独自にCoInitializeEx(とCoUninitialize)の呼び出す事を止めることだ。また、CoInitializeExが呼び出されていない場合は、DoSpecialMakeMoneyの呼び出しに対して、COMの初期化が行われていない事を示すCO_E_NOTINITIALIZEDを返す(あるいはカスタムのエラーコードや、C++かつ環境限定なら例外をスローしても良い)。

CoInitializeExが呼び出されているかどうかは、COMの何らかのAPIを呼び出せばわかる。つまり、DoSpecialMakeMoneyの内部で実際にCOM APIを使用した時点でCO_E_NOTINITIALIZEDを検出し、異常処理を行えばよい。
また、呼び出し元のコードを記述する際は、スレッドを生成したら、速やかにCoInitializeExでアパートメント属性を確定しなければならない。生成したスレッドがどのアパートメントに属するのが適切なのかは、スレッドを生成した者にしかわからないからだ。

#筆者は過去にこのような不適切な実装を行っている、サードパーティ製のコンポーネントを目撃した。
#実際に業務に使用していたため、非常に困り、最終的にはサードパーティに「直させた」経験があるが、問題が解消するまでに、結局3か月近く掛かった…
#(技術的な問題点をお客様に理解してもらい、さらにサードパーティベンダーにも周知してもらい、修正され、解消されたことを確認するまでに掛かった時間)
#ベンダーは技術の内容をよく理解して使ってほしいと思う。

.NETのメインエントリポイントが、[STAThread]や[MTAThread]属性を使ってアパートメントを簡単に指定できるようにしているのは、このような理由によるものと推測している。実際、Windows FormsやWPFアプリケーションをウィザードで生成すると、自動的に[STAThread]が適用される。

勿論、属性を指定していなくても、Thread.CurrentThread.SetApartmentState()を呼び出せば設定できる(内部ではおそらくCoInitializeExを呼び出しているのであろう)が、アパートメント初期化のベストプラクティスは、スレッド生成と同時に設定してしまうというものだ。