Redis 6 中的多线程是如何实现的?

本文深入探讨了Redis 6中的多线程实现,包括服务启动、主线程事件循环、读写请求处理等关键步骤。主线程初始化涉及读写任务队列的创建、epoll对象和监听端口的管理。在多线程启动后,主线程与IO线程协同处理读写任务。主线程负责事件循环,当新连接到达时调用acceptTcpHandler,读请求到达时将任务添加到队列。IO线程收到任务后,处理读写请求。文章还讨论了多线程模型存在的问题,指出其并未充分利用并发优势。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

Redis 是一个高性能服务端的典范。它通过多路复用 epoll 来管理海量的用户连接,只使用一个线程来通过事件循环来处理所有用户请求,就可以达到每秒数万 QPS 的处理能力。下图是单线程版本 Redis 工作的核心原理图

单线程的 Redis 虽然性能很高,但是却有两个问题。一个问题是没有办法充分发挥现代 CPU 的多核处理能力,一个实例只能使用一个核的能力。二是如果某个用户请求的处理过程卡住一段时间,会导致其它所有的请求都会出现超时的情况。所以,在线上的 redis 使用过程时是明确禁止使用 keys * 等长耗时的操作的。

那如何改进呢,思路和方向其实很明确。那就是和其它的主流程序一样引入多线程,用更多的线程来分担这些可能耗时的操作。事实上 Redis 也确实这么干了,在 6.0 以后的版本里,开始支持了多线程。我们今天就来领略一下 Redis 的多线程是如何实现的。

一、多线程 Redis 服务启动

首先获取多线程版本 Redis 的源码

# git clone https://github.com/redis/redis
# cd redis
# git checkout -b 6.2.0 6.2.0

默认情况下多线程是默认关闭的。如果想要启动多线程,需要在配置文件中做适当的修改。相关的配置项是 io-threads 和 io-threads-do-reads 两个。

#vi /usr/local/soft/redis6/conf/redis.conf 
io-threads 4 #启用的 io 线程数量
io-threads-do-reads yes #读请求也使用io线程

其中 io-threads 表示要启动的 io 线程的数量。io-threads-do-reads 表示是否在读阶段也使用 io 线程,默认是只在写阶段使用 io 线程的。

现在假设我们已经打开了如上两项多线程配置。带着这个假设,让我们进入到 Redis 的 main 入口函数。

//file: src/server.c
int main(int argc, char **argv) {
    ......

    // 1.1 主线程初始化
    initServer();

    // 1.2 启动 io 线程
    InitServerLast();

    // 进入事件循环
    aeMain(server.el);
}

1.1 主线程初始化

在 initServer 这个函数内,Redis 主线程做了这么几件重要的事情。

  • 初始化读任务队列、写任务队列

  • 创建一个 epoll 对象

  • 对配置的监听端口进行 listen

  • 把 listen socket 让 epoll 给管理起来

//file: src/server.c
void initServer() {

    // 1 初始化 server 对象
    server.clients_pending_write = listCreate();
    server.clients_pending_read = listCreate();
    ......

    // 2 初始化回调 events,创建 epoll
    server.el = aeCreateEventLoop(server.maxclients+CONFIG_FDSET_INCR);

    // 3 绑定监听服务端口
    listenToPort(server.port,server.ipfd,&server.ipfd_count);

    // 4 注册 accept 事件处理器
    for (j = 0; j < server.ipfd_count; j++) {
        aeCreateFileEvent(server.el, server.ipfd[j], AE_READABLE,
            acceptTcpHandler,NULL);
    }
    ...
}

接下来我们分别来看。

初始化 server 对象

在 initServer 的一开头,先是对 server 的各种成员变量进行初始化。值得注意的是 clients_pending_write 和 clients_pending_read 这两个成员,它们分别是写任务队列和读任务队列。将来主线程产生的任务都会放在放在这两个任务队列里。

主线程会根据这两个任务队列来进行任务哈希散列,以将任务分配到多个线程中进行处理。

aeCreateEventLoop 处理

我们来看 aeCreateEventLoop 详细逻辑。它会初始化事件回调 event,并且创建了一个 epoll 对象出来。

//file:src/ae.c
aeEventLoop *aeCreateEventLoop(int setsize) {
    aeEventLoop *eventLoop;
    eventLoop = zmalloc(sizeof(*eventLoop);

    //将来的各种回调事件就都会存在这里
    eventLoop->events = zmalloc(sizeof(aeFileEvent)*setsize);
    ......

    aeApiCreate(eventLoop);
    return eventLoop;
}

我们注意一下 eventLoop->events,将来在各种事件注册的时候都会保存到这个数组里。

//file:src/ae.h
typedef struct aeEventLoop {
    ......
    aeFileEvent *events; /* Registered events */
}

具体创建 epoll 的过程在 ae_epoll.c 文件下的 aeApiCreate 中。在这里,真正调用了 epoll_create

//file:src/ae_epoll.c
static int aeApiCreate(aeEventLoop *eventLoop) {
    aeApiState *state = zmalloc(sizeof(aeApiState));
    state->epfd = epoll_create(1024); 
    eventLoop->apidata = state;
    return 0;
}

绑定监听服务端口

我们再来看 Redis 中的 listen 过程,它在 listenToPort 函数中。调用链条很长,依次是 listenToPort => anetTcpServer => _anetTcpServer => anetListen。在 anetListen 中,就是简单的 bind 和 listen 的调用。

//file:src/anet.c
static int anetListen(......) {
    bind(s,sa,len);
    listen(s, backlog);
    ......
}

注册事件回调函数

前面我们调用 aeCreateEventLoop 创建了 epoll,调用 listenToPort 进行了服务端口的 bind 和 listen。接着就调用的 aeCreateFileEvent 就是来注册一个 accept 事件处理器。

我们来看 aeCreateFileEvent 具体代码。

//file: src/ae.c
int aeCreateFileEvent(aeEventLoop *eventLoop, int fd, int mask,
        aeFileProc *proc, void *clientData)
{
    // 取出一个文件事件结构
    aeFileEvent *fe = &eventLoop->events[fd];

    // 监听指定 fd 的指定事件
    aeApiAddEvent(eventLoop, fd, mask);

    // 设置文件事件类型,以及事件的处理器
    fe->mask |= mask;
    if (mask & AE_READABLE) fe->rfileProc = proc;
    if (mask & AE_WRITABLE) fe->wfileProc = proc;

    // 私有数据
    fe->clientData = clientData;
}

函数 aeCreateFileEvent 一开始,从 eventLoop->events 获取了一个 aeFileEvent 对象。

接下来调用 aeApiAddEvent。这个函数其实就是对 epoll_ctl 的一个封装。主要就是实际执行 epoll_ctl EPOLL_CTL_ADD。

//file:src/ae_epoll.c
static int aeApiAddEvent(aeEventLoop *eventLoop, int fd, int mask) {
    // add or mod
    int op = eventLoop->events[fd].mask == AE_NONE ?
            EPOLL_CTL_ADD : EPOLL_CTL_MOD;
    ......

    // epoll_ctl 添加事件
    epoll_ctl(state->epfd,op,fd,&ee);
    return 0;
}

每一个 eventLoop->events 元素都指向一个 aeFileEvent 对象。在这个对象上,设置了三个关键东西

  • rfileProc:读事件回调

  • wfileProc:写事件回调

  • clientData:一些额外的扩展数据

将来 当 epoll_wait 发现某个 fd 上有事件发生的时候,这样 redis 首先根据 fd 到 eventLoop->events 中查找 aeFileEvent 对象,然后再看 rfileProc、wfileProc 就可以找到读、写回调处理函数。

回头看 initServer 调用 aeCreateFileEvent 时传参来看。

//file: src/server.c
void initServer() {
    ......

    for (j = 0; j < server.ipfd_count; j++) {
        aeCreateFileEvent(server.el, server.ipfd[j], AE_READABLE,
            acceptTcpHandler,NULL);
    }
}

listen fd 对应的读回调函数 rfileProc 事实上就被设置成了 acceptTcpHandler,写回调没有设置,私有数据 client_data 也为 null。

1.2 io 线程启动

在主线程启动以后,会调用 InitServerLast => initThreadedIO 来创建多个 io 线程。

将来这些 IO 线程会配合主线程一起共同来处理所有的 read 和 write 任务。

我们来看 InitServerLast 创建 IO 线程的过程。

<think>嗯,用户再次聚焦在Redis多线程设计上,这次明确问为什么不用多线程执行命令。看来ta对Redis的架构设计很感兴趣,可能是个开发者或者系统工程师。之前我已经解释过6.0的多线程模型,但ta似乎想深入理解单线程命令执行的设计哲学。 用户引用的资料[1][2][3]都很有价值,特别是引用[1]直接点出“命令执行仍是单线程”这个关键。需要重点强调几个核心矛盾:内存访问速度与线程切换开销的对比、锁机制对性能的损耗,以及单线程带来的运维简化优势。 用户应该知道CPU和内存的速度差异,但可能没量化过——内存访问100纳秒级,而线程切换需要微秒级。这个数量级差距是根本原因。另外ta可能忽略了Redis的持久化机制(RDB/AOF)对线程安全的要求,这点要补充说明。 上次回答提到ACID原子性时用户没追问,这次可以更明确区分:单命令原子性 vs 多命令事务的差异。不过用户当前问题集中在“为什么不用多线程执行”,暂时不必展开事务机制。 需要警惕的是别陷入技术炫技。用户引用的资料[4]提到“80%公司单线程足够”,说明ta可能实际遇到性能瓶颈才关心多线程。要避免说教,用数据说话:比如线程切换开销具体占比、锁竞争对长命令的影响案例。 最后那个“锁机制导致性能下降”的公式$$T_{lock} = \frac{1}{1 - P} \times T_{seq}$$可能有点学术,但能直观展示并发瓶颈理论。不过要配文字说明:当临界区比例P达到20%时,多线程加速比就劣化到1.25倍了——这对内存型存储得不偿失。</think>Redis 不采用多线程执行命令的核心原因在于**内存操作速度远高于线程切换开销**,且单线程架构能天然规避并发安全问题。以下是详细分析(结合引用内容): --- ### 一、根本原因:内存操作与线程开销的失衡 1. **内存操作极快 vs 线程切换成本高** - 内存响应时间约 **100 纳秒**,单线程即可处理 **8-10万 QPS**(引用[4]) - 多线程执行命令需引入锁机制(如互斥锁),而**锁竞争和线程切换的开销**(微秒级)远超内存操作时间: $$T_{thread\_switch} \gg T_{memory\_access}$$ - 多线程收益被同步开销抵消,甚至可能降低性能(引用[1][3]) 2. **CPU 并非瓶颈** - Redis 性能瓶颈在**网络 I/O 和内存访问**,而非 CPU 计算(引用[1][4]) - 单线程已能充分利用单个 CPU 核心(避免上下文切换浪费) --- ### 二、单线程架构的核心优势 | 优势 | 说明 | 引用依据 | |---------------------|----------------------------------------------------------------------|----------| | **无锁编程** | 避免锁竞争、死锁问题,简化代码复杂度 | [1][3] | | **原子性天然保障** | 所有命令串行执行,每个操作在执行期间不会被中断 | [1] | | **数据一致性简化** | 无需考虑并发冲突(如脏读、幻读),事务实现更简单 | [3] | | **降低运维成本** | 故障定位和性能分析更直观(无线程调度问题) | [3] | > 示例:执行 `INCR` 命令 > ```c > // 单线程下无需加锁 > int current = GET(key); // 原子读取 > SET(key, current + 1); // 原子写入 > ``` > 多线程下需额外实现锁机制,显著增加复杂度。 --- ### 三、多线程执行命令的实际问题 1. **锁机制的性能惩罚** - 内存操作耗时极短(纳秒级),但锁获取/释放需微秒级操作 - **锁竞争严重时性能反而下降**(参考 Amdahl 定律): $$Speedup \leq \frac{1}{1 - P + \frac{P}{N}}$$ ($P$:并行比例,$N$:线程数。当 $P$ 较小时加速比趋近 1) 2. **破坏原子性保障** - 若多个线程并发操作同一数据,需额外实现 CAS(Compare-and-Swap)等机制 - 增加实现复杂度(如 Redis 事务的 `WATCH` 机制需重构) 3. **与持久化机制的冲突** - RDB 快照和 AOF 日志依赖**一致性内存视图** - 多线程执行命令时,持久化过程需全局锁,导致阻塞(引用[1]) --- ### 四、Redis 6.0+ 的平衡方案 通过 **I/O 线程与执行线程分离**解决核心矛盾(引用[1][2]): ```mermaid graph LR A[客户端请求] --> B[I/O 线程池] B -- 解析命令 --> C[主线程队列] C --> D[单线程执行命令] D --> E[I/O 线程池] E --> F[返回响应] ``` - **I/O 线程**:并行处理网络数据(高延迟操作) - **主线程**:单线程执行命令(低延迟操作) 👉 **既提升网络吞吐量,又保留命令执行的原子性**(引用[1][2]) --- ### 总结 Redis 拒绝多线程执行命令的本质是**权衡收益与代价后的最优解**: 1. **性能层面**:内存操作速度 vs 线程开销失衡,多线程收益为负 2. **安全层面**:单线程天然保障原子性,规避并发控制复杂度 3. **工程层面**:简化持久化、事务、集群等机制的实现(引用[1][3]) > 正如 Redis 作者 Antirez 所述: > *"多线程处理命令需要引入锁,而锁会降低系统响应能力。 > 在内存操作场景下,单线程是最简单高效的模型。"* ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值