August 1, 2024·6 min read·engineeringvalkeythreadingperformancenetworking

Valkey 8's candidate moves socket work into asynchronous queues

Valkey 8.0 RC1 introduces asynchronous I/O threads. The job queues and client-state checks explain how the candidate moves network work away from its main execution thread.

A database's main thread can spend time transferring bytes that does not require changing the dataset. Moving that work elsewhere can give the execution thread more time for commands, but it introduces another problem. The server must know when a worker still owns a client's buffers. Valkey 8.0.0 RC1, released on August 1, makes that coordination part of its asynchronous I/O implementation.

The candidate's release notes list asynchronous I/O threading among its performance changes. This is a release candidate, so it provides a versioned implementation for evaluation rather than a declaration that the stable major release has shipped. The most useful technical story is how it moves work while retaining explicit ownership and completion state.

I favour Valkey where the Redis model is useful, and I have used that underlying technology since its release. Improving the network path is attractive when it preserves the familiar command model. Thread count alone does not explain the benefit, however. The coordination between the execution thread and its workers determines what work can overlap safely.

I/O threads have a defined job

The candidate's configuration template describes the server as mostly single threaded while identifying operations that already use side threads. Its threaded-I/O section covers client socket reads and writes, including protocol parsing. It does not describe general concurrent execution of every command against the dataset.

The io-threads setting selects the configured thread count. A value of one keeps I/O on the main thread, and the template's example uses four. An explicit configuration therefore looks like the following.

io-threads 4

That value is a configuration example, not a universal optimum. More workers require CPU time and introduce coordination. The template recommends leaving a spare core and enabling the option where the workload warrants it. The implementation also adjusts how many configured threads are active, which further separates the maximum configured count from the workers used at a particular moment.

The I/O thread source gives the main thread the producer role for each worker's queue. A job contains a handler function and a data pointer. The worker consumes those jobs and invokes the handlers. This provides a concrete route for assigning network-related work without turning the command engine into a collection of independent database writers.

A queue entry remains owned until work completes

Each queue uses a ring buffer with atomic head and tail indices. The producer writes the handler and its data into the current entry before publishing the new head with release ordering. The consumer loads the head with acquire ordering before reading the jobs. That ordering establishes visibility of the job fields when the worker sees the entry as available.

The worker does not remove a job before executing it. It peeks at the current entry, invokes the handler, and advances the tail afterward. A source comment explains the reason. If the main thread observes an empty queue, it must be able to conclude that the worker is no longer handling a job from that queue.

This is a useful completion convention because an empty work list can otherwise be ambiguous. It might mean there is no queued work while one item is still in progress. By keeping the current job represented until its handler returns, this implementation gives the empty condition a stronger meaning for its coordination code.

The queue code also avoids allocating a separate job object for every submission. It writes the fields directly into the ring entry. That saves allocation work in the dispatch path, while the assertions check that the reused entry has been cleared. The queue is therefore part of both performance and ownership management.

The full-queue check intentionally uses relaxed loads. Its comments describe the conservative outcome if the main thread temporarily concludes that the queue is full. The main thread can do the work itself. A stale observation in that decision can reduce offloading without permitting the producer to overwrite work the consumer still owns.

The implementation also supplies a queue-draining helper for the main thread. It waits until the worker queues become empty, using acquire fences to refresh its view of the indices. This depends on the convention that a handler's job remains in the ring until completion. The helper would have a different meaning if consuming a queue entry meant only starting the work.

Head and tail fields are aligned to cache-line boundaries in the queue structure. That separates frequently updated producer and consumer indices in memory. It is another example of the implementation addressing the cost of coordination alongside the socket operations themselves.

Client state constrains offloading

The read-dispatch method checks whether asynchronous workers are active and whether threaded reads are enabled. It also checks the client's current read state. A client already assigned for reading must not simultaneously return to the main thread's ordinary read handling. That state check prevents two execution paths from assuming ownership of the same operation.

The source excludes several client classes from the read-offload path. Primary and replica connections remain synchronous there, and Lua debugger clients receive special treatment because the main thread may write directly. Blocked and newly unblocked clients also remain on the main thread in this implementation. These are explicit limits on the set of clients the new path handles.

A separate helper waits for pending client reads and writes to complete. It checks the client state and uses acquire fences while waiting. This makes synchronization visible where another part of the server needs the worker's completed changes. The new code therefore needs more than a queue of function calls. It also needs rules for rejoining ordinary client processing.

These details explain why saying the server has become fully multithreaded is imprecise. The candidate moves particular work into worker jobs and retains exclusions for other paths. Command execution and network transfer have different ownership requirements. The implementation's state checks define where the work can separate and where it must meet again.

Configured threads can become inactive

The thread manager derives a target number of active threads from the current event load and the configured events-per-thread relationship. It bounds the target by the configured thread count. When reducing active workers, it checks that a worker's queue is empty before locking that worker out of activity. Pending jobs therefore constrain deactivation.

On initialization, the server begins with one active thread. Additional workers are created when the configured count exceeds one, but they start stopped. This distinction between creation and activation permits the runtime to change participation without creating a new operating-system thread for every change in event load.

The worker loop also includes a bounded period of checking for jobs before giving the main thread an opportunity to stop it through the mutex. That design exposes a tradeoff between responding to new work and spending CPU time waiting. Its consequences depend on workload and scheduling, which are not captured by the configured thread number alone.

I find this implementation more informative than a headline throughput multiplier. It identifies the network work being moved, the data structure used to move it, and the completion state that makes the handoff usable. Those mechanisms provide reasons a network-heavy workload may improve without claiming that an expensive command becomes cheaper merely because another thread handles its socket.

Replication and observability receive separate work

The candidate also introduces dual-channel full synchronization. Its replication source identifies a dedicated RDB transfer channel and separate handling for the main replication connection. That is another effort to separate work with different lifetimes, although it operates between primary and replica rather than between threads within one server.

Per-slot metrics arrive through CLUSTER SLOT-STATS, with the release notes identifying key counts and resource measurements. These make workload distribution more visible. The same notes mark the RDMA transport as experimental, which distinguishes it from the ordinary transport path an existing deployment already uses.

The candidate thus contains several independent changes around the engine's execution path. Asynchronous I/O moves eligible client work through bounded worker queues. Dual-channel synchronization separates snapshot transfer from another replication channel. Slot metrics help expose where cluster activity occurs. Their mechanisms and maturity differ, so each needs to be understood at its own interface while evaluating this first candidate.