なべひろBlog

プログラミングをメインに仕事に関するアレコレを発信しています。

C#で負荷をかけず取りこぼす事なくログを出力する

不具合が起きた時、不具合の原因を特定する情報として活用できるのがログです。

但しファイルアクセスはプログラム側からしてみればとても遅い処理なので、あまり影響されたくありません。

エラーによっては大量のログが出力されるかもしれません。

プログラム動作に影響を及ぼさずログを出力するには

1.通常のスレッドとは別で動いた方がいい

2.書き込みが遅ければ一時的に貯める場所があるといい

この2つがクリアできれば何とかなりそうです。

使うもの
BlockingCollection

スレッドセーフなコレクションです。

TryAddメソッドで溜まっているコレクションの最後に追加します。

TryTakeメソッドで一番古いデータを取り出して、取り出したデータは消去します。

コレクションにデータがない時はデータが来るまで待ち状態となります。

CancellationTokenSource

TryTakeメソッドでコレクションが0以上になるのを待っている時、例外を発行し待ち状態を解除します。

CancellationTokenSourceのCancelメソッドが実行されると例外OperationCanceledExceptionが発行されTryTakeメソッドの待ち状態から抜けます。

コンストラクタ
// 例外を出さないようにシフトJISのEncodingを取得
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
// BlockingCollectionを監視しログを書き込むTaskを動作
logWrite(AppDomain.CurrentDomain.BaseDirectory + filename);
// 2番目の引数があればログを書き込む
if (message != "")
{
    Message(message);
}

シフトJISで例外を出さないおまじないとログを書くTaskの起動、2つ目の引数があれば即座にログを書き込みます。

2番目の引数を用意したのは、滅多な事ではログは書かないが必要な時はサッと書いて終わる動作をするために用意しました。

使い方としては下記のようになります。

catch (Exception exc)
{
    await using Log log = new(@"\Log.txt", $"例外の内容:{exc.Message}");
}

こんな感じで例外が発生した時だけ内容を書き込んだら非同期で破棄できます。

破棄

破棄する時は念の為書き込まれてないログの存在を確認します。

ログが全て書き込まれてら

tokenSource.Cancel();

でBlockingCollectionのTryTakeメソッドに対してOperationCanceledException例外を発生させログを書き込むTaskを終了させます。

BlockingCollectionもCancellationTokenSourceも最後は破棄しないとメモリリークになりますのできちんと終了させて破棄しましょう。

ログ書き込みTaskを終了させないで破棄するとログ書き込み動作で不要な例外が発生します。

Dispose及びDisposeAsyncの全体像は

// 書き込んでないログがあれば待つ
while (msg.Count != 0)
{
    await Task.Delay(10);
}
// TryTakeをキャンセルさせる
tokenSource.Cancel();
// ログ書き込みTaskが終了するまで待機
taskWait.Wait();
tokenSource = null;
msg.Dispose();
msg = null;
GC.SuppressFinalize(this);
他のプログラムからログを書く

このクラスのインスタンスは生成させたままロクを連続的に記録する場合はMessageメソッドを実行します。

MessageメソッドでBlockingCollectionに日付を付加してログを追加します。

msg.TryAdd($"{DateTime.Now:yyyy/M/d HH:mm:ss,}{message}", Timeout.Infinite);

TryAddがコレクションの最後にログを追加するメソッドです。

ログをファイルに保存するく

実際のファイルへ書き込みとなります。

ファイル書き込みは遅い動作なので他のプログラムから来たログの内容を受け取る部分と実際のファイルにファイルへ書き込む部分を分けないとボトルネックになる可能性があります。

なので別Taskで溜まっているログの書き込みを行います。

// 最初にファイルの有無を確認
if (File.Exists(fileName) == false)
{
    // ファイルが存在してなければ作る
    await using var hStream = File.Create(fileName);
    // 作成時に返される FileStream を利用して閉じる
    hStream.Close();
}

taskWait = Task.Run(async () =>
{
    while (true)
    {
        try
        {
            _ = msg.TryTake(out string log, -1, tokenSource.Token);
            while (true)
            {
                try
                {
                    // ファイルがロックされている場合例外が発生して以下の処理は行わずリトライとなる
                    await using (var stream = new FileStream(fileName, FileMode.Open)) { }
                    // ログ書き込み
                    await using var fs = new FileStream(fileName, FileMode.Append, FileAccess.Write, FileShare.ReadWrite);
                    await using var sw = new StreamWriter(fs, Encoding.GetEncoding("Shift-JIS"));
                    sw.WriteLine(log);
                    break;
                }
                catch
                {
                    await Task.Delay(1000);
                }
            }
        }
        catch (OperationCanceledException)
        {
            break;
        }
    }
});

最初に既存のログファイルの存在を確認して、ファイルが存在してなければ新しいファイルを作成します。

あとはひたすらTryTakeメソットでログが来るのを待ちます。

またログファイルを他のアプリで開いていてログが書き込めない状況だと例外が発生するので正常に書き込めるまでリトライします。

そしてOperationCanceledException例外が発生したら終了するためループから抜けます。