C#からSTAのCOMを使う専用ワーカーの作り方 ── 生成・呼び出し・イベント・終了を一か所で管理する

· · C#, COM, STA, WPF, Windows開発

更新履歴(初版のみ・2026年09月15日公開)
初版公開

計測ボタンを押すと、メーカー製COMのReadを呼ぶ。値は取得できるのですが、ときどき数秒かかり、その間はウィンドウを動かせません。

UIで待っているからだと思ってTask.Runへ移しても、期待どおりにならないことがあります。UIで作ったSTAのオブジェクトは、呼び出し側を変えただけでは別のSTAへ移りません。 正しくマーシャリングされた呼び出しが、元のSTAで実行される場合もあります。1

このケースでは、生成場所から変えます。COM専用のSTAスレッドを一本用意し、画面からは「値を読んでほしい」と依頼する。COMの参照はそのスレッドの外へ出さず、値だけを返す構成です。

対象は、専用STA内で生成・利用できる非表示のCOMコンポーネントです。画面に貼り付けたActiveXコントロールや、特定のメインSTAを要求する製品を、そのまま移す例ではありません。サンプルはWindows・x64・.NET 8以降のC#を使い、メッセージ処理にWPFのDispatcherを利用します。.NET Frameworkへそのままコピーする前提ではありません。

1. COMを画面から切り離す

まず外へ見せるAPIを決めます。画面が必要なのはCOMオブジェクトではなく、計測値と、処理が完了したという通知です。

// サンプルのStaInstrumentが画面へ見せる操作
public Task<int> ReadAsync(int value)
    => _worker.InvokeAsync(session => session.Read(value));

public Task StopAsync() => _worker.StopAsync();

sessionは、COM参照とイベントの受け手を持つProbeSessionです。実際の製品では、このクラスをベンダーのCOMに合わせて書きます。画面へ返すのはintであり、RCW(.NETからCOMを使うためのラッパー)ではありません。2

画面は値を受け取りCOMは専用STA内に残るUIから要求キューへ仕事を渡し、専用STA内のセッションがCOMを利用します。戻すのは参照ではなく独立した値です。UI:ReadAsyncをawait要求キュー専用STA:セッションとCOM結果の値

図1: UIへCOM参照を返さなければ、呼び出す場所と解放する場所をワーカー内で管理できます。

生成も同じ入口へまとめます。StartAsyncへ渡すのは、作成済みのCOMではなく作り方を表す関数です。この関数をSTA上で呼びます。

var worker = await StaWorker<ProbeSession>.StartAsync(
    () => new ProbeSession(publish));

この二層に分ける理由は、UIからInvokeAsyncへ任意の処理を渡せてしまうと、COM参照を返したり、内部Dispatcherを操作したりできるためです。汎用のワーカーは内部実装に留め、業務用のStaInstrumentから必要な操作だけを公開します。COMを別アパートメントから使うこと自体が禁止なのではなく、今回は境界を増やさない方針です。1

図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全5件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle

2. STAの設定と、メッセージを回す処理を用意する

専用スレッドは、開始前にSTAへ設定します。既存のUIスレッドの設定を変えたり、スレッドプールのスレッドを流用したりはしません。

_thread = new Thread(() => Run(factory))
{
    IsBackground = true,
    Name = "Owned COM STA"
};
_thread.SetApartmentState(ApartmentState.STA);
_thread.Start();

これだけでは、待機中にメッセージを処理する部分がありません。STAは別アパートメントからの呼び出しなどをメッセージ経由で受け取るため、ループを持つ必要があります。サンプルではRunの中でCOMを初期化し、Dispatcher.CurrentDispatcherへ生成処理を登録してからDispatcher.Run()へ入ります。34

COMの利用期間を専用STAのループ内に収めるスレッドをSTAに設定しCOMを初期化した後、Dispatcherを回して生成と要求処理を行います。解放後にループとスレッドを終了します。STA設定とCOM初期化Dispatcherを開始COM生成・要求処理・解放ループ終了・COM終了処理

図2: ワーカーへの要求がない間も、STAはメッセージを処理できる状態で待機します。

RunではCoInitializeExの成功を確認し、最後に対応するCoUninitializeを呼びます。既に同じ方式で初期化済みというS_FALSEも成功なので、自分が行った呼び出しの対応を取ります。終了時の解放とCOM終了処理は、同じスレッドで行います。56

キューが空になるたびにWaitOneなどでこのスレッドを止める方式は採りません。また、同期COM呼び出しの間、どのメッセージが処理されるかは呼び出し先にも依存します。Dispatcherを置くことと、処理中にも常にすべてが動けることは別です。

3. 一件終わるまで、次の業務要求を開始しない

要求をすべてDispatcherへ直接投げれば、普段は順番に動きます。しかし、COM呼び出し中に内部でメッセージが処理されると、まだ戻っていない呼び出しの途中へ再入することがあります。STAは一スレッドですが、一スレッドであることは、処理の途中へ入り直さない保証ではありません。7

たとえば、最初の計測中に二回目のボタン操作が来た場合です。機器が一回ずつしか計測できないなら、二回目は待たせる必要があります。

サンプルのStaWorker<T>は自前の要求キューを持ち、次の処理を登録する条件を絞っています。

if (_busy || _scheduled || _terminated.Task.IsCompleted || _dispatcher is null)
    return;

_scheduled = true;
var op = _dispatcher.BeginInvoke(
    DispatcherPriority.Normal, new Action(DrainOne));

一件取り出したら_busyを立て、同期処理が戻るまで下ろしません。その間に要求が来ても、キューへ入れるだけです。COMを呼ぶ瞬間には、キュー操作用のlockを既に抜けています。

メッセージ処理が再入しても次の要求は待機する一件目のCOM呼び出し中に二件目が届いても自前キューに留め、一件目の戻りを確認してから次をDispatcherへ登録します。計測1:実行中busyを保持計測2:要求到着自前キューで待機計測1が戻る計測2を登録

図3: 防ぐのは自分のキューの二重実行です。COM自身のコールバックの再入を消す仕組みではありません。

要求は同期のFunc<T, R>に限定します。中でasync処理を始めてTaskだけ返すと、COM操作が終わる前に次の要求へ進めてしまうからです。同じワーカーへ追加した要求を、そのSTA上で同期的に待つことも禁止します。

結果用のTaskCompletionSourceにはRunContinuationsAsynchronouslyを指定し、結果を確定する箇所で呼び出し側の継続処理まで実行しないようにしています。待機キューの上限は既定64件で、満杯なら例外にします。無制限に受け付けて遅延をためる代わりの、サンプルとしての選択です。8

4. イベントはUIの完了を待たずに返す

計測中にCOMからイベントが届いたとします。その受け手からUIへ同期的にInvokeし、UIもワーカーの完了を同期的に待っていたら、互いを待つ構図になります。画面への通知はPostなどで予約し、COM側へ速やかに戻します。9

public void Publish(int value)
{
    if (Volatile.Read(ref _closed) != 0) return;
    context.Post(_ =>
    {
        if (Volatile.Read(ref _closed) == 0) consume(value);
    }, null);
}

これは配布サンプルのUiMailboxです。UIのSynchronizationContextと表示処理を、UI側で渡して作ります。通知する値はコピー済みのintです。配列なら共有してよい内容かを確かめ、必要なら所有権を切り離してから渡します。

UIへの通知を予約した後でも閉じた画面を避けるCOMの受信処理は値をPostして戻り、UIで実際に通知を実行するときにも終了状態を確認します。COMイベント:値を受信UIへPostして戻るUIで実行する時に再確認閉じていれば破棄開いていれば表示

図4: Postした直後に画面が閉じることもあるため、受信時だけでなく配信時にも確認します。

画面を閉じ始めたら、UI上でメールボックスを無効にします。既にUIキューへ載った通知にも、この判定が効きます。ネイティブへ返るコールバックから.NETの例外を漏らさないよう、サンプルの受信クラスは例外を保存します。実製品では監視・ログへの報告も設計してください。

この検証用COMは、所有スレッドへ同期的にコールバックします。別スレッドから通知するベンダー製品では、イベント引数の寿命やマーシャリングを確認し、別スレッドからワーカー内部の状態を直接操作しないようにします。

5. 終了を、待てる操作にする

ワーカーの終了はStopAsyncです。受付を閉じ、受理済みの要求を終えた後、同じSTAでイベント解除とCOMの解放を行います。それからDispatcherを終了し、スレッドが終わったことまで待ちます。

受付停止からスレッド終了までを待つ新規要求を拒否し、既に受け付けた要求を処理してからイベントを解除しCOMを解放します。ループとスレッドの終了後にStopAsyncが完了します。新規受付を停止受理済み要求を完了イベント解除・COM解放Dispatcherを終了COM終了処理・スレッド終了

図5: 先にメッセージループだけを止めるのではなく、COMの後片付けに必要な環境を残します。

画面のClosingイベントでは、一度クローズを取り消してawaitします。.Wait().Resultは使いません。サンプルのDemoWindowは初期化中に閉じられた場合も同じ初期化Taskを待ち、生成された所有者を止めます。終了時のエラーは表示して記録できるようにし、スレッド終了が確認された後なら再度閉じられます。

StopAsyncを何度呼んでも同じTaskが返り、解放は一度です。終了中の新規要求は失敗します。単独の読み取り失敗はそのTaskへ返し、異常なDispatcher終了では待機中のTaskにも例外を設定します。呼び出し側が「永遠に結果待ち」のまま取り残されないためです。

解放をワーカーへ集める前提

サンプルは、新規作成して誰とも共有しない検証用RCWだけを持ち、イベント解除後にReleaseComObjectします。これは共有RCWを一律に解放する推奨ではありません。別の利用者がいるRCWを切り離す危険は、Microsoftの実例にもあります。10

ベンダーへ差し替える際は、取得したオブジェクトの所有権、専用のClose処理、イベント解除後に通知が残るかを確認します。初期化に失敗するアダプター自身にも、途中まで取得した資源を戻す責任があります。ループで参照数をゼロへ押し込むFinalReleaseComObjectや、終了のたびのGC.Collectで辻褄を合わせる設計にはしません。

6. ビルドして、壊れる条件を試す

ソース一式(ZIP)を展開してください。.NET 8 SDKとVisual StudioのC++ビルドツール(Windows SDKを含む)を用意し、PowerShellから次を実行します。ネイティブ側・C#側ともx64です。レジストリへのCOM登録や管理者権限での登録は行いません。

cd .\sta-com-worker
.\build-test.ps1
# テスト後、同じフォルダーからWPFの操作例を起動
dotnet run --project StaComWorker.csproj -c Release -- --demo

ボタンはReadAsync(21)を呼び、検証用COMが返す42を表示します。実機依存を避けるため、C++で小さなIUnknown実装を用意し、ネイティブ側が保持するC#の受け手へコールバックさせています。単なるC#モックだけではありません。一方、ベンダー製品、OCXの画面埋め込み、COM登録、別アパートメント間のProxy/Stubを試験したものでもありません。

自動テストでは、次を確認します。実行時の.NET・OS情報とケースごとの結果はsta-test-report.jsonへ出力します。

確認対象 壊した条件と期待する結果
スレッドと寿命 ネイティブの生成・Read・最終Releaseが同じスレッド。残存オブジェクト数は0。
再入 テスト用に入れ子のメッセージ処理を起こしても、次の要求は始まらない。
終了の競合 一件実行中・一件待機中でStop。二件を完了し、新しい要求を拒否する。
異常系 生成失敗、読み取り例外、解放例外、予期しないループ終了をTaskで観測できる。
UI通知 予約後に画面を閉じた通知を捨て、同期Sendを使わない。

戻らない呼び出しは、この設計でも止まらない

StopAsyncは同期呼び出しを強制中断しません。タイムアウト付きで待つWaitAsyncも、元の処理を止める操作ではありません。試験では、待ちだけをタイムアウトさせた後も元の処理が残ることを確認し、試験側からブロックを解除して片付けます。11

待つのをやめても元のCOM処理は残る呼び出し側のタイムアウトと、専用STA内の同期処理の終了は別です。処理が戻るまでワーカーの安全な終了は待ちます。呼び出し側:待ちがタイムアウト専用STA:COM処理は実行中戻らなければStopも未完了

図6: 背景スレッドを強制終了して、COMを安全に片付けたことにはできません。

ベンダーが保証する中断・タイムアウトAPIがないのに、自動復旧まで必要なら、専用STAだけでは足りません。別プロセスへ分離して終了を管理する構成を検討します。処理を再送する際に二重実行してよいかも別途決めます。

専用STAワーカーは、COMを速くする仕掛けではなく、使う場所と使い終える手順を限定する実装です。 まずこの単位で生成・呼び出し・イベント・終了を揃え、実際のコンポーネントを差し替えた試験を追加してください。

関連記事

参考リンク

確認日:2026年9月15日。Windowsの規則と、このサンプルで選んだキュー・終了方針は区別しています。コードの動作確認は配布サンプルの自己テストで再実行できます。

  1. Microsoft Learn、Single-Threaded Apartments。参照箇所は本文の規則一覧と、その後のアパートメント間のインターフェース受け渡しの段落。対象オブジェクトの所有スレッドで呼び出しが実行されること、境界を越えるときにマーシャリングが必要なことを参照しています。冒頭のasync Mainの短い例を本サンプルの初期化手順としては使っていません。  2

  2. Microsoft Visual Studio Team、Marshal.ReleaseComObject Considered Dangerous(2010-03-01)。節「Calling COM objects from Managed code」と「Implementing COM interfaces on managed objects」。RCWとCCW、およびネイティブから保持した受け手の寿命の説明を参照しています。 

  3. Microsoft Learn、Single-Threaded Apartmentsの、隠しウィンドウOleMainThreadWndClassとGetMessage/DispatchMessageを説明する段落。本文のメッセージループの必要性の根拠です。特定の待機APIが常にメッセージを一切処理しない、という一般化はしていません。 

  4. Microsoft Learn、Dispatcher.Run、Remarks。現在のスレッドのDispatcherに対するメッセージループを開始することを参照しています。 

  5. Microsoft Learn、CoInitializeEx、Return valueとRemarksの最初の二段落。S_OK/S_FALSEと、成功した呼び出しに対応するCoUninitializeを参照しています。 

  6. Microsoft Learn、CoUninitialize、Remarks。COM終了処理の呼び出し順と、その処理中にもメッセージを扱う場合があることを参照しています。 

  7. Microsoft Learn、Single-Threaded Apartments、末尾の再入を説明する二段落。ページ内検索語はreentered。同じSTAでもメッセージ処理や別スレッドへのORPCで再入できることが根拠です。busyによる業務要求の直列化は、この規則を踏まえた本サンプルの設計です。 

  8. Microsoft Learn、TaskCreationOptions、FieldsのRunContinuationsAsynchronously。継続処理を非同期実行にする指定を参照しています。キュー上限64件はMicrosoftの推奨値ではなく、本サンプルの既定値です。 

  9. Microsoft Learn、Threading model — WPF、節「Overview and the dispatcher」。Invokeが同期呼び出し、BeginInvoke/InvokeAsyncが非同期登録であること、UIを所有するスレッドの説明を参照しています。 

  10. Microsoft Visual Studio Team、Marshal.ReleaseComObject Considered Dangerous、節「Marshal.ReleaseComObject – the silent assassin」。共有RCWを解放すると別の呼び出し側も使えなくなる例を参照しています。ここでの一回の解放は、検証用オブジェクトを独占していることが前提です。 

  11. Microsoft .NET Team、How do I cancel non-cancelable async operations?、冒頭の三種類のキャンセルの区別と、待ちだけを取り消した後も元の処理の後片付けが必要という説明。WaitAsyncの動作はサンプルでも比較します。 

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

この記事は次のサービスページにつながります。近い入口からご覧ください。

よくある質問

この記事のテーマについて、相談時によくある質問をまとめています。

STAのCOMをTask.Runに渡せばUIは止まらなくなりますか?
Task.RunはCOMオブジェクトの所属アパートメントを移しません。UIで作ったSTAオブジェクトへの呼び出しは、マーシャリングによって元のSTAへ戻る場合があります。専用STAで使える非表示のコンポーネントなら、生成から利用・解放までをその専用スレッドにまとめる方法が選べます。
SetApartmentState(STA)だけでは足りませんか?
STAには、別アパートメントからの呼び出しなどを処理するメッセージループも必要です。サンプルは専用スレッドでDispatcher.Runを使います。STAに設定したあと、要求待ちのためにそのスレッドをブロックするだけの実装とは異なります。
専用STAなら再入は起きませんか?
起こり得ます。COM呼び出し中などにメッセージが処理されると、同じスレッドでもコールバックへ再入できます。サンプルでは実行中フラグにより次の業務要求を開始しませんが、COM自身の再入まで禁止するものではありません。イベント処理は短くし、UIの完了を同期的に待たない設計にします。
StopAsyncは、実行中のCOM呼び出しを中断しますか?
中断しません。新規要求を拒否し、受理済みの処理を終えてからイベント解除と解放を行い、スレッドの終了まで待つ設計です。COM呼び出しが戻らなければ終了も待ち続けます。強制的な復旧が必要なら、ベンダーの中断APIや別プロセス化を検討します。
ReleaseComObjectをどのCOMにも使ってよいですか?
いいえ。共有されたRCWを解放すると、別の利用者まで使えなくなります。サンプルは新規作成して外へ渡さない検証用RCWだけを所有し、その契約の下で解放します。ベンダー製品へ差し替える際は、所有権と終了API、イベント解除の条件を別途確認してください。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る