ChrisCrossCrash / ChrisCrossCrash/c3-http-request
Threaded downloads slower than cooperative on localhost (Windows), due to OS.delay_usec ~2 ms floor
- Dominant language
- GDScript
- Stars
- 5
- Forks
- 0
- PR merge metrics
- No merged PRs in 30d
Description
## Summary
When `Options.use_threads` is enabled, downloads against a **localhost** server on **Windows** are slower and far more variable than the cooperative (default) loop. The same code on a real network shows no such problem — in fact threaded is *faster* there — so this is largely a loopback artifact rather than a real-world defect. Filing to document the limitation and the investigation behind the decision not to work around it.
## Environment
- Godot 4.6.2-stable, Windows 11, AMD Ryzen 5 7600
- Reproduced via the benchmark in `examples/benchmark/`
## Symptom (localhost, 8 MB download)
| mode | wall time (median) |
|---|---|
| cooperative | ~35 ms |
| threaded | ~67–300 ms (highly variable; outliers to ~580 ms) |
Reads are byte-for-byte identical between the two modes — the entire gap is time spent *waiting*, not reading.
## Root cause
The threaded poll loop paces itself with `OS.delay_usec(_PUMP_DELAY_USEC)` on every "starve" (a poll that returns no body data). On Windows this call is floored by the scheduler timer granularity: probing shows **any requested value from 0 to 1000 µs sleeps ~2 ms**.
On loopback the *receiver* is the bottleneck (data is available at memory speed), so each 2 ms nap is a gross overshoot that also triggers a **TCP zero-window stall**: the small socket receive buffer fills, the window closes, the sender (`put_data`) blocks, and the pipe sits idle for the whole nap. With instrumentation, threaded performs ~97–144 such naps per 8 MB download (×~2 ms ≈ the observed gap); cooperative performs only ~3–24 (its per-frame yield lets the buffer pre-fill, so it drains in a few large bursts).
## Why it is mostly a localhost artifact
Against Cloudflare's speed-test endpoint (`https://speed.cloudflare.com/__down?bytes=N`), the bottleneck moves to the network and the result **reverses** — threaded becomes faster, because its ~2 ms polling is finer than cooperative's 16.67 ms frame-gated polling and picks up trickling data sooner:
| Cloudflare | cooperative | threaded |
|---|---|---|
| 1 MB | 250 ms | 217 ms |
| 8 MB | 400 ms | 333 ms |
So threaded mode delivers its intended benefit on the connections that actually matter; only artificially fast local links expose the nap.
## Why this is not being worked around
The only ways to wait less than ~2 ms on Windows all break the addon's "drop a few `.gd` files into `addons/`" simplicity:
- **Busy-wait/spin** — achieves sub-ms precision but pins a CPU core (no yield).
- **GDExtension** — could call the Win32 high-resolution waitable timer, but requires shipping/maintaining per-platform compiled binaries.
- **C# + P/Invoke** — same native-timer access, but forces the .NET build of Godot on all users.
Notably, Godot core itself implemented precise `delay_usec` on Windows ([PR #99178](https://github.com/godotengine/godot/pull/99178)) and **reverted it** ([#99688](https://github.com/godotengine/godot/issues/99688)) over excessive CPU usage. The gap is tracked upstream by [godot-proposals#13840 (HighPrecisionTimer)](https://github.com/godotengine/godot-proposals/issues/13840).
## Workaround / guidance for now
- On Windows, **cooperative mode (the default) is the better choice for large local downloads.**
- For real-network downloads, threaded mode is fine and often faster.
- This may resolve upstream if Godot re-lands a non-regressing precise `delay_usec`.
Contributor guide
Assessment
This issue has not been assessed yet.