読者です 読者をやめる 読者になる 読者になる

Control.Invoke() と Control.BeginInvoke() での例外処理

開発 .NET C#

はじめに

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
--- 内部例外スタック トレースの終わり ---
場所 mttest.ControlEx.InvokeEx(Control ctl, Delegate act, Object
args) 場所 Z:\Projects\mttest\Form1.cs:行 69
場所 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を使う際には例外処理に気をつけましょう、ということで。

*1:WPF使ったことなし。

*2:普通にWindows APIでウィンドウを作ると、メッセージポンプを回すスレッドはワーカーでも回せます。例えばMFCのCWinThreadクラスはUIスレッドを作ることが出来ます。

*3:もちろん呼んでもいい。

*4:VS2010/.NET 4.0のとき。

*5:この拡張メソッド、呼んでる最中にウィンドウ消えたりするとかそいういうコトへの対策はしてないです。