一、IO模型简介
1.1 操作系统的内存简介
1.1.1 操作系统的应用与内核
现代计算机是由硬件和操作系统组成,我们的应用程序要操作硬件(如往磁盘上写数据),就需要先与内核交互,然后再由内核与硬件交互;
操作系统可以划分为:内核与应用两部分;
内核提供进程管理、内存管理、网络等底层功能,封装了与硬件交互的接口,通过系统调用提供给上层应用使用。
1.1.2 内核空间与用户空间
现在操作系统都是采用虚拟地址空间,那么对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间(内核空间),也有访问底层硬件设备的所有权限。
为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。内核空间是操作系统内核访问的区域,独立于普通的应用程序,是受保护的内存空间。用户空间是普通应用程序可访问的内存区域。
针对linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间,而将3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间。
用户态的程序不能随意操作内核地址空间,即使用户的程序崩溃了,内核也不受影响。这样对操作系统具有一定的安全保护作用。
1.1.3 CPU指令等级
其实早期操作系统是不区分内核空间和用户空间的,但是应用程序能访问任意内存空间,如果程序不稳定常常把系统搞崩溃,比如清除操作系统的内存数据。后来觉得让应用程序随便访问内存太危险了,就按照CPU 指令的重要程度对指令进行了分级;
CPU指令分为四个级别:Ring0~Ring3,linux 只使用了 Ring0 和 Ring3 两个运行级别,进程运行Ring3级别的指令时运行在用户态,指令只访问用户空间,而运行在 Ring0级别时被称为运行在内核态,可以访问任意内存空间。
1.1.4 进程的内核态和用户态
当进程运行在内核空间时,它就处于内核态;当进程运行在用户空间时,它就处于用户态。
- 那什么时候运行再内核空间什么时候运行再用户空间呢?
- 当我们需要进行IO操作时,如读写硬盘文件、读写网卡数据等,进程需要切换到内核态,否则无法进行这样的操作,无论是从内核态切换到用户态,还是从用户态切换到内核态,都需要进行一次上下文的切换。一般情况下,应用不能直接操作内核空间的数据,需要把内核态的数据拷贝到用户空间才能操作。
比如我们 Java 中需要新建一个线程,调用 start() 方法时,基于Hotspot Linux 的JVM 源码实现,最终是调pthread_create系统方法来创建的线程,这里会从用户态切换到内核态完成系统资源的分配,线程的创建。
当一个任务(进程)执行系统调用而陷入内核代码中执行时,称进程处于内核运行态(内核态)
Tips:除了系统调用可以实现用户态到内核态的切换,软中断和硬中断也会切换用户态和内核态。
- 在内核态下:进程运行在内核地址空间中,此时 CPU 可以执行任何指令。运行的代码也不受任何的限制,可以自由地访问任何有效地址,也可以直接进行端口的访问。
- 在用户态下:进程运行在用户地址空间中,被执行的代码要受到 CPU 的很多检查,比如:进程只能访问映射其地址空间的页表项中规定的在用户态下可访问页面的虚拟地址。
1.2 IO的分类
通常用户进程中的一个完整IO分为两阶段:用户进程空间<- ->内核空间、内核空间<- ->设备空间(磁盘、网络等)。
1.2.1 网络IO和磁盘IO
IO从读取数据的来源分为内存IO、 网络IO和磁盘IO三种,通常我们说的IO指的是后两者(因为内存IO的读写速度比网络IO和磁盘IO快的多)。
I/O按照设备来分的话,分为两种:一种是网络I/O,也就是通过网络进行数据的拉取和输出。一种是磁盘I/O,主要是对磁盘进行读写工作。
- 网络IO:等待网络数据到达网卡→把网卡中的数据读取到内核缓冲区,然后从内核缓冲区复制数据到进程空间。
- 磁盘IO:把数据从磁盘中读取到内核缓冲区,然后从内核缓冲区复制数据到进程空间。
Tips:由于CPU和内存的速度远远高于外部设备(网卡,磁盘等)的速度,所以在IO编程中,存在速度严重不匹配的问题。
1.2.1 同步IO和异步IO
- 同步:A调用B,B的处理是同步的,在处理完之前他不会通知A,只有处理完之后才会明确的通知A。B在没有处理完A的请求时不能处理其他请求;(换句话来说,B最多只能同时处理一个请求)
- 异步:A调用B,B的处理是异步的,B在接到请求后先告诉A我已经接到请求了,然后异步去处理,处理完之后通过回调等方式再通知A。B在处理A请求的同时,也可以接着处理其他人发送过来的请求;
同步和异步最大的区别就是被调用方的执行方式和返回时机。同步指的是被调用方做完事情之后再返回,异步指的是被调用方先返回,然后再做事情,做完之后再想办法通知调用方。
Tips:关于异步IO,我们最常用的解决方案就是采用多线程来解决;
1.2.2 阻塞IO和非阻塞IO
- 阻塞:A调用B,A一直等着B的返回,别的事情什么也不干。
- 非阻塞:A调用B,A不用一直等着B的返回,先去忙别的事情了。
阻塞和非阻塞最大的区别就是在被调用方返回结果之前的这段时间内,调用方是否一直等待。阻塞指的是调用方一直等待别的事情什么都不做。非阻塞指的是调用方先去忙别的事情。
Tips:同步和异步强调的是被调用方(B),阻塞和非阻塞强调的是调用方(A);
1.3 操作系统的五种IO模型
1.3.1 阻塞IO模型
阻塞IO就是当应用A发起读取数据申请时,在内核数据没有准备好之前,应用A会一直处于等待数据状态,直到内核把数据准备好了交给应用A才结束。
Tips:我们之前所学过的所有的套接字,默认都是阻塞方式。
- 优点:开发相对简单,在阻塞期间,用户线程被挂起,挂起期间不会占用CPU资源;
- 缺点:
- 1)连接利用率不高,内核如果没有响应数据,则该连接一直处于阻塞状态,占用连接资源
- 2)一个线程维护一个IO资源,当用大量并发请求时,需要创建等价的线程来处理请求,不适合用于高并发场景;
1.3.2 非阻塞IO模型
非阻塞IO就是当应用A发起读取数据申请时,如果内核数据没有准备好会即刻告诉应用A(返回错误码等),不会让A在这里等待。一旦内核中的数据准备好了,并且又再次收到了A的请求,那么它马上就将数据拷贝到了用户线程,然后返回。
- 优点:每次发起IO调用去内核获取数据时,在内核等待数据的过程中可以立即返回,用户线程不会被阻塞,实时性较好;
- 缺点:
- 1)当用户线程A没有获取到数据时,不断轮询内核,查看是否有新的数据,占用大量CPU时间,效率不高;
- 2)和阻塞IO一样,一个线程维护一个IO资源,当用大量并发请求时,需要创建等价的线程来处理请求,不适合用于高并发场景;
1.3.3 复用IO模型
如果在并发的环境下,可能会N个人向应用B发送消息,这种情况下我们的应用就必须创建多个线程去接收N个人发送过来的请求,每个请求都是一个独立的线程来处理;在并发量呈线性增长时,我们需要创建的线程数也随之而然的激增;
这种情况下应用B就需要创建N个线程去读取数据,同时又因为应用线程是不知道什么时候会有数据读取,为了保证消息能及时读取到,那么这些线程自己必须不断的向内核发送请求来读取数据(非阻塞式);
这么多的线程不断请求数据,先不说服务器能不能扛得住这么多线程,就算扛得住那么很明显这种方式是不是太浪费资源了,线程是我们操作系统的宝贵资源,大量的线程用来去读取数据了,那么就意味着能做其它事情的线程就会少。
后来,有人就提出了一个思路,能不能提供一种方式,可以由一个线程监控多个网络请求(linux系统把所有网络请求以一个fd来标识,我们后面将称为fd即文件描述符),这样就可以只需要一个或几个线程就可以完成数据状态询问的操作,当有数据准备就绪之后再分配对应的线程去读取数据,这么做就可以节省出大量的线程资源出来,这个就是IO复用模型的思路。
IO复用模型的思路就是系统提供了一种函数(select/poll/epoll)可以同时监控多个fd的操作,有了这个函数后,应用线程通过调用select函数就可以同时监控多个fd,如果select监听的fd都没有可读数据,select调用进程会被阻塞;而只要有任何一个fd准备就绪了,select函数就会返回可读状态,这时询问线程再去通知处理数据的线程,对应的线程此时再发起请求去读取内核中准备好的数据;
Tips:在IO复用模型下,允许单线程内处理多个IO请求;
Linux中IO复用的实现方式主要有select,poll和epoll
1)select(时间复杂度O(n))
- select:线性轮询扫描所有的fd,不管他们是否活跃,监听的IO最大连接数不能多于FD_ SIZE(32位操作系统1024,64位操作系统2048)。
Tips:select方式仅仅知道有I/O事件发生了,却并不知道是哪那几个流(可能有一个,多个,甚至全部),用户线程只能无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。所以select具有O(n)的无差别轮询复杂度,同时处理的流越多,无差别轮询时间就越长。
2)poll(时间复杂度O(n))
- poll:原理和select相似,poll底层需要分配一个pollfd结构数组,维护在内核中,它没有数量限制,但IO数量大,扫描线性性能下降。
3)epoll (时间复杂度O(1))
- epoll:用于代替poll和select,没有大小限制。epoll采用事件驱动代替了轮询,epoll会把哪个流发生了怎样的I/O事件通知用户线程,所以我们说epoll实际上是事件驱动(每个事件关联上fd)的,此时用户线程对这些流的操作都是有意义的。(复杂度降低到了O(1)),另外epoll模型采用mmap内存映射实现内核与用户空间的消息传递,减少用户态和内核态数据传输的开销,epoll模型在Linux2.6后内核支持。
select,poll,epoll都是IO多路复用的机制。I/O多路复用就通过一种机制,可以监视多个描述符,一旦某个描述符准备就绪,能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写(一个个的处理),也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。
Tips:epoll跟select都能提供多路I/O复用的解决方案。在现在的Linux内核里有都能够支持,其中epoll是Linux所特有,而select则应该是POSIX所规定,一般操作系统均有实现
4)IO复用模型小结
- 关于IO复用模型,下面这个例子可以很好的说明IO复用模型的原理:
某教室有10名学生和1名老师,这些学生上课会不停的提问,所以一个老师处理不了这么多的问题。那么学校为每个学生都配一名老师,也就是这个教室目前有10名老师。此后,只要有新的转校生,那么就会为这个学生专门分配一个老师,因为转校生也喜欢提问题。如果把以上例子中的学生比作客户端,那么老师就是负责进行数据交换的服务端。则该例子可以比作是多进程的方式。
后来有一天,来了一位具有超能力的老师,这位老师回答问题非常迅速,并且可以应对所有的问题。而这位老师采用的方式是学生提问前必须先举手,确认举手学生后在回答问题。则现在的情况就是IO复用。
- IO复用模型的优点:系统不必创建和维护大量的线程,只使用一个或几个线程来监听select选择器的操作,而一个选择器可同时处理成千上万个连接,大大减少了系统的开销;
- IO复用模型的缺点:select本质上还是同步阻塞模式;
总结:复用IO的基本思路就是通过select或poll、epoll来监控多fd ,来达到不必为每个fd创建一个对应的监控线程,从而减少线程资源创建的目的。复用IO模型的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。
1.3.4 信号驱动IO模型
当进程发起一个IO操作,系统调用sigaction函数执行一个信号处理,该函数向内核注册一个信号处理函数(回调函数),然后进程返回,并且不阻塞当前进程;当内核数据准备好时,内核使用信号(SIGIO)通知应用线程调用recvfrom函数来读取数据(运行回调函数)。
信号驱动IO它也可以看成是一种异步非阻塞IO
我们说信号驱动IO模型是一种异步非阻塞IO模型,指的是用户线程去内核空间请求数据时,直接注册一个信号处理函数,然后用户线程返回(异步),而内核空间接收到请求后,开始处理(此时并不会阻塞,内核空间可以同时接收多个请求,注册多个信号处理函数);
但是,等到内核空间读取到数据之后,应用线程需要将数据从内核空间拷贝到用户空间,此时是用户线程是阻塞的;也就是说:应用程序将数据从内核态拷贝到用户态的过程是阻塞等待的,这是和异步IO的本质区别;
1.3.5 异步IO模型
在前面几种IO模型中,应用线程要获取数据总是先发送请求到内核,然后进行如下处理:
- 1)阻塞IO:应用线程等待内核响应数据,期间什么都不能做
- 2)非阻塞IO:应用线程立即响应,可以去处理其他事情,但需要不断轮询内核去获取数据
- 3)复用IO:采用IO复用机制,请求都先交给select函数,由应用线程调用select函数来轮询所有的请求,当有请求需要获取数据时,应用线程再去内核获取数据;
- 4)信号驱动IO:系统注册一个信号处理函数(回调函数),然后应用线程返回(不阻塞);当内核中准备好数据后,应用线程需要把内核中的数据拷贝到用户空间,此时用户线程是阻塞的;
在以上4种IO模型中,每次要去读取数据时都是事先发送请求询问内核是否有可读数据,然后再发起真正的读取数据请求;
在异步IO模型中,应用只需要向内核发送一个请求,告诉内核它要读取数据后即刻返回;内核收到请求后会建立一个信号联系,当数据准备就绪,内核会主动把数据从内核复制到用户空间(而信号驱动是告诉应用程序何时可以开始拷贝数据),异步IO模型真正的做到了完完全全的非阻塞;
Tips:异步IO模型和前面模型最大的区别是:前4个都是阻塞的,需要自己把用户准备好的数据,去内核拷贝到用户空间。而全异步不同,用户线程完全不需要关心实际的整个IO操作是如何进行的,只需要先发起一个请求,当接收内核返回的成功信号时表示IO操作已经完成,可以直接去使用数据,它是最理想的模型。
1.4 Reactor和Proactor
Reactor和Proactor是计算机IO模型处理中的两种IO处理模式,其中Reactor模式用于同步I/O,而Proactor运用于异步I/O操作。
1.4.1 Reactor模式
Reactor翻译过来是"反应器"的意思,但更多的场景我们还是直接读他的英文名称"Reactor";Reactor模式应用于同步I/O的场景。
在Reactor模式中,主要有如下几个角色:
- 事件分发器(Dispatcher):用于注册/移除事件处理器;
- 事件接收器(Acceptor):无限循环等待新的事件到来,一旦发现有新的事件准备就绪,就会通知分发器(Dispachter)来调用特点的事件处理器(Handler)来处理事件
- 事件处理器(Handler):当事件被触发后执行的函数(业务逻辑);
- 应用程序(Application):处理Reactor模式的应用程序;
我们分别以读操作和写操作为例来看看Reactor中的具体步骤:
- 1)应用程序注册读事件和相关联的事件处理器(Handler)到事件分发器(Dispachter)上
- 2)事件接收器(Acceptor)等待事件的发生
- 3)当发生读事件发生的时候,事件接收器(Acceptor)调用第一步注册的事件处理器
- 4)事件处理器(Handler)首先执行实际的读取操作,然后根据读取到的内容进行进一步的处理
Tips:写入操作类似于读取操作,只不过第一步注册的是写就绪事件。
我们前面讲过的复用IO模型正是采用了Reactor模式来进行实现的;
1.4.2 Proactor模式
Proactor模式应用于异步步I/O的场景。
Reactor和Proactor模式的主要区别就是真正的读取和写入操作是由谁来完成的,在Reactor模式中,当事件发生时,应用程序需要自己去内核中读取数据,而在Proactor模式中,应用程序不需要进行实际的读写过程,它只需要从缓存区读取或者写入即可,操作系统会读取缓存区或者写入缓存区到真正的IO设备;
Proactor模型
- 1)应用程序初始化一个异步读取操作,然后注册相应的事件处理器,此时事件处理器不关注读取就绪事件,而是关注读取完成事件,这是区别于Reactor的关键。
- 2)事件分发器等待读取操作完成事件
- 3)在事件分发器器等待读取操作完成的时候,操作系统调用内核线程完成读取操作,并将读取的内容放入用户传递过来的缓存区中。这也是区别于Reactor的一点,Proactor中,应用程序需要传递缓存区。
- 4)事件分发器捕获到读取完成事件后,激活应用程序注册的事件处理器,事件处理器直接从缓存区读取数据,而不需要进行实际的读取操作。
我们前面讲过的异步IO模型正是采用了Reactor模式来进行实现的;