Skip to content

Commit 3f1a6d9

Browse files
committed
make MutexSlim "fair" in the "2 incomplete async calls from same execution path" scenario
1 parent 9255da5 commit 3f1a6d9

File tree

2 files changed

+50
-28
lines changed

2 files changed

+50
-28
lines changed

src/Pipelines.Sockets.Unofficial/Threading/MutexSlim.cs

Lines changed: 49 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,7 @@ public sealed partial class MutexSlim
2727
* (I'm looking at you, SemaphoreSlim... you know what you did)
2828
* - must be low allocation
2929
* - should allow control of the threading model for async callback
30-
* - *reasonable* "fairness" is needed (we won't get fussy if there
31-
* are rare scenarios that don't guarantee absolute fairness)
30+
* - fairness is desirable; see note "FAIRNESS" below
3231
* - timeout support is required
3332
* value can be per mutex - doesn't need to be per-Wait[Async]
3433
* - a "using"-style API is a nice-to-have, to avoid try/finally
@@ -39,6 +38,31 @@ public sealed partial class MutexSlim
3938
* - async path uses custom awaitable with zero-alloc on immediate win
4039
*/
4140

41+
/* Note on FAIRNESS
42+
43+
in particular, consider the following scenario with two
44+
*incomplete* async calls from the same execution path:
45+
46+
// ** note not awaited yet
47+
var a = obj.DoSomethingAsync("a"); // that uses MutexSlim
48+
var b = obj.DoSomethingAsync("b"); // that uses MutexSlim
49+
50+
await a;
51+
await b;
52+
53+
Now: which runs first? "a"? or "b"? Without fairness, it can
54+
be either; consider that the lock is owned when "a" starts
55+
so a continuation is queued and the execution flow of
56+
DoSomethingAsync is suspended at the TryWaitAsync; now
57+
imagine a rare race when the thing releasing the lock
58+
happens at *exactly* the same time as our call into "b";
59+
MutexSlim allows callers to interlocked-take the token,
60+
so "b" can sneak in and win; so "b" runs, and activates
61+
"a" *on the way out*. The fairness of MutexSlim has
62+
been modified to prevent this problem.
63+
64+
*/
65+
4266
/* usage:
4367
4468
using (var token = mutex.TryWait())
@@ -117,64 +141,62 @@ private PendingLockItem DequeueInsideLock()
117141
[MethodImpl(MethodImplOptions.AggressiveInlining)]
118142
private void Release(int token, bool demandMatch = true)
119143
{
120-
// release the token (we can check for wrongness without needing the lock, note)
121-
Log($"attempting to release token {LockState.ToString(token)}");
122-
if (Interlocked.CompareExchange(ref _token, LockState.ChangeState(token, LockState.Pending), token) != token)
144+
// validate the token
145+
if (Volatile.Read(ref _token) != token)
123146
{
124147
Log($"token {LockState.ToString(token)} is invalid");
125148
if (demandMatch) Throw.InvalidLockHolder();
126149
return;
127150
}
128151

129-
ActivateNextQueueItem();
152+
ActivateNextQueueItemWithValidatedToken(token);
130153
}
131154

132-
private void ActivateNextQueueItem()
155+
private void ActivateNextQueueItemWithValidatedToken(int token)
133156
{
134157
// see if we can nudge the next waiter
135158
lock (_queue)
136159
{
137-
if (_queue.Count == 0)
138-
{
139-
Log($"no pending items to activate");
140-
_uncontested = true;
141-
return;
142-
}
143160
try
144161
{
145-
// there's work to do; try and get a new token
146-
Log($"pending items: {_queue.Count}");
147-
int token = TryTakeLoopIfChanges();
148-
if (token == 0)
162+
if (_queue.Count == 0)
149163
{
150-
// despite it being contested, somebody else
151-
// won the lock while we were busy thinking;
152-
// this is staggeringly rare, and means the
153-
// mutex isn't *absolteuly* fair, but it is
154-
// "fair enough" to be useful and practical
164+
Log($"no pending items to activate");
165+
_uncontested = true;
155166
return;
156167
}
157168

169+
// there's work to do; get a new token
170+
Log($"pending items: {_queue.Count}");
171+
172+
// we're expecting to activate; get a new token speculatively
173+
// (needs to be new so the old caller can't double-dispose and
174+
// release someone else's lock)
175+
Volatile.Write(ref _token, token = LockState.GetNextToken(token));
176+
177+
// try to give the new token to someone
158178
while (_queue.Count != 0)
159179
{
160180
var next = DequeueInsideLock();
161181
if (next.TrySetResult(token))
162182
{
163183
Log($"handed lock to {next}");
164-
return; // so we don't release the token
184+
token = 0; // so we don't release the token
185+
return;
165186
}
166187
else
167188
{
168189
Log($"lock rejected by {next}");
169190
}
170191
}
171-
172-
// nobody actually wanted it; return it
173-
Log("returning unwanted lock");
174-
Volatile.Write(ref _token, LockState.ChangeState(token, LockState.Pending));
175192
}
176193
finally
177194
{
195+
if (token != 0) // nobody actually wanted it; return it
196+
{ // (this could be the original token, or a new speculative token)
197+
Log("returning unwanted lock");
198+
Volatile.Write(ref _token, LockState.ChangeState(token, LockState.Pending));
199+
}
178200
_uncontested = _queue.Count == 0;
179201
SetNextAsyncTimeoutInsideLock();
180202
}

tests/Pipelines.Sockets.Unofficial.Tests/MutexSlimTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -679,7 +679,7 @@ public void DuelingThreadsShouldNotStall(int workerCount, int perWorker)
679679

680680
[Theory]
681681
[InlineData(1, 30000000)] // uncontested
682-
[InlineData(2, 15000000)] // duel
682+
[InlineData(2, 1500000)] // duel
683683
[InlineData(10, 150000)] // battle royale
684684
public async Task DuelingThreadsShouldNotStallAsync(int workerCount, int perWorker)
685685
{

0 commit comments

Comments
 (0)