Skip to content

Commit dc2ef76

Browse files
committed
fix(threading): a waiting writer blocks new readers (#1957, SR-AUD-204) — closes #1957
Rule-14 sweep, final member of #1957. The read-admission predicate was !writerActive_ alone -- it asked whether a writer HELD the lock, never whether one was WAITING for it -- so a steady arrival of readers starved a blocked writer indefinitely. .NET's own comment is the derivation: WaitOnEvent sets WAITING_WRITERS and WAITING_UPGRADER as soon as the first writer waits, under "Setting these bits will prevent new readers from getting in" (ReaderWriterLockSlim.cs:1005-1010). Both bits sit above MAX_READER, so its single test `_owners < MAX_READER` refuses a reader whenever a writer holds OR awaits the lock. This port keeps named fields, so the bit becomes a counter and the predicate gains one term. Both kinds of writer count, as both .NET bits do -- an upgrade-to-write blocks readers too, which is the easy half to miss and is pinned separately. A timed-out writer stops blocking, via an RAII guard mirroring .NET's finally, because that failure mode would be permanent rather than transient. Writer preference cannot deadlock a recursive reader: every thread already holding a read, write or upgrade lock returns before the predicate. This is a fairness change -- a reader-heavy workload that never blocked can now block. That is the point, and it is .NET's documented behaviour. sizeof(ReaderWriterLockSlim) is 120 before and after. Two mutations, both caught. Every probing reader runs on its own thread, and that is not incidental: NoRecursion makes a second read acquisition on the holding thread throw, and an earlier draft did exactly that and took the executable down with "terminate called without an active exception". ThreadSanitizer, with two limits stated rather than implied: the full target CANNOT build under TSan, because Thread::MemoryBarrier() is std::atomic_thread_fence, which gcc rejects under -Werror=tsan -- a pre-existing incompatibility reached from an unrelated file. So the evidence is a focused probe, clean over 1,020,489 read acquisitions; the probe was shown able to report (a deliberate unsynchronised counter yields 2 race warnings); and the probe does NOT reproduce the starvation, reporting writes=6089 even pre-repair, because its readers do not overlap continuously -- the deterministic gtest does that instead. #1957 is now CLOSED: SR-AUD-202 landed as #2341, and SR-AUD-201, SR-AUD-210 and SR-AUD-204 all landed today. Its section-9 approval question is answered by SA-3, granted after the design record was written. Downstream measured: 0 sites in cna, 0 in mobile-eggbert. Gate: 17,467 run, 17,467 passed, 0 failed, 0 skipped across 38 executables (+5 on 17,462; SharpRuntimeTests_Threading 486 -> 491; no other executable moved). Module graph unchanged at 41/93.
1 parent cb12dcb commit dc2ef76

5 files changed

Lines changed: 324 additions & 2 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
<!-- Copyright (c) Robert Vokac and contributors -->
3+
4+
# Migration — a waiting writer now blocks new readers (ticket #1957, SR-AUD-204)
5+
6+
*2026-08-19.* `System::Threading::ReaderWriterLockSlim` gives writers precedence: once a writer is
7+
waiting, a **new** reader waits behind it instead of walking straight in.
8+
9+
**This is a fairness change. A reader-heavy workload that never blocked can now block.** That is
10+
the point — it is what stops the writer starving — and it is .NET's documented behaviour. Read §3
11+
before upgrading.
12+
13+
Landed under **SA-5**, with SA-3's layout condition discharged as **layout-neutral**:
14+
`sizeof(ReaderWriterLockSlim)` is **120 before and after**.
15+
16+
---
17+
18+
## 1. What was wrong
19+
20+
The read-admission predicate was `!writerActive_` — it asked only whether a writer *held* the
21+
lock, never whether one was *waiting for* it. So:
22+
23+
1. reader A holds the lock;
24+
2. writer W blocks in `TryEnterWriteLock`, waiting for `readers_ == 0`;
25+
3. reader B arrives — and was admitted immediately;
26+
4. reader C arrives — admitted;
27+
5. …and `readers_` never reaches zero, so **W waits for ever**.
28+
29+
Nothing bounded that. A steady arrival of readers starved the writer indefinitely.
30+
31+
## 2. What .NET does
32+
33+
.NET keeps the same signal, packed into its single `_owners` word:
34+
35+
```csharp
36+
private const uint WRITER_HELD = 0x80000000;
37+
private const uint WAITING_WRITERS = 0x40000000;
38+
private const uint WAITING_UPGRADER= 0x20000000;
39+
private const uint MAX_READER = 0x10000000 - 2;
40+
...
41+
// Setting these bits will prevent new readers from getting in.
42+
if (_numWriteWaiters == 1) SetWritersWaiting(); // ReaderWriterLockSlim.cs:1005-1010
43+
if (_numWriteUpgradeWaiters == 1) SetUpgraderWaiting();
44+
```
45+
46+
Both waiting bits sit **above** `MAX_READER`, so .NET's single admission test
47+
`if (_owners < MAX_READER)` refuses a new reader whenever a writer holds the lock **or** is
48+
waiting for it. One comparison, two conditions.
49+
50+
This port keeps its state in named fields rather than one packed word, so the bit becomes a
51+
counter, `waitingWriters_`, and the predicate gains one term.
52+
53+
**Both kinds of writer count**, exactly as both .NET bits do: a plain writer *and* an
54+
upgrade-to-write. Counting only plain writers would leave the upgrade path starvable, which is
55+
the easy half to miss — a test pins it.
56+
57+
**A writer that times out stops blocking readers.** .NET clears its bit in `WaitOnEvent`'s
58+
`finally` (`ReaderWriterLockSlim.cs:1039-1042`); here an RAII guard decrements on every exit —
59+
acquired, timed out, or thrown — and wakes the readers it was holding back. A guard that
60+
decremented only on success would wedge every future reader, and that failure mode is
61+
**permanent**, not transient.
62+
63+
## 3. What changes for a caller
64+
65+
| Situation | Was | Is |
66+
|---|---|---|
67+
| no writer waiting | reader enters | reader enters — **unchanged** |
68+
| a writer is waiting | reader enters, writer starves | reader **waits** |
69+
| an upgrader is waiting for the write lock | reader enters | reader **waits** |
70+
| the waiting writer times out || readers flow again immediately |
71+
| a thread re-entering a lock it already holds | unaffected | unaffected (§4) |
72+
73+
`TryEnterReadLock(timeout)` can now return `false` where it used to return `true`. If you were
74+
relying on readers always winning, you were relying on the starvation this removes.
75+
76+
## 4. Writer preference cannot deadlock a recursive reader
77+
78+
Every path that returns *before* the predicate is untouched:
79+
80+
* a thread that already holds a **read** lock returns early (or throws, under `NoRecursion`);
81+
* a thread holding the **write** or **upgrade** lock has implied read access and returns early.
82+
83+
So only a genuinely **new** reader can be delayed — which is precisely .NET's contract.
84+
85+
## 5. Evidence
86+
87+
Two mutations, **both caught**:
88+
89+
| Mutation | Caught by |
90+
|---|---|
91+
| M1 — the read predicate ignores waiting writers | `Fix1957_ANewReaderWaitsBehindABlockedWriter`, `Fix1957_AnUpgraderWaitingForWriteAlsoBlocksNewReaders` |
92+
| M2 — the guard decrements only on success | `Fix1957_ANewReaderWaitsBehindABlockedWriter`, `Fix1957_ATimedOutWriterStopsBlockingReaders` |
93+
94+
**Every probing reader runs on its own thread**, and that is not incidental: the default
95+
`LockRecursionPolicy` is `NoRecursion`, so a second read acquisition on the thread that already
96+
holds one throws `LockRecursionException` rather than testing admission. An earlier draft did
97+
exactly that and took the executable down with `terminate called without an active exception`,
98+
because the escaping exception left two `std::thread`s joinable.
99+
100+
Gate: **17,467 run, 17,467 passed, 0 failed, 0 skipped** across 38 executables — `+5` on 17,462,
101+
exactly the five new cases (`SharpRuntimeTests_Threading` 486 → 491). No other executable moved.
102+
Module graph unchanged at 41/93.
103+
104+
## 6. ThreadSanitizer
105+
106+
`docs/ThreadingNamespaceReviewPlan.md` §12 requires TSan for this member specifically. Three
107+
things are worth stating precisely, because two of them are limitations.
108+
109+
**The full test target cannot be built under TSan, and that is pre-existing.**
110+
`Thread::MemoryBarrier()` is `std::atomic_thread_fence`, which gcc rejects outright:
111+
*"'atomic_thread_fence' is not supported with '-fsanitize=thread' [-Werror=tsan]"*. It is reached
112+
from an unrelated test file and has nothing to do with this change —
113+
`ReaderWriterLockSlim.hpp` does not include `Thread.hpp` at all. This is the same incompatibility
114+
#2298 recorded.
115+
116+
**So the evidence is a focused probe** (`build-probe/1957_probe4_rwls_tsan.cpp`): four readers,
117+
two writers and an upgrader hammering the repaired type for three seconds. Result:
118+
119+
```
120+
reads=1020489 refusedReads=0 writes=6747 shared=6747 TSan: clean, no reports
121+
```
122+
123+
**And the probe was shown capable of reporting**, per the plan's §19.4 rule that a silent
124+
sanitizer is evidence about the probe until proven otherwise: the same probe with one deliberate
125+
unsynchronised counter added produces **2** `WARNING: ThreadSanitizer: data race` reports. The
126+
clean result above is therefore meaningful.
127+
128+
**What the probe does NOT show, stated rather than implied**: it does not reproduce the
129+
starvation. Run against the *pre-repair* predicate it still reports `writes=6089` — its writers
130+
use a timed `TryEnterWriteLock` and its readers sleep, so `readers_` reaches zero often enough
131+
for writers to get in anyway. Reproducing starvation needs readers that overlap *continuously*,
132+
which is what the deterministic unit test does by holding one reader open across the writer's
133+
arrival. The probe's job here is the race question; the gtest's is the fairness question.
134+
135+
## 7. Downstream, measured
136+
137+
`ReaderWriterLockSlim` appears in **zero** places in `cna` and **zero** in `mobile-eggbert`.
138+
Neither repository was modified.

modules/threading/include/System/Threading/ReaderWriterLockSlim.hpp

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,23 @@ namespace System::Threading {
8989
mutable std::mutex stateMtx_;
9090
mutable std::condition_variable cv_;
9191
intcs readers_ = 0;
92+
// Ticket #1957 / SR-AUD-204, cause T-E/2. Without this the read-admission predicate was
93+
// `!writerActive_` alone, so a steady stream of new readers could enter past a writer
94+
// already blocked in TryEnterWriteLock and starve it INDEFINITELY.
95+
//
96+
// .NET keeps the same signal, packed into its single `_owners` word: WaitOnEvent sets
97+
// WAITING_WRITERS (and WAITING_UPGRADER) as soon as the first writer begins waiting,
98+
// under the comment "Setting these bits will prevent new readers from getting in"
99+
// (ReaderWriterLockSlim.cs:1005-1010). Both bits sit above MAX_READER, so .NET's single
100+
// admission test `_owners < MAX_READER` refuses a new reader whenever a writer holds the
101+
// lock OR is waiting for it. This counter is that bit, spelled separately because this
102+
// port keeps its state in named fields rather than one packed word.
103+
//
104+
// BOTH kinds of writer count, exactly as both .NET bits do: a plain writer and an
105+
// upgrade-to-write. .NET clears each in WaitOnEvent's `finally`
106+
// (ReaderWriterLockSlim.cs:1039-1042), so a writer that TIMES OUT stops blocking readers;
107+
// the RAII guard in TryEnterWriteLock does the same here.
108+
intcs waitingWriters_ = 0;
92109
bool writerActive_ = false;
93110
bool upgradeableActive_ = false;
94111
// Ticket #1955 / cause T-A of docs/ThreadingNamespaceReviewPlan.md. This was an
@@ -215,7 +232,13 @@ namespace System::Threading {
215232
}
216233

217234
std::unique_lock<std::mutex> lk(stateMtx_);
218-
if (!waitFor(lk, millisecondsTimeout, [&] { return !writerActive_; })) return false;
235+
// #1957/SR-AUD-204: `waitingWriters_ == 0` is the new term. A thread that already
236+
// holds the read, write or upgrade lock never reaches here -- every one of those
237+
// cases returned above -- so writer preference can only delay a genuinely NEW
238+
// reader, which is precisely .NET's contract and cannot deadlock a recursive one.
239+
if (!waitFor(lk, millisecondsTimeout,
240+
[&] { return !writerActive_ && waitingWriters_ == 0; }))
241+
return false;
219242
++readers_;
220243
counts.reader = 1;
221244
counts.readerCountsTowardGlobal = true;
@@ -271,6 +294,24 @@ namespace System::Threading {
271294
"lock. If an upgrade is necessary, use an upgrade lock in place of the read lock.");
272295

273296
std::unique_lock<std::mutex> lk(stateMtx_);
297+
298+
// Announce the wait BEFORE waiting, so readers arriving from now on are refused --
299+
// .NET sets its bit at the same point, before the wait rather than after it. The
300+
// guard decrements on every exit (acquired, timed out, or thrown) and wakes the
301+
// readers it was holding back, which is what .NET's `finally` does via
302+
// ClearWritersWaiting + ExitAndWakeUpAppropriateReadWaiters.
303+
//
304+
// Declared after `lk` so it is destroyed BEFORE the lock is released: the decrement
305+
// and the notify both happen under the mutex.
306+
++waitingWriters_;
307+
struct WaitingWriterGuard {
308+
intcs& count;
309+
std::condition_variable& cv;
310+
~WaitingWriterGuard() {
311+
if (--count == 0) cv.notify_all();
312+
}
313+
} waitingWriterGuard{waitingWriters_, cv_};
314+
274315
bool acquired = upgradingToWrite
275316
? waitFor(lk, millisecondsTimeout, [&] { return readers_ == 0; })
276317
: waitFor(lk, millisecondsTimeout, [&] { return !writerActive_ && readers_ == 0 && !upgradeableActive_; });

modules/threading/tests/System/Threading/ThreadingBoundaryTests.cpp

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -993,3 +993,146 @@ TEST(BarrierPostPhaseReadabilityTests, Fix1957_TheOtherMembersStillRefuseReentra
993993
EXPECT_EQ(phase.load(), 0);
994994
EXPECT_EQ(participants.load(), 1);
995995
}
996+
997+
// =============================================================================================
998+
// Ticket #1957 / SR-AUD-204 — a waiting writer blocks subsequent readers.
999+
//
1000+
// The read-admission predicate was `!writerActive_` alone, with no waiting-writer term, so a
1001+
// steady stream of new readers entered past a writer already blocked in TryEnterWriteLock and
1002+
// could starve it INDEFINITELY.
1003+
//
1004+
// .NET keeps the same signal packed into its single `_owners` word: WaitOnEvent sets
1005+
// WAITING_WRITERS as soon as the first writer begins waiting, under the comment "Setting these
1006+
// bits will prevent new readers from getting in" (ReaderWriterLockSlim.cs:1005-1010). Both that
1007+
// bit and WAITING_UPGRADER sit above MAX_READER, so .NET's single admission test
1008+
// `_owners < MAX_READER` refuses a new reader whenever a writer holds the lock OR is waiting.
1009+
//
1010+
// Landed under SA-5, with SA-3's layout condition discharged as layout-neutral:
1011+
// sizeof(ReaderWriterLockSlim) is 120 before and after (build-probe/1957_probe3_rwls.cpp).
1012+
//
1013+
// THIS IS A FAIRNESS CHANGE: a reader-heavy workload that never blocked can now block. That is
1014+
// the point -- it is what stops the writer starving -- and it is .NET's documented behaviour.
1015+
//
1016+
// EVERY probing reader below runs on ITS OWN THREAD. The default LockRecursionPolicy is
1017+
// NoRecursion, so a second read acquisition on the thread that already holds one throws
1018+
// LockRecursionException rather than testing admission -- an earlier draft did exactly that and
1019+
// took the executable down with `terminate called without an active exception`, because the
1020+
// escaping exception left two std::threads joinable.
1021+
// =============================================================================================
1022+
1023+
namespace {
1024+
/// Tries to take a read lock on a fresh thread and reports whether it got in.
1025+
bool ReaderOnItsOwnThreadCanEnter(System::Threading::ReaderWriterLockSlim& lock,
1026+
SharpRuntime::intcs timeoutMs) {
1027+
std::atomic<bool> entered{false};
1028+
std::thread probe([&] {
1029+
if (lock.TryEnterReadLock(timeoutMs)) {
1030+
entered.store(true);
1031+
lock.ExitReadLock();
1032+
}
1033+
});
1034+
probe.join();
1035+
return entered.load();
1036+
}
1037+
}
1038+
1039+
TEST(ReaderWriterWriterPreferenceTests, Decl1957_TheWaitingWriterCountIsLayoutNeutral) {
1040+
static_assert(sizeof(System::Threading::ReaderWriterLockSlim) == 120,
1041+
"#1957/SR-AUD-204 must not change ReaderWriterLockSlim's layout");
1042+
static_assert(alignof(System::Threading::ReaderWriterLockSlim) == 8);
1043+
EXPECT_EQ(sizeof(System::Threading::ReaderWriterLockSlim), 120u);
1044+
}
1045+
1046+
TEST(ReaderWriterWriterPreferenceTests, Fix1957_ANewReaderWaitsBehindABlockedWriter) {
1047+
// THE DEFECT. A reader holds the lock; a writer blocks behind it; a NEW reader arrives on
1048+
// another thread. Before the repair that reader walked straight in, so an endless supply of
1049+
// readers starved the writer.
1050+
System::Threading::ReaderWriterLockSlim lock;
1051+
std::atomic<bool> writerWaiting{false};
1052+
std::atomic<bool> writerAcquired{false};
1053+
1054+
lock.EnterReadLock(); // first reader, held by this thread
1055+
1056+
std::thread writer([&] {
1057+
writerWaiting.store(true);
1058+
lock.EnterWriteLock();
1059+
writerAcquired.store(true);
1060+
lock.ExitWriteLock();
1061+
});
1062+
1063+
while (!writerWaiting.load()) std::this_thread::yield();
1064+
std::this_thread::sleep_for(std::chrono::milliseconds(150));
1065+
ASSERT_FALSE(writerAcquired.load()) << "the writer must still be blocked behind the reader";
1066+
1067+
EXPECT_FALSE(ReaderOnItsOwnThreadCanEnter(lock, 200))
1068+
<< "a new reader entered past a waiting writer -- the starvation this ticket removes";
1069+
1070+
lock.ExitReadLock(); // release the original reader; the writer now gets in
1071+
writer.join();
1072+
EXPECT_TRUE(writerAcquired.load());
1073+
1074+
// ...and once the writer is done, readers flow again.
1075+
EXPECT_TRUE(ReaderOnItsOwnThreadCanEnter(lock, 1000));
1076+
}
1077+
1078+
TEST(ReaderWriterWriterPreferenceTests, Fix1957_ATimedOutWriterStopsBlockingReaders) {
1079+
// .NET clears its bit in WaitOnEvent's `finally` (ReaderWriterLockSlim.cs:1039-1042), so a
1080+
// writer that gives up must stop holding readers back. A guard that decremented only on
1081+
// success would wedge every future reader -- the failure mode is permanent, not transient.
1082+
System::Threading::ReaderWriterLockSlim lock;
1083+
lock.EnterReadLock();
1084+
1085+
std::thread writer([&] {
1086+
EXPECT_FALSE(lock.TryEnterWriteLock(100)) << "the writer cannot get in past the reader";
1087+
});
1088+
writer.join();
1089+
1090+
EXPECT_TRUE(ReaderOnItsOwnThreadCanEnter(lock, 1000))
1091+
<< "a timed-out writer must stop blocking readers";
1092+
lock.ExitReadLock();
1093+
}
1094+
1095+
TEST(ReaderWriterWriterPreferenceTests, Fix1957_TheUncontendedPathsAreUnchanged) {
1096+
// Writer preference must cost nothing when no writer is waiting.
1097+
System::Threading::ReaderWriterLockSlim lock;
1098+
EXPECT_TRUE(lock.TryEnterReadLock(0));
1099+
lock.ExitReadLock();
1100+
EXPECT_TRUE(lock.TryEnterWriteLock(0));
1101+
lock.ExitWriteLock();
1102+
lock.EnterReadLock();
1103+
lock.ExitReadLock();
1104+
lock.EnterWriteLock();
1105+
lock.ExitWriteLock();
1106+
EXPECT_TRUE(ReaderOnItsOwnThreadCanEnter(lock, 0));
1107+
}
1108+
1109+
TEST(ReaderWriterWriterPreferenceTests, Fix1957_AnUpgraderWaitingForWriteAlsoBlocksNewReaders) {
1110+
// .NET sets WAITING_UPGRADER for the upgrade-to-write case as well, and it too sits above
1111+
// MAX_READER -- so BOTH kinds of writer block new readers. Counting only plain writers would
1112+
// leave the upgrade path starvable, which is the easy half to miss.
1113+
System::Threading::ReaderWriterLockSlim lock;
1114+
std::atomic<bool> upgraderWaiting{false};
1115+
std::atomic<bool> upgraded{false};
1116+
1117+
lock.EnterReadLock(); // a reader the upgrader must wait to drain
1118+
1119+
std::thread upgrader([&] {
1120+
lock.EnterUpgradeableReadLock();
1121+
upgraderWaiting.store(true);
1122+
lock.EnterWriteLock(); // waits for readers_ == 0
1123+
upgraded.store(true);
1124+
lock.ExitWriteLock();
1125+
lock.ExitUpgradeableReadLock();
1126+
});
1127+
1128+
while (!upgraderWaiting.load()) std::this_thread::yield();
1129+
std::this_thread::sleep_for(std::chrono::milliseconds(150));
1130+
ASSERT_FALSE(upgraded.load()) << "the upgrader must still be waiting for the reader";
1131+
1132+
EXPECT_FALSE(ReaderOnItsOwnThreadCanEnter(lock, 200))
1133+
<< "a new reader entered past an upgrader waiting for the write lock";
1134+
1135+
lock.ExitReadLock();
1136+
upgrader.join();
1137+
EXPECT_TRUE(upgraded.load());
1138+
}

plan.sqlite3

8 KB
Binary file not shown.

0 commit comments

Comments
 (0)