用有界环形队列向 I/O 线程派发任务
Valkey 为每个 I/O 线程维护一条单生产者、单消费者任务环;主线程发布任务,I/O 线程按序执行,队列满时由主线程保留处理路径。
工程问题#
把网络读取交给工作线程能分担主线程工作,但逐任务分配节点、共享多生产者队列或在队列满时等待,都可能让派发本身成为开销来源。
实际实现#
IOJobQueue 预先分配固定槽位,以 head/tail 索引区分生产位置与消费位置,每条队列只有主线程生产、对应 I/O 线程消费。生产者先写任务函数和 data,再以 release store 发布 head;消费者 acquire load 读取可见任务数。任务回调执行完才推进 tail,因此队列包含正在执行的任务。读取派发失败时,readQueryFromClient 仍可在主线程继续处理。
处理流程#
- 网络读回调先尝试 postponeClientRead;I/O 线程未启用、客户端不适合移交等情况保留主线程路径。
- 按客户端与当前 I/O 状态选择线程,检查对应任务队列是否已满;已满时返回 C_ERR。
- 把函数指针和客户端指针写入预分配槽位,再发布新的 head,客户端登记为等待 I/O。
- I/O 线程读取已发布任务数量,逐项调用 handler(data),执行完成后才清空槽位并推进 tail。
- 主线程处理完成状态和后续命令;I/O 派发不等同于把所有数据库命令并行执行。
源码片段#
固定版本 · 原文摘录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}主线程先尝试延后读取;未成功派发且客户端状态允许时,继续调用 readToQueryBuf 和输入处理。队列满导致的 C_ERR 经 postponeClientRead 转为不延后,并非直接丢弃请求。
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;前文已完成适用条件与线程选择;本段在满队列时返回 C_ERR,否则登记客户端状态、发布读任务并加入待完成列表。队列中的数据是 client 指针,其生命周期由外围状态协议管理。
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}next_head 与 tail 相等表示满,因而实际可用容量少一个槽。实现以 relaxed 读取 tail;原注释允许保守地判满并回退,本文不将这一检查当作通用内存回收屏障。
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}主线程直接填入空闲槽的 data 和 handler,再以 release 发布 head。只有入队索引发布使用了这里展示的次序,槽位复用和客户端完成仍要结合消费端及外围协议。
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}消费者用 acquire 读 head,结合自己的 tail 计算可消费数量,并处理绕回。这个固定快照只是本轮工作量,新入队任务可以留待后续轮次。
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);先执行回调再移除任务,避免工作尚在进行时仅因取出就表现为空。removeJob 清空槽位并推进 tail,批次末另有 release fence;这不是仅靠一条原子 head 就完成全部同步。
六段连续原文覆盖实际网络读入口、派发、容量边界、发布、读取及完成后出队。线程暂停用的 mutex、空队列轮询、客户端完成状态和生命周期管理需结合完整源文件阅读。
收益与取舍#
工程分析带来的收益
- 任务槽位预分配,派发时直接存函数和参数,避免为每项任务新建链表节点。
- 每个索引只有一个写入者,队列不需要为多个生产者竞争同一入队位置设计 CAS 循环。
- 有界容量限制排队任务数量;满队列保留同步处理路径,主线程不会因为该次派发而等待空槽。
付出的代价
- 容量为 size−1,保留一个槽位区分空与满;队列不会按负载无限增长。
- 一次入队和索引推进只涉及固定次数操作,但 I/O 回调耗时、忙等轮询和线程调度都影响端到端延迟。
- 这依赖严格的 SPSC 所有权、客户端状态及同步协议;不能把局部 head 的 release/acquire 单独当作可复用的通用并发队列证明。
- 满队列回退会把负载转回主线程,因此有界队列本身不提供严格的吞吐、延迟或公平性保证。
可比较的选择
互斥锁保护的共享任务链表更容易支持多个生产者,但可能引入锁争用和任务节点分配;是否更合适取决于任务来源与生命周期。
满队列时阻塞生产者直接向生产端施加等待,但这里的生产者是承担事件处理的主线程,会连带拖延其他客户端。
版本与适用范围#
固定 Valkey 提交 2b5c7a0dbd61 的 I/O 任务队列及客户端读取派发。只有启用且符合条件的读取会移交线程,复制连接、阻塞客户端等存在旁路。这里的环是进程内任务队列,不是网络协议环或复制日志;它与 Redis 字典渐进 rehash 是不同机制。客户端完成状态、等待队列清空与线程启停另有同步协议,不能只从局部 head 更新推导全部线程安全。
参考来源#
源码、官方文档与原始论文src/networking.c · readQueryFromClient / postponeClientRead / ioThreadReadQueryFromClient / processIOThreadsReadDone
src/io_threads.c · trySendReadToIOThreads / IOJobQueue_push / IOJobQueue_availableJobs / IOThreadMain
相关实现与比较#
4 个相关案例Linux IIO 的 kfifo 缓冲在采样进入内核与用户态读取之间暂存数据,按先进先出顺序交付采样记录。
QEMU 线程池用尾队列组织待处理请求:提交时尾插,worker 持锁从头部领取,然后在锁外执行工作。
LLVM 的 profi 将采样执行计数转成最小费用流问题,用 Moore–Bellman–Ford 的队列松弛变体寻找增广路,再还原基本块与分支的执行次数。
Kubernetes 控制器按对象键排队,用 dirty 和 processing 两个集合合并重复通知;处理期间发生的更新会在本轮结束后触发下一轮。