【C#】非同期メソッド内でファイルIOするときの排他制御

C#で非同期メソッド内でのファイル入出力における排他制御についてまとめました。

Unity2019.4
※Unityの記事ではありませんが動作確認にはUnityを使っています

同時読み込み・書き込みを許可するFileShare

まず例として、以下のようにファイルへの読み込みと書き込みを並列で処理するコードを書きます。

using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using UnityEngine;

public class Example : MonoBehaviour
{
    private const string FilePath = "Example.txt";
    private const string SourceTextFilePath = "Source.txt";

    private async void Start()
    {
        // Source.txtの内容を読み込んでおく
        var sourceText = await ReadAsync(SourceTextFilePath);
        
        // Example.txtへの読み込みと書き込み(計100回ずつ)を並列したTaskで行う
        var tasks = new List<Task>();
        for (var i = 0; i < 100; i++)
        {
            tasks.Add(WriteAsync(FilePath, sourceText));
            tasks.Add(ReadAndLogLengthAsync(FilePath));
        }

        await Task.WhenAll(tasks);
        
        Debug.Log("complete.");
    }

    /// <summary>
    /// ファイルを読み込んで長さをログ出力
    /// </summary>
    private async Task ReadAndLogLengthAsync(string path)
    {
        var text = await ReadAsync(path);
        Debug.Log(text.Length);
    }

    private async Task<string> ReadAsync(string path)
    {
        using (var fs = new FileStream(path, FileMode.OpenOrCreate, FileAccess.Read))
        using (var sr = new StreamReader(fs))
        {
            var text = await sr.ReadToEndAsync();
            return text;
        }
    }
    
    private async Task WriteAsync(string path, string text)
    {
        using (var fs = new FileStream(path, FileMode.Create, FileAccess.Write))
        using (var sr = new StreamWriter(fs)) 
        {
            await sr.WriteLineAsync(text);
        }
    }
}

これを実行するとIOException: Sharing violation on path ...といった例外が出力されます。
これは一つのファイルに同時に書き込むための設定がなされていないためです。
FileStreamの第四引数をFileShare.ReadWriteとして設定すると同時書き込みが許可されこのエラーは発生しなくなります。

    private async Task<string> ReadAsync(string path)
    {
        using (var fs = new FileStream(path, FileMode.OpenOrCreate, FileAccess.Read, FileShare.ReadWrite)) // 第四引数を指定
        using (var sr = new StreamReader(fs))
        {
            var text = await sr.ReadToEndAsync();
            return text;
        }
    }
    
    private async Task WriteAsync(string path, string text)
    {
        using (var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.ReadWrite)) // 第四引数を指定
        using (var sr = new StreamWriter(fs)) 
        {
            await sr.WriteLineAsync(text);
        }
    }

並列読み込み・書き込みを管理する必要性

さて前節のようにFileShareを設定すると例外は発生しなくなりますが、
書き込み中に読み込んでしまっている影響で出力されるファイルの中身の長さは実行するごとにバラついてしまっています。

f:id:halya_11:20201022170937p:plain
ログ

正確に読み込みや書き込みを管理するには以下の実装を行う必要がありそうです。

  • 書き込み中には書き込み禁止
  • 書き込み中には読み込み禁止
  • 読み込み中には書き込み禁止

セマフォ排他制御

ファイルIOの頻度がそこまで多くないのであれば、セマフォで入出力を制御してしまうのが手っ取り早いです。
他の方法もあると思いますが、非同期メソッド内で使うには待機できるSemaphoreSlimクラスを使うのが便利だと思います。

using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;

public class Example : MonoBehaviour
{
    private const string FilePath = "Example.txt";
    private const string SourceTextFilePath = "Source.txt";
    
    private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1);

    private async void Start()
    {
        var sourceText = await ReadAsync(SourceTextFilePath);
        
        var tasks = new List<Task>();
        for (var i = 0; i < 100; i++)
        {
            tasks.Add(WriteAsync(FilePath, sourceText));
            tasks.Add(ReadAndLogLengthAsync(FilePath));
        }

        await Task.WhenAll(tasks);
        
        Debug.Log("complete.");
    }

    private async Task ReadAndLogLengthAsync(string path)
    {
        var text = await ReadAsync(path);
        Debug.Log(text.Length);
    }

    private async Task<string> ReadAsync(string path)
    {
        try
        {
            await _semaphore.WaitAsync();

            using (var fs = new FileStream(path, FileMode.OpenOrCreate, FileAccess.Read, FileShare.Read))
            using (var sr = new StreamReader(fs))
            {
                var text = await sr.ReadToEndAsync();
                return text;
            }
        }
        finally
        {
            _semaphore.Release();
        }
    }
    
    private async Task WriteAsync(string path, string text)
    {
        try
        {
            await _semaphore.WaitAsync();
            using (var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None))
            using (var sr = new StreamWriter(fs)) 
            {
                await sr.WriteLineAsync(text);
            }
        }
        finally
        {
            _semaphore.Release();
        }
    }
}

これで出力も正常になりました。

f:id:halya_11:20201022173234p:plain
出力

本当は読み込みだけは同時に行えるようにするべきですが、そのような実装はセマフォでは行えないのと
大抵のケースは今回の実装で十分そうなのでこれで解決できたものとします。