操作系统IO工作原理
-
写数据时,要把用户缓冲数据拷贝到内核缓冲区,然后写入设备;
-
读数据时,要从设备读取数据到内核缓冲区,然后拷贝到用户缓冲区。
读数据:
- ①首先在网络的网卡上或本地存储设备中准备数据,然后调用
read()
函数。 - ②调用
read()
函数后,由内核将网络/本地数据读取到内核缓冲区中。 - ③读取完成后向
CPU
发送一个中断信号,通知CPU
对数据进行后续处理。 - ④
CPU
将内核中的数据写入到对应的程序缓冲区或网络Socket
接收缓冲区中。 - ⑤数据全部写入到缓冲区后,应用程序开始对数据开始实际的处理。
程序中试图利用
IO
机制读写数据时,仅仅只是调用了内核提供的接口函数而已,本质上真正的IO
操作还是由内核自己去完成的。Linux 系统为了提高 IO 效率,会在用户空间和内核空间都加入缓冲区(缓冲区可以减少频繁的系统 IO 调 用。系统调用需要保存之前的进程数据和状态等信息,而结束调用之后回来还需要恢复之前的信息,为 了减少这种损耗时间、也损耗性能的系统调用,于是出现了缓冲区)
五种IO模型
一个I/O操作分为两个步骤:发起IO请求和实际的IO操作
同步/异步:区别在第二步是否阻塞。阻塞则是同步,os做完IO操作将结果返回则是异步。
阻塞/非阻塞:区别在第一步,发起I/O请求是否会被阻塞。
-
同步阻塞IO----BIO(Blocking-IO):
-
当用户程序执行
read
,线程会被一直阻塞,一直等到内核数据准备好,并把数据从内核缓冲区拷贝到应用程序的缓冲区中,当拷贝过程完成,read
才会返回。 -
阻塞等待的是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程。
在
BIO
这种模型中,为了支持并发请求,通常情况下会采用“请求:线程”1:1
的模型。原因:每连接每线程的模型,之所以使用多线程,主要原因在于socket.accept()、socket.read()、socket.write()三个主要函数都是同步阻塞的,当一个连接在处理I/O的时候,系统是阻塞的,如果是单线程的话必然就挂死在那里;但CPU是被释放出来的,开启多线程,就可以让CPU去处理更多的事情。
缺点:
- 线程的创建和销毁成本很高,在Linux这样的操作系统中,线程本质上就是一个进程。创建和销毁都是重量级的系统函数。
- 线程本身占用较大内存,像Java的线程栈,一般至少分配512K~1M的空间,如果系统中的线程数过千,恐怕整个JVM的内存都会被吃掉一半。
- 线程的切换成本是很高的。操作系统发生线程切换的时候,需要保留线程的上下文,然后执行系统调用。如果线程数过高,可能执行线程切换的时间甚至会大于线程执行的时间,这时候带来的表现往往是系统load偏高、CPU sy使用率特别高(超过20%以上),导致系统几乎陷入不可用的状态。
- 容易造成锯齿状的系统负载。因为系统负载是用活动线程数或CPU核心数,一旦线程数量高但外部网络环境不是很稳定,就很容易造成大量请求的结果同时返回,激活大量阻塞线程从而使系统负载压力过大。
-
-
同步非阻塞IO----NIO(Non-Blocking-IO):
-
非阻塞的 read 请求在数据未准备好的情况下立即返回,可以继续往下执行,此时应用程序不断轮询内核,直到数据准备好,内核将数据拷贝到应用程序缓冲区,
read
调用才可以获取到结果。 -
这里最后一次 read 调用,获取数据的过程,是一个同步的过程,是需要等待的过程。这里的同步指的是内核态的数据拷贝到用户程序的缓存区这个过程。
- 应用进程向操作系统内核,发起recvfrom读取数据。
- 操作系统内核数据没有准备好,立即返回EWOULDBLOCK错误码。
- 应用程序进程轮询调用,继续向操作系统内核发起recvfrom读取数据。
- 操作系统内核数据准备好了,从内核缓冲区拷贝到用户空间。
- 完成调用,返回成功提示。
无论是阻塞IO还是非阻塞IO,第一阶段都需要调用read来获取数据,区别在于第二阶段:
- 如果调用read后没有数据,阻塞IO会阻塞进程,非阻塞IO会让应用程序轮询内核。
- 如果调用read后有数据,则用户进程直接进入第二阶段,读取处理数据。
-
-
**IO多路复用:**等到内核数据准备好了,主动通知应用进程再去进行系统调用。
优点:利用单个线程来同时监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。
- 系统调用,本意是指调用内核所提供的API接口函数。
recvfrom
函数则是指经Socket
套接 字接收数据,主要用于网络IO
操作。read
函数则是指从本地读取数据,主要用于本地的文件IO
操作。
-
信号驱动IO:
-
信号驱动 lO 是与内核建立 SIGIO 的信号关联并设置回调,当内核有 FD 就绪时,会发出 SIGIO 信号通知用户,期间用户应用可以执行其它业务,无需阻塞等待。
缺点:
- 当有大量IO操作时,信号较多,SIGIO处理函数不能及时处理可能导致信号队列溢出。
- 而且内核空间和用户空间的频繁信号交互性能也较低
-
-
异步非阻塞IO----AIO(Asynchronous-Non-Blocking-IO):
-
异步IO的整个过程都是非阻塞的,用户进程调用完异步API后就可以做其他事,内核等待数据就绪并拷贝到用户空间才会递交信号,通知用户进程。
AIO模型基于信号驱动实现,异步 IO与信号驱动 IO的主要区别在于:信号驱动 IO 由内核通知何时可以开始一个 IO 操作,而异步 IO由内核通知 IO 操作何时已经完成。
-
IO多路复用
-
select:
-
过程:
- 将已连接的 Socket 都放到一个文件描述符集合fd_set,select 使用固定长度1024的 BitsMap
- 调用select函数,将fd_set数据拷贝到内核空间
- 内核遍历fd,判断是否就绪
- 数据就绪或者超时后,拷贝fd_set数组到用户空间。
- 在用户态遍历fd_set,找到就绪的fd
-
问题:
-
需要将整个fd_set从用户空间拷贝到内核空间,select结束还要再次拷贝回用户空间
-
select无法得知具体哪个fd就绪,需要遍历整个fd_set
-
fd_set大小限制为1024,最多监听1024个fd
-
-
-
poll:
- poll模式和select差不多,只是不用BitsMap来存储fd,而是将数组拷贝至内核转链表存储。
- 改进:select的fd_set大小固定为1024,poll在内核中采用链表存储,理论上无上限
- 问题:poll模式理论上链表无上限,但实际上过长遍历性能变差,有上限。
select和poll:2 次「遍历」文件描述符集合,一次是在内核态里,一个次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。
-
epoll:
-
过程:
- 在内核中创建epoll实例,eventpoll 函数内部包含了两个东西 :
- 红黑树 :用来记录所有的 fd
- 链表 : 记录已就绪的 fd 、
- 只需要调用一次epoll_ctl函数,将需要监听的fd添加进红黑树中,并设置回调函数,回调函数触发时,会把就绪的fd加入链表中。
- 然后不断调用epoll_wait函数同步阻塞获取就绪的fd,将就绪的fd就内核空间拷贝到用户空间。
- 在内核中创建epoll实例,eventpoll 函数内部包含了两个东西 :
-
改进:
-
epoll 在内核里使用红黑树保存要监听的fd,理论上无上限的,增删改查效率高时间复杂度为O(logn)。
-
每个fd只需要执行一次epoll_ctl添加到红黑树,以后每次epoll_wait无需传递参数,不会重复拷贝fd到内核空间。
-
内核会将就绪的fd直接拷贝到用户空间的指定位置,用户进程无需遍历所有fd就知道哪个fd就绪了。
select/poll 每次操作时都传入全量的文件描述符集合,而 epoll 因为在内核维护了红黑树,可以保存所有待检测的 socket ,所以只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。
-
边缘触发和水平触发:
水平触发和边缘触发最关键的区别就在于当
socket
中的接收缓冲区还有数据可读时。epoll_wait
是否会清空rdllist
。 -