IO模型类型
Linux内核将所有的外部设备都当作文件来处理,对于文件的读写会调用内核提供的命令返回一个
file scripter
(fd文件描述符),对于一个Socket的读写也会有对应的描述符——socket fd
,描述符是一个数字指向内核中的结构体。
阻塞IO
? 最常用的IO模型就是阻塞IO模型,缺省情形下,所有文件操作都是阻塞的。我们以套接字接口为例来讲解此模型:在进程空间中调用 recvfrom
,其系统调用直到数据包到达且被复制到应用进程的缓冲区中或者发生错误时才返回,在此期间直会等待,进程在从调用 recvfrom
开始到它返回的整段时间内都是被阻塞的,因此被称为阻塞IO模型。
-
当用户线程调用了
read
系统调用,内核(kernel
)就开始了IO的第一个阶段:准备数据。很多时候,数据在一开始还没有到达(比如,还没有收到一个完整的Socket
数据包),这个时候kernel就要等待足够的数据到来。 -
当
kernel
一直等到数据准备好了,它就会将数据从kernel
内核缓冲区,拷贝到用户缓冲区(用户内存),然后kernel
返回结果。 -
从开始IO读的
read
系统调用开始,用户线程就进入阻塞状态。一直到kernel返回结果后,用户线程才解除block
的状态,重新运行起来。所以,
blocking IO
的特点就是在内核进行IO执行的两个阶段,用户线程都被block
了。
非阻塞IO
? recvfrom
从应用层到内核的时候,如果该缓冲区没有数据的话就直接返回一个 EWOULDBLOCK
错误,一般都对非阻塞O模型进行轮询检查这个状态,看内核是不是有数据到来。就是一个自旋等待的过程。
- 在内核数据没有准备好的阶段,用户线程发起IO请求时,立即返回。用户线程需要不断地发起IO系统调用,通过
recvfrom
接口查询。 - 内核数据到达后,用户线程发起系统调用,用户线程阻塞。内核开始复制数据。它就会将数据从kernel内核缓冲区,拷贝到用户缓冲区(用户内存),然后
kernel
返回结果。 - 用户线程才解除
block
的状态,重新运行起来。经过多次的尝试,用户线程终于真正读取到数据,继续执行。
NIO的特点:
? 应用程序的线程需要不断的进行 I/O 系统调用,轮询数据是否已经准备好,如果没有准备好,继续轮询,直到完成系统调用为止。
NIO的优点:
? 每次发起的 IO 系统调用,在内核的等待数据过程中可以立即返回。用户线程不会阻塞,实时性较好。
NIO的缺点:
? 需要不断的重复发起IO系统调用,这种不断的轮询,将会不断地询问内核,这将占用大量的 CPU 时间,系统资源利用率较低。
IO复用模型
? Linux提供 select
/pol
,进程通过将一个或多个fd
传递给 select
或poll
系统调用,阻塞在 select
操作上,这样 select
/poll
可以帮我们侦测多个fd
是否处于就绪状态。 select
/poll
是顺序扫描fd
是否就绪,而且支持的fd
数量有限,因此它的使用受到了一些制约。 Linux还提供了一个 epoll
系统调用, epoll
使用基于事件驱动方式代替顺序扫描,因此性能更高。当有fd
就绪时,立即回调函数 rollback
。其实IO复用算是非阻塞IO中的子集。
? 在这种模式中,首先不是进行read
系统调动,而是进行select/epoll
系统调用。当然,这里有一个前提,需要将目标网络连接,提前注册到select/epoll
的可查询socket
列表中。然后,才可以开启整个的IO多路复用模型的读流程。
- 进行
select/epoll
系统调用,查询可以读的连接。kernel
会查询所有select
的可查询socket
列表,当任何一个socket
中的数据准备好了,select
就会返回。当用户进程调用了select
,那么整个线程会被block
(阻塞掉)。 - 用户线程获得了目标连接后,发起
read
系统调用,用户线程阻塞。内核开始复制数据。它就会将数据从kernel
内核缓冲区,拷贝到用户缓冲区(用户内存),然后kernel
返回结果。 - 用户线程才解除
block
的状态,用户线程终于真正读取到数据,继续执行。
多路复用IO的特点:
? IO多路复用模型,建立在操作系统kernel内核能够提供的多路分离系统调用select/epoll
基础之上的。多路复用IO需要用到两个系统调用(system call
), 一个select/epoll
查询调用,一个是IO
的读取调用。
和NIO模型相似,多路复用IO需要轮询。负责select/epoll
查询调用的线程,需要不断的进行``select/epoll`轮询,查找出可以进行IO操作的连接。
另外,多路复用IO模型与前面的NIO模型,是有关系的。对于每一个可以查询的socket
,一般都设置成为non-blocking
模型。只是这一点,对于用户程序是透明的(不感知)。
多路复用IO的优点:
? 用select/epoll
的优势在于,它可以同时处理成千上万个连接(connection
)。与一条线程维护一个连接相比,I/O多路复用技术的最大优势是:系统不必创建线程,也不必维护这些线程,从而大大减小了系统的开销。
Java的NIO(new IO)技术,使用的就是IO多路复用模型。在linux系统上,使用的是epoll系统调用。
多路复用IO的缺点:
? 本质上,select/epoll系统调用,属于同步IO,也是阻塞IO。都需要在读写事件就绪后,自己负责进行读写,也就是说这个读写过程是阻塞的。
异步IO
? 告知内核启动某个操作,并让内核在整个操作完成后(包括将数据从内核复制到用户自己的缓冲区)通知我们。这种模型与信号驱动模型的主要区别是:信号驱动ⅠO由内核通知我们何时可以开始一个IO操作;异步IO模型由内核通知我们LO操作何时已经完成
- 当用户线程调用了read系统调用,立刻就可以开始去做其它的事,用户线程不阻塞。
- 内核(
kernel
)就开始了IO的第一个阶段:准备数据。当kernel一直等到数据准备好了,它就会将数据从kernel内核缓冲区,拷贝到用户缓冲区(用户内存)。 - kernel会给用户线程发送一个信号(
signal
),或者回调用户线程注册的回调接口,告诉用户线程read操作完成了。 - 用户线程读取用户缓冲区的数据,完成后续的业务操作。
异步IO模型的特点:
在内核kernel
的等待数据和复制数据的两个阶段,用户线程都不是block(阻塞)的。用户线程需要接受kernel的IO操作完成的事件,或者说注册IO操作完成的回调函数,到操作系统的内核。所以说,异步IO有的时候,也叫做信号驱动 IO 。
IO多路复用
? 用户程序进行IO的读写,基本上会用到read&write两大系统调用,read系统调用,并不是把数据直接从物理设备,读数据到内存。write系统调用,也不是直接把数据,写入到物理设备。
- Read操作调用时,将数据从内核缓存区复制到进程缓存区中
- Write操作时,从进程缓存区复制到内核缓存区中
为什么需要有缓存区?
? 在进行数据IO操作时,先将数据放入到缓存区中等到数据量到达设定的阈值时再触发IO操作,这样减少陷入内核的次数和执行IO的开销时间
内核接收数据的流程
? 进程调用recv
函数阻塞期间,计算机接收到对方计算机传输的数据。数据由网卡传送到内存中触发CPU中断函数,将网络数据写入到对应socket的接收缓存区中唤醒socket等待列表中的进程。
fd和socket fd模型:
在进程中对socket
进行阻塞操作会将进程放入到fd文件的等待列表中:
如何同时监听多个Socket?
SELECT
准备一个数组fds,让fds存放所有需要被监控的
socket
,然后调用socket函数,如果fds中的所有socket
都没用数据,select
会被阻塞,一旦有socket
中接收到数据,select
会返回并唤醒等待进程,用户可以遍历fds数组来判断是哪个socket
接收到了数据,并处理。
代码示意:
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...)
listen(s, ...)
int fds[] = 存放需要监听的socket
while(1){
int n = select(..., fds, ...) // 执行select方法
for(int i=0; i < fds.count; i++){
if(FD_ISSET(fds[i], ...)){
//fds[i]的数据处理
}
}
}
实现示意:
假如程序同时监视如下图的sock1、sock2和sock3三个socket,那么在调用select
之后,操作系统把进程A分别加入这三个socket
的等待队列中
? 当任何一个socket
接收到了数据,都会将进程A给唤醒,进程A被唤醒后就需要遍历所有的socket
来判断是哪个socket
接收到了数据,处理完成后,进程需要重新将自己放入到socket
列表的等待列表中,这是因为在唤醒后进程就已经不在等待列表中了
缺点:
需要遍历两次socket
列表:
- 从等待队列恢复时,遍历判断哪个socket接收到了数据
- 重新执行select(),需要再次遍历socket列表,放入等待队列
Epoll
epoll就是为了解决select效率低下的痛点所以出现的,解决select通点分为两个措施:
措施一,功能分离
将维护等待队列操作和阻塞进程操作分开,如下伪代码演示:
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...)
listen(s, ...)
int epfd = epoll_create(...);
epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中
while(1){
int n = epoll_wait(...) // 需要阻塞时,才进行阻塞
for(接收到数据的socket){
//处理
}
}
这样做的好处是少一次遍历的过程,不是每次执行等待数据操作时都要重新维护一次socket列表。
措施二,就绪列表
? 上述的功能分离措施解决了select
的一次循环,但是还有一次遍历找到收取数据的socket
的过程不能避免,所以引入了措施二。通过一个就绪列表来找到有数据接收的socket
,这样就能让找到有数据socket
操作的时间复杂度变成O(1)。
具体实现:
? 在执行epoll_create
方法时,会创建一个eventpoll
对象(epfd
),eventpoll
也是文件系统中的一员。在进行epoll_wait
操作时是将evenpoll
对象放入到socket1~3的等待列表中这样当socket
有数据时会将eventpoll
唤醒调用他的回调函数——将此socket
放入到eventpoll
内部的rdlist
中表示此socket可以被读。
通过上述的两个步骤分别解决了,select每次阻塞都需要遍历socket添加等待列表和判断哪个socket接收到数据时的遍历。epoll的效率是很高的。