写在前面
IO模型是编程语言和软件开发中重要的知识。本篇从IO模型这个切入点横向梳理了从操作系统到应用层中IO模型相关知识。考虑到技术本身具有横向迁移的特点,也可以帮助大家在宏观与微观,具体与细节,底层与应用多角度串联技术,本篇是第一篇从IO模型说起。
Linux IO模型
操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也具有访问底层硬件设备的所有权限。
为了保证用户进程不能直接操作内核,保证内核安全,操作系统将虚拟空间划分为两部分:
- 一部分为内核空间
- 一部分为用户空间
获得CPU的进程可以将自己转换为阻塞状态,进程进入阻塞状态,是不占用CPU资源的。
为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进行执行,这个过程称为继承切换。
进程切换的过程:
-
- 保存处理机上下文,包括程序计数器和其他寄存器。
-
- 更新PCB信息。
-
- 把进程的PCB移入相应队列,如就绪,在某个事件阻塞等队列。
-
- 选择另一个进程执行,并更新其PCB。
-
- 更新内存管理的数据结构。
-
- 恢复处理机上下文。
文件描述符用于表述指向文件的引用的抽象化概念,指向内核为每一个进程所维护的该进程打开文件的记录表。
当程序打开一个现有文件或创建一个新文件时,内核向进程返回文件描述符,在程序设计中,一些涉及底层的程序编写往往围绕文件描述符展开。
在linux的缓存io机制中,操作系统将io的数据缓存在文件系统的页缓存中,就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。
网络IO的本质是socket读取,socket在linux系统被抽象为流,io可以理解为对流的操作。
对于一次io访问,数据会先被拷贝到操作系统内核缓冲区,然后从操作系统内核缓冲区拷贝到应用程序地址空间。
Linux系统IO分为内核准备数据和将数据从内核拷贝到用户空间两个阶段。
用户空间(进程)->内核空间->调用磁盘控制器->写入磁盘
应用程序不能直接和硬件互操作,必须借助于操作系统,网络IO的本质是socket的读取,socket在linux系统被抽象成流,IO可以理解为对流的操作。
read操作:
- 等待数据准备;
- 将数据从内核拷贝到操作系统内核缓冲区;
- 从操作系统内核缓冲区拷贝到应用程序地址空间中;
socket操作:
- 等待网络上数据分组到达,复制到内核到某个缓冲区;
- 把数据从内核缓冲区复制到进程缓冲区;
网络IO模型:
-
同步阻塞IO;
-
同步非阻塞IO;
-
同步多路复用IO;
-
信号驱动IO;
-
异步IO;
-
同步和异步描述的是用户线程与内核交互方式,前四种都是同步,只有最后一种是异步。
-
同步需要用户线程发起IO请求,主动等待或轮询获取消息通知。
-
异步是用户线程发起IO请求后,仍继续执行,当内核IO操作完成后,用户线程被动接受消息通知,通过回调,通知,状态等方式被动获取消息。
-
多路复用是在阻塞到select阶段,用户进程是主动等待并调用select函数来获取就绪到状态消息,并且进程状态为阻塞,所以多路复用是同步阻塞模式。
同步阻塞IO
linux中默认所有socket都是blocking。阻塞就是进程被“休息”,cpu处理其他进程去了。
用户空间的应用程序执行一个系统调用,会导致应用程序阻塞,什么也不干,直到数据准备好,并且将数据从内核复制到用户进程,最后进程再处理数据,等待数据到处理数据两个阶段,整个进程被阻塞,不能处理别的网络IO。
阻塞期间不会存在cpu计算。
同步非阻塞IO
同步非阻塞,就是“每隔一会瞄一眼进度”的轮询方式。
这种模型中,设备是以非阻塞形式打开的,意味着IO操作不会立即完成,read操作可能会返回一个错误代码,说明这个命令不能立即满足。
非阻塞将大的整片时间的阻塞分割成N个小的阻塞,所以进程不断有机会被CPU光顾。
- 进程调用内核时,内核会立马返回给进程,如果数据还没准备好,此时返回一个error。
- 进程返回后,可以干点别的事情,然后在发起内核系统调用,重复上面流程,称为轮询。
- 轮询检查内核数据,直到数据准备好,在拷贝数据到进程,进行数据处理,到了拷贝数据的过程时进程仍然是属于阻塞状态的。
多路复用IO
由于同步非阻塞方式需要轮询不断主动轮询,轮询占据很大一部分过程,轮询会消耗大量CPU时间,所以可以轮询多个任务的完成状态,只要有其中一个任务完成,就去处理它。
如果这个轮询工作不是进程自己执行就好了,所以就有了IO多路复用。
Linux下的select,poll,epoll就是干这个的。
IO多路复用有两个特别的系统调用select,poll,epoll函数。
- select调用是内核级别的,select轮询和非阻塞轮询的区别是可以等待多个socket,能同时实现对多个io端口进行监听,当其中一个socket数据准备好,就能返回进行可读,然后进行系统调用,将数据由内核拷贝到进程,这个过程仍是阻塞的。
- select或poll调用之后会阻塞进程,有一部分数据进来就调用用户进程进程处理。
- 内核负责数据监视。
多路复用会同时阻塞多个IO操作,可以同时对多个读操作,多个写操作的IO进行检测,直到有数据可读或可写,才真正调用IO操作函数。
select
select和poll本质没有区别,将用户传入的数据拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪在设备等待队列中加入一项继续遍历,整个过程会有很多次遍历。 它没有最大连接数限制,原因是基于链表存储的,大量的fd数组被整体拷贝到用户态和内核态之间,不管复制是否有意义。
epoll
epoll会用一个文件描述符管理多个描述符,将用户关系文件描述符事件存放到内核一个事件表中,这样在用户空间和内核空间copy只需要一次。 epoll没有最大并发连接限制,能打开fd的上限远大于1024,只有活跃的fd才会调用callback,只管理活着的连接。
信号驱动IO
应用程序执行read请求,调用system call,然后内核开始处理响应到IO操作,程序并不等待内核响应就开始处理其他操作,内核执行完毕,返回read响应,同时产生信号或者执行一个基于线程到回调函数完成这次IO处理。
异步非阻塞IO
异步IO不是顺序执行的,用户进程进行系统调用后,无论内核数据是否准备好,都会直接返回给用户进程,然后用户态进程可以去做别的事情,等到socket数据准备好了,内核直接复制数据给进程,然后从内核向进程发送通知,两个IO阶段,进程都是非阻塞的。
异步IO并不十分常见,不少高性能并发服务程序,使用IO多复路模型+多线程任务处理的架构,基本可以满足需求,考虑到当前操作系统对于异步IO支持并不完善,更多的采用IO多复路模型。
了解完了操作系统层面的IO模型,在应用层讨论IO模型往往是在Nginx,Netty这种角度去讨论了,讨论最多的也是多路复用这种模式,先从Reactor模型说起。
Reactor模型
Apache服务器首先会创建多个进程,每个进程里面再创建多个线程,主要考虑稳定性,即使某个子进程里面的某个线程异常导致整个子进程退出,还会有其他子进程继续服务,不会导致整个服务器挂掉。
为了提升IO能力,可以采用单线程多连接模式,只有当连接有事件时才去处理,这就是IO多路复用的实现来源。
- 当多条连接阻塞在一个对象上时,线程无需循环所有连接,而是关新这个阻塞对象的事件就可以,比如select,epoll,kqueue等。
- 当某条连接有新数据可以处理时,操作系统会通知进程,进程从阻塞状态返回,开始进行业务处理。
IO多路复用结合线程池,就是Reactor模型。Reactor包括监听和分配事件,资源处理交给线程池。C语言使用线程和进程都可以,Java的Netty则是线程,Nginx使用进程。select,accept,read,send都是标准的网络编程API。
单Reactor单进程
单Reactor优点是简单,没有进程间通信,没有进程竞争,全部在一个进程内完成,缺点是无法利用多核CPU性能,只适合处理业务非常快的场景,比如redis就是单Reactor。
单Reactor多线程
主线程通过select监控连接事件,收到事件后进行事件分发。
单Reactor多线程可以充分利用多核多CPU处理能力,但是多线程中子线程处理完毕后需要将结果返回主线程,涉及到数据共享和保护机制,Java的Nio中,selector是线程安全的,但selector.selectKeys()返回但键的集合是非线程安全的,对selected keys的处理必须采用单线程或同步措施进行保护。
多Reactor多线程
Nginx采用的是多Reactor多进程,多Reactor多线程实现有Memcache,Netty。
Nginx IO模型
nginx由一个master进程和多个worker进程组成,master负责管理worker进程。推荐worker数量和cpu数量相等。
包含:接收外界信号,向各个worker发送信号,监控worker进程运行状态,worker进程异常退出,会自动重新启动worker进程。基本的网络事件,在worker进程中处理。
每个worker进程都是相互独立的,不需要加锁,互相之间不受影响,一个进程异常退出,其他进程还在工作,服务不会中断。
nginx采用多路复用IO模型,支持epoll,poll,select。
- select,poll:主动查询,可以同时查多个文件句柄的状态,select有文件句柄限制,poll没有限制。select创建的是读,写,异常三个集合,poll在一个集合内设定三种描述,poll的事件更少,性能上好一些。
- epoll:基于回调函数的,无轮询。如果套接字比较多的时候,每次select都需要便利所有的文件描述符,会浪费好多cpu,所以epoll为每个套接字注册来回调函数,当某个套接字活跃时,自动完成相关操作,避免来轮询。
nginx和apache的区别:
- nginx是基于事件模型,适合于IO密集型任务,比如反向代理。
- apache是基于多进程/多线程模式的,适合于运行长时间计算任务的任务。
所以异步IO和避免线程切换,也是nginx性能很好的原因。
nignx如何做到几十万并发连接,答案是epoll机制,如果100w用户与一个进程保持tcp连接,虽然连接数巨大,但是某个时刻只有一小部分连接是活跃的,所以进程只要处理好这100w连接中的小部分足矣。
如果把100w中活跃的连接交给操作系统,会存在大量的用户态到内核态到拷贝,查找过程也造成巨大到资源浪费,缺点明显。
nginx会在内存中申请一颗红黑树,用于存放所有事件。同时申请双向链表,用于存放活跃事件,所有红黑树中事件都会与网卡驱动建立回调关系,当网卡有事件发生时候,回调函数将事件放入双向链表。所有发生事件的链表复制到内存中。采用红黑树有利于事件到查找和删除。
IO优化
了解了操作系统和应用层层面的IO模型和原因,针对于IO密集型程序存在哪些优化原则呢?
- 增加缓存,减少磁盘的访问次数。
- 优化磁盘的管理系统,设计最优的磁盘访问策略,及磁盘寻址策略,是底层操作系统层面考虑。
- 设置合理的磁盘数据库访问策略,比如给存放数据设计索引,通过寻址索引来加快寻址,减少磁盘的访问量,可以采用异步和非阻塞方式加快磁盘访问速度。
- 合理的RAID策略提升磁盘IO。