Valkey/异步 I/O 任务派发

用有界环形队列向 I/O 线程派发任务

Valkey 为每个 I/O 线程维护一条单生产者、单消费者任务环;主线程发布任务,I/O 线程按序执行,队列满时由主线程保留处理路径。

2b5c7a0db并发与一致性核验 2026-10-12
相关概念

工程问题#

把网络读取交给工作线程能分担主线程工作,但逐任务分配节点、共享多生产者队列或在队列满时等待,都可能让派发本身成为开销来源。

实际实现#

IOJobQueue 预先分配固定槽位,以 head/tail 索引区分生产位置与消费位置,每条队列只有主线程生产、对应 I/O 线程消费。生产者先写任务函数和 data,再以 release store 发布 head;消费者 acquire load 读取可见任务数。任务回调执行完才推进 tail,因此队列包含正在执行的任务。读取派发失败时,readQueryFromClient 仍可在主线程继续处理。

处理流程#

  1. 网络读回调先尝试 postponeClientRead;I/O 线程未启用、客户端不适合移交等情况保留主线程路径。
  2. 按客户端与当前 I/O 状态选择线程,检查对应任务队列是否已满;已满时返回 C_ERR。
  3. 把函数指针和客户端指针写入预分配槽位,再发布新的 head,客户端登记为等待 I/O。
  4. I/O 线程读取已发布任务数量,逐项调用 handler(data),执行完成后才清空槽位并推进 tail。
  5. 主线程处理完成状态和后续命令;I/O 派发不等同于把所有数据库命令并行执行。

源码片段#

固定版本 · 原文摘录
src/networking.c2b5c7a0dbd61fd4281ef6166b7d119ba7fe7368c · L3182–L3195
查看原文件
3182void readQueryFromClient(connection *conn) {3183    client *c = connGetPrivateData(conn);3184    /* Check if we can send the client to be handled by the IO-thread */3185    if (postponeClientRead(c)) return;31863187    if (c->io_write_state != CLIENT_IDLE || c->io_read_state != CLIENT_IDLE) return;31883189    readToQueryBuf(c);31903191    if (handleReadResult(c) == C_OK) {3192        if (processInputBuffer(c) == C_ERR) return;3193    }3194    beforeNextClient(c);3195}
readQueryFromClient

主线程先尝试延后读取;未成功派发且客户端状态允许时,继续调用 readToQueryBuf 和输入处理。队列满导致的 C_ERR 经 postponeClientRead 转为不延后,并非直接丢弃请求。

src/io_threads.c2b5c7a0dbd61fd4281ef6166b7d119ba7fe7368c · L343–L355
查看原文件
343    IOJobQueue *jq = &io_jobs[tid];344    if (IOJobQueue_isFull(jq)) return C_ERR;345346    c->cur_tid = tid;347    c->read_flags = canParseCommand(c) ? 0 : READ_FLAGS_DONT_PARSE;348    c->read_flags |= authRequired(c) ? READ_FLAGS_AUTH_REQUIRED : 0;349350    c->io_read_state = CLIENT_PENDING_IO;351    connSetPostponeUpdateState(c->conn, 1);352    IOJobQueue_push(jq, ioThreadReadQueryFromClient, c);353    c->flag.pending_read = 1;354    listLinkNodeTail(server.clients_pending_io_read, &c->pending_read_list_node);355    return C_OK;
trySendReadToIOThreads

前文已完成适用条件与线程选择;本段在满队列时返回 C_ERR,否则登记客户端状态、发布读任务并加入待完成列表。队列中的数据是 client 指针,其生命周期由外围状态协议管理。

src/io_threads.c2b5c7a0dbd61fd4281ef6166b7d119ba7fe7368c · L44–L52
查看原文件
44static int IOJobQueue_isFull(const IOJobQueue *jq) {45    debugServerAssertWithInfo(NULL, NULL, inMainThread());46    size_t current_head = atomic_load_explicit(&jq->head, memory_order_relaxed);47    /* We don't use memory_order_acquire for the tail due to performance reasons,48     * In the worst case we will just assume wrongly the buffer is full and the main thread will do the job by itself. */49    size_t current_tail = atomic_load_explicit(&jq->tail, memory_order_relaxed);50    size_t next_head = (current_head + 1) % jq->size;51    return next_head == current_tail;52}
IOJobQueue_isFull

next_head 与 tail 相等表示满,因而实际可用容量少一个槽。实现以 relaxed 读取 tail;原注释允许保守地判满并回退,本文不将这一检查当作通用内存回收屏障。

src/io_threads.c2b5c7a0dbd61fd4281ef6166b7d119ba7fe7368c · L56–L73
查看原文件
56static void IOJobQueue_push(IOJobQueue *jq, job_handler handler, void *data) {57    debugServerAssertWithInfo(NULL, NULL, inMainThread());58    /* Assert the queue is not full - should not happen as the caller should check for it before. */59    serverAssert(!IOJobQueue_isFull(jq));6061    /* No need to use atomic acquire for the head, as the main thread is the only one that writes to the head index. */62    size_t current_head = atomic_load_explicit(&jq->head, memory_order_relaxed);63    size_t next_head = (current_head + 1) % jq->size;6465    /* We store directly the job's fields to avoid allocating a new iojob structure. */66    serverAssert(jq->ring_buffer[current_head].data == NULL);67    serverAssert(jq->ring_buffer[current_head].handler == NULL);68    jq->ring_buffer[current_head].data = data;69    jq->ring_buffer[current_head].handler = handler;7071    /* memory_order_release to make sure the data is visible to the consumer (the IO thread). */72    atomic_store_explicit(&jq->head, next_head, memory_order_release);73}
IOJobQueue_push

主线程直接填入空闲槽的 data 和 handler,再以 release 发布 head。只有入队索引发布使用了这里展示的次序,槽位复用和客户端完成仍要结合消费端及外围协议。

src/io_threads.c2b5c7a0dbd61fd4281ef6166b7d119ba7fe7368c · L79–L90
查看原文件
79static size_t IOJobQueue_availableJobs(const IOJobQueue *jq) {80    debugServerAssertWithInfo(NULL, NULL, !inMainThread());81    /* We use memory_order_acquire to make sure the head and the job's fields are visible to the consumer (IO thread). */82    size_t current_head = atomic_load_explicit(&jq->head, memory_order_acquire);83    size_t current_tail = atomic_load_explicit(&jq->tail, memory_order_relaxed);8485    if (current_head >= current_tail) {86        return current_head - current_tail;87    } else {88        return jq->size - (current_tail - current_head);89    }90}
IOJobQueue_availableJobs

消费者用 acquire 读 head,结合自己的 tail 计算可消费数量,并处理绕回。这个固定快照只是本轮工作量,新入队任务可以留待后续轮次。

src/io_threads.c2b5c7a0dbd61fd4281ef6166b7d119ba7fe7368c · L243–L256
查看原文件
243        for (size_t j = 0; j < jobs_to_process; j++) {244            job_handler handler;245            void *data;246            /* We keep the job in the queue until it's processed. This ensures that if the main thread checks247             * and finds the queue empty, it can be certain that the IO thread is not currently handling any job. */248            IOJobQueue_peek(jq, &handler, &data);249            handler(data);250            /* Remove the job after it was processed */251            IOJobQueue_removeJob(jq);252        }253        /* Memory barrier to make sure the main thread sees the updated tail index.254         * We do it once per loop and not per tail-update for optimization reasons.255         * As the main-thread main concern is to check if the queue is empty, it's enough to do it once at the end. */256        atomic_thread_fence(memory_order_release);
IOThreadMain:完成后出队

先执行回调再移除任务,避免工作尚在进行时仅因取出就表现为空。removeJob 清空槽位并推进 tail,批次末另有 release fence;这不是仅靠一条原子 head 就完成全部同步。

六段连续原文覆盖实际网络读入口、派发、容量边界、发布、读取及完成后出队。线程暂停用的 mutex、空队列轮询、客户端完成状态和生命周期管理需结合完整源文件阅读。

收益与取舍#

工程分析

带来的收益

  • 任务槽位预分配,派发时直接存函数和参数,避免为每项任务新建链表节点。
  • 每个索引只有一个写入者,队列不需要为多个生产者竞争同一入队位置设计 CAS 循环。
  • 有界容量限制排队任务数量;满队列保留同步处理路径,主线程不会因为该次派发而等待空槽。

付出的代价

  • 容量为 size−1,保留一个槽位区分空与满;队列不会按负载无限增长。
  • 一次入队和索引推进只涉及固定次数操作,但 I/O 回调耗时、忙等轮询和线程调度都影响端到端延迟。
  • 这依赖严格的 SPSC 所有权、客户端状态及同步协议;不能把局部 head 的 release/acquire 单独当作可复用的通用并发队列证明。
  • 满队列回退会把负载转回主线程,因此有界队列本身不提供严格的吞吐、延迟或公平性保证。

可比较的选择

互斥锁保护的共享任务链表更容易支持多个生产者,但可能引入锁争用和任务节点分配;是否更合适取决于任务来源与生命周期。

满队列时阻塞生产者直接向生产端施加等待,但这里的生产者是承担事件处理的主线程,会连带拖延其他客户端。

版本与适用范围#

固定 Valkey 提交 2b5c7a0dbd61 的 I/O 任务队列及客户端读取派发。只有启用且符合条件的读取会移交线程,复制连接、阻塞客户端等存在旁路。这里的环是进程内任务队列,不是网络协议环或复制日志;它与 Redis 字典渐进 rehash 是不同机制。客户端完成状态、等待队列清空与线程启停另有同步协议,不能只从局部 head 更新推导全部线程安全。

参考来源#

源码、官方文档与原始论文
01
valkey · readQueryFromClient / postponeClientRead / ioThreadReadQueryFromClient / processIOThreadsReadDone

src/networking.c · readQueryFromClient / postponeClientRead / ioThreadReadQueryFromClient / processIOThreadsReadDone

源码2b5c7a0dbd61fd4281ef6166b7d119ba7fe7368c
02
valkey · trySendReadToIOThreads / IOJobQueue_push / IOJobQueue_availableJobs / IOThreadMain

src/io_threads.c · trySendReadToIOThreads / IOJobQueue_push / IOJobQueue_availableJobs / IOThreadMain

源码2b5c7a0dbd61fd4281ef6166b7d119ba7fe7368c

相关实现与比较#

4 个相关案例
LLVM·Sample profile inference · profillvmorg-19.1.0

LLVM 的 profi 将采样执行计数转成最小费用流问题,用 Moore–Bellman–Ford 的队列松弛变体寻找增广路,再还原基本块与分支的执行次数。

附源码片段 · 图处理与编译
12 个匹配案例