用户空间和内核空间的划分是现代操作系统的基础,对应用程序网络模型的设计和优化有着深远的影响。
内核空间与用户空间的分工
现代操作系统为了保证系统的稳定性和安全性,将虚拟内存空间划分为用户空间和内核空间。
一、用户空间
用户空间是用户程序运行的区域,用户程序在这个空间运行,不能访问系统关键资源。样做的好处是,一个用户程序的崩溃不会影响到整个系统。常见的用户程序,如浏览器、文本编辑器等都运行在用户空间。
二、内核空间
内核空间是操作系统内核运行的区域,内核负责系统所有硬件资源的调度和管理,如CPU、内存、磁盘、网络等,所以也负责者对应的进程调度管理、内存管理、文件系统管理、网络协议的IO操作等。
三、内核态与用户态
内核态和用户态是CPU在运行过程中的两种工作状态,拥有不同的执行权限。
- 用户态:CPU在执行的用户程序在用户空间运行时的状态。在用户态下,进程不能访问内核空间和硬件资源,CPU权限Ring3。
- 内核态:CPU在执行操作系统内核代码时所处的状态。在内核态,进程可以访问硬件资源,CPU权限Ring0。
四、内核态与用户态的切换的触发条件
系统调用
用户程序因自身功能需求,需借助操作系统提供的服务时,会通过系统调用切换到内核态。例如,文件读写(read
、write
)、进程创建(fork
)、内存分配(malloc
底层依赖系统调用)等操作,都需进入内核态由操作系统内核完成。
异常
当用户程序执行过程中出现异常状况,会触发中断机制,使 CPU 切换到内核态处理异常。常见异常如下:
- 缺页异常:程序访问的内存页面不在物理内存中,需内核从磁盘将对应页面加载到内存。
- 除零错误:程序执行除法运算时除数为零,内核会捕获该异常并进行相应处理。
- 非法指令:程序执行了无效指令,内核会介入处理。
外部中断
外部设备(如键盘、鼠标、网卡、硬盘等)工作时,会在特定事件发生时向 CPU 发送中断信号。CPU 接收到信号后,暂停当前用户程序执行,切换到内核态处理中断。例如:
- 键盘输入:用户敲击键盘,键盘控制器发送中断信号,内核接收信号后将输入数据传递给相应程序。
- 网络数据包到达:网卡接收到网络数据包后,发送中断信号,内核处理数据包并传递给对应应用程序。
五、用户态和内核态切换过程
切换流程
-
触发切换条件:系统调用、异常、外部中断。
-
保存用户态上下文:CPU寄存器(程序计数器,栈指针)压入内核栈。
-
切换到内核态:CPU权限等级提升(Ring3->Ring0)。
-
执行内核态代码:处理系统调用、异常、外部中断。
-
恢复至用户态:CPU权限等级下降(Ring0->Ring3)。
-
恢复用户态上下文:从内核栈中恢复寄存器。
是 否 用户态运行 触发切换条件? 保存用户态上下文 切换到内核态 执行内核态代码 恢复至用户态 恢复用户态上下文
具体案例
1.文本编辑器使用场景
当你打开一个文本编辑器(如记事本),这其中就涉及用户态和内核态的切换。编辑器本身运行在用户态,它可以执行用户代码,像数学计算、字符串处理等,权限受限,只能访问用户空间的内存(由操作系统分配),无法直接访问硬件设备。当你在编辑器中输入文字并保存文件时,编辑器会通过系统调用请求内核服务来完成保存操作。例如,按下键盘时,硬件中断触发,CPU进入内核态处理输入,内核将输入数据传递给编辑器,然后切换回用户态。这里,编辑器运行应用程序的过程处于用户态,而内核处理输入和保存文件等操作处于内核态1。
2.硬盘读写操作场景
在进行硬盘读写操作时,用户态和内核态也会发生切换。当用户程序需要从硬盘读取数据或向硬盘写入数据时,由于用户态程序无法直接访问硬件设备,它会通过系统调用请求内核的帮助。例如,当程序发起一个硬盘读取请求,会触发系统调用,CPU从用户态切换到内核态,内核接管并执行相应的硬盘读写操作。当硬盘完成读写操作后,会向CPU发出中断信号,CPU暂停当前执行的指令,转而去执行与中断信号对应的处理程序,此时也是从用户态切换到内核态。完成操作后,内核将数据传递给用户程序,CPU再切换回用户态。
3.程序运行中的异常处理场景
当CPU在执行运行在用户态下的程序时,发生了某些事先不可知的异常,比如缺页异常,这时会触发由当前运行进程切换到处理此异常的内核相关程序中,也就转到了内核态。例如,一个用户程序在运行过程中需要访问某一块内存,但该内存页面不在物理内存中,就会产生缺页异常。此时,CPU会进入内核态,由内核负责将所需的页面从磁盘调入物理内存,处理完异常后,再回到用户态继续执行程序。
文件操作场景
用户运行一个程序,该程序所创建的进程开始是运行在用户态的。如果要执行文件操作,如读取文件内容,必须通过系统调用(如read函数),这些系统调用会调用内核中的代码来完成操作。这时,进程会从用户态切换到内核态,进入内核地址空间去执行相应的代码完成文件读取操作。完成后,再切换回用户态,继续执行用户程序。这样,用户态的程序就不能随意操作内核地址空间,具有一定的安全保护作用。
七、数据IO操作时相关流程
以从磁盘读取数据为例,数据 IO 操作的流程如下:
用户态阶段
- 用户程序调用标准库函数(如
read
)发起读取文件的请求。 - 标准库函数通过系统调用进入内核态。
内核态阶段
- 内核接收到系统调用请求后,检查文件描述符的有效性,确定要读取的文件位置和大小。
- 内核向磁盘控制器发送读取命令,磁盘控制器开始从磁盘读取数据到磁盘缓存。
- 磁盘控制器通过 DMA(直接内存访问)技术将数据从磁盘缓存传输到内核缓冲区。
- 内核将数据从内核缓冲区复制到用户缓冲区。
用户态恢复阶段
- 内核完成数据复制后,将控制权返回给用户程序,用户程序从用户缓冲区获取数据。
以下是一个简单的 Java 代码示例,展示了文件读取操作,其中涉及到用户态到内核态的切换:
八、网络IO操作流程(以TCP为例)
1. 数据发送流程(用户程序调用 send()
)
用户态阶段:
- 用户程序调用
send(sockfd, buf, size)
,触发系统调用。 - 标准库(如
glibc
)封装系统调用(例如syscall(SYS_sendto)
),触发从用户态到内核态的切换。
内核态阶段:
- 数据拷贝 :将用户缓冲区
buf
中的数据复制到内核的 Socket发送缓冲区。 - 协议栈处理 :
- TCP层:封装TCP头部(源/目标端口、序列号、校验和等)。
- IP层:封装IP头部(源/目标IP地址)。
- 分片处理(若数据超过MTU)。
- 网卡发送 :
- 内核通过DMA(Direct Memory Access)将数据从Socket发送缓冲区传输到网卡队列。
- 网卡将数据包发送到网络。
用户态恢复阶段:
- 内核返回执行结果(成功发送的字节数)。
- CPU切换回用户态,用户程序继续执行。
2. 数据接收流程(用户程序调用 recv()
)
用户态阶段:
- 用户程序调用
recv(sockfd, buf, size)
,触发系统调用。 - 若内核未准备好数据,线程可能阻塞(阻塞IO模型)或立即返回(非阻塞IO模型)。
内核态阶段:
- 网卡接收数据 :
- 网卡通过DMA将数据包直接写入内核的接收缓冲区(RX Ring队列)。
- 硬中断处理 :
- 网卡触发硬中断,通知CPU有数据到达。
- 内核快速将数据包移出RX队列,避免队列溢出。
- 软中断处理 :
- 内核协议栈解析数据包(IP头部 → TCP头部)。
- 将数据存入对应Socket的接收缓冲区。
- 唤醒用户程序 :
- 若用户程序阻塞在
recv()
,内核将其唤醒;若使用epoll
,触发就绪事件通知。
- 若用户程序阻塞在
用户态恢复阶段:
- 数据拷贝 :内核将Socket接收缓冲区的数据复制到用户缓冲区
buf
。 - 内核返回读取的字节数,CPU切换回用户态,用户程序处理数据。
IO模型
在《UNIX网络编程》一书中,总结归纳了5种IO模型:
- 阻塞IO(Blocking IO)
- 非阻塞IO(Nonblocking IO)
- IO多路复用(IO Multiplexing)
- 信号驱动IO(Signal Driven IO)
- 异步IO(Asynchronous IO)
一、阻塞IO
1.阻塞IO的核心原理
- 定义 :当用户程序发起网络IO操作时,若内核数据为准备好,进程会一直等待,直到数据就绪从内核缓冲区拷贝数据到用户缓冲区后,用户程序恢复执行。
- 特点 :同步执行:I/O操作完全阻塞用户线程,无法处理其他任务。
2.阻塞IO的流程
用户程序 内核空间 调用recv() 1. 检查Socket接收缓冲区是否有数据 2. 将进程状态设为"睡眠",进程阻塞 网卡数据到达,触发硬中断 3. 数据通过DMA写入内核缓冲区 4. 协议栈处理(TCP/IP解析) 5. 数据存入Socket接收缓冲区 6. 将进程状态设为"就绪",内核唤醒用户进程 7. 执行数据拷贝(CPU参与),数据从内核拷贝到用户缓冲区 recv()返回,程序继续执行 数据从内核拷贝到用户缓冲区 recv()返回,程序继续执行 alt [数据未就绪] [数据已就绪] 用户程序 内核空间
步骤详解
- 用户程序调用
recv()
:- 触发系统调用,CPU从用户态切换到内核态。
- 内核检查数据状态 :
- 若Socket接收缓冲区无数据,进程被挂起(状态设为
TASK_INTERRUPTIBLE
),CPU切换回用户态等待数据。
- 若Socket接收缓冲区无数据,进程被挂起(状态设为
- 数据到达与处理 :
- 网卡通过DMA将数据写入内核缓冲区,触发中断,内核协议栈解析数据并存入Socket接收缓冲区。
- 唤醒用户进程 :
- 内核标记进程为就绪状态,触发调度器重新分配CPU时间片。
- 数据拷贝与返回 :
- 内核将数据从Socket缓冲区复制到用户缓冲区,
recv()
返回实际读取的字节数。
- 内核将数据从Socket缓冲区复制到用户缓冲区,
二、非阻塞IO(不使用IO多路复用)
1.非阻塞IO的核心原理
定义:当用户进程发起IO请求后,若内核数据未就绪,立即返回错误,用户程序可继续执行其他任务,并通过轮询机制主动检查多个文件描述符的就绪转态。
用户程序 内核空间 设置FD为非阻塞模式 调用recv() 检查该FD的接收缓冲区 返回EAGAIN 处理已就绪的FD alt [数据未就绪] [数据已就绪] loop [遍历所有FD] 用户程序 内核空间
2.性能问题与缺点
问题 | 说明 |
---|---|
CPU空转 | 持续轮询未就绪的Socket导致CPU占用率接近100%(若不加休眠) |
高延迟 | 若添加休眠降低CPU占用,会导致数据处理的延迟增加 |
三、IO多路复用
1.核心概念
IO多路复用是一种通过单线程监听多个IO事件的机制。允许高效管理多个文件描述符(包括套接字、普通文件、设备文件等)的 I/O 事件,解决阻塞IO持续等待导致用户进程阻塞和非阻塞IO轮询导致的空转问题。
2.实现方式
select
1. 数据结构准备
select
使用三个 fd_set
类型的集合来分别管理不同类型的 I/O 事件:
- 读集合(
readfds
):用于监听文件描述符是否有数据可读。 - 写集合(
writefds
):用于监听文件描述符是否可写。 - 异常集合(
exceptfds
):用于监听文件描述符是否发生异常。
每个 fd_set
本质上是一个位数组,数组的每一位对应一个文件描述符,通过设置相应的位来表示需要监听的文件描述符。
2. 系统调用
应用程序调用 select
系统调用,将需要监听的文件描述符添加到对应的 fd_set
集合中,并指定一个超时时间。调用格式如下:
c
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
nfds
:需要监听的最大文件描述符值加 1。readfds
、writefds
、exceptfds
:分别为读、写、异常事件的文件描述符集合。timeout
:超时时间,若为NULL
则表示无限等待。
3. 内核处理
内核接收到 select
系统调用后,会执行以下操作:
- 复制文件描述符集合 :将用户空间的
fd_set
集合复制到内核空间。 - 轮询检查:内核会不断轮询所有指定的文件描述符,检查是否有相应的 I/O 事件发生(可读、可写或异常)。
- 阻塞等待:如果没有事件发生,且超时时间未到,内核会将进程阻塞,直到有事件发生或超时。
- 更新集合 :当有事件发生或超时后,内核会更新
fd_set
集合,只保留那些发生了相应事件的文件描述符。
4. 返回结果
select
系统调用返回后,应用程序可以通过检查 fd_set
集合来确定哪些文件描述符发生了事件,然后进行相应的处理。返回值表示发生事件的文件描述符总数,若超时则返回 0,若出错则返回 -1。
是 否 否 是 开始 准备 fd_set 集合 将需要监听的文件描述符加入集合 调用 select 系统调用 内核复制 fd_set 到内核空间 内核轮询检查文件描述符 是否有事件发生? 更新 fd_set 集合 是否超时? fd_set集合从内核拷贝会用户空间缓冲区 应用程序检查 fd_set 集合 处理发生事件的文件描述符 结束
性能问题
- 用户态和内核态数据拷贝开销大
在调用 select
时,需要将 fd_set
从用户空间复制到内核空间,当 select
返回时,又要将更新后的 fd_set
从内核空间复制回用户空间。若文件描述符数量众多,频繁的数据拷贝会带来显著的性能损耗。
- 轮询机制效率低
内核采用轮询方式检查所有指定的文件描述符,时间复杂度为 O ( n ) O(n) O(n),这里的 n n n 是文件描述符的数量。随着文件描述符数量的增加,内核检查的时间会变长,性能会明显下降。
可扩展性问题
- 文件描述符数量限制
select
能处理的文件描述符数量存在上限,这个上限由 FD_SETSIZE
宏定义,通常是 1024。尽管可以通过修改内核参数来提高该上限,但这种方式不够灵活,也不是根本的解决办法,无法满足高并发场景下大量文件描述符的需求。
- 不适合大规模并发连接
由于 select
的时间复杂度和文件描述符数量限制,在处理大规模并发连接时,其性能会急剧下降,难以满足高并发网络服务(如 Web 服务器)的需求。
poll
1. 数据结构准备
poll
使用 struct pollfd
结构体数组来管理需要监听的文件描述符及其对应的事件。struct pollfd
结构体定义如下:
c
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 请求监听的事件 */
short revents; /* 返回时发生的事件 */
};
fd
:需要监听的文件描述符。events
:指定要监听的事件类型,如POLLIN
(可读)、POLLOUT
(可写)等。revents
:由内核填充,返回该文件描述符实际发生的事件。
2. 系统调用
应用程序调用 poll
系统调用,传入 struct pollfd
结构体数组、数组中元素的数量以及超时时间。调用格式如下:
c
int poll(struct pollfd fds[], nfds_t nfds, int timeout);
fds
:struct pollfd
结构体数组。nfds
:数组中元素的数量。timeout
:超时时间,单位为毫秒。若为 -1 表示无限等待,若为 0 则立即返回。
3. 内核处理
内核接收到 poll
系统调用后,会执行以下操作:
- 遍历检查 :内核会遍历
struct pollfd
结构体数组,检查每个文件描述符对应的事件是否发生。 - 阻塞等待:如果没有事件发生,且超时时间未到,内核会将进程阻塞,直到有事件发生或超时。
- 更新事件 :当有事件发生或超时后,内核会更新每个
struct pollfd
结构体中的revents
字段,标记实际发生的事件。
4. 返回结果
poll
系统调用返回后,应用程序可以通过检查 struct pollfd
结构体数组中每个元素的 revents
字段来确定哪些文件描述符发生了事件,然后进行相应的处理。返回值表示发生事件的文件描述符总数,若超时则返回 0,若出错则返回 -1。
是 否 否 是 开始 准备 struct pollfd 数组 设置每个元素的 fd 和 events 调用 poll 系统调用 内核遍历检查文件描述符 是否有事件发生? 更新每个元素的 revents 字段 是否超时? poll 返回 应用程序检查每个元素的 revents 字段 处理发生事件的文件描述符 结束
poll 系统调用存在的问题
1. 用户态和内核态数据拷贝开销
和 select
一样,poll
在调用时需要将 struct pollfd
结构体数组从用户空间复制到内核空间,返回时也需要将更新后的信息从内核空间复制回用户空间。当文件描述符数量较多时,频繁的数据拷贝会带来较大的性能损耗。
2. 轮询机制效率低
内核采用轮询方式检查所有指定的文件描述符,时间复杂度为 O ( n ) O(n) O(n),其中 n n n 是文件描述符的数量。随着文件描述符数量的增加,内核检查的时间会变长,性能会明显下降。
3. 大量文件描述符管理问题
虽然 poll
没有像 select
那样固定的文件描述符数量限制(如 FD_SETSIZE
),但随着文件描述符数量的增多,poll
的性能会逐渐变差,因为每次都需要遍历所有的文件描述符。
epoll

1. 创建 epoll 实例
使用 epoll_create
或 epoll_create1
系统调用创建一个 epoll
实例,该实例会在内核中创建一个红黑树和一个链表。红黑树用于存储用户添加的文件描述符,链表用于存储就绪的文件描述符。
c
int epoll_create(int size);
int epoll_create1(int flags);
size
:在 Linux 2.6.8 之后被忽略,但需传入大于 0 的值。flags
:可以设置为EPOLL_CLOEXEC
等标志。
2. 注册文件描述符
使用 epoll_ctl
系统调用向 epoll
实例中添加、修改或删除要监听的文件描述符及其对应的事件。
c
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epfd
:epoll_create
返回的epoll
实例文件描述符。op
:操作类型,如EPOLL_CTL_ADD
(添加)、EPOLL_CTL_MOD
(修改)、EPOLL_CTL_DEL
(删除)。fd
:要操作的文件描述符。event
:指定要监听的事件类型,如EPOLLIN
(可读)、EPOLLOUT
(可写)等。
3. 等待事件发生
使用 epoll_wait
系统调用等待注册的文件描述符上有事件发生。该调用会阻塞进程,直到有事件发生、超时或出错。
c
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
epfd
:epoll
实例文件描述符。events
:用于存储就绪事件的数组。maxevents
:events
数组的最大元素个数。timeout
:超时时间,单位为毫秒。 -1 表示无限等待,0 表示立即返回。
4. 处理就绪事件
epoll_wait
返回后,应用程序可以遍历 events
数组,处理发生事件的文件描述符。
是 否 开始 调用 epoll_create 创建 epoll 实例 使用 epoll_ctl 注册文件描述符及事件 调用 epoll_wait 等待事件 是否有事件发生或超时? 返回就绪事件到 events 数组 应用程序遍历 events 数组 处理发生事件的文件描述符 结束
epoll 系统调用存在的问题
- 跨平台兼容性问题
epoll
是 Linux 特有的系统调用,在其他操作系统(如 Windows、macOS)上没有对应的实现。如果需要编写跨平台的网络程序,就不能单纯依赖 epoll
,需要使用其他跨平台的 I/O 多路复用机制,如 select
或 kqueue
。
性能对比
1.数据结构
- select :使用
fd_set
类型的集合来管理文件描述符,它本质上是一个位数组,数组的每一位对应一个文件描述符。其最大文件描述符数量受FD_SETSIZE
限制,默认通常为 1024。 - poll :使用
struct pollfd
结构体数组来管理文件描述符,每个struct pollfd
包含文件描述符、请求监听的事件和返回时发生的事件。没有固定的文件描述符数量限制。 - epoll:在内核中使用红黑树来存储用户添加的文件描述符,使用链表来存储就绪的文件描述符,理论上支持的文件描述符数量只受系统资源限制。
2. 时间复杂度
- select :采用轮询机制,每次调用都需要遍历所有关注的文件描述符,时间复杂度为 O ( n ) O(n) O(n),其中 n n n 是文件描述符的数量。随着文件描述符数量的增加,性能会显著下降。
- poll :同样使用轮询机制,每次调用也需要遍历所有文件描述符,时间复杂度也是 O ( n ) O(n) O(n),文件描述符数量增多时性能会变差。
- epoll :使用事件驱动机制,当有文件描述符就绪时,内核会直接将其添加到就绪链表中。
epoll_wait
只需访问就绪链表,平均时间复杂度为 O ( 1 ) O(1) O(1),在处理大量并发连接时性能优势明显。
3. 用户态和内核态数据拷贝
- select :每次调用
select
时,都需要将fd_set
从用户空间复制到内核空间,返回时再将更新后的fd_set
从内核空间复制回用户空间,频繁的数据拷贝会带来较大的性能开销。 - poll :与
select
类似,每次调用poll
时,需要将struct pollfd
结构体数组在用户空间和内核空间之间进行复制,文件描述符数量较多时,数据拷贝开销较大。 - epoll :在创建
epoll
实例后,使用epoll_ctl
注册文件描述符,文件描述符信息会被存储在内核中,后续epoll_wait
调用时无需重复复制文件描述符信息,减少了数据拷贝的开销。
特性 | select | poll | epoll |
---|---|---|---|
实现机制 | 位图(fd_set )轮询 |
链表(pollfd )轮询 |
事件驱动(红黑树 + 就绪链表) |
最大连接数 | 1024(受FD_SETSIZE 限制) |
无硬性限制 | 10万+(仅受内存限制) |
时间复杂度 | O(n) | O(n) | O(1)(仅处理就绪事件) |
内存拷贝开销 | 每次调用需拷贝fd_set |
每次调用需传递完整pollfd 列表 |
注册时一次拷贝,后续无拷贝 |
触发模式 | 水平触发(LT) | 水平触发(LT) | 支持LT(默认)和ET(边缘触发) |
跨平台支持 | 全平台(POSIX标准) | 多数Unix系统 | Linux独占 |
编程复杂度 | 中(需手动管理fd_set ) |
中(需遍历pollfd ) |
低(事件回调模型) |
适用场景 | 低并发、跨平台兼容 | 中低并发、需更多连接 | 高并发(如Web服务器、实时通信) |
四、信号驱动 I/O
概念
信号驱动 I/O 是一种 I/O 模型,应用程序先向内核注册一个信号处理函数,然后进程可以继续执行其他任务。当 I/O 事件就绪时,内核会发送一个信号通知应用程序,应用程序在信号处理函数中进行 I/O 操作。
工作原理
- 注册信号处理函数 :应用程序通过系统调用(如
sigaction
)向内核注册一个信号处理函数,指定当 I/O 事件就绪时内核要发送的信号(通常是SIGIO
)。 - 设置文件描述符 :将文件描述符设置为支持信号驱动 I/O 模式,例如使用
fcntl
系统调用设置F_SETOWN
和F_SETFL
标志。 - 进程继续执行:注册完成后,进程可以继续执行其他任务,不会被 I/O 操作阻塞。
- 信号通知:当 I/O 事件就绪(如套接字有数据可读)时,内核发送指定信号给应用程序。
- 处理 I/O 事件:应用程序在信号处理函数中执行相应的 I/O 操作。
优缺点
- 优点 :
- 进程在等待 I/O 事件时不会被阻塞,可以继续执行其他任务,提高了 CPU 的利用率。
- 相比于阻塞 I/O,能更及时地响应 I/O 事件。
- 缺点 :
- 信号处理函数的执行可能会打断进程的正常执行流程,增加了程序的复杂性。
- 信号可能会丢失或被阻塞,导致 I/O 事件处理不及时。
五、异步IO
概念
异步 I/O 是一种更高级的 I/O 模型,应用程序发起 I/O 操作后,无需等待 I/O 操作完成,可以继续执行其他任务。当 I/O 操作完成后,内核会通过回调函数或信号通知应用程序。

工作原理
- 发起 I/O 操作 :应用程序调用异步 I/O 相关的系统调用(如
aio_read
或aio_write
)发起 I/O 操作,并指定回调函数或信号。 - 进程继续执行:发起操作后,进程可以继续执行其他任务,不会被 I/O 操作阻塞。
- I/O 操作完成:内核在后台完成 I/O 操作。
- 通知应用程序:I/O 操作完成后,内核通过回调函数或信号通知应用程序。
优缺点
- 优点 :
- 最大程度地提高了 CPU 的利用率,进程在 I/O 操作期间可以全力执行其他任务。
- 简化了程序的逻辑,应用程序只需发起 I/O 操作,无需关心 I/O 操作的具体完成过程。
- 缺点 :
- 实现复杂度较高,需要对异步 I/O 相关的系统调用和数据结构有深入了解。
- 不同操作系统对异步 I/O 的支持和实现方式可能不同,跨平台性较差。
Redis的网络模型
Redis 是高性能的键值对存储数据库,其网络模型设计对高性能表现至关重要。
I/O 多路复用技术
Redis 基于不同操作系统采用不同的 I/O 多路复用实现,以高效处理大量并发连接。Redis 会按顺序尝试选择最优的实现,选择顺序如下:
- epoll :在 Linux 系统中,Redis 优先使用
epoll
。epoll
采用事件驱动机制,借助红黑树管理文件描述符,使用链表存储就绪事件,时间复杂度为 O ( 1 ) O(1) O(1),在处理大量并发连接时性能出色。 - kqueue :在 FreeBSD、macOS 等系统中,Redis 使用
kqueue
。kqueue
同样是高效的事件通知机制,能有效处理大量并发连接。 - evport :在 Solaris 系统中,Redis 选用
evport
。evport
提供了高效的 I/O 事件通知功能。 - select :若上述方法都不可用,Redis 会退而使用
select
。不过select
存在文件描述符数量限制和性能瓶颈,一般只在旧系统或特殊环境下使用。
单线程模型(Redis 6.0 之前)
工作原理
Redis 6.0 之前主要采用单线程模型处理网络请求。该线程负责监听套接字、接收连接、读取请求、执行命令以及返回响应等所有网络 I/O 操作和命令执行。借助 I/O 多路复用技术,单线程可以高效处理多个客户端连接。
缺点
- 高并发场景压力大:在高并发场景下,大量客户端连接会产生密集的网络 I/O 操作,单线程处理网络 I/O 可能会成为瓶颈。因为网络数据的读写操作需要占用一定的 CPU 时间,单线程需要频繁地在不同客户端连接之间切换处理,容易导致响应延迟增加。
- 带宽利用率不足:单线程处理网络 I/O 难以充分利用服务器的网络带宽资源。当网络带宽较高时,单线程可能无法及时处理所有的网络数据,造成带宽浪费。
多线程模型(Redis 6.0 及之后)
工作原理

Redis 6.0 引入多线程模型,主要用于处理网络 I/O 操作,而命令执行依然由单线程负责。具体流程如下:
- 主线程监听:主线程负责监听套接字,接收客户端连接。
- I/O 线程处理:主线程将读、写网络 I/O 任务分发给多个 I/O 线程并行处理,提升网络 I/O 处理能力。
- 主线程执行命令:I/O 线程读取客户端请求后,将请求数据传递给主线程,主线程执行具体命令。
- I/O 线程返回响应:主线程执行完命令后,将响应数据交回 I/O 线程,由 I/O 线程将响应返回给客户端。
优点
- 提升网络 I/O 性能:多线程并行处理网络 I/O 操作,充分利用多核 CPU 资源,显著提高了 Redis 在高并发场景下的网络 I/O 处理能力。
- 保持命令执行原子性:命令执行仍由单线程负责,保证了 Redis 命令执行的原子性和数据一致性。
Redis网络模型详细工作流程

建立连接阶段
客户端
Jedis
实例创建时,会尝试与指定地址和端口的 Redis 服务器建立 TCP 连接。Java 底层通过 Socket
类创建一个套接字对象,发起三次握手过程。
java
// Jedis 内部建立连接的简化示意
Socket socket = new Socket("localhost", 6379);
Redis 服务器
- 初始化
epoll
实例 :Redis 启动时,主线程会调用epoll_create
系统调用创建一个epoll
实例,用于管理文件描述符。
c
// 简化的 Redis 初始化 epoll 示意
int epoll_fd = epoll_create1(0);
if (epoll_fd == -1) {
perror("epoll_create1");
exit(EXIT_FAILURE);
}
- 监听套接字注册 :主线程创建监听套接字,绑定到指定的 IP 地址和端口,然后调用
listen
开始监听。接着使用epoll_ctl
将监听套接字添加到epoll
实例中,监听EPOLLIN
事件(表示有新的连接请求可读)。
c
// 简化的 Redis 监听套接字注册示意
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
// 绑定地址和端口...
listen(listen_fd, SOMAXCONN);
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = listen_fd;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev) == -1) {
perror("epoll_ctl: listen_fd");
exit(EXIT_FAILURE);
}
- 等待连接请求 :主线程进入事件循环,调用
epoll_wait
阻塞等待事件发生。当有新的连接请求到达时,epoll_wait
返回,主线程通过accept
接收该连接,创建对应的客户端对象,并将其分配给某个 I/O 线程处理。
c
// 简化的 Redis 事件循环示意
struct epoll_event events[MAX_EVENTS];
while (1) {
int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
for (int i = 0; i < nfds; i++) {
if (events[i].data.fd == listen_fd) {
int client_fd = accept(listen_fd, NULL, NULL);
// 创建客户端对象
// 将客户端对象分配给 I/O 线程
}
}
}
发送请求阶段
客户端
调用 jedis.set("key", "value")
时,Jedis 会将该命令按照 Redis 协议(RESP)进行编码,然后通过套接字将编码后的数据发送给 Redis 服务器。
java
// 简化的发送命令示意
OutputStream outputStream = socket.getOutputStream();
String command = "*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n";
outputStream.write(command.getBytes());
outputStream.flush();
Redis 服务器
- I/O 线程监听客户端套接字 :I/O 线程为分配给自己的客户端套接字创建
epoll
实例(或者使用共享的epoll
实例),并使用epoll_ctl
将客户端套接字添加到epoll
实例中,监听EPOLLIN
事件(表示有数据可读)。
c
// 简化的 I/O 线程监听客户端套接字示意
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = client_fd;
if (epoll_ctl(io_thread_epoll_fd, EPOLL_CTL_ADD, client_fd, &ev) == -1) {
perror("epoll_ctl: client_fd");
// 错误处理
}
- 读取请求数据 :I/O 线程进入事件循环,调用
epoll_wait
等待客户端套接字上的数据可读事件。当有数据可读时,epoll_wait
返回,I/O 线程从客户端套接字读取请求数据,将其解析后传递给主线程。
c
// 简化的 I/O 线程事件循环示意
struct epoll_event events[MAX_EVENTS];
while (1) {
int nfds = epoll_wait(io_thread_epoll_fd, events, MAX_EVENTS, -1);
for (int i = 0; i < nfds; i++) {
if (events[i].data.fd == client_fd) {
// 读取客户端请求数据
// 解析请求数据
// 将请求数据传递给主线程
}
}
}
执行命令阶段
Redis 服务器
主线程按顺序从请求队列中取出请求,执行 SET
命令,将键值对存储到内存数据库中,并生成响应结果。
返回响应阶段
Redis 服务器
- 更新监听事件 :主线程将响应结果交回负责该连接的 I/O 线程后,I/O 线程使用
epoll_ctl
修改客户端套接字的监听事件为EPOLLOUT
(表示有数据可写)。
c
// 简化的 I/O 线程更新监听事件示意
struct epoll_event ev;
ev.events = EPOLLOUT;
ev.data.fd = client_fd;
if (epoll_ctl(io_thread_epoll_fd, EPOLL_CTL_MOD, client_fd, &ev) == -1) {
perror("epoll_ctl: client_fd");
// 错误处理
}
- 发送响应数据 :I/O 线程进入事件循环,调用
epoll_wait
等待客户端套接字可写事件。当套接字可写时,epoll_wait
返回,I/O 线程将响应数据按照 RESP 协议编码后,通过套接字发送给客户端。
c
// 简化的 I/O 线程发送响应数据示意
struct epoll_event events[MAX_EVENTS];
while (1) {
int nfds = epoll_wait(io_thread_epoll_fd, events, MAX_EVENTS, -1);
for (int i = 0; i < nfds; i++) {
if (events[i].data.fd == client_fd) {
// 将响应数据按照 RESP 协议编码
// 通过套接字发送响应数据
// 更新监听事件为 EPOLLIN,继续等待下一次请求
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = client_fd;
if (epoll_ctl(io_thread_epoll_fd, EPOLL_CTL_MOD, client_fd, &ev) == -1) {
perror("epoll_ctl: client_fd");
// 错误处理
}
}
}
}
客户端
Jedis 通过套接字读取响应数据,解析后返回给调用者。
java
// 简化的读取响应示意
InputStream inputStream = socket.getInputStream();
byte[] buffer = new byte[1024];
int bytesRead = inputStream.read(buffer);
String response = new String(buffer, 0, bytesRead);
关闭连接阶段
客户端
调用 jedis.close()
方法,关闭套接字连接,释放资源。
java
socket.close();
Redis 服务器
- 检测连接关闭 :I/O 线程在事件循环中,通过
epoll_wait
检测到客户端套接字的异常事件(如EPOLLRDHUP
表示对方关闭连接)。
c
// 简化的 I/O 线程检测连接关闭示意
struct epoll_event events[MAX_EVENTS];
while (1) {
int nfds = epoll_wait(io_thread_epoll_fd, events, MAX_EVENTS, -1);
for (int i = 0; i < nfds; i++) {
if (events[i].events & EPOLLRDHUP) {
// 客户端关闭连接
// 清理相关的客户端对象和资源
close(client_fd);
epoll_ctl(io_thread_epoll_fd, EPOLL_CTL_DEL, client_fd, NULL);
}
}
}
- 清理资源 :I/O 线程关闭客户端套接字,并使用
epoll_ctl
将其从epoll
实例中移除。
是 是 是 是 否 否 否 否 开始 客户端创建Jedis实例 客户端建立TCP连接 发起三次握手 服务器主线程初始化epoll实例 服务器注册监听套接字到epoll 服务器主线程epoll_wait等待连接 是否有新连接请求? 服务器主线程accept接收连接 服务器创建客户端对象 服务器分配客户端到I/O线程 I/O线程注册客户端套接字到epoll 客户端调用jedis.set命令 客户端按RESP协议编码命令 客户端通过套接字发送数据 I/O线程epoll_wait等待可读事件 是否有可读事件? I/O线程读取请求数据 I/O线程解析请求数据 I/O线程传递请求到主线程 主线程从队列取请求 主线程执行SET命令 主线程生成响应结果 主线程交回响应给I/O线程 I/O线程修改监听事件为EPOLLOUT I/O线程epoll_wait等待可写事件 是否有可写事件? I/O线程编码响应数据 I/O线程通过套接字发送响应 I/O线程修改监听事件为EPOLLIN 客户端读取响应数据 客户端解析响应数据 客户端返回结果给调用者 客户端调用jedis.close 客户端关闭套接字连接 I/O线程epoll_wait检测连接关闭事件 是否检测到连接关闭? I/O线程关闭客户端套接字 I/O线程从epoll移除客户端套接字 服务器清理客户端对象和资源 结束