Skip to content

Add futex-based blocking when main thread waits for IO poll results#4003

Open
asafpamzn wants to merge 1 commit into
valkey-io:unstablefrom
asafpamzn:futex-io-poll-blocking-clean
Open

Add futex-based blocking when main thread waits for IO poll results#4003
asafpamzn wants to merge 1 commit into
valkey-io:unstablefrom
asafpamzn:futex-io-poll-blocking-clean

Conversation

@asafpamzn

Copy link
Copy Markdown

Summary

Fixes #4001

When IO threads are enabled and the main thread waits for poll results from an IO thread, it currently busy-spins, consuming ~80- 100% CPU even at low throughput. This PR uses Linux futex to efficiently block the main thread when there's little work pending, while preserving the low-latency spinning behavior at high throughput.

Key changes:

  • Use io_poll_state as the futex variable (no extra synchronization needed)
  • Add io_poll_waiting flag to skip FUTEX_WAKE syscall when main thread is spinning
  • Block on futex with 20µs timeout when pending IO responses < threshold
  • Add configurable io-poll-block-threshold (default 8) for tuning
  • Add INFO stats: io_poll_blocked_count, io_poll_spinning_count
  • Wrapped in #ifdef __linux__ for cross-platform compatibility

Benchmark Results - Pipeline 1

Test Configuration

Parameter Value
Workload 80% GET, 20% SET (parallel)
IO Threads 8
Clients 400 per benchmark (800 total)
Pipeline 1 (no pipelining)
Value Size 512 bytes
Keys 3M
Duration 30 seconds per TPS level

CPU Usage (Main Thread)

image
Target TPS Baseline CPU Futex CPU CPU Saved
100K 69.6% 57.3% +12.3%
200K 88.4% 67.0% +21.4%
300K 95.1% 72.2% +22.9%
400K 98.2% 77.3% +20.9%
500K 98.8% 82.2% +16.6%
600K 99.5% 86.9% +12.6%
700K 99.8% 89.4% +10.4%
800K 100.0% 93.3% +6.7%
900K 100.0% 98.2% +1.8%
1M 100.0% 100.0% 0.0%

Latency

image

P50 Latency (ms)

Target TPS Baseline Futex
100K 0.335 0.343
300K 0.343 0.343
500K 0.351 0.343
700K 0.359 0.367
1M 0.471 0.455

P99 Latency (ms)

Target TPS Baseline Futex
100K 0.623 0.615
300K 2.839 2.407
500K 3.903 3.623
700K 3.559 3.655
1M 3.591 3.615

Throughput

No regression - both baseline and futex achieve ~1M TPS maximum.

Benchmark Results - Pipeline 10

Test Configuration

Parameter Value
Workload 80% GET, 20% SET (parallel)
IO Threads 9
Clients 400 per benchmark (800 total)
Pipeline 10
Value Size 512 bytes
Keys 3M
Duration 30 seconds per TPS level

CPU Usage (Main Thread)

image
Target TPS Baseline CPU Futex CPU CPU Saved
400K 63.5% 58.3% +5.2%
500K 77.0% 60.7% +16.3%
600K 84.4% 65.2% +19.2%
700K 89.3% 69.7% +19.6%
800K 92.3% 74.4% +17.9%
900K 95.1% 78.3% +16.8%
1M 98.4% 81.8% +16.6%
1.1M 99.6% 86.2% +13.4%
1.2M 99.9% 89.9% +10.0%
1.3M 100.0% 92.3% +7.7%
1.4M 102.8% 98.2% +4.6%

Latency

image

P50 Latency (ms)

Target TPS Baseline Futex
500K 0.351 0.359
700K 0.359 0.359
1M 0.367 0.391
1.2M 0.415 0.423
1.4M 3.683 3.639

P99 Latency (ms)

Target TPS Baseline Futex
500K 0.659 0.655
700K 0.675 0.639
1M 8.451 7.647
1.2M 9.427 8.303
1.4M 10.559 9.247

Throughput

No regression - No regression - both baseline and futex achieve 1.6M TPS

Notes

  • All tests were executed 3 times and results are reproducible. P99 is a bit noisy ~10% noise. we may want to run it several more times,

How It Works

  1. When getIOThreadPollResults() finds the IO thread still polling (AE_IO_STATE_POLL):

    • If pending IO responses < threshold (default 8): block on futex with 20µs timeout
    • Otherwise: spin (existing behavior for latency-sensitive high-TPS scenarios)
  2. When the IO thread completes polling in IOThreadPoll():

    • Sets state to AE_IO_STATE_DONE
    • Only calls FUTEX_WAKE if io_poll_waiting flag is set (avoids syscall overhead at high TPS)

When IO threads are enabled and the main thread is waiting for poll
results from an IO thread, this change uses futex to efficiently block
instead of busy-spinning when the pending IO response count is low.

The optimization saves 12-23% CPU at low-medium TPS (50K-700K) with
no throughput regression. At high TPS (>900K), pending responses are
high enough that the main thread spins to minimize latency.

Key changes:
- Use io_poll_state directly as the futex variable
- Add io_poll_waiting flag to skip FUTEX_WAKE syscall during spinning
- Add configurable io-poll-block-threshold (default 8) for tuning
- Add INFO stats: io_poll_blocked_count, io_poll_spinning_count
- Wrapped in #ifdef __linux__ for cross-platform compatibility

Signed-off-by: asafpamzn <asafp@amazon.com>
@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds futex-based blocking to the main thread's IO poll wait path on Linux. When pending IO responses are below a new configurable threshold (io-poll-block-threshold, default 8), the main thread blocks via FUTEX_WAIT_PRIVATE instead of spinning. IO threads issue FUTEX_WAKE_PRIVATE upon completing a poll. New struct fields, a config directive, and INFO stats counters support this mechanism.

Changes

Futex-based IO poll waiting with threshold config

Layer / File(s) Summary
Server struct fields and config directive
src/server.h, src/config.c
Adds io_poll_waiting (_Atomic(int)), io_poll_block_threshold (int), and two long long counters (stat_io_poll_blocked, stat_io_poll_spinning) to valkeyServer. Registers io-poll-block-threshold as a modifiable integer config with range 0–1000 and default value 8.
Futex wake and block/spin logic
src/io_threads.c
Adds Linux futex includes, initializes io_poll_waiting atomically to 0 in initIOThreads(), issues FUTEX_WAKE_PRIVATE from ioThreadPoll() when the main thread is marked waiting, and implements the block-vs-spin decision in getIOThreadPollResults() using FUTEX_WAIT_PRIVATE with a short timeout when pending responses fall below io_poll_block_threshold.
Stats reset and INFO output
src/server.c
Zeroes stat_io_poll_blocked and stat_io_poll_spinning in resetServerStats(), and exposes them as io_poll_blocked_count and io_poll_spinning_count in the stats section of the INFO command.

Sequence Diagram

sequenceDiagram
  participant MainThread
  participant getIOThreadPollResults
  participant ioThreadPoll as IOThread (ioThreadPoll)

  MainThread->>getIOThreadPollResults: call (io_poll_state == AE_IO_STATE_POLL)
  getIOThreadPollResults->>getIOThreadPollResults: read pending response count
  alt pending < io_poll_block_threshold
    getIOThreadPollResults->>getIOThreadPollResults: set io_poll_waiting = 1
    getIOThreadPollResults->>getIOThreadPollResults: FUTEX_WAIT_PRIVATE(io_poll_state, short timeout)
    getIOThreadPollResults->>getIOThreadPollResults: stat_io_poll_blocked++
  else pending >= threshold
    getIOThreadPollResults->>getIOThreadPollResults: spin on io_poll_state
    getIOThreadPollResults->>getIOThreadPollResults: stat_io_poll_spinning++
  end
  ioThreadPoll->>ioThreadPoll: set io_poll_state = AE_IO_STATE_DONE
  ioThreadPoll->>MainThread: FUTEX_WAKE_PRIVATE (if io_poll_waiting == 1)
  MainThread->>MainThread: resume, process poll results
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding futex-based blocking when the main thread waits for IO poll results, which directly addresses the core issue.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, explaining the problem, key changes, benchmark results, and implementation details.
Linked Issues check ✅ Passed The PR fully addresses issue #4001 by implementing futex-based blocking to reduce CPU consumption when the main thread waits for IO poll results, with benchmarks showing 12-23% CPU savings.
Out of Scope Changes check ✅ Passed All code changes are directly related to the objective of reducing CPU consumption through futex-based blocking: config directive, futex coordination logic, stats tracking, and struct fields.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/io_threads.c (1)

718-731: 💤 Low value

Non-Linux platforms silently skip the spin/block decision tracking.

On non-Linux platforms, neither stat_io_poll_blocked nor stat_io_poll_spinning is incremented when io_poll_state == AE_IO_STATE_POLL. This means the INFO stats will always show 0 on those platforms, which could be confusing for operators. Consider adding a spinning counter increment for non-Linux to maintain consistent stat semantics.

💡 Suggested improvement for cross-platform stat consistency
         if (io_state == AE_IO_STATE_POLL) {
                 /* IO thread is still processing poll events. */
 `#ifdef` __linux__
                 /* Decide whether to spin or block based on pending response count. */
                 int pending = getPendingIOResponsesCount();
                 if (pending < server.io_poll_block_threshold) {
                         /* Low pending count - block on futex to save CPU. */
                         atomic_store_explicit(&server.io_poll_waiting, 1, memory_order_release);
                         struct timespec ts = {0, 20000}; /* 20µs timeout */
                         syscall(SYS_futex, &server.io_poll_state, FUTEX_WAIT_PRIVATE, AE_IO_STATE_POLL, &ts, NULL, 0);
                         atomic_store_explicit(&server.io_poll_waiting, 0, memory_order_release);
                         server.stat_io_poll_blocked++;
                 } else {
                         /* High pending count - spin to minimize latency. */
                         server.stat_io_poll_spinning++;
                 }
+#else
+                /* Non-Linux: always spin, no futex available. */
+                server.stat_io_poll_spinning++;
 `#endif`
                 return 0;
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/io_threads.c` around lines 718 - 731, The spin/block decision tracking
inside the `#ifdef __linux__` preprocessor block is not executed on non-Linux
platforms, causing the INFO stats (stat_io_poll_blocked and
stat_io_poll_spinning) to always show 0 on those systems. Add an `#else` clause
after the Linux-specific implementation block to handle non-Linux platforms by
incrementing the stat_io_poll_spinning counter, ensuring consistent stat
semantics and meaningful INFO output across all supported platforms.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/io_threads.c`:
- Around line 245-250: The futex syscall in the io_threads.c file requires a
32-bit integer pointer, but the code passes a pointer to server.io_poll_state
which is of type AeIoState enum. Since the C standard does not guarantee enum
size, add a compile-time assertion to ensure AeIoState is exactly 32 bits. Place
a static_assert statement near the AeIoState enum definition in src/server.h to
verify that sizeof(AeIoState) equals 4 bytes, preventing undefined behavior on
platforms where the enum might be a different size.

---

Nitpick comments:
In `@src/io_threads.c`:
- Around line 718-731: The spin/block decision tracking inside the `#ifdef
__linux__` preprocessor block is not executed on non-Linux platforms, causing
the INFO stats (stat_io_poll_blocked and stat_io_poll_spinning) to always show 0
on those systems. Add an `#else` clause after the Linux-specific implementation
block to handle non-Linux platforms by incrementing the stat_io_poll_spinning
counter, ensuring consistent stat semantics and meaningful INFO output across
all supported platforms.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9cc486a2-59a4-4e8f-bba5-8ef8cf8700c4

📥 Commits

Reviewing files that changed from the base of the PR and between 436dcae and e45d51d.

📒 Files selected for processing (4)
  • src/config.c
  • src/io_threads.c
  • src/server.c
  • src/server.h

Comment thread src/io_threads.c
Comment on lines +245 to +250
#ifdef __linux__
/* Wake main thread via futex only if it's actually waiting. */
if (atomic_load_explicit(&server.io_poll_waiting, memory_order_acquire)) {
syscall(SYS_futex, &server.io_poll_state, FUTEX_WAKE_PRIVATE, 1, NULL, NULL, 0);
}
#endif

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find the definition of AeIoState to verify it's 32-bit compatible
rg -n 'typedef.*AeIoState|enum.*AeIoState|AeIoState' --type=c --type-add 'c:*.h' -A3 -B1

Repository: valkey-io/valkey

Length of output: 772


🏁 Script executed:

sed -n '695,710p' src/server.h

Repository: valkey-io/valkey

Length of output: 518


🏁 Script executed:

rg -n 'sizeof.*AeIoState|static_assert.*AeIoState' --type=c --type-add 'c:*.h'

Repository: valkey-io/valkey

Length of output: 42


🏁 Script executed:

sed -n '240,255p' src/io_threads.c

Repository: valkey-io/valkey

Length of output: 644


🏁 Script executed:

rg -n 'static_assert.*sizeof|_Static_assert' --type=c --type-add 'c:*.h'

Repository: valkey-io/valkey

Length of output: 2234


Add static_assert to verify AeIoState is 32-bit for futex compatibility.

The futex syscall operates on a 32-bit integer (uint32_t*). While AeIoState is a plain enum that typically defaults to int (32-bit on most platforms), the C standard does not guarantee enum size. Add a compile-time check to ensure AeIoState is exactly 32 bits before passing it to futex, matching the pattern already used throughout this codebase:

static_assert(sizeof(AeIoState) == 4, "AeIoState must be 32-bit for futex compatibility");

Place this assertion near the enum definition in src/server.h to prevent undefined behavior on platforms where the enum might be a different size.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/io_threads.c` around lines 245 - 250, The futex syscall in the
io_threads.c file requires a 32-bit integer pointer, but the code passes a
pointer to server.io_poll_state which is of type AeIoState enum. Since the C
standard does not guarantee enum size, add a compile-time assertion to ensure
AeIoState is exactly 32 bits. Place a static_assert statement near the AeIoState
enum definition in src/server.h to verify that sizeof(AeIoState) equals 4 bytes,
preventing undefined behavior on platforms where the enum might be a different
size.

@codecov

codecov Bot commented Jun 18, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 76.74%. Comparing base (436dcae) to head (e45d51d).
⚠️ Report is 53 commits behind head on unstable.

Additional details and impacted files
@@             Coverage Diff              @@
##           unstable    #4003      +/-   ##
============================================
- Coverage     76.75%   76.74%   -0.02%     
============================================
  Files           162      162              
  Lines         81017    81031      +14     
============================================
- Hits          62187    62184       -3     
- Misses        18830    18847      +17     
Files with missing lines Coverage Δ
src/config.c 78.93% <ø> (ø)
src/io_threads.c 76.87% <100.00%> (+1.04%) ⬆️
src/server.c 89.50% <100.00%> (-0.03%) ⬇️
src/server.h 100.00% <ø> (ø)

... and 18 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@zuiderkwast

Copy link
Copy Markdown
Contributor

@valkey-review-bot Please review this PR.

Also, please explain if and why using the futex syscall directly is better than using pthread_mutex. Is there a performance difference for this use case?

@valkey-review-bot

valkey-review-bot Bot commented Jun 18, 2026

Copy link
Copy Markdown

Reviewed the futex coordination, the wake/wait ordering, and the build (compiles clean on __linux__ with -Wall -Wextra). The lost-wakeup case is handled correctly: because the futex word is io_poll_state, a DONE store that races ahead of the main thread makes FUTEX_WAIT return EAGAIN immediately, and the 20µs timeout is a bound on any residual miss — so there is no hang. One interaction with processEventsWhileBlocked is worth addressing, flagged inline.

Re: futex vs pthread_mutex/condvar for this use case — futex is the better fit here, for reasons specific to this design:

  • The hot path is the wake side. With this scheme the IO thread skips the wake syscall entirely (io_poll_waiting == 0) whenever the main thread is spinning, so at high TPS the completion path is just an atomic load + branch, zero syscalls. With a mutex+condvar you cannot elide pthread_cond_signal that cheaply, because suppressing it safely still requires holding the mutex to avoid losing the wakeup.
  • The wait condition is fused into the syscall: FUTEX_WAIT(&io_poll_state, AE_IO_STATE_POLL) atomically rechecks the state and only sleeps if it is still POLL. A condvar needs a separate predicate guarded by a mutex; here the state word doubles as the futex, so no extra lock or predicate is needed.
  • For the common "results already ready" case, FUTEX_WAIT returns without sleeping; a condvar still pays a mutex lock/unlock each time.

The tradeoff is that the raw syscall(SYS_futex, …) is Linux-only (hence the #ifdef __linux__) and lower-level than pthread. Given the design already leans on io_poll_state as a shared atomic and wants a skippable wake, the direct futex is the right call; a mutex would add contention on the wake path that this PR is specifically trying to avoid. There is a measurable difference in this use case, and it favors futex.

Comment thread src/io_threads.c
/* Low pending count - block on futex to save CPU. */
atomic_store_explicit(&server.io_poll_waiting, 1, memory_order_release);
struct timespec ts = {0, 20000}; /* 20µs timeout */
syscall(SYS_futex, &server.io_poll_state, FUTEX_WAIT_PRIVATE, AE_IO_STATE_POLL, &ts, NULL, 0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This FUTEX_WAIT runs even when the event loop was entered with AE_DONT_WAIT. The custom-poll branch in aeProcessEvents (ae.c:428-429) calls custompoll(eventLoop) without consulting AE_DONT_WAIT, and processEventsWhileBlocked() drives the loop with AE_DONT_WAIT (networking.c:6443). Since trySendPollJobToIOThreads() runs unconditionally at the top of beforeSleep (server.c:1850), a poll job can be in flight with the custom poll proc installed during a busy-script yield or RDB/AOF load, and this path then sleeps up to 20µs in a loop that is supposed to make progress without waiting.

Gate the block on the recursive flag so the don't-wait path keeps spinning rather than entering the kernel:

Suggested change
syscall(SYS_futex, &server.io_poll_state, FUTEX_WAIT_PRIVATE, AE_IO_STATE_POLL, &ts, NULL, 0);
int pending = getPendingIOResponsesCount();
extern int ProcessingEventsWhileBlocked;
if (!ProcessingEventsWhileBlocked && pending < server.io_poll_block_threshold) {

(declare ProcessingEventsWhileBlocked via the existing extern in a header if you prefer not to repeat it locally.)

Comment thread src/io_threads.c
Comment on lines +245 to +250
#ifdef __linux__
/* Wake main thread via futex only if it's actually waiting. */
if (atomic_load_explicit(&server.io_poll_waiting, memory_order_acquire)) {
syscall(SYS_futex, &server.io_poll_state, FUTEX_WAKE_PRIVATE, 1, NULL, NULL, 0);
}
#endif

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.

we do not usually do that. we mostly place syscall handling as a macro in config.h

@ranshid

ranshid commented Jun 18, 2026

Copy link
Copy Markdown
Member

@valkey-review-bot Please review this PR.

Also, please explain if and why using the futex syscall directly is better than using pthread_mutex. Is there a performance difference for this use case?

I agree this sounds right. we should be aligned on also on non-linux platforms.
I suggest we add a small valkeyGate abstruction which implementation differ based on HAVE_FUTEX:

void gateInit(valkeyGate *g, uint32_t initial);
void gateDestroy(valkeyGate *g);
void gateWait(valkeyGate *g, uint32_t expected, long timeout_us);
void gateWake(valkeyGate *g);

@valkey-review-bot

valkey-review-bot Bot commented Jun 18, 2026

Copy link
Copy Markdown

Reviewed the futex coordination, the wait/wake ordering, the config/stat plumbing, and built valkey-server from this head (io_threads.c compiles clean). The mechanism itself is correct: because the futex word is io_poll_state, a DONE store on the IO thread that races the main thread's FUTEX_WAIT(expected=AE_IO_STATE_POLL) is caught by the kernel's in-bucket compare and returns EAGAIN immediately, so there's no lost-wakeup window. The io_poll_waiting flag correctly elides the FUTEX_WAKE syscall on the spin path.

The substantive concerns are already on the record and I agree with all three, so I won't re-anchor them: the AE_DONT_WAIT / processEventsWhileBlocked path entering the kernel (valkey-review-bot on io_threads.c:725, with the !ProcessingEventsWhileBlocked gate — that fix is correct and worth taking), the raw syscall(SYS_futex, ...) vs. a config.h capability macro (ranshid), and the missing 32-bit assertion on the futex word (coderabbit). On the last one: on the standard build the enum is 4 bytes (gnu11, no -fshort-enums), so it's hardening rather than a live bug, but the _Static_assert(sizeof(AeIoState) == 4, ...) is cheap and matches house style.

On futex vs. pthread_mutex/pthread_cond for this use case

A condvar requires the signaling side to hold the mutex (or accept a lost-wakeup race), so the IO thread's completion path in ioThreadPoll() would take a lock/unlock on every poll even when nobody is waiting — exactly the hot path this PR is trying to keep cheap. The futex design avoids that: the wake side is a single relaxed/acquire load of io_poll_waiting, and the syscall is skipped entirely when the main thread is spinning. It also lets the wait reuse the io_poll_state atomic the main thread is already polling, with a compare-and-block that a condvar can't express directly, and FUTEX_*_PRIVATE drops the shared-mapping overhead. So for a one-waiter / one-signaler, mostly-uncontended handoff, the futex is the faster primitive here; a condvar would add per-completion locking on the IO thread with no upside. The cost is portability and a hand-rolled wait word.

On non-Linux alignment (per the request)

Right now the whole block is #ifdef __linux__, so on macOS/*BSD the main thread keeps busy-spinning and io_poll_blocked_count / io_poll_spinning_count are always reported as 0 in INFO — the feature silently doesn't exist there. The valkeyGate abstraction in the request is a good fit, and there's already a portable precedent to model the fallback on: src/mutexqueue.c blocks via pthread_mutex + pthread_cond_wait. A gateWait/gateWake that compiles to futex under a HAVE_FUTEX macro (set in config.h alongside the other HAVE_* probes, gated on libc/OS the way dlopen-style features are) and to a pthread condvar otherwise would keep the stats meaningful everywhere and isolate the syscall from io_threads.c. Worth confirming the condvar fallback still shows a CPU win on a non-Linux box before committing to one fallback shape.

No new correctness issues beyond the above; the remaining items are the design/portability points the thread is already converging on.

@ranshid

ranshid commented Jun 18, 2026

Copy link
Copy Markdown
Member

Reviewed the futex coordination, the wait/wake ordering, the config/stat plumbing, and built valkey-server from this head (io_threads.c compiles clean). The mechanism itself is correct: because the futex word is io_poll_state, a DONE store on the IO thread that races the main thread's FUTEX_WAIT(expected=AE_IO_STATE_POLL) is caught by the kernel's in-bucket compare and returns EAGAIN immediately, so there's no lost-wakeup window. The io_poll_waiting flag correctly elides the FUTEX_WAKE syscall on the spin path.

The substantive concerns are already on the record and I agree with all three, so I won't re-anchor them: the AE_DONT_WAIT / processEventsWhileBlocked path entering the kernel (valkey-review-bot on io_threads.c:725, with the !ProcessingEventsWhileBlocked gate — that fix is correct and worth taking), the raw syscall(SYS_futex, ...) vs. a config.h capability macro (ranshid), and the missing 32-bit assertion on the futex word (coderabbit). On the last one: on the standard build the enum is 4 bytes (gnu11, no -fshort-enums), so it's hardening rather than a live bug, but the _Static_assert(sizeof(AeIoState) == 4, ...) is cheap and matches house style.

On futex vs. pthread_mutex/pthread_cond for this use case

A condvar requires the signaling side to hold the mutex (or accept a lost-wakeup race), so the IO thread's completion path in ioThreadPoll() would take a lock/unlock on every poll even when nobody is waiting — exactly the hot path this PR is trying to keep cheap. The futex design avoids that: the wake side is a single relaxed/acquire load of io_poll_waiting, and the syscall is skipped entirely when the main thread is spinning. It also lets the wait reuse the io_poll_state atomic the main thread is already polling, with a compare-and-block that a condvar can't express directly, and FUTEX_*_PRIVATE drops the shared-mapping overhead. So for a one-waiter / one-signaler, mostly-uncontended handoff, the futex is the faster primitive here; a condvar would add per-completion locking on the IO thread with no upside. The cost is portability and a hand-rolled wait word.

On non-Linux alignment (per the request)

Right now the whole block is #ifdef __linux__, so on macOS/*BSD the main thread keeps busy-spinning and io_poll_blocked_count / io_poll_spinning_count are always reported as 0 in INFO — the feature silently doesn't exist there. The valkeyGate abstraction in the request is a good fit, and there's already a portable precedent to model the fallback on: src/mutexqueue.c blocks via pthread_mutex + pthread_cond_wait. A gateWait/gateWake that compiles to futex under a HAVE_FUTEX macro (set in config.h alongside the other HAVE_* probes, gated on libc/OS the way dlopen-style features are) and to a pthread condvar otherwise would keep the stats meaningful everywhere and isolate the syscall from io_threads.c. Worth confirming the condvar fallback still shows a CPU win on a non-Linux box before committing to one fallback shape.

No new correctness issues beyond the above; the remaining items are the design/portability points the thread is already converging on.

I don't think we should preserve a divergence in this kind of behavior between different supported platforms. it is strange that on macos the reported CPU will behave much different than in linux. I think if we change this we should make this change cross all supported platform. It might be fine to apply a portable implementation and lose some tail latency when the throughput is low and we do not have many io tasks pending, but this is something we need to align on and test

@zuiderkwast

Copy link
Copy Markdown
Contributor

I don't think we should preserve a divergence in this kind of behavior between different supported platforms. it is strange that on macos the reported CPU will behave much different than in linux. I think if we change this we should make this change cross all supported platform.

@ranshid We can check if a POSIX fallback for this is performant enough and use it as a fallback, but if not, I think Linux-only is acceptable. Almost only Linux is used in production AFAIK. I do agree though that it'd be surprising to see busy-looping on one platform and not on another.

Btw, it appears you triggered another bot review by quoting my message to the bot. Was it intentional? :D

@ranshid

ranshid commented Jun 19, 2026

Copy link
Copy Markdown
Member

I don't think we should preserve a divergence in this kind of behavior between different supported platforms. it is strange that on macos the reported CPU will behave much different than in linux. I think if we change this we should make this change cross all supported platform.

@ranshid We can check if a POSIX fallback for this is performant enough and use it as a fallback, but if not, I think Linux-only is acceptable. Almost only Linux is used in production AFAIK. I do agree though that it'd be surprising to see busy-looping on one platform and not on another.

I might not fully agree. Even if linux is 99% used in production, I think it might be problematic that many developers will get different numbers and behaviors when running on their dev environments. I AM fine with suffering a little latency cost in low TPS (that would probably impact some benchmarking, but I do not expect that is will be very high - need to test it). Also are we sure there are not many FreeBSD/OpenBSD out there? (also they do have _umtx_op/futex(2) intrinsics ...)

Btw, it appears you triggered another bot review by quoting my message to the bot. Was it intentional? :D

let's make it formal: "Can me and your agent go outside to play?" :)

@dvkashapov dvkashapov 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 have some doubts about max throughput scenarios: I suspect there may be slight degradation in both latency and RPS because of extra branch, stats increment and atomic ops. In my opinion we need benchmarks for max throughput case before making any decisions

Comment thread src/config.c
createIntConfig("cluster-databases", NULL, IMMUTABLE_CONFIG, 1, INT_MAX, server.config_databases_cluster, 1, INTEGER_CONFIG, NULL, NULL),
createIntConfig("port", NULL, MODIFIABLE_CONFIG, 0, 65535, server.port, 6379, INTEGER_CONFIG, NULL, updatePort), /* TCP port. */
createIntConfig("io-threads", NULL, DEBUG_CONFIG | MODIFIABLE_CONFIG, 1, IO_THREADS_MAX_NUM, server.io_threads_num, 1, INTEGER_CONFIG, NULL, updateIOThreads), /* Single threaded by default */
createIntConfig("io-poll-block-threshold", NULL, MODIFIABLE_CONFIG, 0, 1000, server.io_poll_block_threshold, 8, INTEGER_CONFIG, NULL, NULL),

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 don't think any operator would want to change this because it's super low level, lets at least make it hidden config

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I agree, lets make it hidden.

@zuiderkwast zuiderkwast Jun 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm not sure I agree with this. I believe it can be very useful to tweak it to balance between CPU usage and latency. Our customers are very worried about high CPU usage and they monitor it carefully for every micro service, so they will likely want to lower the time spinning. (We haven't started using I/O threads yet btw, partly due to the spinning.) Spare capacity (CPU, memory, network) is important for handling bursts (and things like full sync during full traffic) in a highly reliable system.

But I'm not sure the trigger is the best one. How did you decide to use the number of pending jobs for this threshold instead of e.g. spinning a fixed number of iterations (say 10000, or configurable) and then going to sleep?

Comment thread src/server.c
Comment on lines +6547 to +6548
"io_poll_blocked_count:%lld\r\n", server.stat_io_poll_blocked,
"io_poll_spinning_count:%lld\r\n", server.stat_io_poll_spinning,

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.

And those metrics would not say a lot to a regular user, maybe better to not include them at all?

@asafpamzn asafpamzn Jun 30, 2026

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.

mmm... I agree that they can be removed
Though we may want to keep those in order to have tests.
We need to test it somehow.

We will need to find a way to test it

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We can add them under INFO debug section. Those are undocumented and don't need backward compatibility.

Comment thread src/io_threads.c
Comment on lines +718 to +732
#ifdef __linux__
/* Decide whether to spin or block based on pending response count. */
int pending = getPendingIOResponsesCount();
if (pending < server.io_poll_block_threshold) {
/* Low pending count - block on futex to save CPU. */
atomic_store_explicit(&server.io_poll_waiting, 1, memory_order_release);
struct timespec ts = {0, 20000}; /* 20µs timeout */
syscall(SYS_futex, &server.io_poll_state, FUTEX_WAIT_PRIVATE, AE_IO_STATE_POLL, &ts, NULL, 0);
atomic_store_explicit(&server.io_poll_waiting, 0, memory_order_release);
server.stat_io_poll_blocked++;
} else {
/* High pending count - spin to minimize latency. */
server.stat_io_poll_spinning++;
}
#endif

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'm thinking wether blocking inside the callback changes scheduling? aeProcessEvents() calls the custom poll proc before the aftersleep callback and before processing fired events, so a 20 microsecond timeout can add latency to ready work if the IO poll completion races. Although I don't know how probable is that in the real world case

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.

Thanks @dvkashapov ,

  1. Can you please elaborate on the potential race?
  2. In most workloads the 20us is not something that customers will notice.

Having said the above, please describe scenario and we can further discuss and make changes if required.

@asafpamzn

Copy link
Copy Markdown
Author

I have some doubts about max throughput scenarios: I suspect there may be slight degradation in both latency and RPS because of extra branch, stats increment and atomic ops. In my opinion we need benchmarks for max throughput case before making any decisions

We tested it in max throughput scenarios and there is no degradation, there is a single branch and single counter.
If you have specific use case please share results or let us know and we can run and and report.

Thanks again for the feedback,

@zuiderkwast

Copy link
Copy Markdown
Contributor

This is a very nice feature. I like the approach of balancing spinning and sleeping.

I agree with @ranshid that we should add a fallback to pthread_cond for non-Linux posix systems. It could be done later but it maybe be good to think about it from the start. I don't think it's possible to use the same atomic variable for spinning and blocking with pthread_cond, but I don't know how much that matters. Probably not much in practice.

I think we should look also at the I/O threads and make then too use a balance of spinning and sleeping.

The thread that does epoll (or kqueue or similar) can use sleep on this instead of a futex and other threads can wake it by writing to a pipe. To know if and how a particular thread is sleeping, we could use a bit flags in an atomic variable, so that we can set it a bit for WAITING, one for SPINNING, one for sleeping in a futex/pthread_cond and one for sleeping in epoll/kqueue.

I looked into how Erlang's runtime does this balance when waiting for a lock or shared resource or similar. It spinns a fixed number of iterations configurable in steps (e.g. very high = 40000, high = 20000, medium = 10000, low = 5000, none = 0) and then falls asleep.

I believe the Go runtime does something similar to balance spinning and sleeping in the runtime, e.g. when scheduling go routines among the available threads, or in lockfree queues, locks, etc.

@asafpamzn

Copy link
Copy Markdown
Author

@zuiderkwast , I'm aligned on supporting mutex with cond var on non linux posix systems. This was my first implementation but it caused performance degradation of few percents. Thus, I suggest to use mutex+cond for non linux posix systems. I agree that we should do it from the start for completeness.

To complete the picture: I also tried with event FD, but it was not efficient as well.

We can take I/O threads as step 2.

@zuiderkwast

Copy link
Copy Markdown
Contributor

it caused performance degradation of few percents. Thus, I suggest to use mutex+cond for non linux posix systems. I agree that we should do it from the start for completeness.

OK, yeah, so we may want to spin longer to avoid performance regression, depending on OS?

To complete the picture: I also tried with event FD, but it was not efficient as well.

OK, good to know.

Regarding the 20 microsecond futex timeout, if epoll has been delegated to an IO thread, nothing can happen on the main thread, so we can hang longer on the futex, until an IO thread wakes the main thread?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[NEW] Main thread consumes high CPU spinning while waiting for IO thread poll results

5 participants