一. io模型诞生的原因
io的最终目的是为了访问磁盘或者网络数据(请不要说废话) , 由于cpu,内存和磁盘的读写速度不一致,如何提升读写效率是io模型解决的核心问题.也是io模型诞生的原因.
io操作会涉及到用户空间和内核空间的转换.用户应用程序无法直接操作读写磁盘或者网络,需要通过系统调用来发起读写请求.为了屏蔽各操作系统的差异,大家遵循posix标准,对上层应用提供统一的系统调用接口(如select,poll, epoll_create,epoll_ctl,epoll_wait等).
二. io的层级
上图中,应用程序通过各个框架提供的api,如java的io/nio等,经过jvm调用c++接口,最后发起系统调用读写数据.
io的层次从上到下大致为为四层:
- 应用程序层:用户自己写的程序,一般是独立进程.通过系统调用接口访问文件系统.
- 文件系统层: 包含提供给应用层调用的,通过vfs屏蔽文件系统的差异,负责把用户缓冲区数据拷贝到内核缓冲区
- 块层 : 管理块设备I/O队列,它通过设备驱动直接操作设备
- 设备层 : 通过DMA与内存直接交互,将数据写到磁盘.最下面的设备层可以是磁盘,或者是网卡.
三. 网络io
3.1 网络io过程
- 用户进程通过网络协议如http/websocket 等发起网络请求, 经过socket系统调用,驱动硬件设备网卡向指定目标(dns解析/ip地址确定)发送消息,并等待数据返回.
- web服务进程收到消息后,经过处理,将数据通过网络传输发送给socket监听端的网卡的缓冲区.
- 网卡缓冲区收到数据后,通过中断或者dma将缓冲区中的数据拷贝到内核缓冲区,然后再拷贝到用户缓冲区,唤醒用户进程处理.
3.2 socket与io模型的关系
- 结论:没有直接关系
- 它们是两个层面的概念,一个是系统调用,另外一个是io模型。用于使用上经常会用socket获取文件描述符fd,而通过select/poll/epoll监控fd_set的状态,因此会在逻辑上产生一定的依赖。伪代码描述如下:
fd = socket()
bind(fd)
listener(fd)
fd_set fdsr
FD_SET(sock_fd, &fdsr)
for(;;){
select(fdsr)
for (i = 0; i < conn_amount; i++) {
if (FD_ISSET(fd_A[i], &fdsr)) {
ret = recv(fd_A[i], buf, sizeof(buf), 0);
...
}
}
}
3.3 多路复用io模型与进程/线程关系
- 结论:没有关系
- 这里容易造成的误解是认为多路复用模型是开启了多个线程的结果,实际上多路复用是指操作系统在内核中提供了一个系统调用(linux下如select,epoll_wait),可以传入多个fd,内核线程通过设备中断来监控多个fd的状态,获知设备缓冲区数据是否ready , 唤醒等待队列的用户进程/线程读取数据,与用户级别的进程/线程没有关系。
3.4 系统调用的本质
- 系统调用的本质是软中断调用,中断调用会让系统从用户态切换到内核态
- 软中断会生成一个中断号,根据中断号查询中断向量表得到要跳转执行的代码地址,陷入内核态执行代码
- 用户态与内核态,是指执行代码时所拥有的权限不一样,linux区分为ring0和ring3
- 每个进程都有用户态和内核态,分别对应执行代码期间的用户栈和内核栈。
- 内核态可以执行所有代码,访问所有内存地址;用户态执行代码和访问地址受限
3.5 io系统调用与进程状态
- 程序执行期间进程处于就绪或者运行状态
- io系统调用如recvfrom,进程陷入内核并加入到fd的等待队列,进入阻塞状态
- 当设备数据缓冲区满中断触发cpu,通知进程读取数据,进程被从fd移除,并重新进入就绪或运行状态
3.6 网络io过程中数据的流向
读数据
- 用户态进程通过系统调用recv触发读取网络数据,
- 当没有数据时,进程进入阻塞状态;
- 当网卡收到数据后,网卡通过dma将数据写入到指定的内存空间,
- 通过中断告知内核数据到达,
- 内核查询中断向量表获得跳转执行的代码地址执行
- 内核将数据从fd的内核缓冲区拷贝到用户缓冲区
- 内核将进程从fd等待队列移除,进程恢复运行状态
- 进程执行用户代码
写数据
- 用户态进程通过系统调用send触发写网络数据
- 系统通过驱动将数据写入到网卡的缓冲区
- 网卡发送数据
3.7 socket 与 fd 的关系
socket 是 Unix 中的术语。socket 可以用于同一台主机的不同进程间的通信,也可以用于不同主机间的通信。一个socket包含地址、类型和通信协议等信息,通过 socket() 函数创建
3.8 线程阻塞后会占用CPU资源吗?
线程阻塞后不会占用cpu资源,也即系统调度算法不会将阻塞队列中的线程纳入。
四. 网络io模型
网络io模型的目的是为了提高吞吐效率,减少进程等待时间.提高cpu利用率.
io数据的流转过程大体可以分为两个过程:数据等待过程与数据拷贝过程.根据这两个过程是否阻塞和是否同步产生了5种io模型:
- 同步阻塞io blocking io (bio)
- 同步非阻塞io none blocking io (nio)
- 多路复用io IO multiplexing
- 信号驱动IO
- 异步io asynchronous IO
4.1 同步阻塞io (BIO)
-
特点:等待数据阶段用户进程阻塞,拷贝数据阶段cpu和进程都阻塞
-
当进程触发recvfrom系统调用,由于recvfrom是默认是阻塞调用,当没有数据时,进程会被加入到fd的等待队列,进程进入阻塞态。设备收到数据后,通过dma将数据从设备缓冲区拷贝到内核缓冲区,通过中断调用告知cpu数据准备好了,操作系统会将数据从内核缓冲区拷贝到用户缓冲区,用户进程解除阻塞的状态重新运行。
-
缺陷:读数据阶段,真个进程都是阻塞的,什么都干不了,进程或线程都是系统的宝贵资源,这种方式显然不可取。
4.2 同步非阻塞io (NIO)
-
特点:等待数据阶段用户进程不阻塞,但需要轮询状态,拷贝数据阶段cpu和进程都阻塞
-
当用户进程调用了recvfrom这个系统调用,将recv设置成非阻塞模式.如果kernel中的数据还没有准备好,返回error。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送recvfrom操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的recvfrom,那么它马上就将数据拷贝到了用户内存,然后返回。在非阻塞式IO中,用户进程需要不断的发起recvfrom查询数据是否准备好。
-
缺陷:相对于BIO,虽然它不再阻塞,但是一直不停的发起系统调用查询结果,浪费cpu的计算时间,而且系统调用消耗不菲。
recv(sockfd, buff, buff_size,MSG_DONTWAIT)
4.3 多路复用io (select/poll/epoll)
- 特点:等待数据阶段用户进程阻塞,拷贝数据阶段cpu和进程都阻塞.
- 上面都是一个线程对应一个fd处理,当io任务多时,bio , nio方式都会显得力有未逮。(nio方式开多线程,每个线程处理一个io,在某些情况下也是可取的)
select
- 当一个进程中有多个线程发起socket请求,每个socket对应一个fd,用户进程发起系统调用select,将fd列表从用户空间拷贝到内核空间,同时将进程挂到每个fd的等待队列中,kernel线程会轮询所有select关联的socket,当任何一个socket中的数据准备好了,select就会返回就绪fd总数,用户进程遍历所有的fd,再次再调用recvfrom操作,将数据从kernel拷贝到用户进程。
主要缺陷:
- 每次都需要拷贝所有的fd到内核空间
- 拷贝到系统的fd总数限制为1024
- 当有fd数据消息时,没有返回具体的fd列表,用户进程需要轮询找出具体的fd操作读取
poll
取消fd 1024的限制,采用链表存储fd列表,机制与select基本一致
epoll
epoll为了解决select的两个核心诉求而出现,即性能低下以及fd数量限制
epoll_create
int epoll_create(int size);
创建一个size大小的epoll文件描述符,它是一个虚拟描述符.它的作用是存储两个结构:
- 用红黑树的形式来管理加入的要监听事件的实际socket的fd
- 用链表管理已经就绪的fd的列表。
epoll_ctl
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll_ctl将fd添加到epoll实例(epfd)的监听列表里,这个监听列表的结构为红黑树结构,插入复杂度为o(logn),同时为fd设置一个回调函数,并监听事件event。当 fd 上发生相应事件时,将fd添加到 epoll 实例的就绪队列上,内核会唤醒进程,将 fd 对应事件从内核拷贝(__put_user)到用户空间处理。 参数说明:
- epfd :epoll_create返回值,指向一个epoll实例
- fd 表示要监听的目标文件描述符
- event 表示要监听的读写事件
- op 表示要对fd执行的操作,添加监听,删除监听等
epoll_wait
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout)
typedef union epoll_data {
void *ptr;
int fd;
__uint32_t u32;
__uint64_t u64;
} epoll_data_t
struct epoll_event {
__uint32_t events;
epoll_data_t data;
};
轮询就绪事件队列events,类似于select,epoll_event包含了发生事件的具体的句柄,因此可以直接找到对应的fd处理。查询复杂度为o(1)
4.4 信号驱动io
-
特点:等待数据阶段用户进程不阻塞,拷贝数据阶段用户进程阻塞,唤醒用户进程的方式由用户进程轮询改为信号通知.
-
复用IO模型解决了一个线程可以监控多个fd的问题,不管是select/epoll都有一个遍历查询的过程,select遍历所有的fd队列,epoll遍历就绪队列,这个对于cpu来说都是无谓的消耗。
-
,所以就衍生了信号驱动IO模型。信号驱动IO不是用循环请求询问的方式去监控数据就绪状态,而是在调用sigaction时候建立一个SIGIO的信号联系,当内核数据准备好之后再通过SIGIO信号通知线程数据准备好后的可读状态,当线程收到可读状态的信号后,此时再向内核发起recvfrom读取数据的请求,因为信号驱动IO的模型下应用线程在发出信号监控后即可返回,不会阻塞,所以这样的方式下,一个应用线程也可以同时监控多个fd。
4.5 异步io
-
特点:等待数据阶段用户进程不阻塞,拷贝数据阶段用户进程不阻塞,拷贝完成发送信号通知用户进程完成.
-
用户进程发起aio_read操作之后,kernel收到一个asynchronous_read,会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了
五. 网络io模型对比
前面四种io模型在数据拷贝阶段用户进程都处于阻塞状态,只有异步io模型,用户进程完全不阻塞.
-
关于阻塞与非阻塞:是一个线程级的概念,阻塞和非阻塞关注的是程序在等待调用结果时的状态.阻塞调用是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程
-
关于同步与非同步:所谓同步,就是在发出一个调用时,在没有得到结果之前,该调用就不返回。但是一旦调用返回,就得到返回值了。换句话说,就是由调用者主动等待这个调用的结果。而异步则是相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果。换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果。而是在调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用