はじめに
C#というか.NETの世界では、外部プロセスを起動する際にSystem.Diagnostics.Process
を使用するということになっている。
このProcess
クラスには非同期イベントベースで標準出力をやり取りするためのAPIが存在するが、意図しない動作になる場合があるので要注意という話。
Processの非同期APIについて
非同期で標準出力、エラー出力を受け取り、かつ非同期でプロセス終了を待ちたい場合、以下のようにする。
-
ProcessStartInfo
を以下のように設定する- UseShellExecute=false
- RedirectStandardOutput=true
- RedirectStandardError=true
-
Process
をnewする(Startはしない) -
Process
を以下のように設定する-
OutputDataReceived
にコールバック登録 -
ErrorDataReceived
にコールバック登録 -
EnableRaisingEvents
=true -
Exited
にコールバック登録- 中で終了イベント通知を入れる
-
-
Process.Start
を実行 -
proc.BeginOutputReadLine()
を実行 -
proc.BeginErrorReadLine()
を実行 - Exitedイベントを何らかの方法で待つ(ManualResetEventやCancellationToken等)
- 中断等を実装したい場合、CreateLinkedTokenSource等を駆使する
- ja版の説明だと
WhenAll
みたいな説明だが、実際はWhenAny
なので要注意
- ja版の説明だと
- 中断等を実装したい場合、CreateLinkedTokenSource等を駆使する
コードに直すと以下のようになる。
void AsyncProcTest()
{
var si = new ProcessStartInfo("dotnet", "--info");
// ウィンドウ表示を完全に消したい場合
// si.CreateNoWindow = true;
si.RedirectStandardError = true;
si.RedirectStandardOutput = true;
si.UseShellExecute = false;
using(var proc = new Process())
using(var ctoken = new CancellationTokenSource())
{
proc.EnableRaisingEvents = true;
proc.StartInfo = si;
// コールバックの設定
proc.OutputDataReceived += (sender, ev) =>
{
Console.WriteLine($"stdout={ev.Data}");
};
proc.ErrorDataReceived += (sender, ev) =>
{
Console.WriteLine($"stderr={ev.Data}");
};
proc.Exited += (sender, ev) =>
{
Console.WriteLine($"exited");
// プロセスが終了すると呼ばれる
ctoken.Cancel();
};
// プロセスの開始
proc.Start();
// 非同期出力読出し開始
proc.BeginErrorReadLine();
proc.BeginOutputReadLine();
// 終了まで待つ
ctoken.Token.WaitHandle.WaitOne();
}
}
出力が全部出ない
さて、上記コード、一見問題ないように見える。
しかし、出力が全て表示される場合もあるが、途中で途切れてしまう場合もある。
これはなぜかというと、Exited
イベントが発生した時でも、Process
にある標準出力の内部保持データが残っている場合があるからである。
内部バッファデータを確実に全て消化させるには、Process.WaitForExit()
を実行する必要がある。プロセスは全て終わっているので、ほとんどすぐ終わるはず。
出力コールバック内でキャッチされない例外が稀に発生する
例えば、
var sb = new StringBuilder();
proc.OutputDataReceived += (sender, ev) =>
{
sb.Append(ev.Data);
};
のようなコードを書いた場合、正しく終了した後にも関わらず、稀に不定期なタイミングでキャッチされない例外が発生する場合がある(ObjectDisposedExceptionあるいはNullReferenceException)。
これは、usingブロックを抜けた後にイベントが発生する場合があるからである。
なぜそうなるか、という所は未解明だが、コールバックの中でキャッチするか、あるいは終了後にProcess.CancelOutputRead()
とProcess.CancelErrorRead()
を呼び出すことで回避ができる。
Exitedコールバック内でキャッチされない例外が稀に発生する
Exitedイベントが、完全にプロセスが終了し、各種オブジェクトが破棄された後に起こる場合があり、その時に当然ほとんどのオブジェクトは破棄されているので、ObjectDisposedException
、あるいはNullReferenceException
が発生して、キャッチされない例外になる場合がある。
これに関しては、BeginOutputReadLine
等と組み合わせると発生するようだが、詳しい条件は不明。全く起きない環境もあったりする。
.NET Frameworkでも、.NET Coreでも起きたりするので、もしかしたらOS側のAPIの使い方に原因があるのかもしれない。
回避策は、コールバック内でキャッチするか、あるいはBeginOutputReadLine
等を使用しないようにすれば、ほぼ起きなくなる。
その場合の標準出力の受け取りは、別スレッドで受け取り処理を行えば良い。
void AsyncProcTest2()
{
var si = new ProcessStartInfo("dotnet", "--info");
// ウィンドウ表示を完全に消したい場合
// si.CreateNoWindow = true;
si.RedirectStandardError = true;
si.RedirectStandardOutput = true;
si.UseShellExecute = false;
using(var proc = new Process())
using(var ctoken = new CancellationTokenSource())
{
proc.EnableRaisingEvents = true;
proc.StartInfo = si;
// コールバックの設定
proc.Exited += (sender, ev) =>
{
Console.WriteLine($"exited");
// プロセスが終了すると呼ばれる
ctoken.Cancel();
};
// プロセスの開始
proc.Start();
Task.WaitAll(
Task.Run(() =>
{
while(true)
{
var l = proc.StandardOutput.ReadLine();
if(l == null)
{
break;
}
Console.WriteLine($"stdout = {l}");
}
}),
Task.Run(() =>
{
ctoken.Token.WaitHandle.WaitOne();
proc.WaitForExit();
})
);
}
}
注意点
プロセスが終わらないと、StandardOutputからのRead(Async含む)が戻ってこないため、かならず子プロセス含めてkillする必要がある。
終りに
Processクラス自体は昔からあるクラスなので、イベントベースのAPIが用意されているが、終了イベントはともかく、標準出力に関するイベントはもう使わない方が良いのかもしれない。
ただし、長期に渡るプロセスの実行時は終わるまで制御が戻らない可能性があるため、この辺りは難しいところだ。