目录
- 一、用户空间和内核空间
- 二、Linux的网络模型
-
- 2.1 BIO(阻塞IO)
- 2.2 NIO(非阻塞IO)
- 2.3 IO多路复用
-
- 2.3.1 select
- 2.3.2 poll
- 2.3.3 epoll
- 2.4 信号驱动IO
- 2.5 AIO(异步IO)
- 三、Redis的网络模型
-
- 3.1 Redis对IO多路复用模型的封装
- 3.2 Redis的单线程工作流程
- 3.3 Redis核心网络模型引入多线程
一、用户空间和内核空间
服务器大多采用Linux操作系统,这里以Linux为例,对用户空间和内核空间这两个概念进行剖析。
- 在Linux系统中安装的Java、Redis等用户应用没有办法直接访问计算机硬件
- ubuntu、centos属于Linux的不同发行版,可以理解成Linux内核的一层“外壳”,用户应用通过这层“外壳”与Linux内核交互,再基于Linux内核访问计算机硬件
从上述流程可以看出,Linux系统中用户应用与内核是分离的,具体来说:
- 进程的寻址空间会划分为两部分:内核空间、用户空间
- 用户空间所拥有的权限只能执行Ring3级别的命令(不能直接调用系统资源,需要通过内核提供的System Call Interface来访问)
- 内核空间可以执行Ring0级别的特权命令,调用一切系统资源
- 进程运行在用户空间称之为用户态,运行在内核空间则成为内核态
进程在运行过程中常常需要在用户态和内核态之间进行切换,如用户应用需要从磁盘或者网络中获取数据,以及向磁盘或者网络中写入数据,为了提供IO效率,Linux系统会在内核空间和用户空间都加入缓冲区。
- 写数据时,把用户缓冲区中的数据拷贝到内核缓冲区,然后写入磁盘或网络
- 读数据时,从磁盘或者网络中读取数据到内核缓冲区,然后拷贝到用户缓冲区
对于网络IO来说,从上面的分析可以看出,影响整体IO性能的主要由两个点:
- 当网络中的数据没有传输完成时,需要等待
- 用户缓冲区和内核缓冲区之间数据的拷贝
Linux中几种不同的网络模型针对上述两个方面均有不同的优化方式,下面具体介绍。
二、Linux的网络模型
当用户应用需要基于硬件设备读写数据时,即会发生用户态和内核态之间的切换,本章节以用户应用从网络中读取数据为例,剖析Linux几种IO模型的差异。
- 整个读取数据的流程主要分两个部分:等待内核缓冲区中的数据就绪、将数据从内核缓冲区拷贝到用户缓冲区
- 不同的IO模型的差别也主要体现在对上述两个部分的处理上
2.1 BIO(阻塞IO)
BIO,即Blocking IO,在BIO的网络模型中,等待内核缓冲区中数据就绪以及数据拷贝这两个阶段都会阻塞用户进程。
- 阶段一:用户进程(这里准确的说是进程中调度到的某个用户线程)尝试读取数据,此时内核数据未准备就绪,用户线程处于阻塞状态
- 阶段二:内核数据准备就绪,向用户缓冲区进行拷贝,拷贝过程中,用户线程仍处于阻塞状态,待拷贝完成,用户线程解除阻塞,处理数据
BIO模型中,用户线程全程需要阻塞,性能较差。比如单线程的服务端请求客户端Socket请求时,只能依次处理每个Socket,如果正在处理的Socket数据未就绪,线程就会阻塞,那么其他所有的Socket都必须等待。
2.2 NIO(非阻塞IO)
NIO,即Non-Blocking IO,在NIO的网络模型中,用户进程在等待内核缓冲区中数据就绪的过程中不会阻塞,在数据拷贝阶段会阻塞。
- 阶段一:用户空间的线程尝试读取数据,如果内核数据未准备就绪,会返回异常给用户线程,用户线程会循环往复地尝试获取,直到数据就绪
- 用户线程收到异常之后,不一定会立刻重新发起系统调用,可能会存在一定的等待时间(取决于具体的IO实现) ,而在这等待的期间,用户线程可以被调度做其他事情,因此这个过程区别于BIO的等待,是非阻塞的
- 阶段二:这个过程与BIO一样,内核数据准备就绪,向用户缓冲区进行拷贝,拷贝过程中,用户线程仍处于阻塞状态,待拷贝完成,用户线程
可以看到,NIO模型中,用户线程在第一个阶段是非阻塞,第二个阶段是阻塞状态。用户线程无需一直阻塞等待数据就绪,但轮训过程也会导致CPU空转,浪费资源。
2.3 IO多路复用
无论是NIO还是BIO,用户空间的线程在等待内核数据就绪的阶段其性能都比较差。
要提高效率的思路:
- 多线程
- 内核数据就绪之后能让用户线程及时感知到,然后用户线程再去读取数据
那么用户线程如何感知内核中数据已经就绪呢?
- 文件描述符(File Descriptor,FD):是一个从0 开始的无符号整数,用来关联Linux中的一个文件。在Linux中,常规文件、视频、硬件设备以及Socket等,一切皆文件,都会对应一个FD。
- IO多路复用:利用单个线程同时监听多个FD,并在某个FD就绪时(可读或者可写)得到通知。
IO多路复用模型的IO过程如下:
- 阶段一:用户线程对指定的FD集合开启监听,内核中FD集合存在就绪的FD则返回OK,用户线程感知数据就绪,此过程中用户线程阻塞
- 阶段二:用户线程找到就绪的FD,依次进行系统调用读取数据,内核将数据拷贝到用户空间,拷贝过程中用户线程阻塞
虽然IO多路复用的模型中阶段一用户线程仍处于阻塞态,但能够同时监听多个FD的状态(例如监听多个客户端连接对应的Socket),感知数据就绪后才会发起系统调用。对于BIO和NIO而言,都是对单个FD直接尝试进行读取数据的调用,如果数据未就绪,等待过程都不能充分利用CPU资源。
IO多路复用模型中监听FD的方式有多种实现:
2.3.1 select
select是Linux最早使用的IO多路复用技术,其IO流程如下:
-
select模式中通过bit位标识FD的状态,调用select函数时要监听的FD对应的bit位为1,select函数返回时准备就绪的FDbit位为1
-
调用select函数时,用户空间先创建要监听的FD集合fd_set,并将整个fd_set从用户空间拷贝到内核空间
-
内核空间遍历fd_set,如果没有数据就绪则进入休眠,直到fd_set中某个(或几个)FD数据就绪后被唤醒,并返回监听结果
-
用户空间遍历select函数返回的fd_set,找到具体的已就绪的FD,开始读取数据
-
fd_set中监听的FD数量有限制,最大值为1024(fd_set是长度为32的无符号long型数组,共32*32=1024bit)
2.3.2 poll
poll在select的基础上做了部分改进,其IO流程如下:
- 用户空间创建poll_fd数组,数组大小自定义,用于存储要监听的FD集合
- 调用poll函数,将poll_fd数组拷贝到内核空间后转链表存储,数量无上限
- 内核遍历poll_fd,某个(或几个)FD数据就绪后返回监听结果以及就绪的FD数量n
- 用户空间判断n是否大于0,大于0则遍历poll_fd,找到就绪的FD,开始读取数据
select的fd_set大小固定为1024,而poll_fd在内核中采用链表存储,不存在数量限制。然而监听的FD越多,也意味着遍历的时间变长,影响整理的IO性能。
2.3.3 epoll
epoll对select和poll做了很大的改进,性能上有较大提升,整体的IO流程主要由三个函数组成:
- epoll_create:在内核空间创建一个epoll实例,内部包含一棵红黑树rbr(用于存放监听的FD)和一个链表rdlist(用于存储已经就绪的FD),函数返回值为该epoll实例的句柄
- epoll_ctl:将一个要监听的FD添加到epoll的红黑树中(通过具体的epoll句柄定位),并设置一个关联的回调函数ep_poll_callback,FD就绪后通过触发这个函数将FD加入到rdlist中
- epoll_wait:检查epoll中的rdlist是否为空(通过具体的epoll句柄定位),该函数传入一个空数组events,如果rdlist不为空则返回就绪的FD数量并将就绪的FD放到events中
完整的IO流程:
相比于select和poll,epoll的IO流程有较明显的优化:
- 要监听的FD保存在epoll实例的红黑树中,理论上无上限,效率比数组和链表的存储方式要高许多
- 每个FD只需要执行一次epoll_ctl添加到红黑树中,epoll_wait只需要传递一个空数组的句柄,无需重复拷贝大量的FD集合到内核空间
- epoll_wait返回后events数组中保存的就是就绪的FD集合,需要再去遍历FD集合确认具体是哪些FD就绪
- 内核空间中利用ep_poll_callback机制来监听FD状态,性能不会随监听的FD数量增多而下降
2.4 信号驱动IO
信号驱动IO是与内核建立SIGIO的信号关联并设置回调,FD就绪时内核会发出SIGIO信号通知用户,这个过程中用户进程不会阻塞等待。
- 阶段一:用户线程发起SIGIO关联请求,内核返回成功,监听指定的FD,数据就绪后通过SIGIO信号通知用户线程,此过程中用户线程不阻塞
- 阶段二:用户线程进行系统调用读取数据,内核将数据拷贝到用户空间,拷贝过程中用户线程阻塞
信号驱动IO的模型看起来对用户空间较为友好,但当有大量IO操作时,有可能导致信号队列溢出,而且内核空间与用户空间的频繁信号交互也会影响整体的IO性能。
2.5 AIO(异步IO)
AIO,即Async IO,在AIO的网络模型中,用户进程全程都是非阻塞的,内核等待数据就绪并拷贝到用户空间后才会递交信号,通知用户进程。
- 阶段一:用户进程调用aio_read,创建信号回调函数,内核返回OK,并监听对应的FD
- 阶段二:内核数据就绪后,拷贝到用户缓冲区,完成后递交信号触发aio_read中的回调函数,通知用户进程处理数据
- 整个过程用户进程都不阻塞,可以同时处理其他业务
AIO模型的最大问题就是当并发量较大的时候,内核的压力非常大,这就需要用户线程对应地做些类似于限流的处理,也就是说AIO模型下用户空间的代码实现复杂度较高。
总结:除AIO外其他几种IO模型都是同步IO,IO模型是同步还是异步,取决于内核空间与用户空间的拷贝过程中(阶段二)用户进程是否阻塞。
三、Redis的网络模型
关于Redis是单线程这个问题,其实并不严谨:
- 对于Redis的核心业务部分(命令处理),确实是单线程执行的
- 而对于完整的Redis进程来说,仍然是多线程并行执行
在Redis版本迭代过程中
- Redis v4.0:引入多线程异步处理一些耗时较长的任务,例如异步删除命令unlink
- Redis v6.0:在核心网络模型中引入多线程,进一步提高对于多核CPU的利用率
这其实引入了一个问题,随着硬件技术(尤其是多核技术)的发展,为什么Redis的业务处理始终选择单线程?
- 抛开持久化不谈,Redis是纯内存操作,因此性能瓶颈主要源于网络延迟而不是执行速度,因此多线程并不会带来巨大的性能提升
- 多线程的上下文切换,同样会带来不必要的开销
- 多线程执行需要引入并发控制的安全手段,增加了复杂度的同时,性能也会大打折扣
3.1 Redis对IO多路复用模型的封装
Redis的IO多路复用程序封装了底层的 select、epoll 这些 I/O 多路复用函数,为上层提供了相同的接口。以对epoll的实现为例:
- aeApiCreate:创建多路复用程序,底层调用epoll_create
- aeApiAddEvent:注册要监听的FD,底层调用epoll_ctl
- aeApiPoll:等待FD就绪,底层调用epoll_await
Redis程序在编译时会自动选择系统中性能最优的 I/O 多路复用函数库作为IO多路复用程序的底层实现。
3.2 Redis的单线程工作流程
Redis能够在单线程下支撑每秒数万级别的并发,很大程度上得力于其高性能的网络模型。Redis是一个事件驱动程序,客户端和Redis服务器通过文件事件处理器(File Event Handler,FEH)进行通信:
- Redis服务器与客户端通过Socket进行连接,FEH器使用 I/O 多路复用模式来同时监听多个Socket(准确的说是Socket对应的FD),并为其关联对应的事件处理器。
- 当被监听的Socket准备好执行连接应答、读取、写入、关闭等操作时,对应的文件事件就会生成,这时FEH就会调用Socket关联的事件处理器来处理对应的事件。
虽然FEH以单线程方式运行(串行处理Socket队列中的每个Socket携带的事件),但通过IO多路复用程序来监听多个Socket,实现了高性能的网络通信模型。下面是客户端和redis通过文件事件处理器进行通信的大概流程:
I/O 多路复用程序可以监听Socket的AE_READABLE事件和AE_WRITABLE事件:
- 当Socket变得可读时(客户端对Socket执行write或者close操作),或者有新的可应答(acceptable)Socket出现时(客户端对服务器的Server Socket执行连接操作),Socket产生AE_READABLE 事件。
- 当Socket变得可写时(客户端对Socket执行read操作),Socket产生AE_WRITABLE事件。
服务器会为根据事件类型关联不同的事件处理器,这些处理器定义了某个事件发生时,服务器应该执行的某些动作:
- Redis服务器初始化时,会创建Server Socket用于应答客户端的连接请求,同时将连接应答处理器与Server Socket的AE_READABLE事件关联起来
- 当一个客户端与服务器建立连接后,服务器会将对应Client Socket的AE_READABLE事件与命令请求处理器关联起来。
- 当服务器有命令回复需要传送给客户端时,服务器会将对应Client Socket的AE_WRITABLE事件与命令回复处理器关联起来。
下面剖析一次完整的客户端与服务器连接和一次通信流程:
- 客户端发起连接请求,Server Socket产生AE_READABLE 事件,触发连接应答处理器执行:创建对应的Client Socket,同时将这个Client Socket的AE_READABLE事件和命令请求处理器关联。
- 客户端向Redis服务器发送一个命令请求,对应的Client Socket将产生 AE_READABLE事件,触发命令请求处理器执行:处理器读取客户端的命令内容,然后对命令进行执行。
- 命令执行完毕,Redis服务器准备好给客户端的响应数据后,会将Cleint Socket的AE_WRITABLE事件与命令回复处理器进行关联
- 客户端尝试读取命令响应,Client Socket产生AE_WRITABLE事件, 触发命令回复处理器执行:将响应数据写入Client Socket。写入完成后,服务器就会解除Client Socket的AE_WRITABLE事件与命令回复处理器之间的关联。
3.3 Redis核心网络模型引入多线程
Redis 6.0版本中在核心网络模型中引入了多线程,目的是为了提高IO读写效率。因此在解析客户端命令、写响应结果时(这两个模块正是单线程模型下最影响IO性能的环节)采用了多线程。而核心的命令执行、IO多路复用模块依然是由主线程(单线程)执行。