linux系统也是一种应用,它是基于计算机硬件的一种操作系统软件。当我们接收一次网络传输,计算机硬件的网卡会从网络中将读到的字节流写到linux的buffer缓冲区内存中,然后用户空间会调用linux对外暴露的接口,将linux中的buffer内存中的数据再读取到用户空间。这一次读操作就是一次IO。同样写也是这样的。
不同的操作系统,IO模型不一样,本文主要介绍的是Linux系统的几种IO模型。
这样做是为了保护Linux操作系统,避免外部应用或者人为直接操作内核系统。
当线程操作在用户空间时候的状态称为:用户态。
当线程操作在内核空间时候的状态称为:内核态。
内核态拥有完全的底层资源控制权限,可以执行任何的CPU指令,访问任何内存地址,其占有的处理机是不允许被抢占的。
用户程序是运行在操作系统之上,这些程序运行时称之为用户态,用户态下不能直接访问底层硬件和内存地址,只能通过委托系统调用的方式来访问底层硬件和内存。
从用户态切换到内核态有三种方式:
这三种是用户态切换到内核态的主要方式,系统调用是主动的,后面两种是被动的。
Linux的整体架构图如下所示:
a.用户态与内核态的切换(数据拷贝);
b.读写线程的阻塞等待;
linux的IO模型就是针对这两点去优化的:
同步/异步关注的是消息通信机制。
同步:所谓同步,就是在发出一个功能调用时,在没有得到结果之前,该调用就不返回。等前一件做完了才能做下一件事。
异步:异步的概念和同步相对。当一个异步过程调用发出后,调用者若不能立刻得到结果,此时可以直接返回然后执行其他任务,等到获得了结果之后通过状态、通知或者回调等手段通知调用者。
同步、异步一般发生在不同的线程/进程之间,如Thread1和Thread2是同步执行还是异步执行的。
阻塞和非阻塞关注的是程序在等待调用结果时的状态。
阻塞: 阻塞调用是指调用返回之前,当前线程会被挂起,只有当调用得到结果后才返回。
非阻塞:与阻塞相反,非阻塞调用是指在不能立即得到结果之前,该函数不会将当前线程阻塞,而是立即返回。
IO一般分为磁盘IO和网络IO,这里我们主要关注网络IO。一次完整的网络IO过程如下所示:
从上图可以看出,数据无论从网卡到用户空间还是从用户空间到网卡都需要经过内核。
当应用程序调用一个 IO 函数,其底层会委托操作系统的recvfrom()去完成,当数据还没有准备好时,revfrom会一直阻塞,等待数据准备好。当数据准备好后,从内核拷贝到用户空间,recvfrom 返回成功,IO函数调用完成。过程如下所示:
阻塞IO模型的优点是编程简单,但缺点是需要配合大量线程使用。应用进程没接收一个连接,就需要为此连接创建一个线程来处理该连接上的读写任务。
问题:当一个线程阻塞住了,会导致后续所有的线程都阻塞住,即使后面的读写数据已经就绪,也无法进行读写。
调用进程在等待数据的过程中不会被阻塞,而是会不断地轮询查看数据有没有准备好。当数据准备好后,将数据从内核空间拷贝到用户空间,完成IO函数的调用。等待数据的过程是非阻塞的,但数据拷贝时仍是阻塞的。过程如下所示:
非阻塞io的优点在于可以实现使用一个线程同时处理多个连接的需求,减少线程的大量使用。缺点在于要不断地去轮询检查数据是否准备好,比较耗费CPU。
问题:如果一直没有数据的话,线程会死循环的调用recvfrom函数,频繁使用CPU资源,导致CPU资源的浪费。
为了解决非阻塞IO不断轮询导致CPU占用升高的问题,出现了IO复用模型。IO复用中,使用其他线程帮助去检查多个线程数据的完成情况,提高效率。
Linux中提供了select、poll和epoll三种方式来实现IO复用。一个线程可以对多个IO端口进行监听,当有读写事件产生时会分发到具体的线程进行处理。过程如下所示:
IO复用只需要阻塞在select,poll或者epoll,可以同时处理和管理多个连接。缺点是当select、poll或者epoll 管理的连接数过少时,这种模型将退化成阻塞IO 模型。并且还多了一次系统调用:一次select、poll或者epoll 一次recvfrom。
内核(kernel)利用文件描述符(file descriptor)来访问文件。文件描述符是非负整数值,打开现存文件或新建文件时,内核会返回一个文件描述符。读写文件也需要使用文件描述符来指定待读写的文件。文件包含音频文件、常规文件、硬件设备等等,也包括网络套接字(Socket)。
IO多路复用就是利用单线程去监听多个文件描述符FD,并在某个文件描述符FD可读或可写的时候接收到通知,避免无效的等待,充分利用CPU资源。
用户应用线程调用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存在的问题:
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的改进:
poll没有解决select的问题3和4。另外,虽然poll没有1024个大小的限制,但每次依然需要在用户和内核空间传输这些内容,数量大时效率依然较低。
这几个问题本身实际很简单,核心问题是select/poll方法对于内核来说是无状态的,内核不会保存用户调用传递的数据,所以每次都是全量在用户和内核空间来回拷贝,如果调用时传给内核就保存起来,有新增文件描述符需要关注就再次调用增量添加,有事件触发时就只返回对应的文件描述符,那么问题就迎刃而解了,这就是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);
应用程序可以创建一个信号驱动程序SIGIO,当数据没有处理好时,应用程序继续运行,不会被阻塞。当数据准备好之后,操作系统向应用程序发送信号,之后信号驱动程序就会执行,在信号处理函数中调用 IO函数处理数据。过程如下所示:
信号驱动IO模型的优点在于非阻塞,缺点在于串行处理信号驱动程序,当前一个SIGIO没有被处理的情况下,后一个信号也不能被处理。在信号量大的时候会导致后面的信号不能被及时感知。
问题:当调用的线程过多,对应的信号量会增多,SIGIO函数处理不及时,会导致保存信号的队列溢出;而且内核空间与用户空间频繁的进行信号量的交互,性能很差。
相比于同步IO,异步IO不是顺序执行的。应用进程在执行aio_read系统调用之后,无论数据是否准备好,都会直接返回给用户进程,然后应用进程可以去做别的事情。当数据准备好之后,内核直接复制数据给用户进程,然后内核向进程发送通知。过程如下:
信号驱动IO模型中内核通知应用进程数据何时准备好,而在异步IO模型中内核将数据复制完成之后告知应用进程IO操作已完成。
在异步IO模型中,应用进程调用aio_read以及数据被拷贝到用户空间这两个过程都是非阻塞的。性能上来说也是不错的,就是在实际开发中,需要控制它的线程并发数,所以实现起来会非常麻烦,所以使用很少
IO模型共有五种,前四种模型区别在于第一部分,即系统调用,但是第二部分都是一样的,即将数据从内核空间拷贝到用户空间这个过程,进程阻塞于redvfrom的调用。而最后一种,异步IO模型,在系统调用和数据拷贝过程都是非阻塞的。
五种IO模型对比来说,epoll的效果是最好的,解决了select和poll模式中存在的问题,而redis就是使用的epoll模式的IO模型。
IO多路复用机制是指一个线程处理多个IO流,多路是指网络连接,复用指的是同一个线程。
如果简单从图上看IO多路复用相比阻塞IO似乎并没有什么高明之处,假设服务只处理少量的连接,那么相比阻塞IO确实没有太大的提升,但如果连接数非常多,差距就会立竿见影。
首先IO多路复用会提交一批需要监听的文件句柄(socket也是一种文件句柄)到内核,由内核开启一个线程负责监听,把轮询工作交给内核,当有事件发生时,由内核通知用户程序。这不需要用户程序开启更多的线程去处理连接,也不需要用户程序切换到内核态去轮询,用一个线程就能处理大量网络IO请求。
redis底层采用的就是IO多路复用模型,实际上基本所有中间件在处理网络IO这一块都会使用到IO多路复用,如kafka,rocketmq等。
前面我们介绍的IO多路复用是操作系统的底层实现,借助IO多路复用我们实现了一个线程就可以处理大量网络IO请求,那么接收到这些请求后该如何高效的响应,这就是reactor要关注的事情,reactor模式是基于事件的一种设计模式。在reactor中分为3中角色:
从线程角度出发,reactor又可以分为单reactor单线程、单reactor多线程、多reactor多线程3种。
处理过程:
可以看到这种模式比较简单,读取请求数据,处理请求内容,响应数据都是在一个线程内完成的,如果整个过程响应都比较快,可以获得比较好的结果。缺点是请求都在一个线程内完成,无法发挥多核cpu的优势,如果处理请求内容这一块比较慢,就会影响整体性能。
既然处理请求这里可能有性能问题,那么这里可以开启一个线程池来处理,这就是单reactor多线程模式,请求连接、读写还是由主线程负责,处理请求内容交由线程池处理,相比之下,多线程模式可以利用cpu多核的优势。单仔细思考这里依然有性能优化的点,就是对于请求的读写这里依然是在主线程完成的,如果这里也可以多线程,那效率就可以进一步提升。
多reactor多线程下,mainReactor接收到请求交由acceptor处理后,mainReactor不再读取、写回网络数据,直接将请求交给subReactor线程池处理,这样读取、写回数据多个请求之间也可以并发执行了。
redis网络IO模型底层使用IO多路复用,通过reactor模式实现的,在redis 6.0以前属于单reactor单线程模式。如图:
在linux下,IO多路复用程序使用epoll实现,负责监听服务端连接、socket的读取、写入事件,然后将事件丢到事件队列,由事件分发器对事件进行分发,事件分发器会根据事件类型,分发给对应的事件处理器进行处理。我们以一个get key简单命令为例,一次完整的请求如下:
请求首先要建立TCP连接(TCP3次握手),过程如下:
连接建立后,就开始执行get key请求了。如下:
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也出来了,建议开启多线程。