一、用户空间和内核空间
1.1 linux 简介
服务器大多采用 Linux 系统,这里我们以 Linux 为例来讲解,下面有两个不同的 linux 发行版,分别位 ubuntu 和 centos,其实发行版就是在 Linux 系统上包了一层壳。
任何 Linux 发行版,其系统内核都是 Linux。我们在 Linux 上安装的应用,比如说 mysql、redis 等,它们没有办法直接访问计算机硬件,它们需要先访问内核,然后再基于内核去操作计算机硬件。如下图:
再细化的说下,计算机硬件一般包括 CPU、内存、网卡等各种各样的设备,虽然内核可以操作硬件,但它也需要不同设备的驱动,在设备驱动的基础上就可以形成对内存的管理、进程的管理、文件系统的管理和网络管理等等,如下图:
最后呢,如果我们想让用户应用来访问,就必须在设备驱动的基础上封装一些接口,这样一来,我们的用户应用,包括一些依赖库等就可以调用这些接口,从而间接的实现对硬件设备的访问。
1.2 寻址空间
但是这里有一个问题,我们的内核本质也是个应用,它在运行的过程中也需要一些 cpu 资源,内存资源等。所以为了避免用户应用导致冲突甚至内核崩溃,用户应用与内核是分离的。
首先把进程的寻址空间划分为两部分:内核空间、用户空间。寻址空间是什么呢?其实无论是内核还是用户应用,都无法直接访问物理内存,而是给他们分配虚拟的内存空间,映射到不同的物理内存上,这样一来,我们的内核或者用户应用再去访问虚拟内存空间的时候就需要一个虚拟的地址了,这个地址是一个无符号的整数,从 0 开始,它的最大值取决于 cpu 的地址总线和寄存器的带宽,以一个 32 位系统为例,那么它的带宽一般为 32,因此它的地址的最大值就是 2^32 ,也就是说寻址的范围就是从 0 到 2^32,这个寻找的范围就是寻址空间。
我们内存地址的每一个值代表的就是一个存储单元,也就是一个字节。所以 2^32 这么大的寻址空间最多代表的就是 2^32 字节,也即是 4GB,这也就是为什么一个 32 位的系统它的寻址空间最多就是 4GB 的原因。而这 4GB 又会被分为两部分,内核空间和用户空间。如下图:
光在内存上做一个划分还不够,我们还需要在系统权限上做一个划分,因为在 cpu 运行的各种命令中,有一些命令的风险等级比较低,有一些比较高。
用户空间只能执行受限的命令(Ring3),而且不能直接调用系统资源,必须通过内核提供的接口来访问。
内核空间可以执行特权命令(Ring0),调用一切系统资源。
一般来说用户的应用都是运行在用户空间,而内核的应用运行在内核空间。
但是假设一个进程,执行的业务比较多,可能会执行一些普通的命令,也有可能需要去执行一些特权命令去调用系统资源,因此我们的进程就需要在用户空间和内核空间来回切换,当一个进程运行在用户空间的时候我们就称其为用户态,当期运行在内核空间的时候,就称其为内核态,如下图:
所以我们的进程很有可能在两个状态之间进行切换,那么这个切换的流程到底是什么样子的呢?接下来举个 IO 读写的例子。
Linux 系统为了提高 IO 效率,会在用户空间和内核空间都加入缓冲区:
假设此时用户空间发起请求要去读数据,这个请求到达内核空间之后先去判断有没有数据,假如此时读取的是磁盘的数据,那么此时磁盘就需要去寻址,所以在这个过程中就需要等待;如果读取的不是磁盘的数据,读取的是网卡的数据,网卡的数据是别人传过来的,如果别人还没传过来,你还是需要等待的。所以当你发送一个读的请求时,首先是要等,等数据到达。数据到达之后,先把数据读取到缓冲区里面,然后把数据拷贝回用户空间里面做处理。
其实影响我们 IO 效率最主要的第一个原因就是需要数据的等待,第二个原因是数据的拷贝,所以要想提高 IO 的效率,就需要从这两个点出发。减少无效的等待和减少用户态和内核态之间缓冲区的拷贝。后面的五种不同的 IO 模型都是针对这两个点进行优化的。
二、阻塞 IO
顾名思义,阻塞 IO 就是两个阶段都必须阻塞等待。这个过程需要经历两个阶段。
阶段一:
1、用户进程尝试读取数据(比如网卡数据)
2、此时数据尚未到达,内核需要等待数据
3、此时用户进程也处于阻塞状态
阶段二:
1、数据到达并拷贝到内核缓冲区,代表已就绪
2、将内核数据拷贝到用户缓冲区
3、拷贝过程中,用户进程依然阻塞等待
4、拷贝完成,用户进程解除阻塞,处理数据
可以看到,阻塞 IO 模型中,用户进程在两个阶段都是阻塞状态。
三、非阻塞 IO
顾名思义,非阻塞 IO 的 recvfrom 操作会立即返回结果而不是阻塞用户进程。这个过程需要经历两个阶段。
阶段一:
1、用户进程尝试读取数据(比如网卡数据)
2、此时数据尚未到达,内核需要等待数据
3、返回异常给用户进程
4、用户进程拿到 error 后,再次尝试读取
5、循环往复,直到数据就绪
阶段二:
1、将内核数据拷贝到用户缓冲区
2、拷贝过程中,用户进程依然阻塞等待
3、拷贝完成,用户进程解除阻塞,处理数据
可以看到,非阻塞 IO 模型中,用户进程在第一个阶段是非阻塞,第二个阶段是阻塞状态。虽然是非阻塞,但性能并没有得到提高。而且忙等机制会导致 CPU 空转,CPU 使用率暴增。
四、IO 多路复用
4.1 简介
无论是阻塞 IO 还是非阻塞 IO,用户应用在一阶段都需要调用 recvfrom 来获取数据,差别在于无数据时的处理方案:
1、如果调用 recvfrom 时,恰好没有数据,阻塞 IO 会使 CPU 阻塞,非阻塞 IO 使 CPU 空转,都不能充分发挥 CPU 的作用。
2、如果调用 recvfrom 时,恰好有数据,则用户进程可以直接进入第二阶段,读取并处理数据。
而在单线程情况下,只能依次处理 IO 事件,如果正在处理的 IO 事件恰好未就绪(数据不可读或不可写),线程就会被阻塞,所有 IO 事件都必须等待,性能自然会很差。
上面的情况就比如服务员给顾客点餐,分两步:
1、顾客思考要吃什么(等待数据就绪)
2、顾客想好了,开始点餐(读取数据)
要提高效率有两种办法,第一种增加更多服务员(多线程)。第二种是不排队,谁想好了吃什么(数据就绪了),服务员就给谁点餐(用户应用就去读取数据)。那么用户进程如何知道内核中数据是否就绪呢?此时就需要用到 FD 了。
4.2 文件描述符 FD
文件描述符(File Descriptor):简称 FD,是一个从 0 开始的无符号整数,用来关联 Linux 中的一个文件。在 Linux 中,一切皆文件,例如常规文件、视频、硬件设备等,当然也包括网络套接字(Socket)。
IO 多路复用:是利用单个线程来同时监听多个 FD,并在某个 FD 可读、可写时得到通知,从而避免无效的等待,充分利用 CPU 资源。也是分为两个阶段:
阶段一:
1、用户进程调用 select,指定要监听的 FD 集合。
2、内核监听 FD 对应的多个 socket
3、任意一个或多个 socke t数据就绪则返回 readabl
4、此过程中用户进程阻塞
阶段二:
1、用户进程找到就绪的 socket
2、依次调用 recvfrom 读取数据
3、内核将数据拷贝到用户空间
4、用户进程处理数据
IO 多路复用是利用单个线程来同时监听多个 FD,并在某个 FD 可读、可写时得到通知,从而避免无效的等待,充分利用 CPU 资源。不过监听 FD 的方式、通知的方式又有多种实现,常见的有:select、poll、epoll。它们之间的差异如下:
1、select 和 poll 只会通知用户进程有 FD 就绪,但不确定具体是哪个 FD,需要用户进程逐个遍历 FD 来确认。
2、epoll 则会在通知用户进程 FD 就绪的同时,把已就绪的 FD 写入用户空间。
五、信号驱动 IO
信号驱动 IO 是与内核建立 SIGIO 的信号关联并设置回调,当内核有 FD 就绪时,会发出 SIGIO 信号通知用户,期间用户应用可以执行其它业务,无需阻塞等待。这个过程可以分为两个阶段。
阶段一:
1、用户进程调用 sigaction,注册信号处理函数
2、内核返回成功,开始监听 FD
3、用户进程不阻塞等待,可以执行其它业务
4、当内核数据就绪后,回调用户进程的 SIGIO 处理函数
阶段二:
1、收到 SIGIO 回调信号
2、调用 recvfrom,读取数据。
3、内核将数据拷贝到用户空间
4、用户进程处理数据
当有大量 IO 操作时,信号较多,SIGIO 处理函数不能及时处理可能导致信号队列溢出,而且内核空间与用户空间的频繁信号交互性能也较低。
六、异步 IO
异步 IO 的整个过程都是非阻塞的,用户进程调用完异步 API 后就可以去做其它事情,内核等待数据就绪并拷贝到用户空间后才会递交信号,通知用户进程。整个过程分为两个阶段。
阶段一:
1、用户进程调用 aio_read,创建信号回调函数
2、内核等待数据就绪
3、用户进程无需阻塞,可以做任何事情
阶段二:
1、内核数据就绪
2、内核数据拷贝到用户缓冲区
3、拷贝完成,内核递交信号触发 aio_read 中的回调函数
4、用户进程处理数据
可以看到,异步 IO 模型中,用户进程在两个阶段都是非阻塞状态。
七、同步还是异步
IO 操作是同步还是异步,关键看数据在内核空间与用户空间的拷贝过程(数据读写的 IO 操作),也就是阶段二是同步还是异步:
八、Redis 网络模型
8.1 Redis 是否为单线程
Redis 到底是单线程还是多线程?
1、如果仅仅聊 Redis 的核心业务部分(命令处理),答案是单线程。
2、如果是聊整个 Redis,那么答案就是多线程。
在 Redis 版本迭代过程中,在两个重要的时间节点上引入了多线程的支持
1、Redis v4.0:引入多线程异步处理一些耗时较旧的任务,例如异步删除命令 unlink。
2、Redis v6.0:在核心网络模型中引入多线程,进一步提高对于多核 CPU 的利用率。
因此,对于 Redis 的核心网络模型,在 Redis 6.0 之前确实都是单线程。是利用 epoll(Linux 系统)这样的 IO 多路复用技术在事件循环中不断处理客户端情况。
为什么 Redis 要选择单线程?
1、抛开持久化不谈,Redis 是纯内存操作,执行速度非常快,它的性能瓶颈是网络延迟而不是执行速度,因此多线程并不会带来巨大的性能提升。
2、多线程会导致过多的上下文切换,带来不必要的开销
3、引入多线程会面临线程安全问题,必然要引入线程锁这样的安全手段,实现复杂度增高,而且性能也会大打折扣。
8.2 Redis 网络模型
Redis 6.0 版本中引入了多线程,目的是为了提高 IO 读写效率。因此在解析客户端命令、写响应结果时采用了多线程。核心的命令执行、IO 多路复用模块依然是由主线程执行。如下图: