VBのデリゲートやラムダ式をC++のDLLに渡す時に色々注意があるお話

 関数の中で非同期処理を行い、その終了や結果を呼び出し元にお知らせしたい。そういう事って非常に良くあります。作っているアプリがVBやC#内だけで完結しているのであれば、デリゲートとかラムダ式を関数に渡せば、関数内で任意のタイミングでそれをコールバックできるので、呼び出し元は問題無くお知らせを受け取る事ができます。

 しかし時にC++で作ったDLL内で非同期処理をして、その結果をVBやC#に返したいという事があります。それを実現するにはDLLにデリゲート(ラムダ式)を渡さなければなりません。実はそれ自体は簡単にできるのですが、VBやC#特有の注意点が色々とあり、それをちゃんと回避しないとアプリが即落ちします

 今回は簡単なサンプルを作りながら、VBのデリゲート(ラムダ式)をC++のDLL内で呼ぶ一連の流れを実現してみましょう。開発環境はVisual Studioです。なるべく最新版を使って下さい。サンプルはVBですが、ほとんどの個所はC#にも読み替えられます。

C++のDLL側の実装

 まずはDLL側の実装方法です。新規プロジェクトの「ダイナミックリンクライブラリ(DLL)」でDLL作成環境を立ち上げます:

 プロジェクト名がDLLの名前になるので、本番では適切な物にして下さい。今回はサンプルなので「CallbackDll」にしておきます。

 立ち上げたら「CallbackDll.cpp」を新規で追加します。そこにVB側から呼ばれる事になるfunc関数を以下のように記述しましょう:

#include <stdint.h>
#include <thread>

// コールバック関数定義
typedef void( *ResultCallback( int32_t val, const char* text ) );

void func( int val, ResultCallback callback ) {

	std::thread thread( [val, callback]() {
		// 3数秒止める
		std::this_thread::sleep_for( std::chrono::seconds( 3 ) );

		// 値に100足してコールバック
		callback( val + 100, "Hello World!" );
	} );
	thread.detach();
}

 func関数は非同期関数をエミュレートしています。まずスレッドを立ち上げて、その中で3秒待って、その後引数に渡されたcallbackに結果を渡しています。関数自体は呼ばれると即座に返ります。

 ポイントはResultCallback型です。これは上にある引数と戻り値を持った関数ポインタをtypedefしています。実はVBやC#のデリゲート(ラムダ式)はC++側では単純な関数ポインタとして受け取る事ができます。もちろん型を再定義せずに関数ポインタをfunc関数の引数にゴリっと書いても良いのですが…関数ポインタの型って見通しが超悪いんですよねぇ(^-^;。なので僕はtypedefする事をお勧めします。

 最後にdllにfunc関数を公開するためにモジュール定義ファイル(CallbackDll.def)を追加します:

ソリューションエクスプローラーの適当な所で右クリック→[追加]→[新しい項目]で上のダイアログが出るので、[コード]タブ内にある「モジュール定義ファイル」を選択して適切な名前で追加して下さい。サンプルではCallbackDll.defとします。

 CallbackDll.def内に以下を記述します:

LIBRARY CallbackDll
	EXPORTS
		func

これでfunc関数がCallbackDll.dll内に公開されました。後はVBやC#でこれを呼ぶだけです。

VBクライアント側の実装

 ではVB側でCallbackDll.dll内のfunc関数を呼び出してみましょう。適当なFormアプリケーションの作成環境を新規で立ち上げ、こんな感じのフォームを作りましょう:

 Runボタンを押したら「100」がfuncのvalとして渡されて、結果が下のセルに出る。そんなイメージです。

 RunボタンをダブルクリックしてForm1.vbにハンドラを作ってもらい、中身を記述します。まずはコアとなる所です:

Public Class Form1
    ' コールバックデリゲート
    Delegate Sub ResultCallback(val As Int32, text As IntPtr)

    ' DLLインターフェイス
    <DllImport("CallbackDll.dll", CharSet:=CharSet.Ansi, CallingConvention:=CallingConvention.Cdecl)>
    Shared Sub func(val As Int32, callback As ResultCallback)
    End Sub

    Private Sub RunBtn_Click(sender As Object, e As EventArgs) Handles RunBtn.Click
        ....
    End Sub
End Class

 VB側でC++へデリゲートやラムダ式を渡すにはデリゲート型を定義しなければなりません。上ではResultCallbackというデリゲート型を定義しています。これはC++側の

typedef void( *ResultCallback( int32_t val, const char* text ) );

に対応しています。文字列はIntPtrでポインタとして受け取ります。VBのString型だと色々面倒くさいんです(^-^;

 続いてDLL内のfunc関数をDllImportで属性定義しています。これは、まぁこういう書き方をするんだくらいの認識でOKです。僕も過去の自分のコードから基本コピペですww。CallingConvention:=CallingConvention.Cdecl がちょっと重要。C言語のDLLはCdeclという関数の形(コーデックルール)なので、それを指定しないと正しく関数をコールしてくれません。

 属性を定義したらfunc関数のインターフェイスを宣言します。ポイントは関数の前のShared。これはこの関数が静的関数(C言語でいうstatic関数)だよという属性です。DLLの関数はグローバルなので、これを付けないとコンパイルが通らないんですね。お約束の一つです。

 これでDLL内のfunc関数を呼ぶ事ができるようになりました。

別スレッドからControl内を変更してはいけない!

 次にRunボタンを押した時のハンドリングを実装します。実装するんですが、まずはベタにfunc関数を呼ぶコードを試してみましょう:

    Private Sub RunBtn_Click(sender As Object, e As EventArgs) Handles RunBtn.Click

        Dim val As Int32 = ValTxt.Text
        Dim callback As ResultCallback =
            Sub(resVal As Int32, text As IntPtr)
                '文字列に変換
                Dim t = Marshal.PtrToStringAnsi(text)

                '結果をText欄に書き込み
                ResTxt.Text = $"{resVal}, {t}"
            End Sub

        func(val, callback)

    End Sub

 フォーム内のValTexに入力された数値をvalに格納し、DLLに渡すデリゲート(callback)を作っています。func関数へはそれらを渡しているだけ。単純簡単ですね。

 デリゲート内ではコールバックされてくるresVal(100足された数値)と文字列へのポインタを処理しています。文字列はMarshal.PtrToStringAnciメソッドを使うとVBのString型にコンバートしてくれます。便利(^-^)。後は結果のテキストコントロール(ResTxt)に適当な結果を書き込んでいるだけです。

 さて、これでアプリを実行してRunボタンを押すとどうなるか?func関数が呼ばれ、DLL内で3秒待っているので、1、2、3秒経過してコールバックされてきて…こうなります:

 これは「別スレッドからコントロールを操作しようとした」という例外です。

 VB(C#)のフォーム(Control)に配置した各コントロールは、そのフォームのスレッドからしか呼び出してはいけないという約束があります。所がDLL内では堂々と別スレッドを立ち上げたのでした。そしてその別スレッドがコールバックを呼び出し、デリゲート内でコントローラ(ResTxt)を変更しようとしたので「だめ!」っと怒られてしまったわけです。

 DLL内で非同期処理をする場合、おそらくこの図式から逃れられません。つまりVB側での対策が必要です。

Invoke関数を通してアクセスセーフに

 別スレッドからフォーム上のコントローラを触りたい時にはテンプレ的なやり方がちゃんとあります。それが「Invoke関数を通す」です:

    Private Sub RunBtn_Click(sender As Object, e As EventArgs) Handles RunBtn.Click

        Dim val As Int32 = ValTxt.Text
        Dim callback As ResultCallback =
            Sub(resVal As Int32, text As IntPtr)
                '文字列に変換
                Dim t = Marshal.PtrToStringAnsi(text)

                '結果をCotrolセーフにText欄に書き込み
                If InvokeRequired Then
                    Invoke(
                        Sub()
                            ResTxt.Text = $"{resVal}, {t}"
                        End Sub
                    )
                Else
                    ResTxt.Text = $"{resVal}, {t}"
                End If
            End Sub

        func(val, callback)

    End Sub

 Invoke関数は第1引数に渡されたデリゲートを、コントロールを操作して良いタイミングて遅延実行してくれます。上のコードのようにラムダ式を渡しても構いません。

 Invoke関数を呼ぶ直前にInvokeRequiredというフラグで条件分けしていますが、このフラグはプログラムがコントロールを操作してはいけない状況の時はTrueになり、操作して良い状況ではFalseになります。どちらの状況も起こり得る場合はこの条件分岐が必須です。もしくは、パフォーマンスを考慮しなくて良いならInvoke関数を常に通すでも構いません。

 この回避策を講じたコードでアプリを実行すると…Runを押して、1、2、3秒で、

ちゃんとコールバック結果が返って来ました!100に100が足されて200になっていますし、DLL内で渡したHello world!も表示されています。

 「やれやれ、これでめでたしめでたし…」と思ったあなた!!いや、僕も実は当初「これでばっちりだぜ」なんて思ったんですが、実はこれで終わりじゃないんです。このままだと潜在的なバグが起こってしまうんです。それはVBやC#などのマネージドメモリが絡む面倒くさ~い事が起因で、絶対に回避しなければなりません。

ガベージコレクトよ、動かないで!(>_<)

ガベコレされたコールバックがC++で呼ばれてしまう

 先のコードには潜在的なバグが埋め込まれてしまっています。それは「DLLに渡されたcallback変数がC++側でコールバックされる合間にVB側で破棄されてしまう可能性がある」というバグです。破棄されたコールバックをC++で呼ぶと速攻でアプリが落ちてしまいますから危険過ぎます…。

 それが起こる筋道を説明します。

 先のコードはcallback変数をローカル変数として定義しています。と言う事はRunBtn_Click関数を抜けると、callback変数を誰も参照しなくなるので、この変数はガベージコレクトの対象になってしまうんです。

 「え?でもfunc関数に渡していて内部で保持しているんだから、参照されていてガベコレされないんじゃ?」と思うかもしれません。しかし、callback変数はVBの関数ではなくDLLに渡しています。DLLはアンマネージメントな世界です。ですから内部でそのポインタを保持したとしても、ガベコレはそれを感知しないんです。

 筋道の続きを見てみましょう。callback変数がDLLのfunc関数に渡されます。C++側のfunc関数では渡された関数ポインタを生で保存し、即座に呼び出し元に戻ります。

 処理が即座にVB側に戻った後、RunBtn_Click関数も即時に終わってしまいます。ここでcallback変数はガベコレ対象に切り替わります。

 一方、C++側はスレッドがまだ動いています。内部で保持した関数ポインタを通してコールバックが呼ばれるのは…そう3秒後です。この段階ですでにcallback変数はガベコレ対象になっていて、いつでも開放される危険があります。運悪くガベコレされてしまっていたら、アプリは即落ちてしまいますよね。

 「いやいや、そうは言っても、実際それ起こるの?」って疑ったあなた。起こります!!先のコードの最後にガベージコレクトを強制するGC.Collect()を追加して、

func(val, callback)

GC.Collect()  ' GCを強制

アプリを実行し、Runボタンを10回くらい素早く連続で押し続けると…、スコンとアプリが落ちます:

 それが10回なのか5回なのか100回なのかはガベコレの気まぐれ。でもかなり再現率高く起きます。なのでこれは絶対対策しないといかんのです。

callbackが終わるまでガベコレ対象から外す

 ガベージコレクタには「対象のオブジェクトをガベージコレクタの対象外にする」という設定方法が用意されています。それを施したコードがこちら:

    Private Sub RunBtn_Click(sender As Object, e As EventArgs) Handles RunBtn.Click

        Dim val As Int32 = ValTxt.Text

        'callback変数をガベコレ対象から外すためのハンドル
        Dim handle As GCHandle = Nothing

        Dim callback As ResultCallback =
            Sub(resVal As Int32, text As IntPtr)
                '文字列に変換
                Dim t = Marshal.PtrToStringAnsi(text)

                '結果をCotrolセーフにText欄に書き込み
                If InvokeRequired Then
                    Invoke(
                        Sub()
                            ResTxt.Text = $"{resVal}, {t}"
                        End Sub
                    )
                Else
                    ResTxt.Text = $"{resVal}, {t}"
                End If

                'ガベコレ対象に
                handle.Free()
            End Sub

        'callbackをガベコレ対象外に
        handle = GCHandle.Alloc(callback, GCHandleType.Normal)

        func(val, callback)
    End Sub

 まずGCHandle型の変数handleを一つ用意します。GCHandleはオブジェクトに対するガベージコレクタの振る舞いを設定してくれるクラスです。

 callbackデリゲートを一先ず作ったら、そのcallbackオブジェクトをGCHandle.Allocメソッドでガベコレ対象外に設定してあげます。第2引数のGCHandleType.Normalは、第1引数のオブジェクトをガベージコレクタの対象から外すフラグです。この状態でfunc関数を呼べば、callback変数は存在しているので問題を回避できます。

 しかし、GCHandleでcallback変数をガベコレ対象から外したという事は、自前でそれを解放しないとメモリリークになってしまいます。callback変数を解放して良いタイミングは、コールバックが呼ばれた後です。上コードではそのためデリゲータ内の最後でhandle.Free()と解放処理を入れています。

 「handleもローカル変数で定義しててまずくない?」と思った人は鋭い。でも大丈夫。なぜならhandleはデリゲート内で使っているからです。つまり有効な参照が存在しているので、callback変数が消えない限りはガベコレされないんですね。

 これでGC.Collect()を追加してアプリを立ち上げてRunボタンを連射してもアプリは落ちなくなります。

終わりに

 今回はVBからC++で作ったDLLの非同期関数にコールバックを渡す方法と注意点について解説してみました。フォームのコントロールを触れるようにするためInvoke関数を通さないといけない所やガベコレを抑制する施策など、考慮しなければならない組み込み必須の面倒くさい所がありました。

 これ別の観点でやばいのは、DLLを作る人とVBなどのクライアントを作る人が分かれちゃっている場合です。VB側、つまりGUIを担当している人がこういう勘所に精通していない事が多々あります。そういう人に「ガベコレを止める必要が…」とか「Invokeを通さないと…」などと説明しても「???(T-T)」と泣かれてしまいます。なのでそういう事を気にしなくて良くなるように適切なラッパー関数やラッパークラスを作ってあげるのが優しさかなと思います。

 DLL内からコールバックできるというのは、アプリケーションの非同期化を推し進める強力な武器になりますので、是非導入してみて下さい。

 ではまた(^-^)/

いいなと思ったら応援しよう!