1. 知識の準備
プログラムを開発する過程では、どうしてもエラーログを書き込む重要な機能を避けて通れません。この機能を実現するには、サードパーティのログプラグインを使用する、データベースを使用する、あるいは自前の簡単なメソッドを書いてエラー情報をログファイルに記録するといった選択肢があります。
最後の方法で実装する場合、ファイル操作とスレッド同期に詳しくないと問題が発生する可能性があります。なぜなら、同じファイルへの複数スレッドによる同時書き込みは許可されておらず、そうすると「別のプロセスでファイルが使用されているため、このプロセスはファイルにアクセスできません」というエラーが発生するからです。
これがファイルの並行書き込み問題であり、スレッド同期が必要になります。マイクロソフトはスレッド同期のためにいくつかのクラスを提供しており、本記事で使用する System.Threading.ReaderWriterLockSlim もその一つです。
このクラスはリソースアクセスのロック状態を管理し、マルチスレッドでの読み取りや排他的書き込みアクセスを実現します。このクラスを利用することで、同一時間帯に複数のスレッドが同じファイルに同時書き込みを行うことによる並行書き込み問題を回避できます。
読み書きロックは ReaderWriterLockSlim オブジェクトをロック管理のリソースとして使用します。異なる ReaderWriterLockSlim オブジェクトで同一のファイルをロックしても、それぞれ別のロックとして管理されるため、この違いが再びファイルの並行書き込み問題を引き起こす可能性があります。したがって、ReaderWriterLockSlim は可能な限り読み取り専用の静的オブジェクトとして定義すべきです。
ReaderWriterLockSlim にはいくつかの重要なメソッドがありますが、本記事では書き込みロックのみを説明します。
EnterWriteLockメソッドを呼び出すと書き込み状態に入ります。呼び出しスレッドがロック状態に入るまでブロックされ続けるため、永遠に戻らない可能性があります。TryEnterWriteLockメソッドを呼び出すと書き込み状態に入ります。ブロックの間隔時間を指定でき、指定された間隔内に呼び出しスレッドが書き込みモードに入らなければ false を返します。ExitWriteLockメソッドを呼び出すと書き込み状態を終了します。finally ブロック内で ExitWriteLock メソッドを実行し、呼び出し元が確実に書き込みモードを終了するようにすべきです。
2. マルチスレッドによるファイルへの同時書き込み
class Program
{
static int LogCount = 100;
static int WritedCount = 0;
static int FailedCount = 0;
static void Main(string[] args)
{
//ログ書き込みを繰り返し実行。複数のスレッドが同時に同じファイルに書き込むとエラーが発生する
Parallel.For(0, LogCount, e =>
{
WriteLog();
});
Console.WriteLine(string.Format("\r\nLog Count:{0}.\t\tWrited Count:{1}.\tFailed Count:{2}.", LogCount.ToString(), WritedCount.ToString(), FailedCount.ToString()));
Console.Read();
}
static void WriteLog()
{
try
{
var logFilePath = "log.txt";
var now = DateTime.Now;
var logContent = string.Format("Tid: {0}{1} {2}.{3}\r\n", Thread.CurrentThread.ManagedThreadId.ToString().PadRight(4), now.ToLongDateString(), now.ToLongTimeString(), now.Millisecond.ToString());
File.AppendAllText(logFilePath, logContent);
WritedCount++;
}
catch (Exception ex)
{
FailedCount++;
Console.WriteLine(ex.Message);
}
}
}

3. マルチスレッドで読み書きロックを使ってファイルへの同期書き込み
class Program
{
static int LogCount = 100;
static int WritedCount = 0;
static int FailedCount = 0;
static void Main(string[] args)
{
//ログ書き込みを繰り返し実行
Parallel.For(0, LogCount, e =>
{
WriteLog();
});
Console.WriteLine(string.Format("\r\nLog Count:{0}.\t\tWrited Count:{1}.\tFailed Count:{2}.", LogCount.ToString(), WritedCount.ToString(), FailedCount.ToString()));
Console.Read();
}
//読み書きロック。リソースが書き込みモードのとき、他のスレッドの書き込みは今回の書き込み終了を待つ必要がある
static ReaderWriterLockSlim LogWriteLock = new ReaderWriterLockSlim();
static void WriteLog()
{
try
{
//読み書きロックを書き込みモードに設定しリソースを排他的に占有。他の書き込み要求は今回の書き込み終了まで待つ
//注意:長時間読取りスレッドロックまたは書込みスレッドロックを保持すると、他のスレッドが枯渇 (starve) する可能性があります。最良のパフォーマンスを得るには、書込みアクセスの持続時間を最小限に抑えるようにアプリケーションを再構成することを検討してください。
// パフォーマンス面から、書き込みモードへの移行要求はファイル操作の直前にすべきです。ここで書き込みモードに入るのはコードの複雑さを減らすためだけです。
// 書き込みモードへの移行と終了は同じtry finallyブロック内で行う必要があるため、移行要求の前に例外が発生してはいけません。そうしないと、解放回数が要求回数を上回り例外が発生します。
LogWriteLock.EnterWriteLock();
var logFilePath = "log.txt";
var now = DateTime.Now;
var logContent = string.Format("Tid: {0}{1} {2}.{3}\r\n", Thread.CurrentThread.ManagedThreadId.ToString().PadRight(4), now.ToLongDateString(), now.ToLongTimeString(), now.Millisecond.ToString());
File.AppendAllText(logFilePath, logContent);
WritedCount++;
}
catch (Exception)
{
FailedCount++;
}
finally
{
//書き込みモードを終了し、リソース占有を解放
//注意:1回の要求に対して1回の解放が必要
// 解放回数が要求回数を上回ると例外が発生します[書き込みロックが保持されていないのに解放されました]
// 要求処理後に解放しないと例外が発生します[このモードでは書き込みロックの再帰的取得は許可されていません]
LogWriteLock.ExitWriteLock();
}
}
}

読み書きロックを使用することで、すべてのログが正常にログファイルに書き込まれました。
4. 複雑なマルチスレッド環境での読み書きロックを使ったファイルへの同期書き込みテスト
class Program
{
static int LogCount = 1000;
static int SumLogCount = 0;
static int WritedCount = 0;
static int FailedCount = 0;
static void Main(string[] args)
{
//スレッドプールにタスクを追加し、N個のログを繰り返し書き込む
SumLogCount += LogCount;
ThreadPool.QueueUserWorkItem((obj) =>
{
Parallel.For(0, LogCount, e =>
{
WriteLog();
});
});
//新しいスレッドで、N個のログ書き込みタスクをスレッドプールに追加
SumLogCount += LogCount;
var thread1 = new Thread(() =>
{
Parallel.For(0, LogCount, e =>
{
ThreadPool.QueueUserWorkItem((subObj) =>
{
WriteLog();
});
});
});
thread1.IsBackground = false;
thread1.Start();
//N個のログ書き込みタスクをスレッドプールに追加
SumLogCount += LogCount;
Parallel.For(0, LogCount, e =>
{
ThreadPool.QueueUserWorkItem((obj) =>
{
WriteLog();
});
});
//新しいスレッドで、N個のログを繰り返し書き込む
SumLogCount += LogCount;
var thread2 = new Thread(() =>
{
Parallel.For(0, LogCount, e =>
{
WriteLog();
});
});
thread2.IsBackground = false;
thread2.Start();
//現在のスレッドで、N個のログを繰り返し書き込む
SumLogCount += LogCount;
Parallel.For(0, LogCount, e =>
{
WriteLog();
});
Console.WriteLine("Main Thread Processed.\r\n");
while (true)
{
Console.WriteLine(string.Format("Sum Log Count:{0}.\t\tWrited Count:{1}.\tFailed Count:{2}.", SumLogCount.ToString(), WritedCount.ToString(), FailedCount.ToString()));
Console.ReadLine();
}
}
//読み書きロック。リソースが書き込みモードのとき、他のスレッドの書き込みは今回の書き込み終了を待つ必要がある
static ReaderWriterLockSlim LogWriteLock = new ReaderWriterLockSlim();
static void WriteLog()
{
try
{
//読み書きロックを書き込みモードに設定しリソースを排他的に占有。他の書き込み要求は今回の書き込み終了まで待つ
//注意:長時間読取りスレッドロックまたは書込みスレッドロックを保持すると、他のスレッドが枯渇 (starve) する可能性があります。最良のパフォーマンスを得るには、書込みアクセスの持続時間を最小限に抑えるようにアプリケーションを再構成することを検討してください。
// パフォーマンス面から、書き込みモードへの移行要求はファイル操作の直前にすべきです。ここで書き込みモードに入るのはコードの複雑さを減らすためだけです。
// 書き込みモードへの移行と終了は同じtry finallyブロック内で行う必要があるため、移行要求の前に例外が発生してはいけません。そうしないと、解放回数が要求回数を上回り例外が発生します。
LogWriteLock.EnterWriteLock();
var logFilePath = "log.txt";
var now = DateTime.Now;
var logContent = string.Format("Tid: {0}{1} {2}.{3}\r\n", Thread.CurrentThread.ManagedThreadId.ToString().PadRight(4), now.ToLongDateString(), now.ToLongTimeString(), now.Millisecond.ToString());
File.AppendAllText(logFilePath, logContent);
WritedCount++;
}
catch (Exception)
{
FailedCount++;
}
finally
{
//書き込みモードを終了し、リソース占有を解放
//注意:1回の要求に対して1回の解放が必要
// 解放回数が要求回数を上回ると例外が発生します[書き込みロックが保持されていないのに解放されました]
// 要求処理後に解放しないと例外が発生します[このモードでは書き込みロックの再帰的取得は許可されていません]
LogWriteLock.ExitWriteLock();
}
}
}

複雑なマルチスレッド環境でも読み書きロックを使用することで、すべてのログが正常にログファイルに書き込まれました。ThreadIdとDateTimeから、異なるスレッドによって同期書き込みされていることがわかります。
5. あとがき
読み書きIOには共有モードもありますが、実装可能ではあるものの推奨されません。
static void WriteLog()
{
try
{
var logFilePath = "log.txt";
var now = DateTime.Now;
var logContent = string.Format("Tid: {0}{1} {2}.{3}\r\n", Thread.CurrentThread.ManagedThreadId.ToString().PadRight(4), now.ToLongDateString(), now.ToLongTimeString(), now.Millisecond.ToString());
//File.AppendAllText(logFilePath, logContent);
var logContentBytes = Encoding.Default.GetBytes(logContent);
using (FileStream logFile = new FileStream(logFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.Write))
{
logFile.Seek(0, SeekOrigin.End);
logFile.Write(logContentBytes, 0, logContentBytes.Length);
}
WritedCount++;
}
catch (Exception ex)
{
FailedCount++;
Console.WriteLine(ex.Message);
}
}