읽기와 쓰기 충돌을 회피하는 비잠금 비동기 방법
이철우
같은 데이터에 대해 하나의 작업이 비동기 쓰기를 하고 몇 개의 작업이 비동기 읽기를 하는 경우에, 데이터 충돌을 회피하는 기본적인 방법은 lock 문[참고 4]를 활용하는 것이었다. .Net Core 1.0/.Net Framework 3.5/.Net Standard 1.0 부터는 클래스 ReaderWriterLockSlim[참고 2]를 소개했다. 또한 .NET 9 및 C# 13부터는 클래스 Lock[참고 3] 형식을 쓸 수 있다.
이 글에서는 ReaderWriterLockSlim 를 대체할 수 있는 비잠금 비동기 AsyncReaderWriterLock[참고 1] 을 소개한다. 이는 시스템 상태를 감시하기에 좋다. 아래 AsyncReaderWriterLock 시험 코드는 .Net 9 프로젝트이다. Nuget 에서 DotNext.Threading 를 찾아 설치한다.
아래 시험 코드에서 한 개의 작업이 10 ~ 19 msec 간격으로 쓰기를 하고, 다섯 개의 작업이 3 ~ 6 msec 간격으로 읽기를 할 것이다. 5 초 지나면 시험을 멈춘다. 시험을 확인하기 위하여 읽기와 쓰기 뒤에 데이터와 시각(Tick)을 출력하였다.
public class TestAsyncReaderWriterLock
{
private AsyncReaderWriterLock? _asyncLock;
private CancellationTokenSource? _cts;
private bool _isRunning;
private readonly Random _random = new();
private int _data;
public async Task Run()
{
if (_isRunning)
{
return;
}
using var asyncLock = new AsyncReaderWriterLock();
using var cts = new CancellationTokenSource();
_asyncLock = asyncLock;
_cts = cts;
_isRunning = true;
_ = Write(cts.Token).ConfigureAwait(false);
_ = Read("1", cts.Token).ConfigureAwait(false);
_ = Read("2", cts.Token).ConfigureAwait(false);
_ = Read("3", cts.Token).ConfigureAwait(false);
_ = Read("4", cts.Token).ConfigureAwait(false);
_ = Read("5", cts.Token).ConfigureAwait(false);
while (!cts.IsCancellationRequested)
{
await Task.Delay(_random.Next(1, 10)).ConfigureAwait(false);
}
_isRunning = false;
}
public void Stop()
{
if (_isRunning)
{
_cts?.Cancel();
}
}
private async Task Read(string id, CancellationToken token)
{
await Task.Run(async () =>
{
var data = 0;
var tick = 0L;
while (!token.IsCancellationRequested)
{
await _asyncLock!.EnterReadLockAsync(token).ConfigureAwait(false);
try
{
data = _data;
tick = TimeProvider.System.GetLocalNow().Ticks;
}
finally
{
_asyncLock!.Release();
Console.WriteLine($"Read: {id} {data} {tick % 100000000L}.");
await Task.Delay(_random.Next(3, 7), token).ConfigureAwait(false);
}
}
}, token).ConfigureAwait(false);
}
private async Task Write(CancellationToken token)
{
await Task.Run(async () =>
{
var data = 0;
var tick = 0L;
while (!token.IsCancellationRequested)
{
await _asyncLock!.EnterWriteLockAsync(token).ConfigureAwait(false);
try
{
data = _random.Next(10, 20);
_data = data;
tick = TimeProvider.System.GetLocalNow().Ticks;
}
finally
{
_asyncLock!.Release();
Console.WriteLine($"Write: {data} {tick % 100000000L}.");
await Task.Delay(data, token).ConfigureAwait(false);
}
}
}, token).ConfigureAwait(false);
}
}
위 시험 코드를 구동하는 코드가 아래 있다.
Console.WriteLine("Hello, World!");
var asyncLock = new TestAsyncReaderWriterLock();
_ = asyncLock.Run().ConfigureAwait(false);
await Task.Delay(5000).ConfigureAwait(false);
asyncLock.Stop();
Console.WriteLine("Bye.");
[참고 1] AsyncReaderWriterLock
[참고 2] ReaderWriterLockSlim
[참고 3] class Lock
[참고 4] lock statement