はじめに
C#のWinForms*1はUIスレッドを一つしか持てず*2、そのUIスレッドに対して他のスレッドからちょっかいをかけようとするとSystem.InvalidOperationException例外が飛びます。
private void button1_Click(object sender, EventArgs e) { textBox1.Text = "[開始]"; Task.Factory.StartNew(() => { Thread.Sleep(5000); textBox1.Text += "[5秒経ったよ!]"; // ここで例外が発生する }); }
そこで、Control.InvokeないしControl.BeginInvokeを使ってUIスレッドに該当の処理をさせなさい、というのは有名な話。
今回はControl.Invokeを使ってハマった話。
MSDNなどの説明
- Control.Invoke
- コントロールの基になるウィンドウ ハンドルを所有するスレッド上で、デリゲートを実行します。
- Control.BeginInvoke
- コントロールの基になるハンドルが作成されたスレッド上で、非同期的にデリゲートを実行します。
Control.InvokeはUIスレッドの処理を止めて直ちに処理を行い、Control.BeginInvokeはUIスレッドが適当に実行するという違いがあるようです。この辺はみんな大好きstackoverflowのWhat's the difference between Invoke() and BeginInvoke()あたりに書いてあります。ちなみに先述のstackoverflowやMSDNにも書いてありますが、Control.BeginInvokeは対応するControl.EndInvokeを呼ぶ必要がありません*3。
ちなみに、呼び出されるデリゲート直前のコールスタックを眺めると、Invokeの時もBeginInvokeの時もデリゲートを叩いているのは「System.Windows.Forms.Control.InvokeMarshaledCallbackDo(System.Windows.Forms.Control.ThreadMethodEntry tme) + 0x7a バイト」*4だったのでどちらも同じコードから呼ばれてるようです。
まぁここまではいいんですな。問題はこのあと。
デリゲート内で例外が飛んだ時どうなる
UIスレッドをつつく際に例外が飛ぶこともあるでしょう。しかし、ここに微妙な罠があります。例えば、こんなコードを走らせてみます。
private void button1_Click(object sender, EventArgs e) { Task.Factory.StartNew(() => { Debug.WriteLine("Start Invoke"); Invoke((MethodInvoker)(() =>{ // BeginInvoke((MethodInvoker)(() =>{ throw new MyException("hoge"); })); }); }
public class MyException : Exception { public MyException(string value) : base(value) { } }
Control.Invoke()
Start Invoke
'mttest.MyException' の初回例外が mttest.exe で発生しました。
'mttest.MyException' の初回例外が System.Windows.Forms.dll で発生しました。
Control.BeginInvoke()
Start Invoke
'mttest.MyException' の初回例外が mttest.exe で発生しました。
'mttest.MyException' のハンドルされていない例外が mttest.exe で発生しました。追加情報: hoge
ハンドルされない例外になるときと、ならない時がある。なんぞそれ。
そこで、Control.InvokeとControl.BeginInvokeの例外に関する文言を並べてみます
Control.Invoke
呼び出し中に発生する例外は、呼び出し元まで反映されます。
Exceptions that are raised during the call will be propagated back to the caller.
Control.BeginInvoke
Delegate メソッド内の例外は、トラップされていないと見なされ、アプリケーションのトラップされていない例外ハンドラーに送られます。
Exceptions within the delegate method are considered untrapped and will be sent to the application's untrapped exception handler.
要するに、Control.Invokeは同期的に実行されるので、例外を取れるはずだから自分でハンドルしろってことなのか。未ハンドル例外ハンドラで受け止めてくれないのは何故だろう。
まぁそれはそれとしてcatchしてみましょう。
private void button1_Click(object sender, EventArgs e) { Task.Factory.StartNew(() => { Debug.WriteLine("Start Invoke"); try { Invoke((MethodInvoker)(() => // 30行目 { throw new MyException("hoge"); })); } catch (MyException ex) { Debug.WriteLine("exception:" + ex.ToString()); } }); }
イミディエイトウィンドウ見てみます。
Start Invoke
'mttest.MyException' の初回例外が mttest.exe で発生しました。
'mttest.MyException' の初回例外が System.Windows.Forms.dll で発生しました。
'mttest.vshost.exe' (マネージ (v4.0.30319)): 'C:\Windows\Microsoft.Net\assembly\GAC_MSIL\mscorlib.resources\v4.0_4.0.0.0_ja_b77a5c561934e089\mscorlib.resources.dll' が読み込まれました
exception:mttest.MyException: hoge
場所 System.Windows.Forms.Control.MarshaledInvoke(Control caller, Delegate method, Object args, Boolean synchronous)
場所 System.Windows.Forms.Control.Invoke(Delegate method, Object args)
場所 System.Windows.Forms.Control.Invoke(Delegate method)
場所 mttest.Form1.b__0() 場所 Z:\Projects\mttest\Form1.cs:行 30
Invoke以降のスタックトレースが消えてます。例外が発生した場所そのものは何処行ってもうたんや。
ちなみに、例外をInvoke内で受け止めていればちゃんと例外を投げた場所はコールスタックに残ってるので、Invoke外へ例外を投げ出す際にコールスタックが消えてしまうようです。つまり、Invoke内での例外オブジェクトを保存した上で、Invoke終了後にそれをもう一度投げればいいことになるはず。
ということで、拡張メソッドをこねくりだしてみる*5。
static class ControlInvokerEx { public static object InvokeEx(this Control ctl, Delegate act) { return InvokeEx(ctl,act,null); //48行目 } public static object InvokeEx(this Control ctl, Delegate act,params object[] args) { if (!ctl.InvokeRequired) return act.DynamicInvoke(args); Exception exp = null; var ret = ctl.Invoke((MethodInvoker)(() => { try { act.DynamicInvoke(args); //61行目 } catch (Exception ex) { exp = ex; } })); if (exp != null) throw new System.Reflection.TargetInvocationException(exp); //69行目 return ret; } }
これを使って先述のコードを書き換えてみます
private void button1_Click(object sender, EventArgs e) { Task.Factory.StartNew(() => { Debug.WriteLine("Start Invoke"); try { this.InvokeEx((MethodInvoker)(() => Func())); //28行目 } catch (Exception ex) { Debug.WriteLine("Exception:" + ex.ToString()); } }); } private void Func() { throw new MyException("hoge"); //39行目 }
さぁ実行だ
Start Invoke
'mttest.MyException' の初回例外が mttest.exe で発生しました。
'mttest.vshost.exe' (マネージ (v4.0.30319)): 'C:\Windows\Microsoft.Net\assembly\GAC_MSIL\mscorlib.resources\v4.0_4.0.0.0_ja_b77a5c561934e089\mscorlib.resources.dll' が読み込まれました
'System.Reflection.TargetInvocationException' の初回例外が mscorlib.dll で発生しました。
'System.Reflection.TargetInvocationException' の初回例外が mttest.exe で発生しました。
Exception:System.Reflection.TargetInvocationException: 呼び出しのターゲットが例外をスローしました。 ---> System.Reflection.TargetInvocationException: 呼び出しのターゲットが例外をスローしました。 ---> mttest.MyException: hoge
場所 mttest.Form1.Func() 場所 Z:\Projects\mttest\Form1.cs:行 39
場所 mttest.Form1.b__1() 場所 Z:\Projects\mttest\Form1.cs:行 28
--- 内部例外スタック トレースの終わり ---
場所 System.RuntimeMethodHandle.InvokeMethod(Object target, Object arguments, Signature sig, Boolean constructor)
場所 System.Reflection.RuntimeMethodInfo.UnsafeInvokeInternal(Object obj, Object parameters, Object arguments)
場所 System.Delegate.DynamicInvokeImpl(Object args)
場所 System.Delegate.DynamicInvoke(Object args)
場所 mttest.ControlEx.<>c__DisplayClass1.b__0() 場所 \Projects\mttest\Form1.cs:行 61 args) 場所 Z:\Projects\mttest\Form1.cs:行 69
--- 内部例外スタック トレースの終わり ---
場所 mttest.ControlEx.InvokeEx(Control ctl, Delegate act, Object
場所 mttest.ControlEx.InvokeEx(Control ctl, Delegate act) 場所 Z:\Projects\mttest\Form1.cs:行 48
場所 mttest.Form1.b__0() 場所 Z:\Projects\mttest\Form1.cs:行 28
Delegate.DynamicInvokeを使ってるので、そのぶんの例外も含まれてますが、例外を投げ出したForm1.Func()内の行数までちゃんと取れてます。やったね。未ハンドル例外ハンドラを叩けないのはどうしたもんかという感じですが。
拡張クラスはgistに貼り付けておきました。まぁ動くと思いますが、未保証です。
https://gist.github.com/walkure/62ef05e33016b9439834
例によって艦これ専ブラいじってる時にハマりました。いろいろ考えてControl.BeginInvokeで揃えましたが、Control.Invokeを使う際には例外処理に気をつけましょう、ということで。