Skip to content

Windows: Don't make RwLock writers race readers - #165337

Open
R3nzTheCodeGOD wants to merge 1 commit into
rust-lang:mainfrom
R3nzTheCodeGOD:windows-rwlock-writers
Open

R3nzTheCodeGOD wants to merge 1 commit into
rust-lang:mainfrom
R3nzTheCodeGOD:windows-rwlock-writers

Conversation

@R3nzTheCodeGOD

@R3nzTheCodeGOD R3nzTheCodeGOD commented Nov 2, 2026 •

Copy link
Copy Markdown

Fixes #165314

The futex RwLock is meant to prefer writers. #95801 does that by waking a writer first and only waking readers if futex_wake says there was no writer to wake. WakeByAddressSingle doesn't report that, so on Windows futex_wake is always false and we wake both and let them race, which is the thing #95801 set out to avoid. The writer usually loses.

Just returning true doesn't work: WRITERS_WAITING can be stale, and then nothing ever wakes the readers. So this counts the writers going to sleep on writer_notify. If the count isn't zero, a writer still has to retry the lock. It'll either take it or find it held, and either way whoever unlocks will see READERS_WAITING.

The compare-exchange before wake_writer had to become Acquire. With Relaxed it could see the unlock of a writer that had already left and still read the old count, which leaves the readers asleep for good. Miri finds this if you force the interleaving, and a litmus test of just this ordering on an Apple M4 saw the old count 718829 times in 2.9 billion runs with Relaxed and never in 2.8 billion with Acquire. It can't happen on x86, where the generated code doesn't change.

I also made write unlocks wake the readers even when a writer is waiting, since otherwise a queue of writers can keep them out for as long as it lasts. That's the one place this differs from Linux, and it's easy to drop if you'd rather match it exactly. With 4 writers and 12 readers it gives 1.3 to 3 times the reads of the count alone, for about half the writes.

On a Ryzen 5 5600 with Windows 10, before and after:

writes/s Writer p99 reads/s
Program from the issue 600 -> 6440 8 ms -> 30 us
1 writer every 1 ms, 6 readers x1.8 5 ms -> 40 us +13%
3 writers, 6 readers x2.2 2 ms -> 84 us -29%
4 writers, 12 readers, 5 us holds x18 24 ms -> 237 us -63%

That last row is the real cost. Reads were only that fast because writers barely got in, and total operations drop 60%. Read-heavy HashMap cache loads kept their throughput, though reader p99 got worse in some of them, mostly with more threads than CPUs. In real code Typst doesn't contend at all, so nothing changes, and the OpenTelemetry SDK gained 14-32% in a tight loop and nothing once there was work between calls.

RwLock<T> grows by 4 bytes unless T is aligned to 8. The uncontended paths are effectively unchanged. The queue-based RwLock is the obvious alternative and loses much less under heavy writes, but it makes uncontended reads 32% slower.

./x test library/std and Miri pass, but only on x86_64. I don't have Windows on ARM. A copy of the algorithm ran 460 million mixed operations on 4 to 200 threads on the M4 without hanging, but that says little, since the Relaxed version passes that stress test too. FreeBSD, DragonFly and Fuchsia have the same futex_wake behavior, and I've left them alone.

r? ChrisDenton

@rustbot rustbot added S-waiting-on-review Status: Awaiting review from the assignee but also interested parties. T-libs Relevant to the library team, which will review and decide on the PR/issue. labels Nov 2, 2026
@rustbot

rustbot commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the pull request, and welcome! The Rust Project has assigned @ChrisDenton (or someone else) to review your changes, you should hear from them (or someone else) within the next two weeks.

Please see the contribution instructions and our LLM policy for more information.

@joboet joboet left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll need to double-check this for correctness, but it makes sense to me...

View changes since this review

writer_notify: Futex,
// The number of writers going to sleep on `writer_notify`, for `wake_writer`.
#[cfg(target_os = "windows")]
blocked_writers: Futex,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't need to be futex-sized, but it must be large enough to hold the number of possible threads in the system if every one of them tried to write-lock at once. I suggest using AtomicU32 explicitly, that's large enough on Windows (thread IDs are 32-bit, so there cannot be more than $2^{32}$ threads in the process).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@ChrisDenton ChrisDenton left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix in general looks good but I'm a little uncomfortable sprinkling #[cfg(windows)] about the place, especially as this affects other platforms. Maybe this can be done using a separate wrapper type (e.g. BlockedWriters) that's just a ZST on most platforms? Or some way to make it easy to opt platforms in/out of this specifically.

View changes since this review

if self.state.compare_exchange(state, READERS_WAITING, Relaxed, Relaxed).is_err() {
// Acquire, so that `wake_writer` can't see a `blocked_writers` count from
// before the unlock of a writer that has already left.
if self.state.compare_exchange(state, READERS_WAITING, Acquire, Relaxed).is_err() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't have thought so but does this change have any measurable effect on Linux?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No. x86_64 generates the same code for all of std. On aarch64 it's just __aarch64_cas4_relax -> __aarch64_cas4_acq on the path where readers and writers are both waiting. I compared the asm but didn't run aarch64. Can make the Acquire conditional if you prefer.

`WakeByAddressSingle` doesn't report whether it woke anything, so
`futex_wake` is always false on Windows and `wake_writer_or_readers`
wakes a writer and the readers together. The writer usually loses.

Count the writers going to sleep on `writer_notify` and treat a non-zero
count as a wakeup that reached a writer. Write unlocks also wake the
readers, so that they don't have to wait for a whole queue of writers.
@R3nzTheCodeGOD

Copy link
Copy Markdown
Author

Done. Windows only for now. FreeBSD, DragonFly and Fuchsia would just need adding to the cfg_select!, but I haven't tried them.

@ChrisDenton

Copy link
Copy Markdown
Member

Thanks, I'll hand this over to joboet to double check the correctness

r? joboet

@rustbot rustbot assigned joboet and unassigned ChrisDenton Nov 3, 2026
@rustbot

rustbot commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator

joboet is currently at their maximum review capacity.
They may take a while to respond.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

S-waiting-on-review Status: Awaiting review from the assignee but also interested parties. T-libs Relevant to the library team, which will review and decide on the PR/issue.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RwLock writers get starved on Windows

4 participants