###概要
COMのスレッドモデルについて理解を深めるためにサンプルを作製して実験していたのですが、実験結果を上手く考察出来ません。もやもやして気持ち悪いのでどなたか考察のお手伝いをしてくださいませんでしょうか。
###実験
VisualStudio2015のATLプロジェクトを使用して、MyATLTestClassというインプロセスサーバを作製しました。クラスのスレッドモデルには"アパートメント"(STA)を選択しました。
MyATLTestClassには、GetThreadIDというメソッドを追加しました。
C++
1// 現在のスレッドのIDを返す。 2STDMETHODIMP CMyATLTestClass::GetThreadID(LONG* id) 3{ 4 // Processthreadsapi.h のGetCurrentThreadIDを使用。 5 *id = GetCurrentThreadId(); 6 return S_OK; 7}
また、このCOMの動作を確認するため、WindowsFormsアプリケーション(C#)を作製しました。
参照から先程のCOMクラスを追加して、以下のようにコーディングしました。
C#
1 public partial class Form1 : Form 2 { 3 //ATLプロジェクトで作ったCOMクラス 4 ATLTestLib.MyATLTestClass myAtl = new ATLTestLib.MyATLTestClass(); 5 6 public Form1() 7 { 8 InitializeComponent(); 9 } 10 11 private void button1_Click(object sender, EventArgs e) 12 { 13 //ボタン1では、GetThreadIDをメインスレッドから呼ぶ。 14 int threadID = 0; 15 myAtl.GetThreadID(out threadID); 16 17 //メッセージボックスを使って表示 18 MessageBox.Show("ボタン1 ThreadID=" + threadID); 19 } 20 21 private void button2_Click(object sender, EventArgs e) 22 { 23 //ボタン2では、GetThreadIDをTask内(別スレッド)で呼ぶ。 24 int threadID = 0; 25 Task task = Task.Run(() => { myAtl.GetThreadID(out threadID); }); 26 27 //タスクの完了を待機してから、メッセージボックスを使って表示 28 task.Wait(); 29 MessageBox.Show("ボタン2 ThreadID=" + threadID); 30 } 31 }
###考察
メインスレッド上から呼び出そうが、Task内から呼び出そうが、COMオブジェクトのGetThreadIDは同じ値を返しました。これは、COMのスレッドモデルをSTAとしたことから来る挙動だと私は考えています。スレッドモデルをSTAとした場合、COMオブジェクトはそのオブジェクトが作成されたSTAスレッド(今回の実験で言うとWindowsFormsアプリのメインスレッド)に属します。STAスレッドに属するCOMオブジェクトのメソッドが別のスレッドから呼び出された場合、STAスレッドのメッセージループを利用してメソッド呼び出しが同期されるような機構が働くため、Task内での呼び出しにも関わらずGetThreadIDの実行はメインスレッドで行われたものと考えられます。
###考察の穴
上記の"考察"に穴を発見しました。
Task内でのGetThreadID()呼び出しがメッセージに変換されメインスレッドで処理されたと考えるのであれば、不思議な点があります。
Button2クリック時の処理がすべて完了しないことには次のメッセージは処理されないはず、すなわちGetThreadIDの処理命令が実行されないはずです。一方でButton2クリック時の処理はGetThreadIDの完了を待ち受けてから終了します。これはいわゆるデッドロックの形ではないでしょうか。
考察をすすめるうちに、そもそもButton2を押したときに固まらなかったことが不可解に思えてきました。なぜデッドロックしないのでしょうか?
###助けて欲しいこと
考察の穴を解消してください。
【追記】
「もしかするとCOMオブジェクト生成時に別スレッドが起動されその中でメッセージループが回っているからデッドロックが回避されているのではないか」との指摘がありましたので、メインスレッドのスレッドIDを(COMを経由せず)表示するコードをForm1に追加してもう一度実験を行いました。
追加したコードは下記のとおりです。
C#
1 private void button0_Click(object sender, EventArgs e) 2 { 3 //ボタン0では、 4 //AppDomain.GetCurrentThreadIDをメインスレッドから呼ぶ。 5 int threadID = AppDomain.GetCurrentThreadId(); 6 7 //メッセージボックスを使って表示 8 MessageBox.Show("ボタン0 ThreadID=" + threadID); 9 }
ボタン0を押したときに使用しているAppDomain.GetCurrentThreadIDはObsoleteのようです。
よって、ボタン0を処理しているときのスレッドIDを念のためデバッガ上の"スレッド"の表示においても確認しています。
結果として、以下3つが全て同一のスレッドIDを示すことが確認できました。
・メインスレッドのスレッドID
・メインスレッドから呼び出されたCOMのGetThreadIDが返すスレッドID
・別スレッドから呼び出されたCOMのGetThreadIDが返すスレッドID
つまりは、Taskを使用して別スレッドから呼び出されたCOMのGetThreadIDは確実にメインスレッドのメッセージループで実行されているようなので、Button2内のWait()でデッドロックしない理由については未だ納得できない状態です。
【追記2】
「もしかするとTask.Waitでメインスレッドが完全にブロッキングされる訳ではなくCOMメッセージの処理に限っては継続されるのではないか」という旨の指摘をいただきましたので、Trace.WriteLineを用いて処理順序を追ってみました。
具体的には、ボタン2クリック時の処理にTrace.WriteLineを何行か追加して、次のようにしました。
C#
1private void button2_Click(object sender, EventArgs e) 2{ 3 //ボタン2では、GetThreadIDをTask内(スレッドプール)で呼ぶ。 4 int threadID = 0; 5 Task task = Task.Run(() => 6 { 7 Trace.WriteLine("TaskStart"); 8 myAtl.GetThreadID(out threadID); 9 Trace.WriteLine("TaskEnd"); 10 }); 11 12 //タスクの完了を待機してから、メッセージボックスを使って表示 13 Trace.WriteLine("BeforeWait"); 14 task.Wait(); 15 Trace.WriteLine("AfterWait"); 16 MessageBox.Show("ボタン2 ThreadID=" + threadID); 17}
デバッグ実行してボタン2をクリックしたときの出力画面の様子は以下のとおりです。
TaskStart->BeforeWait->TaskEnd->AfterWait の順で処理が進行したようです。
"BeforeWait"が出力された直後にはtask.Wait();
が行われてメインスレッドがブロッキングされている。
にも関わらず、メインスレッドのメッセージループで実行されるべきmyAtl.GetThreadID(out threadID);
を含むタスクが無事終了していることが "TaskEnd" の出力から分かります。
ここで、myAtl.GetThreadID(out threadID);
の行がtask.Wait();
よりも前に処理されたかtask.Wait();
の中で処理されたかですが、中で処理されたと考えるほうがありえそうに思います。なぜならtask.Wait();
よりも前には他のメッセージの割り込みを許しそうなコードは一切ないからです。
かといってtask.Wait();
によるメインスレッドのブロッキング中に、COMのメッセージだけは選択的に処理されるという動作が自然かというと私は結構不自然だと思いました。
この挙動を説明するMSDNの記述を見つけたいところですね。
【追記3】
[追記2]の実験に幾つかの改良を加えました。
-
メインスレッドの
task.Wait();
到達よりもタスク内でのmyAtl.GetThreadID();
実行が確実に遅れるように、タスク内の最初で数秒スリープするようにした。 -
デバッグ出力文字列にスレッドIDを添えるようにした。
-
COMメソッド内部でもデバッグ文字列を出力するようにした。
具体的には、まずCMyATLTestClassのGetThreadID()の中身を以下のように変更しました。
c++
1STDMETHODIMP CMyATLTestClass::GetThreadID(LONG* id) 2{ 3 *id = GetCurrentThreadId(); 4 5 CString str; 6 str.Format(_T("InGetThreadID ThreadID=%d\n"), *id); 7 OutputDebugString(str); 8 9 return S_OK; 10}
また、このCOMを利用するWindowsForms側のButton2クリック時の処理を以下のように変更しました。
c#
1private void button2_Click(object sender, EventArgs e) 2{ 3 //ボタン2では、GetThreadIDをTask内(スレッドプール)で呼ぶ。 4 5 int threadID = 0; 6 Task task = Task.Run(() => 7 { 8 //3秒待機すれば、 9 //メインスレッドは間違いなくWait()に突入しているだろう。 10 Thread.Sleep(3000); 11 12 Trace.WriteLine("TaskStart ThreadID=" + AppDomain.GetCurrentThreadId()); 13 myAtl.GetThreadID(out threadID); 14 Trace.WriteLine("TaskEnd ThreadID=" + AppDomain.GetCurrentThreadId()); 15 }); 16 17 //タスクの完了を待機してから、メッセージボックスを使って表示 18 Trace.WriteLine("BeforeWait ThreadID=" + AppDomain.GetCurrentThreadId()); 19 task.Wait(); 20 Trace.WriteLine("AfterWait ThreadID=" + AppDomain.GetCurrentThreadId()); 21 MessageBox.Show("ボタン2 ThreadID=" + threadID); 22}
Button2をクリックすることに依るデバッグ出力の結果は以下のように成りました。
(折角なので、紹介して頂いたDebugViewを使用しています)
順序はこうです。
1. "BeforeWait"がメインスレッド(ID=6128)で出力された。
2. "TaskStart"が別スレッド(ID=7964)で出力された。
(タスクの最初に3秒スリープしたことで、1と2の出力には大きな時間差があります。この間にメインスレッドは確実にtask.Wait();
に突入しているでしょう。)
3. "InGetThreadID"がメインスレッド(ID=6128)で出力された。
(メインスレッドはtask.Wait();
でブロックされているはずなのにメインスレッドでCOMのメソッドが実行されています)
4. "TaskEnd"が別スレッド(ID=7964)で出力された。
5. "AfterWait"がメインスレッド(ID=6128)で出力された。
(このタイミングでようやくメインスレッドのブロッキングは解除されています)
つまりこの実験から何が分かるのかというと、task.Wait();
による待ちでスレッドの処理が完全にブロックされるのではなくて、別スレッドからSTA-COMメソッドを呼び出したときに発生するメッセージは特別扱いで処理できるようだということです。
追伸:皆さんからコメントで頂いたURLはまだ読んでいないので、今日明日のうちに目を通してみます。

回答5件
あなたの回答
tips
プレビュー
バッドをするには、ログインかつ
こちらの条件を満たす必要があります。
2016/09/21 22:26