はじめに
12月19日、中部のCLR系勉強会であるCenterCLRの番外編?的な企画であるCenter CLR ChanlkTalkにて、ありがたいことに私のリクエストに応えていただいて、COMの概念を解説していただきました。
本記事ではそこで学んだことと、私の考えをまとめ、整理しておきたいと思います。。
とはいえ私はCOM全盛期?にお仕事をしていたわけではなく、数時間COMの勉強をしただけなので必ず間違ったことを書いていることがあるかもしれません。間違いを見つけた場合は@garicchiまでお知らせいただけるとありがたいです。
もしかしなくても私より良いまとめをされている主催のkekyo先生のページを見たほうがいいかもです
.NetアプリケーションはCPUに関係なく動く
まず、COMというものは現在の.NetFrameworkのランタイム環境であるCLRが生まれる前の概念であるということを理解する必要があります。
現在の.NetFramework上ではC#やVisualBasic、C++などの異なるプログラミング言語で記述したプログラムがx86 CPUだろうが、x64だろうが、ARMだろうが異なるCPUであっても共通の動作をします。
例えばWPFなどでアプリケーションで作ったアプリケーション(exe)が64bitのWindowsだろうが、32bitのWindowsだろうが関係なく共通の動作をするあたりなどがそうです。
僕みたいな初めて作ったWindowsのプログラミング環境が.Netアプリケーションで、CPUアーキテクチャをあまり理解していない場合、これらのことは一見普通のことに見えますが実際にCPUのアーキテクチャを考えるとものすごく不思議なことでもあります。
CPUの基本的なアーキテクチャ
室蘭工業大学さんのサイトからCPUアーキテクチャの図をお借りすると、計算機は基本的にこのような構造になっています。
http://par.mcr.muroran-it.ac.jp/~ohkama/2010comp2/lec2/lec2/node50.html
大学の授業などでCPUアーキテクチャを学んだ方はこの図でどのようにデータが流れ、どのように計算が行われるかが理解できるはずです。
ではCPUごとに違う動作をするのはどこかというと、バスを流れるデータのbit数が一番顕著だと私は考えます。
例えば32bitCPUの場合メモリ上には32桁のデータが並び、CPU1クロックでバスを通って処理されるデータのbit数は32bitです。
対して64bitCPUの場合はメモリ上には64桁のデータが並び、CPU1クロックで64bitのデータがバスを通ります。
1クロックで処理されるデータが違うということはデータ内であらわされるデータの意味も変わります。例えば計算する命令をあらわすOPコードなどはCPUによって変わります。*1
ではプログラムのコンパイル結果であるバイナリデータはどのようになっているかというと、2bitデータの羅列となっているため、これを異なるCPU間で統一することはできません。
なので基本的には、32bitCPUなら32bit用、64bitCPUなら64bitCPU用にバイナリデータをコンパイルし、別々のプログラムとして存在させます。
Common Language Runtime
ではなぜ現在の.Netアプリケーションは異なるCPU上でも共通の動作をするかというとそれはCLRのおかげであると言えます。
.Net上のアプリケーションはC#やVB、C++などの異なるプログラミング言語で記述されたプログラムを、バイナリではなく中間言語(IL)と呼ばれるものに一旦コンパイルします。
なので.Netで作られたexeはこの中間言語が記述されていて、中間言語はILSpyなどのアプリケーションで見ることができます。
そしてコンパイルされたexe(中身はIL)は、ダブルクリックなどで実行されたとき、CPU上でプログラムとして動いている仮想的なCPUの上で動作します。
この仮想CPUは32bit CPUや64bit CPUなど、異なるCPUの上に共通に乗っているCPUであり、それぞれのCPUの命令に合わせた動きをします。
つまりこのように中間言語と仮想CPUを用いることによってプログラミング言語間の差異と、CPU間の差異を吸収したものをランタイムといい、.NetFramework上で実現されるランタイムをCLR(Common Language Runtime)と呼びます。
このような基盤があるおかげで、C++で書いた.Netなライブラリ(dll)がC#から利用できたりなどが実現できています。
CLR上で実現されるアプリケーションは中間言語を実行時に変換しながら仮想CPUで動かすため、一般的にネイティブコンパイル(中間言語ではなくバイナリにコンパイル)されたアプリケーションより動作が遅いとされています。
CLR登場前のCPU差異吸収方法
さて、ではこのようなCPUやプログラミング言語の差異を吸収してくれるランタイムが登場する以前ではどのようにしてプログラムの共通化を行っていたのでしょうか。
その答えとして、MicrosoftはComponent Object Modelというアーキテクチャを提案していたわけです。
COMとはCPUなどから独立したプログラム部品といえますが、このように足し算を行うプログラムという部品を異なるCPUやコンパイラから使えるようにした部品という感じです。
とにかくこのころは、ソフトウエアの再利用を目的として、異なる実行環境でも依存せずに動くコンポーネント(部品)をつくることで共通化を行っていたようです。
このような仕組みをしっかりとつくったおかげで、いまだに.NetなアプリケーションからCOMでできたオブジェクトを参照できるというわけですね。
ではどのようにして異なるCPUにおいても共通の動作をする部品をつくるかというと、COMではこのように共通のインターフェースを提供することでCPUごとの差異を吸収しています。
COMはCPUの差異以外にも、コンパイラの違いによる差異(gccとvc++など)、プログラミング言語による差異(CとC++)などもインターフェースを提供することで共通化することができます。*2
しかしただ単にインターフェースを提供するだけで差異を吸収できるかと言ったらそんなうまい話はなく、そこにはさまざまな問題が存在します。
メソッド呼び出しの問題 – メソッド特定方法
例えば先ほどの例で、足し算を行うメソッドを共通化しようと思うと、さまざまな問題が生じます。
ライブラリはコンパイルすることで関数を一意に特定するシンボルを吐き出しますが
gccで作ったライブラリと、vc++で作ったライブラリでは出力されるシンボルが違います。
http://www.kekyo.net/2015/12/21/5510。
また、コンパイラだけでなく、言語間によっても出力されるシンボルが違います。
C++はCと違ってメソッドのオーバーロードや名前空間などの概念がありますが、それらを表現するためには出力された関数シンボルに一定の記号を与えて一意にしなければなりません。
その影響もあって、C++などで記述されたプログラムのシンボルはこのように複雑なシンボルとなります。
http://www.kekyo.net/2015/12/21/5510
世の中にはそのようなシンボル変換規則としてcdeclやpascal形式などがあるそうですが、それらは処理系依存であり、シンボルではメソッドを一意に特定できず、それ以外の方法でメソッドを一意に特定する必要があります。
COMでは関数シンボルではなく、vtableというものを用いてメソッドの特定を行うそうです。
vtableとは「仮想関数へのポインタのテーブル」だそうですが要するにコンポーネントの提供するメソッドへのポインタの一覧が入っているテーブルが提供されるということだと思われます。
関数へのポインタが提供されるということは、シンボルを用いなくても関数のあるメモリ上のアドレスを一意に特定できるため、関数を呼び出すことができます。
仮想関数のテーブルと呼ばれている理由としては、コンポーネントのインターフェースが提供する関数だから仮想関数であると思われます。
メソッド呼び出しの問題 – クラス特定方法
メソッドはvtableによって一意に特定できるようになったが、それらのメソッドを集約するクラス(COMコンポーネントと同義?)がどのパスにあるdllに実装されているのかを知る方法がないとまだメソッドを呼び出せないわけです。
そこでCOMでは、COMオブジェクトをインストールするときにレジストリにCOMコンポーネントを一意に特定するIDであるClassIDと、実装があるdllのパスを登録します。
そうすることによって、あるClassIDのCOMコンポーネントを呼び出したいとき、レジストリにあるパスにあるdllを参照し、メソッドを特定します。
これで呼び出したいメソッドを一意に特定できます。
メソッド呼び出し問題 – 呼び出し規則
メソッドを一意に特定できても、呼び出す方法も処理系依存です。
例えばメソッドの引数をどのようにスタックに積めば正しくメソッドが動くのかもわからず、その規則を統一する必要があります。
COMではstdcallという呼び出し規則に統一することで呼び出し規則による依存問題を回避しているようです。
インターフェースの型認識問題
先ほどの例で挙げた、「足し算interface」ですが「足し算interfaceを知る」処理も処理系に依存します。
より正確に言うと、interfaceの型キャストが処理系に依存するそうです。
そこで「特定のCOMコンポーネントの提供してくれるインターフェースを知る」ために、COMではIUnknownインターフェースを提供することでこの問題に対処しています。
すべてのCOMコンポーネントはIUnknownインターフェースを実装します。
IUnknownインターフェースにはQueryInterfaceというメソッドが存在します。
(AddRefとRelease)については後に解説します。
アプリケーションは使いたいCOMコンポーネントに対し、QueryInterfaceというメソッドを実行します。
そのとき、COMコンポーネントは処理系に応じて正しく型キャストしたインターフェイスポインタを返します。
そうすることによってアプリケーションは足し算を行うコンポーネントが提供するインターフェースである足し算Interfaceを正しくキャストされた形で認識できます。
処理系依存してしまうインターフェースの型キャストをIUnknownインターフェースによって差異吸収することができました。
生存時間の問題
COMの世界にはガベージコレクションなどの概念はないため、C++などと同じように自分で確保したメモリはちゃんと開放しなければメモリリークしてしまいます。
このCOMコンポーネントメモリ上に確保されてから、開放されるまでの時間を生存時間とすると、COMコンポーネントもちゃんと生存時間管理をしなければなりません。
その生存時間管理を行ってくれるのがIUnknownインターフェースのAddRefとReleaseメソッドです。
IUnknownを実装するCOMコンポーネントでは、内部に参照カウンタを保持しておき、AddRefメソッドの実装では参照カウンタを+1する処理を記述します。
逆にReleaseメソッドの実装では参照カウンタを-1する処理を記述します。
これらのメソッドはCOMコンポーネントが参照されるたびにAddRef、参照が外れるたびにReleaseされます。
そしてReleaseがよびだされたとき、参照カウンタが0になるとCOMコンポーネントが確保しているメモリを破棄するような実装をします。
こうすることでCOMがいかなるところから呼ばれたとしても、呼ばれているうちは生存し続け、完全に呼ばれなくなった瞬間に自殺するようにできます。
COMの世界はこのような参照カウンタ方式で生存時間を管理しています。
概念上の実装例
以上のIUnknownインターフェースを概念上実装した例として、Kekyo先生のサイトにC#での実装例があるため、それを見ると大体イメージがつかめると思います。
以上でCOMの基本的な概念の解説は終わりになります。次はCOMをマルチスレッドで使うときの問題について。
COMのマルチスレッド
COMをマルチスレッドにするとき、注意しなければいけない概念として、ワーカースレッドからのUIスレッドの操作があります。
マルチスレッドな.Net系のアプリケーションを作ったことがある人は知っていると思いますが、ワーカースレッドからUIスレッドは直接操作ができません。
たとえばWPFで別スレッドからUIスレッドを操作するような処理を書くとこのようになります。
COMにも同様の概念が適用されます。
マルチスレッド上に置かれるCOMにおいて、アパートメントという概念と、その中のSTA(シングルスレッドアパートメント)とMTA(マルチスレッドアパートメント)の2つの概念が重要になってきます。
STAとはその名のとおり、シングルスレッドなアパートメントで1つのSTAはシングルスレッドとして動作します。
UIスレッドもほぼSTAです。(MTAで作れるとかなんとかちらっと聞いたような)
それに対して、MTAとはMTAの中に複数のスレッドを置くことができるアパートメントです。
そしてCOMでマルチスレッドを行う場合、COM自身がどちらのアパートメントに所属するのかを宣言する必要があります。
要するにCOMでマルチスレッドを行う場合、COMが属するアパートメントを自身で指定する必要があるということらしいです。
そしてアパートメント間、STAとSTA間、STAとMTA間で通信を行うには「マーシャリング」という概念が必要になるそうです。
MTAとMTA間では普通に通信できるらしいです。
私は実際にCOMアパートメントを行ったことがあるわけではないので詳しくはkekyo先生のページで
実際にCOMオブジェクトを作る場合
では実際にCOMオブジェクトを作る時に、いちいちIUnknownインターフェース実装から始めるというとそんなことはなく(できないわけではないがすごくめんどくさそう)
ATL(Active Template Library)というライブラリを使います。
ATLプロジェクトは新規プロジェクトのVisualC++から利用できます。
COM関連技術
OLE
OLE(Object Linking and Embedding)とはアプリケーション間で通信する方式らしく、COMの派生形かとおもったらe-wordsを見るとOLEから派生して標準規格としたものがCOMらしい。
簡単な例としてはWordのオブジェクトの挿入をするとExcelなどが張り付けられるがその技術らしいです。
Active X
OLEがソフトウエア上のオブジェクトと通信するなら、ActiveXはインターネット上のオブジェクトと通信する方式らしい。
例としてはInternetExplorerのPDF表示なんかがそれにあたるらしいです。
そしてWindowsRuntimeへ
最後にWindowsRuntimeの話になります。
CLRのおかげで複数言語、複数CPUの差異を吸収した.NetFrameworkが登場し、WindowsRuntimeはその技術を受けついだ.Netで動くものかと思ったら実はそんなことはないそうです。
あくまでWindowsRuntimeは「.Netそっくりに書けるようにしたCOMの進化形」らしく、WindowsRuntimeを使ったUWPはネイティブ実行されます。
WindowsRuntimeComponentはIUnknownインターフェースだけでなく、IInspactableインターフェースの実装を必須とします。
WindowsRuntimeから.Netなクラスを呼び出す時
WindowsRuntimeからもいくつかの.Netframeworkのクラスを使うことができますがどうやらWindowsRuntimeから.netなクラスを呼び出すときなRCWが介入するようです。
RCWとはCLR環境からCOMコンポーネントを呼び出す技術としてあるものですが、WindowsRuntimeからは逆向きに、COMからRCWを介して.Netなクラスを操作するそうです。
だからWindowsRuntimeから.Netなクラスを操作すると少なからずオーバーヘッドは起きるとのこと。
まとめ
- COMとはCLR以前のソフトウエア再利用を目的としたコンポーネント化技術であり、衰退しつつある(らしい)
- CLRの登場によって、衰退するかと思われたCOMですが、WindowsRuntimeに姿を変えて再登場
- .NetframeworkとWindowsRuntimeのクラスを一緒にしてはいけない。似せてるだけ
- だからCLR環境よりはWindowsRuntimeは早い(気がする)
謝辞
私のCOM理解のためにご協力くださったKekyo先生とCenterCLRコミュニティメンバーのみなさんに感謝します。
*1 現代のCPUアーキテクチャではCPUbit数(論理バス幅)と実際の物理バス幅は必ずしも一緒ではないようです
*2 COMはCPUの差分を吸収したDLLを作れるというわけではなくあくまで共通の作り方で異なるCPUのDLLを作れるという方法のようです