Redis原理篇-网络模型

本文先介绍了Linux的五种IO模型,重点解读了IO多路复用模型的select/poll/epoll的演进与原理,最后详解Redis单线程及6.0多线程网络模型的核心设计与实现

本文基于黑马2022的Redis课程原理篇编写,课程地址:黑马程序员Redis入门到实战教程,深度透析redis底层原理+redis分布式锁+企业解决方案+黑马点评实战项目

用户态与内核态的 IO 读写

Linux分层架构

服务器大多都采用Linux系统,下面以Linux为例来介绍:

Ubuntu 和 CentOS 都是 Linux 的发行版,发行版可以看成对 Linux 包了一层壳,任何 Linux 发行版,其系统内核都是 Linux。我们的应用都需要通过 Linux 内核与硬件交互。

用户应用(如 MySQL、Redis)无法直接访问硬件设备(CPU、内存、网卡等),必须通过内核来操作硬件。内核包含设备驱动、内存管理、进程管理、文件系统、网络管理等模块,并封装成系统调用接口供用户应用使用。

计算机硬件包括 CPU、内存、网卡等,内核(通过寻址空间)可以操作硬件,但是内核需要不同设备的驱动,有了这些驱动之后,内核就可以对计算机硬件进行内存管理、文件系统管理、进程管理等。

image-20260606211206856

资源隔离

内核本身也是软件,运行时需要消耗 CPU 和内存等资源(操作系统本身也会占用一部分内存)。若不限制用户直接操作内核,用户应用随意使用资源可能导致冲突甚至系统崩溃。

所以需要将用户应用与内核隔离开,如何隔离呢?

首先需要将进程的寻址空间划分为两个部分:内核空间用户空间

什么是寻址空间呢?

应用程序也好,内核也好,都无法直接访问物理内存,而是通过虚拟地址来使用内存,由内核负责将虚拟地址映射到物理内存上。

内核和应用程序访问虚拟内存时,就需要一个虚拟地址,这个地址是一个无符号的整数。比如一个32位的操作系统,其地址总线宽度为32位,虚拟地址空间大小就是2的32次方,也就是说它的寻址范围是0到2³²−1,这片寻址空间对应的就是2的32次方个字节,也就是4 GB。这个4 GB,通常会有3 GB分给用户空间,1 GB分给内核空间。这样在内存层面上将用户应用和内核隔离开,从而杜绝冲突和崩溃。

image-20260606215909963

用户态与内核态及 IO 缓冲区

CPU 会将指令分为 4 个特权级别(Ring 0 ~ Ring 3),Ring 3 权限最低,Ring 0 权限最高。

Linux只使用Ring0和Ring3:

  • Ring3:用户空间可执行的指令,不能直接操作硬件资源,必须通过系统调用。
  • Ring0:内核空间可执行的指令,可以操作所有硬件资源。

进程在执行过程中可能需要在用户空间和内核空间之间切换:

  • 运行在用户空间时称为用户态
  • 运行在内核空间时称为内核态

Linux系统为了提高IO效率,用户空间和内核空间都设有缓冲区:

  • 用户缓冲区:用于IO流的缓冲(如BufferedInputStream)。
  • 内核缓冲区:用于缓存数据,避免频繁物理访问。

写数据的流程如下:

  1. 用户态进程执行普通运算(无需切换)。
  2. 需要写入磁盘时,先写入用户缓冲区。
  3. 发起系统调用,切换到内核态。
  4. 内核将用户缓冲区的数据拷贝到内核缓冲区。
  5. 再将内核缓冲区的数据写入磁盘。

读数据的流程如下:

  1. 用户态进程发起读请求(如从磁盘或网卡读数据)。
  2. 系统调用后切换到内核态,内核判断数据是否就绪(例如等待网络数据到达网卡)。
  3. 数据就绪后,从硬件读取到内核缓冲区。
  4. 从内核缓冲区拷贝数据到用户缓冲区。
  5. 切换回用户态,对数据进行处理。

image-20260606221740255

所以将上述IO读写流程应用到HTTP服务器场景时流程大致如下:

  • 客户端与服务器建立连接后,发送请求数据。
  • 服务器在读取请求时,等待数据到达网卡(阻塞等待)。
  • 数据到达后,从网卡读入内核缓冲区,再拷贝到用户缓冲区。
  • 用户空间进行业务处理(如解析请求、生成响应)。
  • 响应数据写入用户缓冲区,拷贝到内核缓冲区,再写到网卡发送给客户端。

整个过程中涉及多次状态切换和数据拷贝。

核心问题

从上面的流程中可以发现无效等待和数据拷贝过多。当数据未到达时,进程需要阻塞等待,浪费CPU时间,而数据在内核空间和用户空间之间来回拷贝,耗费性能。

所以后续要介绍的五种IO模型(阻塞IO、非阻塞IO、IO多路复用、信号驱动IO、异步IO)均围绕以下两点进行优化:

  • 减少无效等待(如非阻塞、多路复用等)。
  • 减少用户态与内核态之间的数据拷贝(如零拷贝技术等)。

阻塞IO

阻塞IO的核心特征就是在“等待数据就绪”和“数据拷贝”这两个阶段,用户进程全程处于阻塞等待状态

执行流程如下:

  1. 用户进程发起系统调用(例如 recvfrom)尝试读取数据。
  2. 内核检查是否有数据就绪。
    • 若无数据,内核不会立即返回错误,而是让进程等待。
  3. 阻塞等待阶段:用户进程被挂起,直到硬件设备(如网卡)将数据准备好并放入内核缓冲区。
  4. 数据拷贝阶段:内核将数据从内核缓冲区复制到用户缓冲区。此阶段用户进程依然阻塞
  5. 系统调用返回成功结果,用户进程恢复运行并开始处理数据。

image-20260607012053755

由于整个过程中用户线程在等待IO时被完全阻塞,无法执行其他任务,因此这种模型的性能较差。

非阻塞IO

非阻塞IO(Non-Blocking IO)是相对于阻塞IO(Blocking IO)而言的一种模型。其核心特征在于“不阻塞”。

与阻塞IO的对比:

  • 阻塞IO:在调用 recvfrom 系统函数时,如果内核没有准备好数据,用户进程会阻塞(等待),直到数据就绪。
  • 非阻塞IO:在调用 recvfrom 系统函数时,如果内核没有准备好数据,不会阻塞用户进程,而是立即返回一个失败的信息(例如错误码 EWOULDBLOCK)。

下图展示了非阻塞IO的完整流程,主要分为两个阶段:

image-20260607013933139

流程详解:

  1. 用户应用发起系统调用:调用 recvfrom 等函数,向内核请求数据。
  2. 内核检查数据:
    • 情况一(数据未就绪):内核发现数据尚未准备好(例如网卡数据未到达),会立即返回一个状态,告知用户应用“暂无数据”。这个状态通常由错误码 EWOULDBLOCK 表示。
    • 情况二(数据已就绪):如果数据已存在于内核缓存区,则直接进入数据拷贝阶段。
  3. 用户应用轮询(Polling):当收到“暂无数据”的返回后,用户应用不会挂起或阻塞,而是可以立即执行其他操作,或者立即再次调用 recvfrom 进行询问。这个“不断询问”的过程被称为“忙轮询(Busy Polling)”。
  4. 数据就绪与拷贝:在某一次轮询中,内核发现数据已就绪。此时,内核会启动数据拷贝操作,将数据从内核缓存区复制到用户进程的用户缓存区这个拷贝过程是耗时的,在此期间,用户进程是阻塞的。
  5. 返回结果:数据拷贝完成后,recvfrom 调用成功,用户应用获得数据并进行处理。

如果轮询期间就仅仅是反复调用 recvfrom 进行询问,单从数据获取的效率看,非阻塞IO的性能相比于阻塞IO,并没有本质的提升。因为用户进程最终依然需要等到数据真正就绪并完成拷贝才能获得结果。

在阻塞IO中,进程在等待期间被挂起(不消耗CPU),但在非阻塞IO中,进程为了获取数据,需要进行忙轮询(不断发起系统调用),这会产生大量的上下文切换和CPU时间消耗,从而导致CPU使用率暴增,性能可能反而下降。

尽管单纯使用非阻塞IO性能不佳,但它是一个重要的基础构建块。它为更高级的模型(如 I/O多路复用)提供了可能性。I/O多路复用必须结合非阻塞IO,才能发挥出更好的性能,避免单个连接阻塞整个进程的问题。

IO多路复用

什么是IO多路复用

在阻塞IO和非阻塞IO模型中,用户应用在第一阶段(数据等待阶段)都需要调用recvfrom系统调用来尝试获取数据。它们的差别在于当数据尚未就绪时的处理方式:

  • 阻塞IO:如果调用recvfrom时数据未就绪,进程/线程会被阻塞,无法执行其他任务。
  • 非阻塞IO:如果调用recvfrom时数据未就绪,系统调用会立即返回一个错误,CPU可能会空转(轮询),效率低下。

这两种模型的主要问题在于,当处理多个Socket连接时,如果第一个连接的数据尚未就绪,就会导致后续所有连接的等待(无论是线程阻塞还是CPU空轮询),造成无效等待,无法充分利用CPU资源,性能较差。

为了形象说明上述问题,下面以快餐店点餐为例:

  • 顾客(Socket连接)排队到收银台(单个处理线程)。
  • 服务流程分两步:① 顾客思考吃什么(等待数据就绪);② 顾客点餐,服务员将信息传到后厨(读取数据)。
  • 问题:如果排在最前面的顾客(第一个Socket)有“选择困难症”(数据迟迟未就绪),那么后面所有已经想好要吃什么(数据已就绪)的顾客都必须等待,整个点餐(IO处理)效率极低。

要提升效率,早期的思路是增加增加线程/进程,即多线程/多进程模型。但线程/进程增多会导致CPU上下文切换开销增大,成本高,性能未必持续提升。 IO多路复用模型提供了另一种高效的解决方案:

  • 不让顾客排队,而是让服务员(单个线程)同时监听所有顾客(多个Socket连接)。谁想好要吃什么了(哪个Socket的数据就绪了),服务员就去处理谁,避免无效等待。
  • IO多路复用是利用单个线程来同时监听多个文件描述符(File Descriptor, FD),并在某个FD可读或可写时得到通知,从而避免无效等待,充分利用CPU资源。

什么是文件描述符?

FD是一个从0开始递增的无符号整数,用来关联Linux中的一个文件。在Linux中,“一切皆文件”。这包括普通文件、视频、硬件设备等,当然也包括网络套接字(Socket)。

当每个客户端与服务器建立连接时,都会产生一个对应的Socket,也就有了一个对应的FD。因此,IO多路复用监听多个FD,本质上就是同时监听多个网络连接。

IO多路复用的流程可以清晰地分为两个阶段,这两个阶段都是阻塞的,但关键在于监听的粒度不同,从而避免了无效等待。

image-20260607142152766

阶段一:等待数据就绪(监听FD)

  • 用户应用调用一个如select的系统调用,将所有需要监听的FD列表传递给内核。
  • 内核开始监听这些FD。如果任意一个或多个FD的数据准备就绪(可读),内核就会返回,告诉用户进程哪些FD可读。
  • 如果所有FD都没有就绪,用户进程会在此阻塞等待,直到有FD就绪。这个等待是必要的,但不同于阻塞IO,它是在一个线程上监听多个连接

阶段二:处理数据(读取与拷贝)

  • 用户进程从select调用返回后,获得了就绪的FD列表。
  • 进程循环处理列表中的每一个就绪FD:
    • 针对某个就绪的FD,调用recvfrom系统调用去读取数据。此时数据一定已就绪,所以不会在recvfrom上阻塞
    • 内核将数据从内核空间拷贝到用户空间。
    • 进程处理拷贝完成的数据。
  • 处理完所有就绪的FD后,进程可以再次调用select去监听下一批就绪的FD。

Linux系统下,IO多路复用的具体实现有多种,常见的有三种:selectpollepoll。它们的核心差异在于通知用户进程就绪FD的方式

  1. select 和 poll:
    • 工作方式:内核在检测到有FD就绪后,只会通知用户进程“有FD可读/可写”,但不会明确告知是具体哪几个FD
    • 用户进程的操作:用户进程需要逐个遍历所有监听的FD,来找出到底哪几个是真正就绪的。这就像服务员面前的灯亮了,但不知道是哪桌的客人,只能挨个去问。
  2. epoll:
    • 工作方式:内核在检测到有FD就绪后,不仅会通知用户进程,还会直接把那些就绪的FD放入一个指定的数据结构(用户空间内存)中
    • 用户进程的操作:用户进程可以直接从该数据结构中获取到就绪的FD列表,无需遍历所有监听的FD,效率更高。这就像服务员面前的叫号机,直接显示了是几号桌的客人好了。

select

结构

select 函数用于监听多个文件描述符(FD)上的 I/O 事件。定义如下:

1
2
3
4
5
6
7
int select(
    int nfds,                  // 要监听的 FD 集合中的最大 FD 值 + 1,作为遍历上限。
    fd_set *readfds,           // 要监听的“可读事件” FD 集合。
    fd_set *writefds,          // 要监听的“可写事件” FD 集合。
    fd_set *exceptfds,         // 要监听的“异常事件” FD 集合。
    struct timeval *timeout    // 超时时间。
);
  • nfds:这是一个重要参数,代表要监听的文件描述符的最大值加1,它告诉内核只需检查从 0 到 nfds-1 的 FD,避免无效遍历。
  • fd_set 集合:内核将 I/O 事件分为三类:可读、可写、异常。程序员需要将待监听的 FD 分别放入对应的集合中。例如,一个新连接到来,其 FD 应放入 readfds
  • timeout 超时时间:
    • NULL:永久阻塞,直到有 FD 就绪。
    • 0:非阻塞,无论有无 FD 就绪立即返回。
    • >0:阻塞等待指定时间后返回。

fd_set 是一个特殊的结构体,其核心是用于表示 FD 集合的位图(bit map)。结构如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 定义类型别名 __fd_mask,本质是 long int
typedef long int __fd_mask;

// fd_set 记录要监听的 fd 集合,及其对应状态
typedef struct {
    // fds_bits 是 long 类型数组,长度为 1024/32 = 32
    // 共 1024 个比特位,每个比特位代表一个 fd,0 代表未就绪,1 代表就绪
    __fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
    // ...
} fd_set;
  1. 数组大小:在典型的 Linux 实现中,__FD_SETSIZE 为 1024,__NFDBITS 为 32(一个 long 类型占的位数)。因此数组大小为 1024 / 32 = 32 个元素。
  2. 每个元素是 long 类型,在 32 位系统中占 4 字节(32 位),在 64 位系统中占 8 字节(64 位)。以 32 位为例,每个元素有 32 个比特位。
  3. 总容量:数组有 32 个元素,每个元素有 32 个比特位,总共 32 * 32 = 1024 个比特位。
  4. 存储方式:每个比特位对应一个 FD。例如,要监听 FD=1,就将索引为 1 的比特位设置为 1;要监听 FD=5,就将索引为 5 的比特位设置为 1。这种方式极大地节省了内存。

执行流程

一次 select 调用的完整生命周期如下:

第一步:用户空间准备

  1. 创建 fd_set:在用户空间创建并初始化一个 fd_set 结构体,例如 rfds,用于存放要监听“可读事件”的 FD。
  2. 设置比特位:使用宏(如 FD_SET(fd, &rfds))将需要监听的 FD 对应的比特位设置为 1。例如,要监听 FD 1、2、5,则将 rfds 中索引为 1、2、5 的比特位置为 1。

image-20260607150957347

第二步:调用 select 并进入内核

  1. 用户态 -> 内核态切换:调用 select(nfds, &rfds, NULL, NULL, 3)。这个过程会触发用户态到内核态的切换。
  2. 数据拷贝:用户空间的 rfds 结构体被拷贝到内核空间。内核获得了一个副本。
  3. 内核遍历与监听:
    • 内核遍历传入的 fd_set 副本中所有被设置为 1 的比特位(即所有被监听的 FD)。
    • 检查这些 FD 的实际 I/O 就绪状态(例如,接收缓冲区是否有数据)。
    • 如果没有 FD 就绪:内核会使当前进程休眠,并等待两种唤醒条件:
      • 超时:设定的 3 秒时间到达。
      • 事件驱动:某个被监听的 FD 上发生了 I/O 事件(例如,网络数据到达,FD=1 变为可读)。
    • 如果有 FD 就绪:内核停止遍历,继续执行后续步骤。

image-20260607151621152

第三步:内核返回结果

  1. 修改 fd_set 副本:内核不会直接返回就绪的 FD 列表,而是直接修改用户空间拷贝过来的那个

    fd_set 副本。

    • 它将所有未就绪的 FD 对应的比特位重新设置为 0
    • 保留就绪的 FD 对应的比特位为 1。
  2. 内核态 -> 用户态切换select 函数返回,同时将修改后的 fd_set 副本拷贝回用户空间的原始 rfds 中。此时,用户空间的 rfds 已经只包含就绪的 FD 了。

  3. 返回值select 返回一个整数,表示就绪的 FD 总数(例如,有 2 个 FD 可读,就返回 2)。

image-20260607152022296

第四步:用户空间处理

  1. 遍历 fd_set:由于 select 只返回了就绪 FD 的数量,并未指出具体是哪个,因此用户程序必须再次遍历 rfds 位图中的每一个比特位。
  2. 识别并处理:检查每个比特位是否为 1。如果是,则说明该比特位对应的 FD 已就绪,可以对其进行非阻塞的读/写操作。
  3. 循环:处理完一次 select 的结果后,如果还需要继续监听,需要重新创建并设置 fd_set(因为上一次调用已经改变了 rfds 的内容),然后再次调用 select,进入下一轮循环

image-20260607152245569

核心缺陷

通过上述流程分析,可以总结出 select 模型存在的三个主要问题:

  1. 每次调用都需要内存拷贝:每次调用 select,都需要将用户空间的 fd_set 拷贝到内核空间;select 返回后,又需要将内核修改过的 fd_set 拷贝回用户空间。每次循环都会经历两次用户态与内核态的数据拷贝和上下文切换,性能开销较大。
  2. 无法直接获取就绪的 FDselect 返回的是就绪 FD 的数量,而不是一个就绪列表。用户程序必须线性遍历整个 fd_set 位图(最多 1024 个比特位),才能找出具体是哪些 FD 就绪。这增加了额外的 CPU 开销。
  3. 最大 FD 数量受限:由于 fd_set 结构是固定大小的位图(通常为 1024 位),select 能够监听的文件描述符数量存在上限。这个限制在源码中定义,修改需要重新编译内核,不灵活。在高并发场景下,1024 个连接的上限通常不够用。

正是由于这些缺陷,在现代 Linux 系统中,select已逐渐被更高效的 pollepoll 机制所取代。

poll

结构

poll模式是对select模式的改进,但性能提升不明显。主要改进在于简化了参数,并解除了对监听fd数量的上限限制

函数原型如下:

1
2
3
4
5
int poll(
    struct pollfd *fds, // pollfd数组,可以自定义大小
    nfds_t nfds,       // 数组元素个数
    int timeout        // 超时时间
);
  • 参数简化:与select不同,poll只需传递一个统一的pollfd数组,不再区分读、写、异常三个独立集合。
  • fds参数:指向pollfd结构体数组的指针,数组包含了所有需要监听的文件描述符(fd)信息。
  • nfds参数:指定数组中需要监听的元素个数。
  • timeout参数:超时时间(毫秒),含义与select相同。

pollfd结构如下:

1
2
3
4
5
struct pollfd {
    int fd;            // 要监听的文件描述符
    short int events;  // 监听的事件类型(输入)
    short int revents; // 实际发生的事件类型(输出)
};
  • events字段:在调用poll()前设置,指定要监听的事件类型。常见事件类型包括:
    • POLLIN:可读事件。
    • POLLOUT:可写事件。
    • POLLERR:错误事件。
    • POLLNVAL:文件描述符未打开。
  • revents字段:由内核在poll()返回时填充,表示实际发生的事件。如果无事件发生,该值为0。

执行流程

  1. 创建数组:在用户空间创建pollfd数组,并填充所有需要监听的fd及其关注的事件类型(events字段)。
  2. 调用poll函数:调用poll(),将整个pollfd数组从用户空间拷贝到内核空间。
  3. 内核处理:内核将数组数据转为链表存储(无上限),并遍历所有fd,检查是否有就绪事件。
  4. 等待与返回:如果没有就绪事件,内核会等待(直到超时或被信号中断)。最终返回就绪fd的数量n
  5. 数据返回:内核将处理后的pollfd数组(其中revents字段已更新)拷贝回用户空间。
  6. 用户处理:用户程序检查返回值n
    • n > 0,表示有fd就绪,需要遍历整个pollfd数组,检查每个元素的revents字段来找到具体的就绪fd。
    • n == 0,表示超时且无事件就绪。
    • n < 0,表示发生错误。

对比select

对比维度 select模式 poll模式 分析与说明
参数复杂度 需传递三个独立的fd集合(读、写、异常)。 只需传递一个统一的pollfd数组。 poll模式接口更简洁
fd上限 受限于FD_SETSIZE(通常为1024)。 数组大小可自定义,内核用链表存储,理论上无上限 poll模式解决了最大连接数限制问题
数据拷贝 每次调用都需要在用户空间和内核空间之间拷贝三个fd集合。 每次调用都需要拷贝整个pollfd数组。 均存在数据拷贝开销,未解决此问题。
事件检测 返回后,需遍历所有fd才能找到就绪的fd。 返回后,仍需遍历整个pollfd数组检查revents字段。 均存在O(n)的遍历开销,未解决此问题。

优势:poll模式的主要优势是突破了select的1024个文件描述符的限制,适用于需要监听大量连接的场景。

劣势与未解决的问题:

  1. 数据拷贝:每次系统调用都需要在用户空间和内核空间之间进行数据拷贝,存在开销。
  2. 线性遍历:内核需要遍历所有被监听的fd,用户程序在返回后也需要遍历数组以查找就绪fd。当监听的fd数量非常大时,这种线性扫描(O(n)复杂度)的性能会显著下降

结论:poll模式可以看作是“换汤不换药”的改进,虽然解决了最大连接数的问题,但其性能瓶颈(数据拷贝和线性扫描)并未得到根本性解决。因此,在实际的高性能网络编程中,select和poll模式都很少被直接使用,业界更倾向于使用性能更优的epoll等模式。

epoll

结构

epoll模式是对select和poll模式的巨大改进,其性能相较于前两者有非常大的提升

epoll提供了三个核心函数:epoll_createepoll_ctlepoll_wait,通过这些函数实现了高效的事件通知机制。

在内核中,通过epoll_create会创建一个名为eventpoll的结构体,其内部包含两个关键属性:

  1. rbr(红黑树)
    • 作用:记录所有需要监听的FD(File Descriptor)。
    • 特点:FD本身是无符号整数且有序递增,使用红黑树存储可以实现高效的增、删、改、查操作,且性能稳定,不会随节点数量增加而显著下降。
  2. rdllist(链表)
    • 作用:记录已经就绪的、有事件的FD。
    • 特点:当某个FD的事件就绪时,会通过回调函数将其添加到此链表中,便于快速获取所有就绪的FD。

image-20260607200252983

  1. epoll_create(int size)

功能:在内核中创建一个eventpoll结构体实例。

参数:size(建议的大小,但在现代Linux内核中常被忽略)。

返回值:成功时返回epfd,即该eventpoll实例的唯一文件描述符句柄。每次调用都会创建一个新的实例。

  1. epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)

功能:向指定的epoll实例(由epfd标识)添加、修改或删除一个需要监听的FD。它只负责管理监听列表,执行非常快。

参数:

  • epfdepoll实例的句柄。

  • op:操作类型。

    • EPOLL_CTL_ADD添加一个新的FD到监听的红黑树上。
    • EPOLL_CTL_MOD修改已注册FD的监听事件类型。
    • EPOLL_CTL_DEL删除一个已注册的FD。
  • fd:要操作的文件描述符。

  • event:指向epoll_event结构体的指针,指定要监听的事件类型(如读事件EPOLLIN、写事件EPOLLOUT、异常事件等)。

关键机制(回调函数):

  • 当通过epoll_ctl将一个FD添加到红黑树时,内核会为该FD设置一个回调函数(称为ep_poll_callback)。
  • 当这个FD对应的事件(如网卡收到数据)就绪时,内核会自动触发这个回调函数
  • 回调函数的逻辑非常简单:将就绪的FD添加到eventpoll结构体的rdllist就绪链表中
  1. epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)

功能:在epoll实例上等待事件就绪。它相当于select/poll中“等待”功能的部分,是阻塞或轮询的核心。

参数:

  • epfdepoll实例的句柄。

  • events:一个空的epoll_event结构体数组,用于接收内核返回的就绪事件

  • maxeventsevents数组的最大容量,即每次调用最多能返回多少个就绪事件。

  • timeout:超时时间(毫秒)。

    • -1永久阻塞,直到有事件就绪。
    • 0非阻塞,立即返回当前是否有事件就绪。
    • >0阻塞指定时间,超时后返回。

执行流程与返回值:

  1. 内核首先检查rdllist就绪链表是否为空
  2. 如果链表不为空,则函数立即返回,并将rdllist中的所有就绪FD信息拷贝到用户提供的events数组中(其实在拷贝之前还有操作,将在后面补充),返回值为就绪的FD数量。
  3. 如果链表为空,则进程根据timeout参数决定是阻塞、非阻塞或定时等待。
  4. 在等待期间,若有新事件就绪,其对应的回调函数会将其添加到rdllist中。
  5. 等待结束或超时后,再次检查rdllist,并返回相应结果。

执行流程

  1. 用户空间:调用epoll_ctl添加监听FD(如3,5,6,7,8,9)后,内核的红黑树(rbr)中会包含这些FD。
  2. 当FD6和FD8就绪时,内核会通过已注册的回调函数将它们添加到就绪链表(rdllist)中。
  3. 用户空间调用epoll_wait,内核检查rdllist,发现有元素6和8,于是将这两个FD的信息拷贝到用户传入的空数组events中并返回数量:2。此后用户程序通常会循环再次调用 epoll_wait 继续等待新事件。
  4. 与select/poll的关键区别:拷贝回用户空间的仅仅是就绪的FD,而不是所有监听的FD。

image-20260607202152971

解决的问题

select模式的三大问题:

  1. FD数量限制:由FD集合(位图)决定,最大通常为1024
  2. 内存拷贝开销:每次调用select,都需要将整个FD集合从用户空间拷贝到内核空间;返回时,又需要将整个FD集合拷贝回用户空间。存在两次数据拷贝。
  3. 遍历效率低下select返回后,用户进程必须遍历整个FD集合,才能判断出哪些FD是就绪的。

poll模式的改进与问题:

  • 改进:将FD集合改为链表,解决了FD数量的理论上限问题
  • 遗留问题:
    1. 依然存在两次内存拷贝(全量FD集合的拷贝)。
    2. 依然需要遍历整个链表来判断就绪状态。当FD数量很多时,遍历链表的性能会非常差

epoll模式如何解决上述问题:

  1. 解决FD数量限制与遍历性能

    epoll使用红黑树存储所有监听的FD。红黑树理论上没有上限,且其增删改查的时间复杂度为O(log N),性能稳定,不会因FD数量增多而显著下降。

    这解决了select的数量上限问题,也解决了poll因链表过长导致的遍历性能问题。

  2. 解决内存拷贝问题

    对于监听的FD:每个FD只需要通过epoll_ctl添加一次到内核的红黑树。之后的所有epoll_wait调用,都不需要再次传递FD列表,因此避免了重复的、全量的FD集合拷贝

    对于就绪的FDepoll_wait返回时,内核只将就绪链表rdllist中的FD信息拷贝回用户空间的events数组。相比select/poll拷贝所有FD,拷贝的数据量大大减少

  3. 解决遍历判断问题

    epoll_wait返回的events数组中,直接就是就绪的FD。用户进程拿到这个数组后,无需再遍历判断,可直接处理。而select/poll返回的是包含所有FD的集合,必须自己遍历筛选。

epoll的事件通知机制

两种通知模式

在IO多路复用中,当文件描述符(File Descriptor, FD)有数据可读时,调用 epoll_wait 就可以得到通知。但事件通知的模式分为两种:

  • Level Triggered(水平触发,简称LT):当FD有数据可读时,会重复通知多次,直到数据被完全处理完毕,是epoll的默认模式
  • Edge Triggered(边缘触发,简称ET):当FD有数据可读时,只会被通知一次,无论数据是否被处理完毕。

下面举个例子来说明,假设一个客户端socket对应的FD已经注册到了epoll实例中,流程如下:

  1. 客户端发送数据:客户端socket发送了2KB的数据,此时FD有数据可读。
  2. 服务端首次通知:服务端调用 epoll_wait,得到通知说该FD就绪。
  3. 服务端读取部分数据:服务端从FD读取了1KB数据(数据未读完)。
  4. 服务端再次询问:服务端再次调用 epoll_wait,询问内核“FD是否就绪”。
    • 在LT模式下
      • 因为内核检测到FD中仍有1KB数据未被读取(数据可读状态持续存在),所以会再次通知服务端说“FD就绪”。
      • 服务端便可以继续读取剩下的1KB数据,直至数据读完。之后再调用 epoll_wait 就不会收到通知了。
      • 循环往复,直到数据被彻底读完。
    • 在ET模式下
      • 由于内核只在FD状态从无数据变为有数据的瞬间(边沿)通知一次,而之前已经通知过了,因此再次调用 epoll_wait不会再得到通知
      • 如果服务端没有一次性读完所有数据,剩余的1KB数据将残留在缓冲区中,再也没有机会被处理,除非有新的数据到达再次触发状态变化。

内核原理分析

在epoll的内部实现中,当调用 epoll_wait 时,内核会做两件事:

  1. 将就绪的FD数量返回。
  2. 将就绪的FD拷贝到用户空间。

前面没提到的是,在完成拷贝动作之前,内核会先将这些FD从内核的就绪列表(ready list) 中移除:

  • 对于LT模式:在拷贝完成后,内核会再次检查这些FD。如果发现它们的数据仍未被读取完毕,则会将它们重新添加回就绪列表。这样,下一次 epoll_wait 时,这些FD仍然会被包含在返回结果中,从而实现“重复通知”。
  • 对于ET模式:在拷贝完成后,内核不会将FD重新添加回就绪列表。因此,只有在FD的状态发生新的变化(如新数据到达)时,它才会再次出现在就绪列表中。

ET模式的数据残留问题及解决方案

由于ET模式只通知一次,如果在一次通知中没有读取完所有数据,就会导致数据残留。有以下两种解决方案:

方案一:手动重新注册FD。在首次读取完数据后,如果判断FD中仍有数据,则手动调用 epoll_ctl 函数。

  • 使用 EPOLL_CTL_MOD 操作修改该FD的监控事件。
  • 这个操作会触发内核再次检查该FD,如果发现仍有数据,就会将其重新添加回就绪列表
  • 缺点:这相当于模拟了LT模式的行为,会产生额外的 epoll_ctl 系统调用开销,并导致内核重复拷贝数据到用户空间,性能上并无优势。

方案二:使用非阻塞IO一次性读取完所有数据。在一次通知的处理中,循环调用读取函数,直到将FD缓冲区中的所有数据全部读完。

  • 关键点:必须使用非阻塞IO。如果使用阻塞IO,当数据读完时,程序会阻塞在 read 系统调用上等待新数据,从而导致整个进程被阻塞。
  • 使用非阻塞IO时,当数据读完,read 函数会返回一个特定标识(如 EAGAIN),程序可以据此跳出循环。
  • 优点:避免了频繁的 epoll_wait 调用和内核-用户空间的数据拷贝,性能更优。

两种模式对比

LT模式

  • 优点:编程简单,实现容易,不容易遗漏数据。
  • 缺点:
    1. 性能影响:由于重复通知,会导致频繁的 epoll_wait 系统调用和内核与用户空间之间的数据拷贝,影响性能。
    2. 惊群效应(Thundering Herd):如果多个进程同时监听同一个FD,当FD就绪时,LT模式会唤醒所有监听它的进程。但只有一个进程能成功处理数据,其他进程的唤醒是多余的,这会引发惊群问题,造成资源浪费。

ET模式

  • 优点:
    1. 性能更高:减少了系统调用次数和数据拷贝次数。
    2. 避免惊群:FD就绪后只通知一次,只会唤醒一个进程,有效避免了惊群效应。
  • 缺点:编程相对复杂,需要确保在一次通知中读取完所有数据,否则可能导致数据丢失。

epoll服务端流程

本节将介绍基于 epoll 模型实现一个 Web 服务(TCP 服务端)的运行流程。

Redis 的底层网络模型正是基于这种 epoll 机制,理解此流程是后续学习 Redis 网络模型的基础。整个流程涉及内核空间与用户空间的协作,通过事件驱动来处理客户端连接与请求。

启动与初始化阶段

  1. 创建epoll实例:服务器启动后,首先基于epoll API创建一个epoll实例(epoll_create)。

    然后会在内核中准备两个关键数据结构:红黑树和就绪链表。

  2. 初始化Server Socket:因为是基于TCP协议的Web服务,需要创建一个服务器端套接字(Server Socket)。在Linux中,Server Socket也被视为一个文件,因此拥有自己的文件描述符,记作SSFD。

注册监听阶段

  1. 注册SSFD到epoll:使用 epoll_ctl 命令,指定操作类型为ADD,将代表Server Socket的文件描述符(SSFD)添加到epoll实例的红黑树上。
  2. 绑定回调函数:同时,为SSFD绑定一个回调函数(epoll_callback)。当对应的FD就绪时(例如,有连接请求到达),会被内核立即执行,并将该就绪的FD添加到epoll的就绪列表中。

事件循环与监听阶段

  1. 等待就绪事件:调用epoll_wait,开始阻塞等待事件发生。

    工作原理:epoll_wait会检查就绪列表是否为空。

    • 如果就绪列表非空,则直接返回就绪的FD列表。
    • 如果就绪列表为空,则会阻塞等待指定的时间(超时时间)。

    返回处理:epoll_wait返回后,必须判断返回值:

    • 如果返回值为0,表示在等待期间没有任何FD就绪,需重新调用 epoll_wait(通常置于一个循环中)。
    • 如果返回值大于0,则表示有事件就绪,返回值即为就绪FD的数量,这些就绪 FD 的信息可以从传入的 events 数组中获取。
  2. 事件判断与分发:获得就绪事件后,需要判断事件类型和来源。

    • 初始情况:对于Server Socket,只有当有客户端发起连接请求时,其FD(SSFD)才会产生**读事件(EPOLLIN)**并被添加到就绪列表。

    • 随着服务运行:epoll实例监听的FD会越来越多,事件类型也会增多(如读、写、异常等)。因此需要根据事件类型进行判断。

处理具体事件

  1. 处理新客户端连接事件:

    • 判断条件:就绪事件是读事件(EPOLLIN),并且是SSFD可读

    • 处理动作:表明有新的客户端Socket试图连接服务器。调用 accept() 系统调用,接受该客户端连接,得到客户端Socket对应的FD

    • 后续操作:将新的客户端FD,像之前SSFD一样,通过 epoll_ctl 注册到epoll实例(添加到红黑树),并绑定相应的回调函数。然后继续等待事件。

  2. 处理客户端数据请求事件:

    • 判断条件:就绪事件是读事件(EPOLLIN),但不是SSFD可读(即不是新连接),而是某个普通的客户端Socket FD可读

    • 处理动作:表示有客户端发送了请求数据(请求参数)。

      • 从该客户端Socket中读取请求参数。

      • 根据参数处理业务逻辑。

      • 处理完成后,将响应结果写回给客户端Socket,完成一次请求-响应流程。

  3. 处理异常事件:除了读事件,还可能遇到异常事件(如EPOLLERR)。

    • 处理动作:即使是异常,也需要向客户端返回响应(可能是错误信息),完成本次交互。

image-20260607233432225

简单总结一下流程:

基于 epoll 的 TCP 服务端启动后,首先调用 epoll_create 在内核中创建 epoll 实例并初始化红黑树与就绪链表;接着创建服务端 Socket(SSFD),通过 epoll_ctl 将其注册到 epoll 的红黑树上并绑定回调函数。随后进入事件循环:调用 epoll_wait 阻塞等待就绪事件,当有客户端连接时,SSFD 产生读事件,服务端调用 accept 获取客户端 FD 并同样注册到 epoll 中;当已连接的客户端 FD 产生读事件时,服务端读取请求、处理业务逻辑并返回响应,完成一次请求-响应交互。

信号驱动IO

信号驱动IO是与内核建立SIGIO的信号关联并设置回调,当内核有FD就绪时,会发出SIGIO信号通知用户,期间用户应用可以执行其它业务,无需阻塞等待。

执行流程:

  1. 注册信号处理函数:用户进程通过 sigaction 系统调用,向内核指定一个文件描述符(FD)并绑定一个信号处理函数。此调用立即返回,不阻塞
  2. 内核监控数据就绪:内核监控指定的FD。当有数据到达时,内核标记该FD为就绪状态。
  3. 发送信号通知:内核向用户进程发送SIGIO信号。
  4. 执行信号处理函数:用户进程被唤醒,执行预先注册的信号处理函数。
  5. 主动发起读取:在信号处理函数中,用户进程主动调用 recvfrom 等函数去读取数据。此时是阻塞的,需要等待数据从内核空间拷贝到用户空间完成。
  6. 数据处理:读取完成后,进行数据处理。

image-20260608151302314

性能分析:

  • 优点:第一阶段(等待数据就绪)是非阻塞的,用户进程无需轮询,可以处理其他连接请求,性能较好。
  • 缺点:
    1. 信号队列溢出:在高并发场景下,大量的IO操作会产生大量信号,这些信号会进入一个队列。如果信号过多,可能导致信号队列溢出,从而丢失部分信号通知。
    2. 上下文切换开销:频繁地在内核态和用户态之间传递信号,会产生一定的性能开销。
  • 结论:由于上述缺点,该模型在并发场景下存在问题,实际使用相对较少。

异步IO

异步IO模式下,用户进程发起IO操作后,完全不需要关心后续的数据等待和拷贝过程,全部由内核代劳,内核完成后会主动通知用户进程。这是真正的、彻底的异步

执行流程:

  1. 发起异步读取:用户进程调用异步读取接口(如 aio_read),指定要读取的FD以及数据读取到的用户空间缓冲区地址,然后立即返回
  2. 内核全程处理并通知:内核接管了整个IO过程:等待数据就绪 -> 将数据从内核空间拷贝到用户空间指定的缓冲区,完成后通过指定的信号或回调函数通知用户进程“数据已准备就绪”。
  3. 直接处理数据:用户进程收到通知时,数据已经在用户空间的缓冲区中,可以直接处理,无需再发起读取操作。

image-20260608153122536

性能分析:

  • 优点:用户进程在两个阶段(等待数据、数据拷贝)都是非阻塞的,实现了真正的异步,极大地提升了用户体验和系统效率。
  • 缺点:
    1. 内核负担重:在高并发下,所有IO等待和拷贝任务都堆积在内核中,可能给内核带来较大的内存和稳定性压力。
    2. 实现复杂:异步IO的回调或信号通知机制,其代码实现复杂度远高于其他模型。
  • 结论:虽然理论上非常优秀,但由于内核实现复杂和高并发下的资源压力,应用并不如IO多路复用广泛。

五种模型对比

前四种模型都算是同步,只有最后一种才算是异步。判断一个IO操作是同步还是异步,核心在于其第二阶段:数据从内核空间拷贝到用户空间的过程是否阻塞

同步IO:在第二阶段(数据拷贝),用户进程是阻塞的,必须等待数据拷贝完成才能继续执行。包括前四种模型:

  1. 阻塞IO:两个阶段全程阻塞。
  2. 非阻塞IO:第一阶段非阻塞(轮询),但第二阶段(拷贝)阻塞。
  3. IO多路复用:第一阶段(等待FD就绪)阻塞在select/poll/epoll上,第二阶段(拷贝)也阻塞。
  4. 信号驱动IO:第一阶段非阻塞(信号通知),但第二阶段(在信号处理函数中发起拷贝)阻塞。

异步IO:两个阶段(等待数据、数据拷贝)全程非阻塞,由内核完成后通知。

注意,“阻塞”与“非阻塞”描述的是第一阶段(等待数据)用户进程的状态。“同步”与“异步”描述的是第二阶段(数据拷贝)用户进程是否参与并等待。

所以非阻塞IO仍然是同步IO,因为它在数据拷贝时需要用户进程主动发起且等待。

Redis网络模型

单线程还是多线程

Redis是单线程还是多线程?

  • 如果仅仅聊Redis的核心业务部分(命令处理),答案是单线程
  • 如果是聊整个Redis,那么答案就是多线程

Redis 在其发展过程中,在不同版本引入了对多线程的支持,这导致了“多线程”说法的出现。关键版本节点如下:

Redis v4.0:引入后台线程

  • 主要用于处理一些耗时较长的任务,例如异步删除命令 UNLINK
  • 当需要删除一个很大的 Key 时,为了避免阻塞主线程,Redis 会先标记删除,然后由后台线程异步执行实际的删除操作。

Redis v6.0:在网络模型中引入多线程

  • 为了充分利用现代多核 CPU 的性能,Redis 6.0 在其网络模型(处理网络 I/O)中引入了多线程。
  • 并没有改变 Redis 命令处理(核心部分)单线程的本质。多线程仅用于提高网络数据读写、解析的效率。

所以即使在 Redis 6.0 中引入了多线程,其执行命令的核心部分仍然是单线程的。

为什么选择单线程

为什么Redis选择单线程?

尽管硬件性能不断提升,Redis 长期以来一直坚持其核心命令处理为单线程。主要原因如下:

  1. 纯内存操作,性能瓶颈在网络延迟
    • Redis 的操作绝大部分都是内存操作,执行速度极快(纳秒或微秒级)。
    • 性能瓶颈不在于 CPU 的计算速度,而在于网络 I/O 的处理开销(如系统调用、数据拷贝等)。
    • 因此,通过多线程来提升 CPU 利用率对整体性能的提升有限。
  2. 避免多线程上下文切换开销
    • 引入多线程会导致频繁的线程上下文切换。
    • 上下文切换需要保存和恢复线程状态,这是一个有开销的操作,可能会降低程序的整体执行效率,甚至导致性能下降。
  3. 无需考虑线程安全,简化实现与保证性能
    • 在单线程模型下,所有命令顺序执行,天然保证了线程安全。
    • 开发者无需为复杂的并发控制(如加锁、解锁)而烦恼。
    • 引入多线程处理命令后,必须引入线程锁等安全机制,这会使代码复杂度急剧上升,且锁的竞争可能再次拖累性能。

单线程网络模型

技术实现

先了解Redis 6.0之前的单线程网络模型。

Redis为了提高单线程运行性能,底层采用了IO多路复用技术。但不同操作系统的实现有所差异:

  • epoll:Linux系统的默认实现。
  • kqueue:FreeBSD/Mac OS等系统的实现。
  • select/poll:跨平台的通用实现。
  • evport:Solaris系统的实现。

Redis对上述不同的IO多路复用实现进行了二次封装,提供了统一的接口(API),使得上层代码无需关心底层具体实现。

这些封装文件位于Redis源码中,例如:ae_epoll.cae_kqueue.cae_select.cae_evport.c,它们对外暴露了统一的 API,核心接口包括:

  • aeApiCreate(): 创建IO多路复用实例(例如 epoll_create)。
  • aeApiAddEvent(): 注册文件描述符(FD)到IO多路复用实例(例如 epoll_ctl 注册 EPOLLINEPOLLOUT 事件)。
  • aeApiDelEvent(): 从IO多路复用实例中移除对某个FD的监听。
  • aeApiPoll(): 等待FD就绪(例如 epoll_wait)。

Redis在编译或启动时,通过判断当前系统的宏定义(如 HAVE_EPOLL, HAVE_KQUEUE),在 ae.c 文件中动态选择并引入对应的实现文件:

  • 如果支持 epoll,则引入 ae_epoll.c
  • 如果支持 kqueue,则引入 ae_kqueue.c
  • 如果都不支持,则回退到 ae_select.c

核心流程

Redis服务器启动和运行的核心流程,本质是一个单线程的事件循环

服务端启动 (server.c 中的 main 函数)

  1. 初始化服务 (initServer):

    • 创建事件循环实例:调用 aeCreateEventLoop(),内部会调用 aeApiCreate(),创建IO多路复用实例(如 epoll_create)。对于 epoll,内核会为该实例维护一个红黑树(记录所有监听的 FD)和一个就绪链表(记录就绪的 FD)。

    • 监听TCP端口,创建ServerSocket:调用 listenToPort(),根据配置文件中的端口和IP创建服务端Socket,并得到其文件描述符(ipfd)。

    • 注册连接处理器:将ServerSocket的FD注册到事件循环,并为其绑定一个读事件处理器——acceptTcpHandler。这意味着一旦ServerSocket可读(即有新客户端连接),就会调用此处理器。

    • 设置事件循环的前置处理器:调用 aeSetBeforeSleepProc(),注册一个 beforeSleep 函数。该函数将在每次事件循环等待(aeApiPoll)前执行。

      beforeSleep 函数的作用:

      该函数在每次调用 aeApiPoll 等待前执行,主要工作是为所有待写数据的客户端注册写事件监听

      • 工作流程:
        1. 获取一个指向 server.clients_pending_write 队列(存储了所有有响应数据待发送的客户端)的迭代器。
        2. 遍历该队列中的每一个客户端(client)。
        3. 对于每个客户端,调用 aeApiAddEvent() 将其FD注册为对 写事件(AE_WRITABLE 的监听。
        4. 同时,为该客户端绑定写事件处理器——sendReplyToClient
      • 设计目的:Redis将“准备好数据”和“实际写入Socket”这两个步骤分离。命令执行后,只将结果放入客户端的输出缓冲区,并标记该客户端为“待写”。在每次事件循环的间隙,统一处理这些“待写”客户端,为它们开启写监听。这样当IO多路复用检测到Socket可写时,就可以直接进行写入,提高了效率。
  2. 开始事件监听循环 (aeMain)

    • 进入一个 while 循环,条件是事件循环未停止。
    • 在循环中,调用 aeProcessEvents()

image-20260608203915234

事件处理 (aeProcessEvents)

这是网络模型的核心,其流程如下:

  1. 调用 beforeSleep 处理器:执行在事件循环等待前需要完成的工作。

  2. 等待FD就绪:调用 aeApiPoll()(底层对应 epoll_wait 等),阻塞等待至少有一个FD就绪。

  3. 处理就绪事件aeApiPoll 返回就绪的FD列表。循环遍历这些FD,并根据其注册的处理器类型进行分发:

    • ServerSocket有读事件(代表有新连接):
      • 触发 acceptTcpHandler
      • 处理器内部执行两步:
        1. 调用 accept() 接受客户端连接,得到客户端Socket的FD(cfd)。
        2. 将这个新的客户端FD注册到事件循环,并为其绑定一个读事件处理器——readQueryFromClient
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    
    // 数据读处理器
    void acceptTcpHandler(...) {
        // ...
        // 接收socket连接,获取FD
        fd = accept(s, sa, len);
        // ...
        // 创建connection,关联fd
        connection *conn = connCreateSocket();
        conn->fd = fd;
    
        // ...
        // 内部调用aeApiAddEvent(fd, READABLE),
        // 监听socket的FD读事件,并绑定读处理器readQueryFromClient
        connSetReadHandler(conn, readQueryFromClient);
    }
    
    • 客户端Socket有读事件(代表客户端有命令请求到达):
      • 触发 readQueryFromClient
      • 处理器内部主要完成以下工作:
        1. 从客户端Socket读取数据到查询缓冲区(queryBuf)。
        2. 解析缓冲区数据,转换成Redis命令(参数存入 c->argv 数组)。
        3. 处理该命令。
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    
    void readQueryFromClient(connection *conn) {
        // 获取当前客户端,客户端中有缓冲区用来读和写
        client *c = connGetPrivateData(conn);
        // 获取 c->querybuf 缓冲区大小
        long int qblen = sdslen(c->querybuf);  // 注:sdslen 是 SDS 字符串长度函数
        // 读取请求数据到 c->querybuf 缓冲区
        connRead(c->conn, c->querybuf + qblen, readlen);
    
        // ...
        // 解析缓冲区字符串,转为 Redis 命令参数存入 c->argv 数组
        processInputBuffer(c);
    
        // ...
        // 处理 c->argv 中的命令
        processCommand(c);
    }
    
    int processCommand(client *c) {
        // ...
        // 根据命令名称,寻找命令对应的 command,例如 setCommand
        c->cmd = c->lastcmd = lookupCommand(c->argv[0]->ptr);  // 注:argv[0] 是命令名,.ptr 指向 SDS 内容
    
        // ...
        // 执行 command,得到响应结果,例如 ping 命令,对应 pingCommand
        c->cmd->proc(c);  // 调用命令处理函数
    
        // 把执行结果写出,例如 ping 命令,就返回 "pong" 给 client,
        // shared.pong 是 字符串 "pong" 的 SDS 对象
        addReply(c, shared.pong);
    
        return C_OK;
    }
    
    void addReply(client *c, robj *obj) {
        // 尝试把结果写到 c->buf 客户端写缓冲区
        if (_addReplyToBuffer(c, obj->ptr, sdslen(obj->ptr)) != C_OK) {
            // 如果 c->buf 写不下,则写到 c->reply,这是一个链表,容量无上限
            _addReplyProtoToList(c, obj->ptr, sdslen(obj->ptr));
        }
        // 将客户端添加到 server.clients_pending_write 这个队列,等待被写出
        listAddNodeHead(server.clients_pending_write, c);
    }
    

    命令请求处理详解 (readQueryFromClient -> processCommand):

    当客户端有数据到达时,readQueryFromClient 被触发,内部调用 processCommand 处理命令。

    1. 读取与解析请求
      • readQueryFromClient 从客户端Socket读取字节数据,存入该客户端的 queryBuf 缓冲区。
      • processInputBuffer 函数负责将 queryBuf 中的字节流,按照RESP协议解析成多个Redis命令和参数,存储在 c->argv(一个 robj* 数组)中。
    2. 执行命令 (processCommand)
      • 查找命令:根据 c->argv[0](命令名称,如 set, get),在命令表中查找对应的 redisCommand 结构体(其中包含处理函数指针 proc)。此过程调用 lookupCommand
      • 执行命令:调用找到的命令处理函数,如 setCommand->proc(c)
      • 返回结果:命令执行完成后,结果被封装成Redis对象。调用 addReply(c, obj) 函数将结果写入该客户端的输出缓冲区(c->bufc->reply 链表)。
    3. 管理写队列
      • addReply 函数中,如果成功将数据写入缓冲区,则会将该客户端(client)添加到 server.clients_pending_write 队列中。
      • 这样,在下一次 beforeSleep 执行时,这个客户端就会被注册写事件监听,等待实际将数据发送出去。
    • 客户端Socket有写事件(代表可以向客户端发送响应):
      • 触发 sendReplyToClient(该处理器通常是在 beforeSleep 中为待发送响应的客户端绑定的)。
      • 处理器将响应数据从输出缓冲区写入客户端Socket。

image-20260608212105279

多线程网络模型

Redis 6.0引入了多线程,目的是为了提高IO读写的效率,因为网络IO操作往往是性能瓶颈。

核心思想是将耗时的网络IO操作(读取客户端请求、写入响应结果)交由多个IO线程并行处理,而命令的执行依然由主线程单线程串行完成,以保证线程安全和原子性。

引入多线程的环节:

  1. 读取客户端请求数据:多线程并行读取多个客户端Socket的数据。
  2. 写入响应结果数据:多线程并行向多个客户端Socket写入数据。

未引入多线程的环节:

  1. IO多路复用模块(epoll_wait等):依然由主线程执行。
  2. 命令解析:在读取数据后,将其解析为Redis命令的过程,依然由主线程执行。
  3. 命令执行:纯粹的内存操作,速度极快,由主线程执行。

image-20260608213709514

对比单线程,流程的变化如下:

  1. 接收请求:主线程通过IO多路复用(aeApiPoll)得知有多个客户端Socket可读。
  2. 多线程读:主线程将这些可读的客户端Socket分配给多个IO线程。每个IO线程负责读取对应Socket的数据,存入客户端的查询缓冲区(queryBuf)。
  3. 主线程处理:所有IO线程读取完毕后,主线程遍历这些客户端,对每个客户端的 queryBuf 进行解析,转换为Redis命令(processInputBuffer),然后执行命令(processCommand)。命令执行的结果被放入客户端的输出缓冲区。
  4. 多线程写:主线程将那些输出缓冲区有数据的客户端,分配给多个IO线程。每个IO线程负责将对应客户端输出缓冲区的数据写入Socket。
  5. 等待下一次循环:主线程再次调用IO多路复用,等待新的事件。

虽然单次请求的响应时间(RTT)没有缩短,但通过并行处理多个客户端的IO操作,服务器的整体吞吐量(QPS) 相比单线程模型有了显著提升。命令执行阶段仍然是单线程,避免了复杂的锁机制,保证了Redis数据结构操作的原子性和一致性。

在Redis配置文件中,可以通过 io-threadsio-threads-do-reads 参数来开启和配置IO线程的数量。

本站于2025年3月26日建立
使用 Hugo 构建
主题 StackJimmy 设计