Skip to content

Commit eb3486a

Browse files
committed
Fix race conditions
1 parent a37d4cc commit eb3486a

File tree

1 file changed

+114
-16
lines changed

1 file changed

+114
-16
lines changed

src/libraries/System.Private.CoreLib/src/System/Threading/SemaphoreSlim.cs

Lines changed: 114 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ public class SemaphoreSlim : IDisposable
4040
// The number of synchronously waiting threads, it is set to zero in the constructor and increments before blocking the
4141
// threading and decrements it back after that. It is used as flag for the release call to know if there are
4242
// waiting threads in the monitor or not.
43-
private int m_waitCount;
43+
private volatile int m_waitCount;
4444

4545
/// <summary>
4646
/// This is used to help prevent waking more waiters than necessary. It's not perfect and sometimes more waiters than
@@ -57,14 +57,19 @@ public class SemaphoreSlim : IDisposable
5757
private volatile ManualResetEvent? m_waitHandle;
5858

5959
// Head of list representing asynchronous waits on the semaphore.
60-
private TaskNode? m_asyncHead;
60+
private volatile TaskNode? m_asyncHead;
6161

6262
// Tail of list representing asynchronous waits on the semaphore.
6363
private TaskNode? m_asyncTail;
6464

6565
// No maximum constant
6666
private const int NO_MAXIMUM = int.MaxValue;
6767

68+
// Used to track if we are attempting the fast path (without taking the lock).
69+
// Therefore, if another thread takes the lock, shouldn't start its operations until
70+
// all threads finish their attempt to use the fast path.
71+
private volatile int m_threadsTryingFastPathCount;
72+
6873
// Task in a linked list of asynchronous waiters
6974
private sealed class TaskNode : Task<bool>
7075
{
@@ -106,6 +111,10 @@ public WaitHandle AvailableWaitHandle
106111
// lock the count to avoid multiple threads initializing the handle if it is null
107112
lock (m_lockObjAndDisposed)
108113
{
114+
if (m_threadsTryingFastPathCount > 0)
115+
{
116+
SpinUntilFastPathFinishes();
117+
}
109118
// The initial state for the wait handle is true if the count is greater than zero
110119
// false otherwise
111120
m_waitHandle ??= new ManualResetEvent(m_currentCount != 0);
@@ -310,6 +319,51 @@ public bool Wait(int millisecondsTimeout, CancellationToken cancellationToken)
310319
return WaitCore(millisecondsTimeout, cancellationToken);
311320
}
312321

322+
/// <summary>
323+
/// Tries a fast path to wait on the semaphore without taking the lock.
324+
/// </summary>
325+
/// <returns>true if the fast path succeeded; otherwise, false.</returns>
326+
[UnsupportedOSPlatform("browser")]
327+
private bool TryWaitFastPath()
328+
{
329+
bool result = false;
330+
if (!Monitor.IsEntered(m_lockObjAndDisposed) && m_waitHandle is null)
331+
{
332+
Interlocked.Increment(ref m_threadsTryingFastPathCount);
333+
// It's possible that the lock is taken by now, don't attempt the fast path in that case.
334+
// However if it's not taken by now, it's safe to attempt the fast path.
335+
// If the lock is taken after checking this condition, because m_threadsTryingFastPathCount is already greater than 0,
336+
// the thread holding the lock wouldn't start its operations.
337+
// If the wait handle is not null, we have to follow the slow path taking the lock to avoid race conditions.
338+
// We need to re-evaluate the conditions before proceeding as another thread might have taken the lock, did its work and released it
339+
// before this thread updated m_threadsTryingFastPathCount.
340+
if (!Monitor.IsEntered(m_lockObjAndDisposed) && m_waitHandle is null)
341+
{
342+
int currentCount = m_currentCount;
343+
if (currentCount > 0 &&
344+
Interlocked.CompareExchange(ref m_currentCount, currentCount - 1, currentCount) == currentCount)
345+
{
346+
result = true;
347+
}
348+
}
349+
Interlocked.Decrement(ref m_threadsTryingFastPathCount);
350+
}
351+
return result;
352+
}
353+
354+
/// <summary>
355+
/// Blocks the current thread until all the threads trying the fast path finish.
356+
/// </summary>
357+
[UnsupportedOSPlatform("browser")]
358+
private void SpinUntilFastPathFinishes()
359+
{
360+
SpinWait spinner = default;
361+
while (m_threadsTryingFastPathCount > 0)
362+
{
363+
spinner.SpinOnce();
364+
}
365+
}
366+
313367
/// <summary>
314368
/// Blocks the current thread until it can enter the <see cref="SemaphoreSlim"/>,
315369
/// using a 32-bit unsigned integer to measure the time interval,
@@ -336,15 +390,9 @@ private bool WaitCore(long millisecondsTimeout, CancellationToken cancellationTo
336390
return false;
337391
}
338392

339-
// Perf: If there is no wait handle, we can try entering the semaphore without using the lock.
340-
// Otherwise, we would actually need to take the lock to avoid race conditions when calling m_waitHandle.Reset()
341-
if (m_waitHandle is null)
393+
if (TryWaitFastPath())
342394
{
343-
int currentCount = m_currentCount;
344-
if (currentCount > 0 && Interlocked.CompareExchange(ref m_currentCount, currentCount - 1, currentCount) == currentCount)
345-
{
346-
return true;
347-
}
395+
return true;
348396
}
349397

350398
long startTime = 0;
@@ -385,6 +433,10 @@ private bool WaitCore(long millisecondsTimeout, CancellationToken cancellationTo
385433
}
386434
}
387435
Monitor.Enter(m_lockObjAndDisposed, ref lockTaken);
436+
if (m_threadsTryingFastPathCount > 0)
437+
{
438+
SpinUntilFastPathFinishes();
439+
}
388440
m_waitCount++;
389441

390442
// If there are any async waiters, for fairness we'll get in line behind
@@ -699,8 +751,17 @@ private Task<bool> WaitAsyncCore(long millisecondsTimeout, CancellationToken can
699751
if (cancellationToken.IsCancellationRequested)
700752
return Task.FromCanceled<bool>(cancellationToken);
701753

754+
if (TryWaitFastPath())
755+
{
756+
return Task.FromResult(true);
757+
}
758+
702759
lock (m_lockObjAndDisposed)
703760
{
761+
if (m_threadsTryingFastPathCount > 0)
762+
{
763+
SpinUntilFastPathFinishes();
764+
}
704765
// If there are counts available, allow this waiter to succeed.
705766
if (m_currentCount > 0)
706767
{
@@ -813,6 +874,10 @@ private async Task<bool> WaitUntilCountOrTimeoutAsync(TaskNode asyncWaiter, long
813874
// we no longer hold the lock. As such, acquire it.
814875
lock (m_lockObjAndDisposed)
815876
{
877+
if (m_threadsTryingFastPathCount > 0)
878+
{
879+
SpinUntilFastPathFinishes();
880+
}
816881
// Remove the task from the list. If we're successful in doing so,
817882
// we know that no one else has tried to complete this waiter yet,
818883
// so we can safely cancel or timeout.
@@ -839,6 +904,38 @@ public int Release()
839904
return Release(1);
840905
}
841906

907+
/// <summary>
908+
/// Tries to release the semaphore without taking the lock.
909+
/// </summary>
910+
/// <returns>The previous count of the <see cref="SemaphoreSlim"/> if successful; otherwise, -1.</returns>
911+
private int TryReleaseFastPath(int releaseCount)
912+
{
913+
int result = -1;
914+
if (!Monitor.IsEntered(m_lockObjAndDisposed) && m_waitHandle is null && m_waitCount == 0 && m_asyncHead is null)
915+
{
916+
Interlocked.Increment(ref m_threadsTryingFastPathCount);
917+
// It's possible that the lock is taken by now, don't attempt the fast path in that case.
918+
// However if it's not taken by now, it's safe to attempt the fast path.
919+
// If the lock is taken after checking this condition, because m_threadsTryingFastPathCount is already greater than 0,
920+
// the thread holding the lock wouldn't start its operations.
921+
// The wait handle and async head need to be null and wait count to be zero to take the fast path.
922+
// Otherwise we have to follow the slow path taking the lock to avoid race conditions.
923+
// We need to re-evaluate the conditions before proceeding as another thread might have taken the lock, did its work and released it
924+
// before this thread updated m_threadsTryingFastPathCount.
925+
if (!Monitor.IsEntered(m_lockObjAndDisposed) && m_waitHandle is null && m_waitCount == 0 && m_asyncHead is null)
926+
{
927+
int currentCount = m_currentCount;
928+
if (m_maxCount - currentCount >= releaseCount &&
929+
Interlocked.CompareExchange(ref m_currentCount, currentCount + releaseCount, currentCount) == currentCount)
930+
{
931+
result = currentCount;
932+
}
933+
}
934+
Interlocked.Decrement(ref m_threadsTryingFastPathCount);
935+
}
936+
return result;
937+
}
938+
842939
/// <summary>
843940
/// Exits the <see cref="SemaphoreSlim"/> a specified number of times.
844941
/// </summary>
@@ -860,19 +957,20 @@ public int Release(int releaseCount)
860957
nameof(releaseCount), releaseCount, SR.SemaphoreSlim_Release_CountWrong);
861958
}
862959

863-
if (m_waitCount == 0 && m_waitHandle is null && m_asyncHead is null)
960+
int fastPathResult = TryReleaseFastPath(releaseCount);
961+
if (fastPathResult >= 0)
864962
{
865-
int currentCount = m_currentCount;
866-
if (m_maxCount - currentCount >= releaseCount && Interlocked.CompareExchange(ref m_currentCount, currentCount + releaseCount, currentCount) == currentCount)
867-
{
868-
return currentCount;
869-
}
963+
return fastPathResult;
870964
}
871965

872966
int returnCount;
873967

874968
lock (m_lockObjAndDisposed)
875969
{
970+
if (m_threadsTryingFastPathCount > 0)
971+
{
972+
SpinUntilFastPathFinishes();
973+
}
876974
// Read the m_currentCount into a local variable to avoid unnecessary volatile accesses inside the lock.
877975
int currentCount = m_currentCount;
878976
returnCount = currentCount;

0 commit comments

Comments
 (0)