비동기 세마포, 잠금 및 판독기/기록기 조정

비동기 코드에 제한, 상호 제외 또는 판독기/기록기 조정이 필요한 경우 직접 빌드하는 대신 기본 제공 .NET 형식을 사용합니다. 이 문서에서는 이러한 형식을 적용하는 방법을 보여 줍니다. 그런 다음 사용자 지정 구현을 안내하여 내부적으로 작동하는 방법을 설명합니다.

비동기 세마포 - 동시 액세스 제한

세마포는 리소스에 동시에 액세스할 수 있는 호출자 수를 제한합니다. SemaphoreSlim 에서는 스레드를 WaitAsync 차단하지 않고 항목을 대기할 수 있는 메서드를 제공합니다.

public static class SemaphoreSlimDemo
{
    public static async Task RunAsync()
    {
        using var semaphore = new SemaphoreSlim(3);

        Task[] tasks = Enumerable.Range(1, 6).Select(id => Task.Run(async () =>
        {
            await semaphore.WaitAsync();
            try
            {
                Console.WriteLine($"Task {id}: entered (count = {semaphore.CurrentCount})");
                await Task.Delay(100);
            }
            finally
            {
                semaphore.Release();
                Console.WriteLine($"Task {id}: released");
            }
        })).ToArray();

        await Task.WhenAll(tasks);
    }
}
Public Module SemaphoreSlimDemo
    Public Async Function RunAsync() As Task
        Using semaphore As New SemaphoreSlim(3)
            Dim tasks As Task() = Enumerable.Range(1, 6).Select(
                Function(id) Task.Run(Async Function()
                    Await semaphore.WaitAsync()
                    Try
                        Console.WriteLine($"Task {id}: entered (count = {semaphore.CurrentCount})")
                        Await Task.Delay(100)
                    Finally
                        semaphore.Release()
                        Console.WriteLine($"Task {id}: released")
                    End Try
                End Function)).ToArray()

            Await Task.WhenAll(tasks)
        End Using
    End Function
End Module

항상 블록 내에서 WaitAsyncRelease를 쌍으로 사용하세요try/finally. 릴리스를 잊어버린 경우 세마포 수는 절대 증가하지 않으며 다른 호출자는 무기한 대기합니다.

비동기 세마포의 작동 방식

내부적으로 비동기 세마포는 대기자 수와 큐를 유지 관리합니다. 개수가 0을 초과하면 WaitAsync 개수가 감소하고 즉시 반환됩니다. 개수가 0 WaitAsync 이면 a TaskCompletionSource 를 큐에 넣기하고 해당 작업을 반환합니다. Release 웨이터를 큐에서 제거하고 완료하거나 개수를 증가시킵니다.

// Educational only — use SemaphoreSlim instead of this sample implementation.
public class AsyncSemaphore
{
    private readonly Queue<TaskCompletionSource> _waiters = new();
    private int _currentCount;

    public AsyncSemaphore(int initialCount)
    {
        ArgumentOutOfRangeException.ThrowIfNegative(initialCount, nameof(initialCount));
        _currentCount = initialCount;
    }

    public Task WaitAsync()
    {
        lock (_waiters)
        {
            if (_currentCount > 0)
            {
                _currentCount--;
                return Task.CompletedTask;
            }
            else
            {
                var waiter = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
                _waiters.Enqueue(waiter);
                return waiter.Task;
            }
        }
    }

    public void Release()
    {
        TaskCompletionSource? toRelease = null;

        lock (_waiters)
        {
            if (_waiters.Count > 0)
                toRelease = _waiters.Dequeue();
            else
                _currentCount++;
        }

        toRelease?.TrySetResult();
    }
}
' Educational only — use SemaphoreSlim instead of this sample implementation.
Public Class AsyncSemaphore
    Private ReadOnly _waiters As New Queue(Of TaskCompletionSource)()
    Private _currentCount As Integer

    Public Sub New(initialCount As Integer)
        If initialCount < 0 Then Throw New ArgumentOutOfRangeException(NameOf(initialCount))
        _currentCount = initialCount
    End Sub

    Public Function WaitAsync() As Task
        SyncLock _waiters
            If _currentCount > 0 Then
                _currentCount -= 1
                Return Task.CompletedTask
            Else
                Dim waiter As New TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)
                _waiters.Enqueue(waiter)
                Return waiter.Task
            End If
        End SyncLock
    End Function

    Public Sub Release()
        Dim toRelease As TaskCompletionSource = Nothing

        SyncLock _waiters
            If _waiters.Count > 0 Then
                toRelease = _waiters.Dequeue()
            Else
                _currentCount += 1
            End If
        End SyncLock

        toRelease?.TrySetResult()
    End Sub
End Class

Release 메서드는 잠금 외부에서 TaskCompletionSource을(를) 완료하며, 이는 비동기 조정 기본 형식 빌드하기AsyncAutoResetEvent와 마찬가지입니다. 이 방법은 잠금이 유지되는 동안 동기 연속이 실행되지 않도록 방지합니다.

메모

AsyncSemaphore 는 교육 구현입니다. 대신 사용 SemaphoreSlim - 취소 토큰, 시간 제한을 지원하며 철저히 테스트되었습니다.

비동기 잠금: awaits 간에 상호 제외

개수가 1인 잠금은 상호 배제를 제공합니다. C# lock 문과 Lock(.NET 9 이상)은 스레드 아핀이므로 await 경계에서 작동하지 않습니다. 획득한 스레드가 동일해야 하는 스레드 고정적 잠금은 해제도 같은 스레드가 수행해야 합니다. 전체 await에서 연속 작업을 다시 시작하는 스레드는 잠금을 획득한 스레드가 아니어도 해당 요구 사항을 위반합니다. 대신 1의 수와 함께 사용합니다 SemaphoreSlim .

public static class SemaphoreSlimAsLockDemo
{
    private static readonly SemaphoreSlim s_lock = new(1, 1);
    private static int s_sharedCounter;

    public static async Task RunAsync()
    {
        Task[] tasks = Enumerable.Range(1, 5).Select(_ => Task.Run(async () =>
        {
            await s_lock.WaitAsync();
            try
            {
                int before = s_sharedCounter;
                await Task.Delay(10);
                s_sharedCounter = before + 1;
            }
            finally
            {
                s_lock.Release();
            }
        })).ToArray();

        await Task.WhenAll(tasks);
        Console.WriteLine($"Counter = {s_sharedCounter} (expected 5)");
    }
}
Public Module SemaphoreSlimAsLockDemo
    Private ReadOnly s_lock As New SemaphoreSlim(1, 1)
    Private s_sharedCounter As Integer

    Public Async Function RunAsync() As Task
        Dim tasks As Task() = Enumerable.Range(1, 5).Select(
            Function(unused) Task.Run(Async Function()
                Await s_lock.WaitAsync()
                Try
                    Dim before As Integer = s_sharedCounter
                    Await Task.Delay(10)
                    s_sharedCounter = before + 1
                Finally
                    s_lock.Release()
                End Try
            End Function)).ToArray()

        Await Task.WhenAll(tasks)
        Console.WriteLine($"Counter = {s_sharedCounter} (expected 5)")
    End Function
End Module

비동기 잠금 작동 방식

자동 릴리스를 지원하는 형식으로 세마포 패턴을 래핑할 수 있습니다 using . 이 LockAsync 메서드는 사용할 수 있는 Releaser를 반환합니다. Releaser가 삭제되면 세마포어가 해제됩니다.

// Educational only — use SemaphoreSlim(1, 1) with try/finally instead of this sample implementation.
public class AsyncLock : IDisposable
{
    private readonly SemaphoreSlim _semaphore = new(1, 1);
    private readonly Task<Releaser> _releaser;

    public AsyncLock()
    {
        _releaser = Task.FromResult(new Releaser(this));
    }

    public Task<Releaser> LockAsync()
    {
        Task wait = _semaphore.WaitAsync();
        return wait.IsCompleted
            ? _releaser
            : wait.ContinueWith(
                  (_, state) => new Releaser((AsyncLock)state!),
                  this,
                  CancellationToken.None,
                  TaskContinuationOptions.ExecuteSynchronously,
                  TaskScheduler.Default);
    }

    public struct Releaser : IDisposable
    {
        private readonly AsyncLock? _toRelease;

        internal Releaser(AsyncLock toRelease) => _toRelease = toRelease;

        public void Dispose() => _toRelease?._semaphore.Release();
    }

    public void Dispose() => _semaphore.Dispose();
}
' Educational only — use SemaphoreSlim(1, 1) with Try/Finally instead of this sample implementation.
Public Class AsyncLock
    Implements IDisposable

    Private ReadOnly _semaphore As New SemaphoreSlim(1, 1)
    Private ReadOnly _releaser As Task(Of Releaser)

    Public Sub New()
        _releaser = Task.FromResult(New Releaser(Me))
    End Sub

    Public Function LockAsync() As Task(Of Releaser)
        Dim wait As Task = _semaphore.WaitAsync()
        If wait.IsCompleted Then
            Return _releaser
        Else
            Return wait.ContinueWith(
                Function(unused, state) New Releaser(DirectCast(state, AsyncLock)),
                Me,
                CancellationToken.None,
                TaskContinuationOptions.ExecuteSynchronously,
                TaskScheduler.Default)
        End If
    End Function

    Public Structure Releaser
        Implements IDisposable

        Private ReadOnly _toRelease As AsyncLock

        Friend Sub New(toRelease As AsyncLock)
            _toRelease = toRelease
        End Sub

        Public Sub Dispose() Implements IDisposable.Dispose
            _toRelease?._semaphore.Release()
        End Sub
    End Structure

    Public Sub Dispose() Implements IDisposable.Dispose
        _semaphore.Dispose()
    End Sub
End Class

사용은 간결하고 안전합니다.

public static class AsyncLockDemo
{
    private static readonly AsyncLock s_lock = new();
    private static int s_sharedValue;

    public static async Task RunAsync()
    {
        Task[] tasks = Enumerable.Range(1, 5).Select(id => Task.Run(async () =>
        {
            using (await s_lock.LockAsync())
            {
                int before = s_sharedValue;
                await Task.Delay(10);
                s_sharedValue = before + 1;
                Console.WriteLine($"Task {id}: incremented to {s_sharedValue}");
            }
        })).ToArray();

        await Task.WhenAll(tasks);
        Console.WriteLine($"Final value = {s_sharedValue} (expected 5)");
    }
}
Public Module AsyncLockDemo
    Private ReadOnly s_lock As New AsyncLock()
    Private s_sharedValue As Integer

    Public Async Function RunAsync() As Task
        Dim tasks As Task() = Enumerable.Range(1, 5).Select(
            Function(id) Task.Run(Async Function()
                Using Await s_lock.LockAsync()
                    Dim before As Integer = s_sharedValue
                    Await Task.Delay(10)
                    s_sharedValue = before + 1
                    Console.WriteLine($"Task {id}: incremented to {s_sharedValue}")
                End Using
            End Function)).ToArray()

        Await Task.WhenAll(tasks)
        Console.WriteLine($"Final value = {s_sharedValue} (expected 5)")
    End Function
End Module

메모

AsyncLock 는 교육 구현입니다. 직접 1으로 초기화된 SemaphoreSlim 사용try/finally —여기에 표시된 AsyncLock 형식은 일회성 방출자 패턴을 보여주지만, 제공된 SemaphoreSlim 이상의 기능을 추가하지 않습니다.

비동기 판독기/작성기 조정

판독기/기록기 잠금은 여러 개의 동시 판독기를 허용하지만 단독 작성기는 하나만 허용합니다. .NET은 두 개의 ConcurrentExclusiveSchedulerPair 인스턴스를 통해 작업에 대한 읽기/쓰기 예약 기능을 제공하는 TaskScheduler을 제공합니다.

  • ConcurrentScheduler - 단독 작업이 활성화되어 있지 않은 한 작업을 동시에 실행합니다(예: 판독기).
  • ExclusiveScheduler - 다른 작업 없이 단독으로 작업 실행(예: 작성기).
public static class ConcurrentExclusiveDemo
{
    public static async Task RunAsync()
    {
        var pair = new ConcurrentExclusiveSchedulerPair();
        var factory = new TaskFactory(pair.ExclusiveScheduler);

        int sharedValue = 0;

        Task writerTask = factory.StartNew(() =>
        {
            sharedValue = 42;
            Console.WriteLine($"Writer: set value to {sharedValue}");
        });

        var readerFactory = new TaskFactory(pair.ConcurrentScheduler);

        Task[] readerTasks = Enumerable.Range(1, 3).Select(id =>
            readerFactory.StartNew(() =>
            {
                Console.WriteLine($"Reader {id}: value = {sharedValue}");
            })).ToArray();

        await writerTask;
        await Task.WhenAll(readerTasks);
    }
}
Public Module ConcurrentExclusiveDemo
    Public Async Function RunAsync() As Task
        Dim pair As New ConcurrentExclusiveSchedulerPair()
        Dim exclusiveFactory As New TaskFactory(pair.ExclusiveScheduler)

        Dim sharedValue As Integer = 0

        Dim writerTask As Task = exclusiveFactory.StartNew(Sub()
            sharedValue = 42
            Console.WriteLine($"Writer: set value to {sharedValue}")
        End Sub)

        Dim readerFactory As New TaskFactory(pair.ConcurrentScheduler)

        Dim readerTasks As Task() = Enumerable.Range(1, 3).Select(
            Function(id) readerFactory.StartNew(Sub()
                Console.WriteLine($"Reader {id}: value = {sharedValue}")
            End Sub)).ToArray()

        Await writerTask
        Await Task.WhenAll(readerTasks)
    End Function
End Module

Important

ConcurrentExclusiveSchedulerPairawait 경계를 넘지 않고 작업 단위로 보호합니다. 큐에 대기 중인 작업이 불완전한 작업의 await을 포함하는 경우, await는 양보할 때 배타적 잠금이 해제되고, 연속 작업이 실행될 때 다시 획득됩니다. 다른 배타적 또는 동시 작업은 해당 간격 동안 실행할 수 있습니다. 이 동작은 메모리 내 데이터 구조를 보호하고 중요한 섹션을 중단하지 않도록 await 할 때 잘 작동합니다. 대기 중 잠금을 유지해야 하는 시나리오의 경우 다음 섹션에 표시된 것과 같은 사용자 지정 AsyncReaderWriterLock 을 사용합니다.

사용자 지정 비동기 판독기/기록기 잠금

다음 구현에서는 읽기 작업보다 쓰기 작업에 우선 순위를 부여합니다. 작성자가 대기 중인 경우 새 독자가 대기합니다. 작성기가 완료되고 다른 작성자가 대기하지 않으면 대기 중인 모든 판독기가 함께 실행됩니다.

public class AsyncReaderWriterLock
{
    private readonly Queue<TaskCompletionSource<Releaser>> _waitingWriters = new();
    private TaskCompletionSource<Releaser> _waitingReader =
        new(TaskCreationOptions.RunContinuationsAsynchronously);
    private int _readersWaiting;
    private int _status; // 0 = free, -1 = writer active, >0 = reader count

    private readonly Task<Releaser> _readerReleaser;
    private readonly Task<Releaser> _writerReleaser;

    public AsyncReaderWriterLock()
    {
        _readerReleaser = Task.FromResult(new Releaser(this, isWriter: false));
        _writerReleaser = Task.FromResult(new Releaser(this, isWriter: true));
    }

    public Task<Releaser> ReaderLockAsync()
    {
        lock (_waitingWriters)
        {
            if (_status >= 0 && _waitingWriters.Count == 0)
            {
                _status++;
                return _readerReleaser;
            }
            else
            {
                _readersWaiting++;
                return _waitingReader.Task;
            }
        }
    }

    public Task<Releaser> WriterLockAsync()
    {
        lock (_waitingWriters)
        {
            if (_status == 0)
            {
                _status = -1;
                return _writerReleaser;
            }
            else
            {
                var waiter = new TaskCompletionSource<Releaser>(TaskCreationOptions.RunContinuationsAsynchronously);
                _waitingWriters.Enqueue(waiter);
                return waiter.Task;
            }
        }
    }

    private void ReaderRelease()
    {
        TaskCompletionSource<Releaser>? toWake = null;

        lock (_waitingWriters)
        {
            _status--;
            if (_status == 0 && _waitingWriters.Count > 0)
            {
                _status = -1;
                toWake = _waitingWriters.Dequeue();
            }
        }

        toWake?.SetResult(new Releaser(this, isWriter: true));
    }

    private void WriterRelease()
    {
        TaskCompletionSource<Releaser>? toWake = null;
        bool toWakeIsWriter = false;

        lock (_waitingWriters)
        {
            if (_waitingWriters.Count > 0)
            {
                toWake = _waitingWriters.Dequeue();
                toWakeIsWriter = true;
            }
            else if (_readersWaiting > 0)
            {
                toWake = _waitingReader;
                _status = _readersWaiting;
                _readersWaiting = 0;
                _waitingReader = new TaskCompletionSource<Releaser>(TaskCreationOptions.RunContinuationsAsynchronously);
            }
            else
            {
                _status = 0;
            }
        }

        toWake?.SetResult(new Releaser(this, toWakeIsWriter));
    }

    public struct Releaser : IDisposable
    {
        private readonly AsyncReaderWriterLock? _lock;
        private readonly bool _isWriter;

        internal Releaser(AsyncReaderWriterLock lockObj, bool isWriter)
        {
            _lock = lockObj;
            _isWriter = isWriter;
        }

        public void Dispose()
        {
            if (_lock is not null)
            {
                if (_isWriter) _lock.WriterRelease();
                else _lock.ReaderRelease();
            }
        }
    }
}
Public Class AsyncReaderWriterLock
    Private ReadOnly _waitingWriters As New Queue(Of TaskCompletionSource(Of Releaser))()
    Private _waitingReader As New TaskCompletionSource(Of Releaser)(TaskCreationOptions.RunContinuationsAsynchronously)
    Private _readersWaiting As Integer
    Private _status As Integer ' 0 = free, -1 = writer active, >0 = reader count

    Private ReadOnly _readerReleaser As Task(Of Releaser)
    Private ReadOnly _writerReleaser As Task(Of Releaser)

    Public Sub New()
        _readerReleaser = Task.FromResult(New Releaser(Me, isWriter:=False))
        _writerReleaser = Task.FromResult(New Releaser(Me, isWriter:=True))
    End Sub

    Public Function ReaderLockAsync() As Task(Of Releaser)
        SyncLock _waitingWriters
            If _status >= 0 AndAlso _waitingWriters.Count = 0 Then
                _status += 1
                Return _readerReleaser
            Else
                _readersWaiting += 1
                Return _waitingReader.Task
            End If
        End SyncLock
    End Function

    Public Function WriterLockAsync() As Task(Of Releaser)
        SyncLock _waitingWriters
            If _status = 0 Then
                _status = -1
                Return _writerReleaser
            Else
                Dim waiter As New TaskCompletionSource(Of Releaser)(
                    System.Threading.Tasks.TaskCreationOptions.RunContinuationsAsynchronously)
                _waitingWriters.Enqueue(waiter)
                Return waiter.Task
            End If
        End SyncLock
    End Function

    Private Sub ReaderRelease()
        Dim toWake As TaskCompletionSource(Of Releaser) = Nothing

        SyncLock _waitingWriters
            _status -= 1
            If _status = 0 AndAlso _waitingWriters.Count > 0 Then
                _status = -1
                toWake = _waitingWriters.Dequeue()
            End If
        End SyncLock

        toWake?.SetResult(New Releaser(Me, isWriter:=True))
    End Sub

    Private Sub WriterRelease()
        Dim toWake As TaskCompletionSource(Of Releaser) = Nothing
        Dim toWakeIsWriter As Boolean = False

        SyncLock _waitingWriters
            If _waitingWriters.Count > 0 Then
                toWake = _waitingWriters.Dequeue()
                toWakeIsWriter = True
            ElseIf _readersWaiting > 0 Then
                toWake = _waitingReader
                _status = _readersWaiting
                _readersWaiting = 0
                _waitingReader = New TaskCompletionSource(Of Releaser)(TaskCreationOptions.RunContinuationsAsynchronously)
            Else
                _status = 0
            End If
        End SyncLock

        toWake?.SetResult(New Releaser(Me, toWakeIsWriter))
    End Sub

    Public Structure Releaser
        Implements IDisposable

        Private ReadOnly _lock As AsyncReaderWriterLock
        Private ReadOnly _isWriter As Boolean

        Friend Sub New(lockObj As AsyncReaderWriterLock, isWriter As Boolean)
            _lock = lockObj
            _isWriter = isWriter
        End Sub

        Public Sub Dispose() Implements IDisposable.Dispose
            If _lock IsNot Nothing Then
                If _isWriter Then
                    _lock.WriterRelease()
                Else
                    _lock.ReaderRelease()
                End If
            End If
        End Sub
    End Structure
End Class

사용법은 AsyncLock와 동일한 일회용 릴리스 패턴을 따릅니다.

public static class AsyncReaderWriterLockDemo
{
    private static readonly AsyncReaderWriterLock s_rwLock = new();
    private static string s_data = "initial";

    public static async Task RunAsync()
    {
        Task writer = Task.Run(async () =>
        {
            using (await s_rwLock.WriterLockAsync())
            {
                Console.WriteLine("Writer: acquired exclusive lock");
                await Task.Delay(50);
                s_data = "updated";
                Console.WriteLine("Writer: data updated");
            }
        });

        Task[] readers = Enumerable.Range(1, 3).Select(id => Task.Run(async () =>
        {
            await Task.Delay(10);
            using (await s_rwLock.ReaderLockAsync())
            {
                Console.WriteLine($"Reader {id}: data = {s_data}");
            }
        })).ToArray();

        await writer;
        await Task.WhenAll(readers);
    }
}
Public Module AsyncReaderWriterLockDemo
    Private ReadOnly s_rwLock As New AsyncReaderWriterLock()
    Private s_data As String = "initial"

    Public Async Function RunAsync() As Task
        Dim writer As Task = Task.Run(Async Function()
            Using Await s_rwLock.WriterLockAsync()
                Console.WriteLine("Writer: acquired exclusive lock")
                Await Task.Delay(50)
                s_data = "updated"
                Console.WriteLine("Writer: data updated")
            End Using
        End Function)

        Dim readers As Task() = Enumerable.Range(1, 3).Select(
            Function(id) Task.Run(Async Function()
                Await Task.Delay(10)
                Using Await s_rwLock.ReaderLockAsync()
                    Console.WriteLine($"Reader {id}: data = {s_data}")
                End Using
            End Function)).ToArray()

        Await writer
        Await Task.WhenAll(readers)
    End Function
End Module

Tip

프로덕션 판독기/기록기 잠금에는 재진입, 오류 경로, 취소 및 공정성 정책과 같은 에지 사례에 대한 철저한 테스트가 필요합니다. 자체 라이브러리를 빌드하기 전에 설정된 라이브러리(예: Nito.AsyncEx)를 고려합니다.

조정 패턴의 대안으로서의 채널

Channel<T>는 읽기 및 쓰기를 async 지원하는 스레드 안전의 생산자-소비자 큐를 제공합니다. 제한된 채널(CreateBounded)은 자연스러운 백프레셔를 제공하며, 일부 시나리오에서 제한을 위해 세마포어를 사용하는 것을 대체합니다.

자세한 내용은 System.Threading.Channels를 참조하세요.

참고하십시오