Skip to content

RwLock writers get starved on Windows #165314

Description

@R3nzTheCodeGOD

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

  1. added
    needs-triageThis issue may need triage. Remove when done. See docs forge.rust-lang.org/release/issue-triaging
    on Nov 2, 2026
  2. R3nzTheCodeGOD commented on Nov 2, 2026

    @R3nzTheCodeGOD
    Author

    @rustbot claim

  3. Jules-Bertholet commented on Nov 2, 2026

    @Jules-Bertholet
    Contributor

    @rustbot label T-libs O-windows A-concurrency I-slow

  4. added
    I-slowIssue: Problems and improvements with respect to performance of generated code.
    O-windowsOperating system: Windows
    T-libsRelevant to the library team, which will review and decide on the PR/issue.
    on Nov 2, 2026
  5. ds84182 commented on Nov 2, 2026

    @ds84182

    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 :).

  6. R3nzTheCodeGOD commented on Nov 2, 2026

    @R3nzTheCodeGOD
    Author

    Yes, FreeBSD, DragonFly and Fuchsia have the same futex_wake behavior, 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 RwLock user 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.

  7. ds84182 commented on Nov 2, 2026

    @ds84182

    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.

  8. R3nzTheCodeGOD commented on Nov 2, 2026

    @R3nzTheCodeGOD
    Author

    I checked, and that loop isn't involved here. I added counters to queue.rs, and across the whole benchmark lock_contended was 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 its try_update unlock with a fetch_sub removes all of that (+0.1% and -0.8%). The real lock can't simply do this, because state may hold a queue pointer, so the cost looks tied to the design and not to the spin loop.

  9. ds84182 commented on Nov 2, 2026

    @ds84182

    I checked, and that loop isn't involved here.

    Typo, sorry. I was referring to the writer's p99 latency still being quite high compared to Linux.

  10. R3nzTheCodeGOD commented on Nov 2, 2026

    @R3nzTheCodeGOD
    Author

    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_queue splits it off and leaves the lock unlocked with QUEUED set, but the next reader to enqueue takes QUEUE_LOCKED and runs unlock_queue itself. 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.

  11. added
    C-optimizationCategory: An issue highlighting optimization opportunities or PRs implementing such
    and removed
    needs-triageThis issue may need triage. Remove when done. See docs forge.rust-lang.org/release/issue-triaging
    on Nov 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

A-concurrencyArea: ConcurrencyC-optimizationCategory: An issue highlighting optimization opportunities or PRs implementing suchI-slowIssue: Problems and improvements with respect to performance of generated code.O-windowsOperating system: WindowsT-libsRelevant to the library team, which will review and decide on the PR/issue.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions