Repository navigation
RwLock writers get starved on Windows #165314
Description
On Windows, a writer on a contended RwLock can wait 5-8 ms at p99 while the readers keep going. The same program on Linux stays under 100 us.
With both readers and writers waiting, wake_writer_or_readers is meant to wake one writer and leave the readers asleep. It only does that if futex_wake reports that it woke someone. On Windows it can't, since WakeByAddressSingle returns nothing, so futex_wake always returns false. Every contended unlock then wakes all the readers too, and the writer has to race them for the lock. It usually loses: about 30 wakeups per write in the program below.
The lock stays correct and the docs don't promise a priority policy, so this isn't a soundness problem. The latency is just bad. I have a fix and will open a PR shortly.
use std::hint::spin_loop;
use std::sync::RwLock;
use std::sync::atomic::{AtomicBool, Ordering::Relaxed};
use std::time::{Duration, Instant};
const SECS: u64 = 5;
fn busy(d: Duration) {
let t = Instant::now();
while t.elapsed() < d {
spin_loop();
}
}
fn main() {
let readers: usize = std::env::args().nth(1).map_or(6, |s| s.parse().unwrap());
let hold = Duration::from_micros(20);
let lock = RwLock::new(());
let stop = AtomicBool::new(false);
let mut waits = Vec::new();
std::thread::scope(|s| {
for _ in 0..readers {
s.spawn(|| {
while !stop.load(Relaxed) {
let guard = lock.read().unwrap();
busy(hold);
drop(guard);
busy(Duration::from_micros(5));
}
});
}
let start = Instant::now();
while start.elapsed() < Duration::from_secs(SECS) {
let t = Instant::now();
let guard = lock.write().unwrap();
let waited = t.elapsed();
busy(hold);
drop(guard);
waits.push(waited);
busy(Duration::from_micros(100));
}
stop.store(true, Relaxed);
});
waits.sort();
let q = |p: f64| waits[((waits.len() - 1) as f64 * p) as usize].as_micros();
println!("writes/s {} p50 {}us p99 {}us", waits.len() as u64 / SECS, q(0.5), q(0.99));
}Built with rustc -O, run with 6 readers on a Ryzen 5 5600:
| Setup | writes/s | p50 | p99 |
|---|---|---|---|
| Windows 10, 1.99.1 | 558-732 | 649-1063 us | 6802-8102 us |
| Windows 10, f9ceb4a | 531-674 | 733-1196 us | 8025-8428 us |
| Linux 6.18 (WSL2, musl), 1.99.1 | 4289-4323 | 41-42 us | 65-72 us |
The Linux row is from a VM, so it's only a rough comparison.
rustc 1.99.1 (58cf8f9c3 2026-10-13)
binary: rustc
commit-hash: 58cf8f9c35b355199727627d07ae34a4e2846d67
commit-date: 2026-10-13
host: x86_64-pc-windows-msvc
release: 1.99.1
LLVM version: 23.1.1
Activity
R3nzTheCodeGOD commentedon Nov 2, 2026
@rustbot claim
Jules-Bertholet commentedon Nov 2, 2026
@rustbot label T-libs O-windows A-concurrency I-slow
ds84182 commentedon Nov 2, 2026
This should be a problem for all platforms with futex primitives that do not return whether a thread was woken up. It might be best to switch all of them to the queue implementation (which is modeled after Windows' SRWLOCK :).
R3nzTheCodeGOD commentedon Nov 2, 2026
Yes, FreeBSD, DragonFly and Fuchsia have the same
futex_wakebehavior, though I can only measure Windows.I tried switching Windows to the queue lock. It does help, with a writer p99 of 832 us against 5070 us today on a similar load, and it loses less under heavy writes. But uncontended reads go from 3.8 ns to 5.0 ns, and read-only access on 6 threads gets 36% slower, so every
RwLockuser pays for it. Counting blocked writers leaves those paths alone and gets p99 down to 49 us on that same load, at the cost of 4 bytes. I'll put the full numbers in the PR.ds84182 commentedon Nov 2, 2026
Contended writes are likely slower than Linux because the queue implementation does not check the lock state while spinlooping like the futex implementation does, so it can't break out of the loop early.
R3nzTheCodeGOD commentedon Nov 2, 2026
I checked, and that loop isn't involved here. I added counters to
queue.rs, and across the whole benchmarklock_contendedwas never entered: 0 calls, 0 spin rounds, and reads still took 5.0 ns.The cost is in
read_unlock. With both fast paths reduced to a single atomic, the queue path is about 31% slower on one thread (3.5 ns to 4.6 ns) and 29% slower on 6. Replacing itstry_updateunlock with afetch_subremoves all of that (+0.1% and -0.8%). The real lock can't simply do this, becausestatemay hold a queue pointer, so the cost looks tied to the design and not to the spin loop.ds84182 commentedon Nov 2, 2026
Typo, sorry. I was referring to the writer's p99 latency still being quite high compared to Linux.
R3nzTheCodeGOD commentedon Nov 2, 2026
I measured this, and it isn't the spin loop. Checking the state on every spin left the writer p99 where it was (797 us before, 823 us after).
The writer loses its turn after it's woken.
unlock_queuesplits it off and leaves the lock unlocked withQUEUEDset, but the next reader to enqueue takesQUEUE_LOCKEDand runsunlock_queueitself. That call sees an unlocked lock and a reader at the tail, so it wakes all the readers and clears the queue. The writer then finds the lock read-locked and queues again.With 6 readers and 1 writer, 89% of these wakeups lost the lock, and an enqueuer had woken the readers in 99.5% of those losses, 1.3 us after the split on average. The writer takes 5.6 us to wake up. Linux behaves the same way (p99 1023 us on a copy of
queue.rs, 75 us on the futex lock).Handing the lock straight to the woken writer in that copy brings p99 down to 42 us on Windows and 73 us on Linux. It isn't a fix as it stands, since it costs throughput with short critical sections, and it doesn't change the read path. I'll file this separately.