1 IO模型
linux系统也是一种应用,它是基于计算机硬件的一种操作系统软件。当我们接收一次网络传输,计算机硬件的网卡会从网络中将读到的字节流写到linux的buffer缓冲区内存中,然后用户空间会调用linux对外暴露的接口,将linux中的buffer内存中的数据再读取到用户空间。这一次读操作就是一次IO。同样写也是这样的。
不同的操作系统,IO模型不一样,本文主要介绍的是Linux系统的几种IO模型。
这样做是为了保护Linux操作系统,避免外部应用或者人为直接操作内核系统。
当线程操作在用户空间时候的状态称为:用户态。
当线程操作在内核空间时候的状态称为:内核态。
1.1 网络IO模型基本概念
1.1.1 内核态
内核态拥有完全的底层资源控制权限,可以执行任何的CPU指令,访问任何内存地址,其占有的处理机是不允许被抢占的。
1.1.2 用户态
用户程序是运行在操作系统之上,这些程序运行时称之为用户态,用户态下不能直接访问底层硬件和内存地址,只能通过委托系统调用的方式来访问底层硬件和内存。
1.1.3 用户态到内核态如何切换
从用户态切换到内核态有三种方式:
系统调用:这是用户态主动要求切换到内核态的一种方式。用户进程通过系统调用申请使用操作系统提供的某些服务以便完成工作,比如,调用fork()指令实际上就是执行了一个创建新进程的系统调用。系统调用的机制其核心在于使用了操作系统为用户特别开放的一个中断来实现的,例如Linux的int 80h中断;
外设中断:当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号。这时CPU会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序。如果先前执行的是用户态下的指令,那么这个切换过程就是用户态转为内核态。比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作;
异常:当CPU在执行运行处于用户态的程序时,发生了一些不可知的异常,这个时候就会触发由当前运行进行切换到处理此异常的内核相关程序中,也就是转到了内核态,比如缺页异常;
这三种是用户态切换到内核态的主要方式,系统调用是主动的,后面两种是被动的。
Linux的整体架构图如下所示:
1.1.4 IO的性能瓶颈
a.用户态与内核态的切换(数据拷贝);
b.读写线程的阻塞等待;
linux的IO模型就是针对这两点去优化的:
1.2 同步/异步
同步/异步关注的是消息通信机制。
同步:所谓同步,就是在发出一个功能调用时,在没有得到结果之前,该调用就不返回。等前一件做完了才能做下一件事。
异步:异步的概念和同步相对。当一个异步过程调用发出后,调用者若不能立刻得到结果,此时可以直接返回然后执行其他任务,等到获得了结果之后通过状态、通知或者回调等手段通知调用者。
同步、异步一般发生在不同的线程/进程之间,如Thread1和Thread2是同步执行还是异步执行的。
1.3 阻塞和非阻塞
阻塞和非阻塞关注的是程序在等待调用结果时的状态。
阻塞: 阻塞调用是指调用返回之前,当前线程会被挂起,只有当调用得到结果后才返回。
非阻塞:与阻塞相反,非阻塞调用是指在不能立即得到结果之前,该函数不会将当前线程阻塞,而是立即返回。
2 Linux系统的五种 IO 模型
IO一般分为磁盘IO和网络IO,这里我们主要关注网络IO。一次完整的网络IO过程如下所示:
从上图可以看出,数据无论从网卡到用户空间还是从用户空间到网卡都需要经过内核。
2.1 阻塞IO模型
当应用程序调用一个 IO 函数,其底层会委托操作系统的recvfrom()去完成,当数据还没有准备好时,revfrom会一直阻塞,等待数据准备好。当数据准备好后,从内核拷贝到用户空间,recvfrom 返回成功,IO函数调用完成。过程如下所示:
阻塞IO模型的优点是编程简单,但缺点是需要配合大量线程使用。应用进程没接收一个连接,就需要为此连接创建一个线程来处理该连接上的读写任务。
问题:当一个线程阻塞住了,会导致后续所有的线程都阻塞住,即使后面的读写数据已经就绪,也无法进行读写。
2.2 非阻塞IO模型
调用进程在等待数据的过程中不会被阻塞,而是会不断地轮询查看数据有没有准备好。当数据准备好后,将数据从内核空间拷贝到用户空间,完成IO函数的调用。等待数据的过程是非阻塞的,但数据拷贝时仍是阻塞的。过程如下所示:
非阻塞io的优点在于可以实现使用一个线程同时处理多个连接的需求,减少线程的大量使用。缺点在于要不断地去轮询检查数据是否准备好,比较耗费CPU。
问题:如果一直没有数据的话,线程会死循环的调用recvfrom函数,频繁使用CPU资源,导致CPU资源的浪费。
2.3 IO复用模型
为了解决非阻塞IO不断轮询导致CPU占用升高的问题,出现了IO复用模型。IO复用中,使用其他线程帮助去检查多个线程数据的完成情况,提高效率。
Linux中提供了select、poll和epoll三种方式来实现IO复用。一个线程可以对多个IO端口进行监听,当有读写事件产生时会分发到具体的线程进行处理。过程如下所示:
IO复用只需要阻塞在select,poll或者epoll,可以同时处理和管理多个连接。缺点是当select、poll或者epoll 管理的连接数过少时,这种模型将退化成阻塞IO 模型。并且还多了一次系统调用:一次select、poll或者epoll 一次recvfrom。
2.3.1 文件描述符(FD)
内核(kernel)利用文件描述符(file descriptor)来访问文件。文件描述符是非负整数值,打开现存文件或新建文件时,内核会返回一个文件描述符。读写文件也需要使用文件描述符来指定待读写的文件。文件包含音频文件、常规文件、硬件设备等等,也包括网络套接字(Socket)。
IO多路复用就是利用单线程去监听多个文件描述符FD,并在某个文件描述符FD可读或可写的时候接收到通知,避免无效的等待,充分利用CPU资源。
2.3.2 select模式
用户应用线程调用select函数去监听多个FD文件描述符,如果没有数据,还是要等待,如果有就绪的文件FD,说明有数据,那就去读对应的FD就绪的文件数据,此时内核会将文件FD集合拷贝到用户内存中,然后去遍历FD集合,找到可以读数据的FD,然后再去读取,读完了之后会将FD的集合再拷贝到内核内存中。
类似于你去餐厅排队点餐,这时候有一个服务员,服务员通过平板监控后厨,你只需要询问服务员有没有东西吃就可以了,如果没有你还是需要等待,但是如果有了,服务员通过监控就知道有东西可以吃了,就会让你点餐了。
linux最开始提供的是select函数,方法如下:
select(int nfds, fd_set *r, fd_set *w, fd_set *e, struct timeval *timeout)
该方法需要传递3个集合,r,e,w分别表示读、写、异常事件集合。集合类型是bitmap,通过0/1表示该位置的fd(文件描述符,socket也是其中一种)是否关心对应读、写、异常事件。例如我们对fd为1和2的读事件关心,r参数的第1,2个bit就设置为1。
用户进程调用select函数将关心的事件传递给内核系统,然后就会阻塞,直到传递的事件至少有一个发生时,方法调用会返回。内核返回时,同样把发生的事件用这3个参数返回回来,如r参数第1个bit为1表示fd为1的发生读事件,第2个bit依然为0,表示fd为2的没有发生读事件。用户进程调用时传递关心的事件,内核返回时返回发生的事件。
select存在的问题:
FD集合(数组)大小有限制为1024,由于每次select函数调用都需要在用户空间和内核空间传递这些参数,为了提升拷贝效率,linux限制最大为1024。
这3个集合有相应事件触发时,会被内核修改,所以每次调用select方法都需要重新设置这3个集合的内容。
当有事件触发select方法返回,需要遍历整个FD集合(数组)才能找到就绪的文件描述符,例如传1024个读事件,只有一个读事件发生,需要遍历1024个才能找到这一个。
同样在内核级别,每次需要遍历集合查看有哪些事件发生,效率低下。
需要将整个FD数组从用户空间拷贝到内核空间,select结束还要再次拷贝到用户空间;
2.3.3 poll模式
poll模式其实和select模式原理差不多,不同的点在于,poll模式底层加上了一个event事件,分成读事件、写事件、异常事件等等。
流程:
a.先添加需要监听的事件,是读事件,还是写事件,可以是多个事件;
b.将监听到的事件FD,转换成链表,保存在内核缓冲区;
c.内核缓冲区将事件FD链表拷贝到用户缓冲区,并返回就绪的FD数量;
d.用户缓冲区判断就绪的FD数量,如果大于0则开始遍历事件FD链表;
poll函数对select函数做了一些改进:
poll(struct pollfd *fds, int nfds, int timeout)struct pollfd { int fd; short events; short revents;}
poll函数需要传一个pollfd结构数组,其中fd表示文件描述符,events表示关心的事件,revents表示发生的事件,当有事件发生时,内核通过这个参数返回回来。
poll相比select的改进:
传不固定大小的数组,没有1024的限制了(问题1)
将关心的事件和实际发生的事件分开,不需要每次都重新设置参数(问题2)。例如poll数组传1024个fd和事件,实际只有一个事件发生,那么只需要重置一下这个fd的revent即可,而select需要重置1024个bit。
poll没有解决select的问题3和4。另外,虽然poll没有1024个大小的限制,但每次依然需要在用户和内核空间传输这些内容,数量大时效率依然较低。
这几个问题本身实际很简单,核心问题是select/poll方法对于内核来说是无状态的,内核不会保存用户调用传递的数据,所以每次都是全量在用户和内核空间来回拷贝,如果调用时传给内核就保存起来,有新增文件描述符需要关注就再次调用增量添加,有事件触发时就只返回对应的文件描述符,那么问题就迎刃而解了,这就是epoll做的事情。
2.3.4 epoll模式
epoll模式是在poll模式的基础上再次改进,首先将存储事件的FD链表改成了红黑树(理论上也是无上限的),红黑树的遍历性能稳定,其次就是将具体的就绪事件单独复制出来然后拷贝给用户缓冲区,用户缓冲区拿到的是已经就绪的事件,无需遍历整个红黑树,性能再次提升。
流程:
a.先添加要监听的事件;
b.将所有监听到的事件FD挂载在一个红黑树中;
c.当FD就绪调用回调函数将对应的FD复制到一个链表中;
d.将链表从内核缓冲区拷贝到用户缓冲区,并返回链表大小n;
e.用户线程直接判断n大小,当n不为0的时候,直接读取链表(全部是就绪的FD)的数据即可;
epoll对应3个方法:
int epoll_create(int size);int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
epoll_create负责创建一个上下文,用于存储数据,底层是用红黑树,以后的操作就都在这个上下文上进行。
epoll_ctl负责将文件描述和所关心的事件注册到上下文。
epoll_wait用于等待事件的发生,当有事件触发,就只返回对应的文件描述符了。
2.4信号驱动IO模型
应用程序可以创建一个信号驱动程序SIGIO,当数据没有处理好时,应用程序继续运行,不会被阻塞。当数据准备好之后,操作系统向应用程序发送信号,之后信号驱动程序就会执行,在信号处理函数中调用 IO函数处理数据。过程如下所示:
信号驱动IO模型的优点在于非阻塞,缺点在于串行处理信号驱动程序,当前一个SIGIO没有被处理的情况下,后一个信号也不能被处理。在信号量大的时候会导致后面的信号不能被及时感知。
问题:当调用的线程过多,对应的信号量会增多,SIGIO函数处理不及时,会导致保存信号的队列溢出;而且内核空间与用户空间频繁的进行信号量的交互,性能很差。
2.5 异步IO模型
相比于同步IO,异步IO不是顺序执行的。应用进程在执行aio_read系统调用之后,无论数据是否准备好,都会直接返回给用户进程,然后应用进程可以去做别的事情。当数据准备好之后,内核直接复制数据给用户进程,然后内核向进程发送通知。过程如下:
信号驱动IO模型中内核通知应用进程数据何时准备好,而在异步IO模型中内核将数据复制完成之后告知应用进程IO操作已完成。
在异步IO模型中,应用进程调用aio_read以及数据被拷贝到用户空间这两个过程都是非阻塞的。性能上来说也是不错的,就是在实际开发中,需要控制它的线程并发数,所以实现起来会非常麻烦,所以使用很少
2.6 网络IO模型总结
IO模型共有五种,前四种模型区别在于第一部分,即系统调用,但是第二部分都是一样的,即将数据从内核空间拷贝到用户空间这个过程,进程阻塞于redvfrom的调用。而最后一种,异步IO模型,在系统调用和数据拷贝过程都是非阻塞的。
五种IO模型对比来说,epoll的效果是最好的,解决了select和poll模式中存在的问题,而redis就是使用的epoll模式的IO模型。
3 Redis网络IO模型
3.1 Redis中用到的IO多路复用(IO Multiplexing)技术
IO多路复用机制是指一个线程处理多个IO流,多路是指网络连接,复用指的是同一个线程。
如果简单从图上看IO多路复用相比阻塞IO似乎并没有什么高明之处,假设服务只处理少量的连接,那么相比阻塞IO确实没有太大的提升,但如果连接数非常多,差距就会立竿见影。
首先IO多路复用会提交一批需要监听的文件句柄(socket也是一种文件句柄)到内核,由内核开启一个线程负责监听,把轮询工作交给内核,当有事件发生时,由内核通知用户程序。这不需要用户程序开启更多的线程去处理连接,也不需要用户程序切换到内核态去轮询,用一个线程就能处理大量网络IO请求。
redis底层采用的就是IO多路复用模型,实际上基本所有中间件在处理网络IO这一块都会使用到IO多路复用,如kafka,rocketmq等。
3.2 reactor模式
前面我们介绍的IO多路复用是操作系统的底层实现,借助IO多路复用我们实现了一个线程就可以处理大量网络IO请求,那么接收到这些请求后该如何高效的响应,这就是reactor要关注的事情,reactor模式是基于事件的一种设计模式。在reactor中分为3中角色:
Reactor:负责监听和分发事件
Acceptor:负责处理连接事件
Handler:负责处理请求,读取数据,写回数据
从线程角度出发,reactor又可以分为单reactor单线程、单reactor多线程、多reactor多线程3种。
3.2.1 单reactor单线程模式
处理过程:
reactor负责监听连接事件;
当有连接到来时,通过acceptor处理连接,得到建立好的socket对象;
reactor监听scoket对象的读写事件;
读写事件触发时,交由handler处理;
handler负责读取请求内容,处理请求内容,响应数据。
可以看到这种模式比较简单,读取请求数据,处理请求内容,响应数据都是在一个线程内完成的,如果整个过程响应都比较快,可以获得比较好的结果。缺点是请求都在一个线程内完成,无法发挥多核cpu的优势,如果处理请求内容这一块比较慢,就会影响整体性能。
3.2.2 单reactor多线程
既然处理请求这里可能有性能问题,那么这里可以开启一个线程池来处理,这就是单reactor多线程模式,请求连接、读写还是由主线程负责,处理请求内容交由线程池处理,相比之下,多线程模式可以利用cpu多核的优势。单仔细思考这里依然有性能优化的点,就是对于请求的读写这里依然是在主线程完成的,如果这里也可以多线程,那效率就可以进一步提升。
3.2.3 多reactor多线程
多reactor多线程下,mainReactor接收到请求交由acceptor处理后,mainReactor不再读取、写回网络数据,直接将请求交给subReactor线程池处理,这样读取、写回数据多个请求之间也可以并发执行了。
3.3 Redis6.0以前的网络IO模型
redis网络IO模型底层使用IO多路复用,通过reactor模式实现的,在redis 6.0以前属于单reactor单线程模式。如图:
在linux下,IO多路复用程序使用epoll实现,负责监听服务端连接、socket的读取、写入事件,然后将事件丢到事件队列,由事件分发器对事件进行分发,事件分发器会根据事件类型,分发给对应的事件处理器进行处理。我们以一个get key简单命令为例,一次完整的请求如下:
请求首先要建立TCP连接(TCP3次握手),过程如下:
redis服务启动,主线程运行,监听指定的端口,将连接事件绑定命令应答处理器。
客户端请求建立连接,连接事件触发,IO多路复用程序将连接事件丢入事件队列,事件分发器将连接事件交由命令应答处理器处理。
命令应答处理器创建socket对象,将ae_readable事件和命令请求处理器关联,交由IO多路复用程序监听。
连接建立后,就开始执行get key请求了。如下:
客户端发送get key命令,socket接收到数据变成可读,IO多路复用程序监听到可读事件,将读事件丢到事件队列,由事件分发器分发给上一步绑定的命令请求处理器执行。
命令请求处理器接收到数据后,对数据进行解析,执行get命令,从内存查询到key对应的数据,并将ae_writeable写事件和响应处理器关联起来,交由IO多路复用程序监听。
客户端准备好接收数据,命令请求处理器产生ae_writeable事件,IO多路复用程序监听到写事件,将写事件丢到事件队列,由事件分发器发给命令响应处理器进行处理。
命令响应处理器将数据写回socket返回给客户端。
3.4 Redis6.0的网络IO模型
reids 6.0以前网络IO的读写和请求的处理都在一个线程完成,尽管redis在请求处理基于内存处理很快,不会称为系统瓶颈,但随着请求数的增加,网络读写这一块存在优化空间,所以redis 6.0开始对网络IO读写提供多线程支持。需要知道的是,redis 6.0对多线程的默认是不开启的,可以通过 io-threads 4 参数开启对网络写数据多线程支持,如果对于读也要开启多线程需要额外设置 io-threads-do-reads yes 参数,该参数默认是no,因为redis认为对于读开启多线程帮助不大,但如果你通过压测后发现有明显帮助,则可以开启。
redis 6.0多线程模型思想上类似单reactor多线程和多reactor多线程,但不完全一样,这两者handler对于逻辑处理这一块都是使用线程池,而redis命令执行依旧保持单线程。如下:
可以看到对于网络的读写都是提交给线程池去执行,充分利用了cpu多核优势,这样主线程可以继续处理其它请求了。
开启多线程后多redis进行压测结果可以参考这里,如下图可以看到,对于简单命令qps可以达到20w左右,相比单线程有一倍的提升,性能提升效果明显,对于生产环境如果大家使用了新版本的redis,现在7.0也出来了,建议开启多线程。
参考链接
一文搞懂 Redis 架构演化之路
Redis设计与实现
redis架构
Redis高可用方案—主从(masterslave)架构
Redis高可用架构—哨兵(sentinel)机制详细介绍
Redis高可用架构—Redis集群(Redis Cluster)详细介绍
Redis基本概念知识
Redis-基本概念
redis架构
03 Redis 网络IO模型简介
详解redis网络IO模型
redis的IO模型