Word アドイン開発におけるC# の asyc/await
BoostDraft で UI チームの リードエンジニアをしています. 常山です(LinkedIn / Github).
今日紹介するのはC#の構文の中で, 気軽に非同期処理を行える async/await構文とその挙動, VSTOにおける挙動です. BoostDraftではプロダクトの一つとしてWordのアドインをC#で開発しており, 非同期処理をするために async/await 構文を積極的に利用しています.
アドインの種類とBoostDraftの選択
アドインにはいくつか種類があるのですが, 大きくネイティブアドインとWEBアドインの二つに分けられます. 実はよりモダンなのはWEBアドインなのですが, 弊社ではあえてネイティブアドインであるVSTO アドインと呼ばれる技術を採用しております. WEBアドインではその名の通りJavaScriptベースのWEB技術を使ってOffice上にアドインを構築する仕組みなのですが, 仕組みとしてはWEBアプリと同等で, Office内に専用のWebViewを配置することで API経由でドキュメントを操作することができます.
しかし, 我々の実現したいことはこれだけでは足りず, 面倒な設定の自動化や参照や定義語のハイライトをインタラクティブに実現しようと思うとどうしてもドキュメントのコンテンツ以外のUIを文書中に表示する必要性があります. そのためにはWindowハンドルを取得したり, 画面上の座標を計算したりと低レイヤーな処理を記述するVSTOアドインが必要でした. Windowsでしか動かないという欠点がありますが, その点を踏まえてでもインタラクティブなUI表示により我々が提供できる価値は高いと考え, VSTOアドインで開発することを選択しました.
VSTO アドインとWPFのスレッド制約
VSTOアドインは .NET Framework上で動作しC# で開発することができます. 最新の .NET Core系 (.NET Core 3.1, .NET 6, .NET 8) のランタイムでは動かす事はできませんが, .NET Frameworkでも利用可能な WPF(Windows Presentation Framework)を使ってUIを開発することができます.
しかし, アドインは純粋なWPFアプリケーションではないので, あくまでもUIを表示するコンポーネントとして利用する必要があります. ここが我々のプロダクトの中でも難しい所であり, 多くの問題を抱えています.
一般的にWPFをはじめとするデスクトップアプリのGUIフレームワークではUI スレッドという概念があり, 基本的にUIオブジェクトはUIスレッドで操作するという決まりがあります. UIオブジェクトをスレッドセーフにしようと思うと余計なコストが多くかかり, パフォーマンス面でも大きな問題もあります. Windows Form等も同様ですがGUIアプリケーションでは最初からシングルスレッドを想定したほうが結果的にパフォーマンスが向上するという考えがあります.
WPFではオブジェクトを動作させるために STAスレッド(Single-Threaded Apartment)であるという制約が必要です. STAというのはスレッドの属性で, 主にCOMオブジェクトがどのスレッドに所属しているかという事を表します. WPFでもFIleDialogやDragAndDrop event の一部の処理は間接的にCOMオブジェクトを利用しており, それらを扱うためにSTAスレッドの制約が必要です.
ここまではWPFのスレッドの話でした. 一方でBoostDraftはアドインであり, Word上で動作すると書きましたが, 実はWordを操作するAPIも COM でありスレッドの制約を受けます.
Wordアドインが動作するスレッドは VSTO_Mainという名前が付けられているスレッドであり, STAです. そのため Word の APIを呼び出すには VSTO_Mainで呼び出す必要があります. 厳密には VSTOのWord API は別のスレッドからでも呼び出す事ができますが, 内部的にはマーシャリングされているようで, Main スレッドから呼び出す場合と, 他のスレッドから呼び出す場合とで大きな実行速度差があります.
ならば, VSTO_Mainから呼び出すのが最適解なのか?というとそういう訳でもなく, VSTO_Main は WordのUI スレッド でもあり, これを占有してしまうと Word 本体の フリーズを招きます.
数百ページにも及ぶ大きなドキュメントの場合, Word API の呼び出しだけで数秒掛かってしまう場合もあり, これを意図的にワーカースレッドに逃がすこともあります. 例えばVSTO_Mainで5秒かかる場合でも, ワーカースレッドでは10秒になったりもしますが, ワーカースレッドの場合UIがフリーズしないので, 時間はかかっても結果的にワーカースレッドが良いケースもあります.
ただ, 全てのWord API をワーカースレッドに逃がすという訳にもいかず, そこはケースバイケースでチューニングしています.
さて, ここまで WPF のスレッドの話とVSTOのスレッドの話をしてきましたが, GUIを含むアドインで最も気を付けなければいけないのがUIのフリーズを避ける事で, それはユーザーエクスぺリエンスに直結します.
現在のBoostDraftのアーキテクチャでは VSTO_Main スレッドをWPFのUIスレッドとしても扱っており, WPFが動作するスレッドのフリーズも Word のUIフリーズに直結します.
async / await とスレッドの関係
前置きが長くなりましたが, 表題にある async/awaitのVSTOにおける挙動の話をします.
async/await 構文とは非同期処理を同期処理の様に扱えるC# の便利な構文です.
以下の様に記述することで同期処理を非同期処理として扱うことができます.
public async Task OnClickAsync()
{
await DoLogicAsync();
ShowUI();
}
この時, ShowUI()は DoLogicAsync()の完了を待ってから実行されます.
GUIの async/awaitでは, この時, 暗黙的にawaitから復帰した際にスレッドを元のスレッドに戻してくれるという特徴があります(厳密にいえばこの説明は正しくありませんが, 本稿と逸れるので一旦, 左記の定義として進めます).
以下の様にTask.Runで別のスレッドで実行した場合もawaitを記載することで元のスレッドに制御が戻ります.
public async Task OnClickAsync()
{
Debug.WriteLine(Thread.CurrentThread.ManagedThreadId); // "1"
await Task.Run(()=>
{
Debug.WriteLine(Thread.CurrentThread.ManagedThreadId); // "2"
});
Debug.WriteLine(Thread.CurrentThread.ManagedThreadId); // "1"
ShowUI();
}
なぜこのような仕様になっているかというと, GUIフレームワークではUIオブジェクトは UI スレッドで操作する必要があり, async/awaitを用いた際にこれが自動的に制御されている方が都合が良いからです. つまりはGUIフレームワークの都合です.
では, 非GUIアプリケーションではどうなのでしょうか?
以下はコンソールアプリケーションでasync/awaitを実行する例です.
public void Main()
{
// Wait()はasync/awaitを用いる際のアンチパターンの一つですが,
// Mainは例外的に asyncを利用できず, 制御を戻さなければ最初のawaitの実行時点で
// アプリケーションが終了するため, 便宜上記述しています.
MainProcessAsync().Wait();
}
public async Task MainProcessAsync()
{
Debug.WriteLine(Thread.CurrentThread.ManagedThreadId); // "1"
await Task.Run(()=>
{
Debug.WriteLine(Thread.CurrentThread.ManagedThreadId); // "2"
});
Debug.WriteLine(Thread.CurrentThread.ManagedThreadId); // "2"
}
GUIバージョンとの違いは Task.Runの後のDebug.Writelineの結果が”2”となっていることです. GUIバージョンではスレッドはメインスレッドの制御に戻っていたため”1”になっていました.
なぜこのような動作になるのでしょうか. それを理解するためには await に隠された内部の実装を知る必要があります.
await 後のスレッド遷移を深堀してみる SynchronizationContextについて
await の後にプログラムがどのような振る舞いをするかどうかはSynchronizationContextを使って行われます. このSynchronizationContextはstaticなC#のオブジェクトです. SynchronizationContext.Currentを介してawaitの内部から利用されます.
await がやっていることをSynchronizationContext.Currentを使って書き直してみましょう. 厳密な挙動とは違いますが, 以下が async/awaitを利用せずに同様の処理を書いてみたサンプルコードです.
//public async Task MainProcessAsync()
//{
// Debug.WriteLine(Thread.CurrentThread.ManagedThreadId);
// await Task.Run(()=>
// {
// Debug.WriteLine(Thread.CurrentThread.ManagedThreadId);
// });
// Debug.WriteLine(Thread.CurrentThread.ManagedThreadId);
//}
// このメソッドは上記のメソッドを await を利用せずに実装した場合とほぼ等価です.
public Task MainProcessAsync()
{
Debug.WriteLine(Thread.CurrentThread.ManagedThreadId); // "1"
// Taskを開始する前のスレッドに紐づくSynchronizationContextをキャプチャします.
SynchronizationContext synchronizationContext = SynchronizationContext.Current;
Task task = Task.Run(() =>
{
Debug.WriteLine(Thread.CurrentThread.ManagedThreadId); // "2"
})
.ContinueWith(_ =>
{
// SynchronizationContextが設定されている場合はsynchronizationContext経由で同期的
// にawaitの後続処理を実行します.
if (synchronizationContext is not null)
{
// 実行スレッドはSynchronizationContextの実装に依存します.
// 通常, WPFなどのGUIフレームワークから実行する場合
// SynchronizationContextは元のスレッドに制御を戻す実装になっています.
synchronizationContext.Send(_ =>
{
Debug.WriteLine(Thread.CurrentThread.ManagedThreadId); // "1"
},
null);
}
// SynchronizationContextが設定されていない場合, 現在のスコープで
// 処理をそのまま実行します.
else
{
// この場合, Task.Runが実行されたスレッドと同じスレッドで即時実行されます.
Debug.WriteLine(Thread.CurrentThread.ManagedThreadId); // "2"
}
});
return task;
}
// ※このサンプルコードでは, 説明の簡素化のためにConfigureAwait(false)の挙動については言及していません.
いくつかの箇所についてはインラインにて説明コメントを挿入していますが, 最も重要な箇所はSynchronizationContextです. 通常コンソールアプリケーションではこれは null になっているため, await の後続処理は即時に実行されます.
他方でSynchronizationContextに何者かが設定されている場合, そのSynchronizationContextを用いて後続の処理が実行されることになります.
一般的なWPFアプリケーションの場合, Applicationクラスを作成したタイミングでDispatcherSynchronizationContextが作成されUIスレッド内に配置される Queueにディスパッチされるようになり, await の後続処理はUIスレッドで実行されるという仕組みです.
VSTO アプリのSynchronizationContext
では, VSTOのアプリケーションではSynchronizationContextは何に設定されるのでしょうか?
答えは, 未設定(null)です. nullの場合どうなるかというと, await の後続処理はSynchronizationContextを経由せずそのまま実行されます. つまり, VSTO_Mainスレッド上にWPFオブジェクトを作成した上で async/await 構文を利用すると一般的なWPFアプリケーションとは異なり, awaitの後にVSTO_Mainスレッドに復帰しないという問題があります.
そのため, これを解決するには明示的にSynchronizationContextを設定する必要があります.
Dispatcher自体の作成はメインループが存在するSTAスレッド内でWPFのUIオブジェクト(DispatcherObjectを継承しているクラス)を作成した時点で作成されます.
その後, 以下の様なコードで 当該スレッドに紐づくDispatcherを取得してSynchronizationContextを設定します.
Dispatcher uiDispatcher = Dispatcher.FromThread(s_mainThread);
SynchronizationContext.SetSynchronizationContext(new
DispatcherSynchronizationContext(s_lazyUiDispatcher.Value));
アドインの初期化時にこれらを実行しておくことで, 一般的なWPFアプリと同じようにawaitの後にUIスレッドに復帰させることができるようになります.
ちなみに, VSTOアドイン上でDispatcherがどのように振る舞うかという疑問があると思いますが, 詳細な文献などは見つけられず挙動を観察する上ではどうもVSTO_Mainスレッドに含まれるWindowプロシージャにフックする形で実現されているようでした.
そして, これが何を意味するかというと独自のWindowプロシージャを利用している場合当該箇所では毎回SynchronizationContext.Currentがnullにリセットされるという事があります. BoostDraftでは一部のWindowsイベントを処理するためにWin32 APIのSetWindowsHookEx を利用しているのですが, どうもこの経路で来た場合, VSTO_Mainだが, SynchronizationContext.Currentが毎回 null になるという問題がありました.
そのため, いくつかの経路では適切にSynchronizationContext.Currentが設定されているかを厳密にチェックする必要があります.
このようにVSTO アプリケーション上でGUIフレームワークを動かす場合, async/await, スレッドについてはかなり強く意識する必要があります.
今後の課題
今までVSTO_MainスレッドがWordのUIスレッドで, アドインのUIスレッドでもあるという話をしましたが, 本質的にこれらは分離可能です.
VSTO_Mainとは別に新たにアドインのUIスレッドを作成するアプローチもあります. この場合, UIスレッドの停止=Wordのフリーズにつながらないので, 何か問題が起きた場合でもWordの挙動を止めることなく, 操作可能な状態を維持できます.
ただし, そうした場合はUIオブジェクトの操作はより細心の注意を払う必要があります.
最後に
弊社では, C# / .NET のスペシャリストを随時募集中です. 以下のページより申し込みをお待ちしております.
執筆者: 常山 勇希 (Lead Software Engineer)
SNS: LinkedIn | Github
経歴: 大阪生まれ. もともとゲームクリエイターを目指していたが, 学習の過程でプログラミングそのものの面白さに気付く. 学生時代には独学で3Dグラフィックスを中心に学び, シリコンスタジオ株式会社でゲームエンジンや3Dグラフィックスを活かしたソフトウェア開発に従事. その後, 自身の活躍領域を広げるためにYahoo!(現: LINEヤフー株式会社)を経て, BoostDraftにジョイン. 現在はリードエンジニアとして, アドインのUIチームおよび新プロダクト開発チームを統括している.
Discussion