Redis 8 中的 I/O 线程

425 阅读11分钟

在 Redis 6 之前,Redis 采用的是单线程 io 模型,所有网络请求的接收、解析、命令执行和响应发送,几乎都在主线程中完成。虽然这种模型简单、高效,但在面对高并发场景时,尤其是大量 slow client 或网络瓶颈时,主线程容易成为性能瓶颈。

为了缓解这个问题,Redis 6 引入了 io 多线程机制。具体来说,在读取请求阶段,主线程将已就绪的连接分发给多个 I/O 线程(包括自己),由它们并发读取客户端发送的数据并完成命令解析,然后主线程等待所有 io 线程完成,再统一执行所有命令。同理,在响应阶段,主线程也将写操作交由自己与 io 线程处理。这种“主线程 + io 线程协作”的模型显著降低了网络 io 的开销,提升了在高并发场景下的性能,但是为了避免竞争,实现的比较简单。

到了 Redis 8,io 模型进一步演化。主线程彻底退出 I/O 阶段,每个 io 线程拥有独立的事件循环,独立完成连接监听、请求读取、命令解析与响应写入。只有在需要执行命令时,才将请求通过队列传递给主线程,大大提升了并发处理能力和资源利用率。

对应的 PR

事件循环

在 8 版本以前,只有主线程有事件循环,而 8 版本的 io 线程也有了,下面是事件循环的几个重要步骤。

  1. 执行 beforeSleep(),在下一次阻塞前处理一些事情,例如:正在等待读取数据的客户端、子/主线程通知后等待处理的客户端。

  2. 等待事件(可能会阻塞),例如 epoll_wait,如果有时间即将到期,那么就阻塞最小的到期时间。

  3. 执行 afterSleep,(子线程没设置这个函数)。

  4. 处理文件事件,就是处理第二步返回的网络事件。

  5. 调用 processTimeEvents 处理时间到期相关的事件。

Redis 6 中的多线程

image.png

子线程等待

在 redis 6 中,io 线程的主函数 IOThreadMain 是放在 ionetworking.c 中的。函数中只有 while(1) 死循环,循环中判断 pending 的 client 数量来判断,然后通过一个锁来暂停线程,直到主线程释放对应锁,子线程才会去处理客户端。

void *IOThreadMain(void *myid) {
    // ...
    
    while(1) {
        /* Wait for start */
        for (int j = 0; j < 1000000; j++) {
            if (getIOPendingCount(id) != 0) break;
        }

        /* Give the main thread a chance to stop this thread. */
        if (getIOPendingCount(id) == 0) {
            // 主线程会持有🔐,这里就被阻塞了,如果有客户端需要处理,会释放这个锁。
            pthread_mutex_lock(&io_threads_mutex[id]);
            pthread_mutex_unlock(&io_threads_mutex[id]);
            continue;
        }

        serverAssert(getIOPendingCount(id) != 0);

        // 遍历分配到自己这里的客户端,分别执行读写操作。
        listIter li;
        listNode *ln;
        listRewind(io_threads_list[id],&li);
        while((ln = listNext(&li))) {
            client *c = listNodeValue(ln);
            if (io_threads_op == IO_THREADS_OP_WRITE) {
                writeToClient(c,0);
            } else if (io_threads_op == IO_THREADS_OP_READ) {
                readQueryFromClient(c->conn);
            } else {
                serverPanic("io_threads_op value is unknown");
            }
        }
        listEmpty(io_threads_list[id]);
        setIOPendingCount(id, 0);
    }
}

主线程分配任务

主线程在 io 多线程的模式下,如果事件循环监听到客户端可读事件,不会马上处理,而是留 handleClientsWithPendingWritesUsingThreads以及 handleClientsWithPendingReadsUsingThreads 函数来分配任务,下面以 handleClientsWithPendingReadsUsingThreads 为例,另一个逻辑类似。

void readQueryFromClient(connection *conn) {
    // ...

    if (postponeClientRead(c)) return;

    // ...
}

// 判断是否需要分配给多个线程
// 配置中启用了 io 线程,非主节点、从节点等特殊客户端,io 线程处于空闲状态
int postponeClientRead(client *c) {
    if (server.io_threads_active &&
        server.io_threads_do_reads &&
        !ProcessingEventsWhileBlocked &&
        !(c->flags & (CLIENT_MASTER|CLIENT_SLAVE|CLIENT_BLOCKED)) &&
        io_threads_op == IO_THREADS_OP_IDLE)
    {
        listAddNodeHead(server.clients_pending_read,c);
        c->pending_read_list_node = listFirst(server.clients_pending_read);
        return 1;
    } else {
        return 0;
    }
}

接着,在 beforeSleep 函数中,会调用handleClientsWithPendingReadsUsingThreads 分配待处理的 client。

io_threads_list 变量是一个数组,每一个元素对应一个 io 线程要处理的 clients 列表。

**io_threads_list[0] **对应的是主线程自己。😋

int handleClientsWithPendingReadsUsingThreads(void) {
    // ...
    listIter li;
    listNode *ln;
    listRewind(server.clients_pending_read,&li);
    int item_id = 0;
    while((ln = listNext(&li))) {
        client *c = listNodeValue(ln);
        // 均匀分配 0 - io_threads_num - 1
        int target_id = item_id % server.io_threads_num;
        listAddNodeTail(io_threads_list[target_id],c);
        item_id++;
    }

    // 设置每个线程要处理的客户端数量
    io_threads_op = IO_THREADS_OP_READ;
    for (int j = 1; j < server.io_threads_num; j++) {
        int count = listLength(io_threads_list[j]);
        setIOPendingCount(j, count);
    }

    // 干分配给自己的那一部分,调用 readQueryFromClient 读取与解析命令
    listRewind(io_threads_list[0],&li);
    while((ln = listNext(&li))) {
        client *c = listNodeValue(ln);
        readQueryFromClient(c->conn);
    }
    listEmpty(io_threads_list[0]);

    // 这里是重点,等待所有 io 线程处理完
    while(1) {
        unsigned long pending = 0;
        for (int j = 1; j < server.io_threads_num; j++)
            pending += getIOPendingCount(j);
        if (pending == 0) break;
    }

    io_threads_op = IO_THREADS_OP_IDLE;

    /* Run the list of clients again to process the new buffers. */
    // ..。后续开始执行这些命令。
}

Redis 8 中的多线程

Redis 8 直接让主线程不再处理 io,更加纯粹。主线程与 io 子线程之间使用了链表来转移客户端,使用管道来通信。

image.png

线程初始化

Redis 8 则对 io 线程做了大改造,io 线程有了自己独立的文件😄,每个线程都有自己的事件循环。

void *IOThreadMain(void *ptr) {
    IOThread *t = ptr;
    char thdname[16];
    snprintf(thdname, sizeof(thdname), "io_thd_%d", t->id);
    redis_set_thread_title(thdname);
    redisSetCpuAffinity(server.server_cpulist);
    makeThreadKillable();
    aeSetBeforeSleepProc(t->el, IOThreadBeforeSleep);
    aeMain(t->el); // 启动循环
    return NULL;
}

// t->el 是在 initThreadedIO 函数中创建
void initThreadedIO(void) {
    if (server.io_threads_num <= 1) return;

    server.io_threads_active = 1;

    /* Spawn and initialize the I/O threads. */
    for (int i = 1; i < server.io_threads_num; i++) {
        IOThread *t = &IOThreads[i];
        t->id = i;
        // 创建事件循环结构体,和主线程的循环基本一致
        // 比如 linux 下就会创建 epoll
        t->el = aeCreateEventLoop(server.maxclients+CONFIG_FDSET_INCR);
        t->el->privdata[0] = t;

        // ... 

        // 与主线程通信的管道。并将管道的读端注册到子线程的循环
        // 注意这里的 handleClientsFromMainThread 函数
        t->pending_clients_notifier = createEventNotifier();
        if (aeCreateFileEvent(t->el, getReadEventFd(t->pending_clients_notifier),
                              AE_READABLE, handleClientsFromMainThread, t) != AE_OK)
        {
            serverLog(LL_WARNING, "Fatal: Can't register file event for IO thread notifications.");
            exit(1);
        }

        /* Create IO thread */
        if (pthread_create(&t->tid, NULL, IOThreadMain, (void*)t) != 0) {
            serverLog(LL_WARNING, "Fatal: Can't initialize IO thread.");
            exit(1);
        }

        // 初始化一些队列
        mainThreadPendingClientsToIOThreads[i] = listCreate();
        mainThreadPendingClients[i] = listCreate();
        mainThreadProcessingClients[i] = listCreate();
        pthread_mutex_init(&mainThreadPendingClientsMutexes[i], attr);
        mainThreadPendingClientsNotifiers[i] = createEventNotifier();
        if (aeCreateFileEvent(server.el, getReadEventFd(mainThreadPendingClientsNotifiers[i]),
                              AE_READABLE, handleClientsFromIOThread, t) != AE_OK)
        {
            serverLog(LL_WARNING, "Fatal: Can't register file event for main thread notifications.");
            exit(1);
        }
        if (attr) zfree(attr);
    }
}

主线程分发客户端

与 redis 6 不一致的是,在 accept 到 client 后,主线程现将客户端可读事件注册到自己的循环中,这一点和以前一样。

client *createClient(connection *conn) {
    client *c = zmalloc(sizeof(client));

    // 这里和以前一样,先注册读事件到主线的循环中
    if (conn) {
        connEnableTcpNoDelay(conn);
        if (server.tcpkeepalive)
            connKeepAlive(conn,server.tcpkeepalive);
        connSetReadHandler(conn, readQueryFromClient); // 这个函数中会注册
        connSetPrivateData(conn, c);
    }
    
    // ...
}

但是后续多了一个 assignClientToIOThread 步骤,会挑出客户端最少得那个线程分配,保证各个线程之间的客户端均衡。

void clientAcceptHandler(connection *conn) {
    client *c = connGetPrivateData(conn);

    // .....

    // 将客户端分配给 io 线程
    if (server.io_threads_num > 1) assignClientToIOThread(c);
}


void assignClientToIOThread(client *c) {
    serverAssert(c->tid == IOTHREAD_MAIN_THREAD_ID);
    
    // 找到客户端最少得那个线程
    int min_id = 0;
    int min = INT_MAX;
    for (int i = 1; i < server.io_threads_num; i++) {
        if (server.io_threads_clients_num[i] < min) {
            min = server.io_threads_clients_num[i];
            min_id = i;
        }
    }

    // 由于客户端现在注册在主线程,所以主线程的客户端数量要 -1
    server.io_threads_clients_num[c->tid]--;
    c->tid = min_id;
    c->running_tid = min_id;
    server.io_threads_clients_num[min_id]++; // 目标线程 +1

    // 从主线程的事件循环中删除对应的事件,调用 epoll_ctl,这个时候,客户端没有监听任何事件。
    connUnbindEventLoop(c->conn);
    c->io_flags &= ~(CLIENT_IO_READ_ENABLED | CLIENT_IO_WRITE_ENABLED);
    // 将客户端挂到 mainThreadPendingClientsToIOThreads 对应的线程中
    listAddNodeTail(mainThreadPendingClientsToIOThreads[c->tid], c);
}

紧接着,在主线程的 beforeSleep 函数中,会调用sendPendingClientsToIOThreads通知子线程。

int sendPendingClientsToIOThreads(void) {
    int processed = 0;
    for (int i = 1; i < server.io_threads_num; i++) {
        int len = listLength(mainThreadPendingClientsToIOThreads[i]);
        if (len > 0) {
            IOThread *t = &IOThreads[i];
            pthread_mutex_lock(&t->pending_clients_mutex);
            // 将mainThreadPendingClientsToIOThreads中的客户端塞入对应线程的 pending_clients
            listJoin(t->pending_clients, mainThreadPendingClientsToIOThreads[i]);
            pthread_mutex_unlock(&t->pending_clients_mutex);
            // 往管道中写一个字节内容
            triggerEventNotifier(t->pending_clients_notifier);
        }
        processed += len;
    }
    return processed;
}


int triggerEventNotifier(struct eventNotifier *en) {
#ifdef HAVE_EVENT_FD
    uint64_t u = 1;
    if (write(en->efd, &u, sizeof(uint64_t)) == -1) {
        return EN_ERR;
    }
#else
    char buf[1] = {'R'};
    if (write(en->pipefd[1], buf, 1) == -1) {
        return EN_ERR;
    }
#endif
    return EN_OK;
}

子线程接收客户端

前面子线程初始化的时候,handleClientsFromMainThread 函数被注册到了管道的读端,所以主线程发送一个字节消息的时候,子线程会调用 handleClientsFromMainThread 函数

handleClientsFromMainThread 会遍历自身的 pending_clients 中的客户端,将他们重新注册到自己的事件循环中。

void handleClientsFromMainThread(struct aeEventLoop *ae, int fd, void *ptr, int mask) {
    IOThread *t = ptr;

    // 将 pending_clients 中的客户端挂到 processing_clients
    // 表示正在处理的 客户端
    pthread_mutex_lock(&t->pending_clients_mutex);
    listJoin(t->processing_clients, t->pending_clients);
    pthread_mutex_unlock(&t->pending_clients_mutex);
    if (listLength(t->processing_clients) == 0) return;

    listIter li;
    listNode *ln;
    listRewind(t->processing_clients, &li);
    while((ln = listNext(&li))) {
        client *c = listNodeValue(ln);

        // 将客户端塞到 clients 数组
        listAddNodeTail(t->clients, c);
        c->io_thread_client_list_node = listLast(t->clients);

        // ...

        /* Enable read and write and reset some flags. */
        c->io_flags |= CLIENT_IO_READ_ENABLED | CLIENT_IO_WRITE_ENABLED;
        c->io_flags &= ~CLIENT_IO_PENDING_COMMAND;

        // 重新将客户端绑定注册到自己的事件循环
        if (!connHasEventLoop(c->conn)) {
            connRebindEventLoop(c->conn, t->el);
            serverAssert(!connHasReadHandler(c->conn));
            connSetReadHandler(c->conn, readQueryFromClient);
        }

        // 如果客户端有待写的内容,那么就写
        if (clientHasPendingReplies(c)) {
            writeToClient(c, 0);
            if (!(c->io_flags & CLIENT_IO_CLOSE_ASAP) && clientHasPendingReplies(c)) {
                connSetWriteHandler(c->conn, sendReplyToClient);
            }
        }
    }
    listEmpty(t->processing_clients);
}

通知主线程

6 - 8 版本都是通过 readQueryFromClient 函数读取数据,然后调用 processInputBuffer 函数解析命令。区别就是 8 版本在processInputBuffer函数解析完成后调用的是enqueuePendingClientsToMainThread来处理后续的命令执行环节 ,如果是主线程,那么就直接调用 processCommandAndResetClient 函数执行命令。

enqueuePendingClientsToMainThread 函数将客户端塞入子线程自身的pending_clients_to_main_thread链表中

void enqueuePendingClientsToMainThread(client *c, int unbind) {
    /* If the IO thread may no longer manage it, such as closing client, we should
     * unbind client from event loop, so main thread doesn't need to do it costly. */
    if (unbind) connUnbindEventLoop(c->conn);
    /* Just skip if it already is transferred. */
    if (c->io_thread_client_list_node) {
        listDelNode(IOThreads[c->tid].clients, c->io_thread_client_list_node);
        c->io_thread_client_list_node = NULL;
        /* Disable read and write to avoid race when main thread processes. */
        c->io_flags &= ~(CLIENT_IO_READ_ENABLED | CLIENT_IO_WRITE_ENABLED);
        listAddNodeTail(IOThreads[c->tid].pending_clients_to_main_thread, c);
    }
}

紧接着在 IOThreadBeforeSleep 函数中,会把 pending_clients_to_main_thread 链表中的客户端塞进 mainThreadPendingClients 中,然后通知主线程

void IOThreadBeforeSleep(struct aeEventLoop *el) {
    IOThread *t = el->privdata[0];
    // ...

    if (listLength(t->pending_clients_to_main_thread) > 0) {
        pthread_mutex_lock(&mainThreadPendingClientsMutexes[t->id]);
        listJoin(mainThreadPendingClients[t->id], t->pending_clients_to_main_thread);
        pthread_mutex_unlock(&mainThreadPendingClientsMutexes[t->id]);
        // 通知主线程
        triggerEventNotifier(mainThreadPendingClientsNotifiers[t->id]);
    }
}

主线程执行命令

主线程收到子线程的事件后,会触发 handleClientsFromIOThread 函数,

void handleClientsFromIOThread(struct aeEventLoop *el, int fd, void *ptr, int mask) {
    IOThread *t = ptr;
    /// ...
    /* Get the list of clients to process. */
    pthread_mutex_lock(&mainThreadPendingClientsMutexes[t->id]);
    listJoin(mainThreadProcessingClients[t->id], mainThreadPendingClients[t->id]);
    pthread_mutex_unlock(&mainThreadPendingClientsMutexes[t->id]);
    if (listLength(mainThreadProcessingClients[t->id]) == 0) return;

    // 处理 client,执行命令
    processClientsFromIOThread(t);
}

processClientsFromIOThread函数会执行命令,接着如果客户端有待写的内容,那么就把客户端丢到 mainThreadPendingClientsToIOThreads 链表中

然后通过触发对应线程的 pending_clients_notifier 通知子线程

pending_clients_notifier对应的事件回调函数前面提到过,在末尾会将待回复的内容写入 socket。

总结

Redis 的 I/O 模型在 6.0 和 8.0 版本中经历了重大演进,提升高并发环境下的网络处理性能。Redis 6 引入了多线程 I/O 模式,将原本由主线程独揽的网络读写操作拆分给多个 I/O 子线程执行。主线程负责事件监听及命令执行,而具体的数据读取与响应发送则由主线程与子线程并行处理。这种“协作式并发”显著缓解了主线程的压力,特别是在 slow client 较多的场景中效果明显。

在实现上,Redis 6 中的 I/O 线程通过一个简单的死循环不断检查是否有待处理的 client,并在主线程通过锁唤醒后执行读或写操作。而主线程在事件循环的 beforeSleep 阶段,通过如 handleClientsWithPendingReadsUsingThreads 的逻辑分发任务给子线程,并等待所有子线程处理完成,最后统一执行命令。

到了 Redis 8,这一模型更进一步优化。I/O 子线程不再依赖主线程统一调度,而是拥有独立的事件循环和连接管理能力。主线程在接收到连接后,按照负载均衡策略将 client 分发给子线程,并通过管道进行通知。每个子线程完全独立地监听事件、读取请求、解析命令并写入响应,极大增强了系统的并发处理能力。