Redis网络模型
约 4566 字大约 15 分钟
2025-11-22
Redis的“单线程”主要是指Redis的网络IO和键值对读写是由一个线程来完成的,Redis在处理客户端的请求时包括获取、解析、执行、内容返回等都由一个顺序串行的主线程处理,这也是Redis对外提供键值对存储服务的主要流程。
网络编程模型
Redis采用Reactor模式的网络模型,对于一个客户端请求,主线程负责一个完整的处理过程。

网络请求先后经历服务器网卡、内核、连接建立、读取数据、业务处理、数据写回等一系列过程。其中连接建立、数据读取、数据写回等操作都是需要操作系统内核提供的系统调用,最终由内核与网卡进行数据交互,这些IO调用消耗一般比较高,比如IO等待、数据传输等。
阻塞IO
阻塞IO通常是用户态线程通过系统调用阻塞读取网卡传递的数据。在该模式下,用户线程会一直阻塞等待网卡数据准备就绪,直到完成数据读写完成;可以看到,用户线程大部分都在等待IO时间就绪,造成资源的急剧浪费。

当用户线程需要从网卡读取数据的时候,是需要调用系统线程来完成网卡调用的(操作系统的安全措施,核心操作必须由操作系统来完成)。所以用户线程从发出read请求到返回数据的这段时间是阻塞的,它需要一直等到系统线程从网卡中读取到所有的数据后才会继续往下执行。
非阻塞IO
与阻塞IO相反,非阻塞IO在发送玩read请求后,就会返回发送结果。然后通过轮询的方式不断地查询数据的读取的结果,期间用户线程是不阻塞的,仍然可以处理其它的任务。

进程发起IO系统调用后,如果内核缓冲区没有数据,需要到IO设备中读取,进程返回一个错误而不会被阻塞;进程发起IO系统调用后,如果内核缓冲区数据,内核就会把数据返回进程。
- 进程轮询(重复)调用,消耗CPU的资源;
- 实现难度低,开发应用相对阻塞IO模式较难;
- 适用并发量较小,且不需要即使相应的网络应用开发;
IO多路复用
IO多路复用是非阻塞IO的一种特例,也是目前最经典、最常用的高性能IO模型。其具体的处理方式是:
- 先查询IO事件是否准备就绪,当IO事件准备就绪了,则会通过系统调用实现数据读写;
- 查询操作,不管是否数据准备就绪都会立即返回,即非阻塞;因此,通常情况下,会通过轮询不断监听IO事件是否准备就绪;因为操作都是非阻塞的,这个过程中通常只需要少量线程来处理这个轮询操作,极大的解决了阻塞模式下IO枯竭问题。
“多路”指多个网络连接,“复用”是指复用同一个线程。通过系统调用监控多个连接,只有当连接真正有数据可读/可写时,才进行实际的IO操作。

IO多路复用的核心思想是:用单线程(或少量线程)监听多个IO事件,当某个连接就绪(可读/可写)时,再进行处理。这种“事件驱动”模型大幅度减少了资源消耗,成为了Nginx、Redis等高性能服务的底层基石。
这里需要与非阻塞IO区别开来,与传统的轮询不同,IO多路复用依赖内核通知机制:
- 内核负责监控所有注册的fd,当某个fd就绪时,通知应用程序;
- 应用程序只需要处理已就绪的fd,避免无效遍历;
IO多路复用实现
Linux系统内三种主要的IO多路复用实现:select、poll或epoll。这是一个渐进式演进的过程,每一种都是为了解决前一种的痛点而诞生的。
IO多路复用的核心思想:将轮询这个工作从应用程序委托给操作系统内核。应用程序只需一次系统调用,内核会监听所有的注册的文件描述符(fd),并只在某个或多个fd就绪(有数据可读/可写)时才通知应用程序进行处理。这样,单个线程就能高效管理大量连接。
select
select时最早的IO多路复用实现,遵循POSIX标准,跨平台支持性好。工作流程:
- 应用程序初始化一个
fd_set集合(本质是一个bitmap),并通过FD_SET添加需要监控的fd; - 调用
select函数,将fd_set拷贝到内核; - 内核先行扫描所有传入的fd,判断其是否就绪;
- 如果没有fd就绪,内核会使进程阻塞,直到有fd就绪或超时;
- 当有fd就绪或超时后,select返回;
- select修改传入的
fd_set,将其置为仅包含就绪的fd集合。因此应用程序需要遍历整个初始fd集合,适用FD_ISSET检查每个fd是否被置位,从而直到哪个fd可操作。
graph TD
A[应用程序调用select] --> B[用户态切换到内核态]
B --> C[内核检查fd就绪状态]
C --> D{有fd就绪?}
D -- 是 --> E[立即返回结果]
D -- 否 --> F[线程加入等待队列<br>真正进入阻塞]
F --> G[内核调度其他进程运行]
G --> H[数据到达/超时/信号]
H --> I[内核唤醒线程]
I --> J[返回用户态继续执行]select的缺点:
- 监控数量有限:fdset的大小是固定的(通常为1024),有FDSETSUZE宏定义,编译期时确定,无法修改;
- 性能随连接数线性下降:每次调用select,内核和应用程序都需要线性扫描所有被监控的fd。连接数很大时,复杂度成为瓶颈;
- 内核态的开销:内核也需要线性扫描所有传入的fd;
poll
poll的出现是为了解决select的监控数量限制问题。其工作流程:
- 应用程序准备一个
struct pollfd类型的数组,为每个要监控的fd设置一个结构体,指定fd和关心的事件events - 调用
poll函数,将数组传入内核 - 内核线性扫描该数组,检查每个fd的状态
- 当有fd就绪或超时后,poll返回
- 内核将就绪的事件填写到每个
structured pollfd的revents字段中 - 应用程序需要遍历整个pollfd数组,检查每个元素
revents字段,判断哪个fd就绪以及就绪的事件类型;
poll的改进与遗留问题:
- 改进:解决了select的监控数量限制,因为pollfd数组的大小由调用者决定;
- 遗留问题:
- 性能问题仍未解决:和select一样,内核和应用程序仍然能需要O(n)的遍历开销;
- 内存拷贝开销:每次调用仍然需要将整个pollfd数组在用户态与内核态之间拷贝;
区别于select而言,poll只是将fd(文件描述符)从文件中变成内存数组了
epoll
epoll是Linux2.6引入的,专门为了处理大量并发连接而设计,彻底解决了select和poll的性能瓶颈。它是目前高性能网络编程实施的标准。
核心API
epoll_create:创建一个epoll实力,返回一个文件描述符;epoll_ctl:向epoll实例添加、修改或删除需要监控的fd及其事件;epoll_wait:等待事件发生,返回就绪的fd信息;
epoll高效的原因:
- 内核数据结构优化
- epoll采用红黑树来存储所有通过应用程序注册的fd。这使得添加、删除和修改fd的操作就非常高效。时间复杂度为O(logn);
- 适用一个双线链表就绪队列,当某个fd就绪时,内核的中断处理程序或回调函数就会将其放入这个就绪队列;
- 事件驱动,避免遍历:
- 只需要检查就绪链表是否为空即可,如果不为空,就将链表中的就绪的fd信息拷贝到用户空间
- 所以阻塞等待的事件与就绪的fd数量成正比,而与监控的fd总数无关,这实现了O(1)的事件检测复杂度。
- 内存共享,减少拷贝
- 通过
epoll_ctl建立监控关系后,内核已经持有了fd与事件之间的映射关系。调用epoll_wait时,只需要拷贝就绪的那部分的事件数据,避免了像select/poll那样每次调用都拷贝全部的监控列表;
epoll的触发模式
这是epoll的重要特点,select和poll都是只支持水平触发
- 水平触发:只要fd的读/写缓冲区非空/非满,epoll_wait就会持续报告该事件。确保数据会被处理;
- 边缘触发:仅在fd状态发生变化时(如缓冲区由非空变为空)报告一次。应用程序必须一次性读完或写完所有数据,否则可能永远无法再收到通知。
边缘触发的效率更高,但是编程的复杂度也更高,必须使用非阻塞IO。
总结
其实无论是对于select、poll还是epoll,它都是先由应用程序创建一个fd集合,操作系统会监听这个fd集合中的,当有fd就绪或者超时的时候就会将就绪的fd信息返回给的应用程序。
- 在select模式下:
- 这个fd集合(fd_set)的大小是固定的,通常为1024,在编译期就决定了,后续无法修改;
- 当有fd就绪的时候,操作系统会修改这个fdset,然后将整个fdset从内核线程拷贝到用户线程,然后用户线程会遍历整个fd_set来判断哪些fd是就绪状态;
- 在poll模式下:
- 这fd集合是一个pollfd的数组,它的大小是不固定的,所以它能解决select模式下fd数量有限制的问题;
- 当有fd就绪的时候,操作系统会就到pollfd中的元素的revents字段,然后将这个数组从内核线程中拷贝到用户线程,然后用户线程会遍历整个pollfd数组,根据revents字段来判断哪些fd是就绪的;
- 在epoll模式下:
- 这个fd集合是一个红黑树的集合,它提高了fd的增删改查的性能;
- 当有fd就绪的时候,操作系统会将就绪的fd保存到一个双向链表的就绪队列中,然后将这个双向链表的就绪队列从内核线程拷贝到用户线程,然后用户线程只需要遍历这个双向链表就可以知道哪些fd是就绪状态;
所以对于select和poll模式而言,它们在性能上是没有什么区别的,因为最终用户线程都需要遍历整个fd集合。只是poll模式采取的pollfd数组,可以修改它的大小,解决select的fd数量限制问题。
在epoll中对于fd的集合和用来存储就绪fd的数据结构都发生了变化,首先是fd的结合从原本的数组或者集合的模式修改为了红黑树,对于就绪的fd也不再是返回用户线程创建的所有的fd集合,而是只返回就绪的fd集合,它的数据结构是一个双向链表。这样的话,对于用户线程而言,它只需要遍历就绪的fd的双向链表就好了。
对于select和poll模式而言,它的触发模式都是水平触发,也就是当fd的缓冲区可读或者可写的时候会一直触发这个事件;
对于epoll而言除了水平触发以外,还额外支持边缘触发模式,当fd的缓冲区由非空变为空或者由空变为非空的时候会触发事件,要求应用程序接收 到这个事件之后需要一次读取完缓冲区内所有数据。
Reactor网络编程模型
在现代高并发网络服务中,如何高效地处理大量并发连接是一个核心挑战。如果每个连接都对应一个线程,系统就会因为线程数剧烈增加,线程上下文切换频繁等问题而变得低效甚至崩溃。Reactor模型就是为了解决这个问题而诞生的,它是一种非阻塞、事件驱动的设计模式,广泛应用于高性能网络服务框架中。
Reactor模型是一种事件驱动的编程范式,它通过一个或多个Reactor来监听并分发事件。这里的“事件”通常是指IO事件,例如新连接到来、数据可读、数据可写等。当事件发生时,Reactor不会直接处理事件,而是将事件派发给对应的事件处理器来执行具体的业务逻辑。
Reactor模型的核心组件
Reactor(反应器)
- 核心:它是整个模型的心脏,负责监听和收集IO事件;
- 事件循环:Reactor内部有一个无限循环,不断地等待或获取事件;
- 多路复用器:Reactor利用IO多路复用即使来监控多个IO通道。当一个或多个通道准备就绪,多路复用会通知Reactor。
- 分发:一旦检测到事件,Reactor会将事件分发给对应的事件处理器;
EventHandler(事件处理器)
- 执行者:它们是真正处理业务逻辑的地方。每个事件处理器通常负责处理某一类或某个特定IO通道上的事件;
- 回调函数:事件处理器通常以回调函数的形式注册到Reactor中。当Reactor检测到相关事件时,就会调用这些回调函数;
- 非阻塞:事件处理器在执行任务时必须是非阻塞的,这意味着它们不应该执行耗时操作,否则会阻塞整个Reactor的事件循环,导致其它事件无法及时处理。如果需要执行耗时操作,应该将其提交到线程池或单独的线程中进行异步处理;
Handler(句柄/描述符)
- IO通道:代表一个可进行IO操作的实体,通常是套接字文件描述符。Reactor监听的就是这些句柄上的事件。
Reactor模型的工作流程
- 注册事件处理器:应用程序启动时,会将各种IO通道和对应的事件处理器注册到Reactor中;
- 事件循环启动:Reactor事件循环开始运行,调用底层IO多路复用机制,等待事件发生;
- 事件检测:当一个或多个IO事件发生时,多路复用器会通知Reactor;
- 事件分发:Reactor收到通知后,识别出具体的事件类型以及对应的句柄,然后根据预先注册的关系,将事件派发给相应的事件处理器
- 事件处理:被派发的事件处理器被激活,执行对应的处理方法,这个处理应该是非阻塞的,确保事件循环能够快速响应下一个事件;
- 循环往复:事件处理器完成任务后,控制权返回Reactor,继续等待下一个事件;
Reactor 模型的优点
- 高并发和可伸缩性:通过少量线程(甚至单线程)管理大量并发连接,避免了传统模型中大量的线程/进程创建和切换开销,显著提升了服务器的并发能力。
- 资源消耗低:相较于“每连接一线程”模型,Reactor 模型在内存和 CPU 资源上更为高效,尤其是在处理“长连接”但“低活跃度”的场景。 I/O 密集型应用表现优异:对于网络 I/O 密集型的应用(如代理服务器、消息队列)
- Reactor 模型能够充分发挥其非阻塞 I/O 的优势。 简化编程模型:将事件监听和事件处理分离,使得代码结构更清晰,业务逻辑更易于维护。
Reactor模型缺点与挑战
同步阻塞问题:如果事件处理器中包含任何阻塞事件,整个Reactor的事件循环就会被阻塞,导致其它所有事件都无法及时响应,从而影响系统的吞吐量和响应速度;
这里可以参考下Netty的设计,它通过两个EventLoop循环来处理,一个作为负责监听事件,一个负责业务处理;
代码复杂性:相对于简单的阻塞式变成,事件驱动的编程模型可能需要处理更多的回调和状态管理,代码逻辑有时会显得更为复杂,尤其是在没有良好的框架支持下;
调试难度:由于事件的异步和回调特性,调试问题可能比同步代码更具有挑战性;
CPU密集型任务不适用:Reactor模型的核心是处理IO事件,如果应用程序中包含大量的CPU密集型任务,单线程的Reactor会成为瓶颈。