I/O相关概念记录
I/O模式
对于一次IO访问(以read举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。所以说,当一个read操作发生时,它会经历两个阶段:
等待数据准备 (Waiting for the data to be ready)
将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)
正式因为这两个阶段,linux系统产生了下面五种网络模式的方案。
- 阻塞 I/O(blocking IO)
- 非阻塞 I/O(nonblocking IO)
- I/O 多路复用( IO multiplexing)
- 信号驱动 I/O( signal driven IO)
- 异步 I/O(asynchronous IO)
阻塞I/O
在linux中,所有的socket默认都是blocking的
当用户进程调用了
recvfrom
这个系统调用,kernel就开始了I/O的第一个阶段:准备数据
(对于网络IO来说,很多时候数据在一开始还没有到达。比如,还没有收到一个完整的UDP包。这个时候kernel就要等待足够的数据到来)。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的。而在用户进程这边,整个进程会被阻塞
(当然,是进程自己选择的阻塞)。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存
,然后kernel返回结果,用户进程才解除block的状态,重新运行起来
- blocking I/O的特点是I/O执行的两个阶段全部都被block了
非阻塞I/O(nonBlocking I/O)
在linux下,可以通过设置socket使其变成non-blocking。当对一个non-blocking socket执行读操作时,流程如下:
当用户进程发出
read
操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程
,而是立刻返回一个error
。从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read
操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存
,然后返回
- non-blocking的特点是用户进程需要不断地主动询问kernel数据准备好了没有
I/O多路复用(I/O multiplexing)
IO多路复用指内核一旦发现进程指定的一个或者多个IO条件准备读取,他就通知该进程,有时候也被称为事件驱动I/O(event driven I/O)
IO多路复用适用于以下场景:
- 当客户处理多个描述符(一般是交互式输入和网络套接口),必须使用IO多路复用
- 当一个客户同时处理多个套接口时
- 当一个TCP服务器既要处理监听套接口,又要处理已连接套接口
- 当一个服务器既要处理TCP,又要处理UDP
- 当一个服务器要处理多个服务或多个协议
与多进程和多线程相比,IO多路复用的优势是系统开销小,系统不必创建线程或者进程,也不必维护这些线程或进程
IO多路复用就是通过一种机制,一个进程可以监控多个描述符,一旦某个描述符就绪(读/写就绪),能够通知程序进行相应的读写操作
目前支持I/O多路复用的系统调用有select
,poll
,epoll
,pselect
,本质上都是同步I/O
,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的
select
select
函数监视的文件描述符有3类,分别是writefds
,readfds
,exceptfds
。调用后select函数会阻塞,直到有描述符就绪(有数据读、写、except)或者超时,函数返回。当select函数返回后,可以遍历fdset,来找到就绪的描述符。
- 优点:支持跨平台
- 缺点:单个进程能监视的描述符数量存在最大限制,Linux上一般为1024。
select本质上是通过设置或检查描述符标志位的数据结构进行下一步处理,这样带来几个缺点:
- 单个进程监控的描述符最大数量有限制,32位机1024,64位机2048
- 多socket进行扫描是线性的,采用轮训,效率低下
当socket比较多时,每次select都要遍历FD_SETSIZE个socket,不管哪个是活跃的,都要遍历一遍,会浪费很多时间(
epoll和kqueue对此有改进
) - 需要维护一个存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大
poll
poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。
-
poll
没有最大连接数,因为它是基于链表来存储的,其余的和select没有多大区别
epoll
epoll有3个系统调用:
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event event);
int epoll_wait(int* epfd, struct epoll_event events, int* maxevents, int timeout)
- epoll_create建立一个epoll对象,参数size是内核保证能正确处理的最大句柄数,多于这个数内核不保证效果)
- epoll_ctl可以操作上面建立的epoll对象。 将socket放入epoll让其监控,或者把监控的某个socket句柄移除,不再监控(将I/O流放到内核)
- epoll_wait在调用时,在给定的timeout时间内,当在监控的句柄中有事件发生时,就返回用户态的进程(在内核层面捕获可读写的I/O事件)
epoll高效的地方还在于:epoll里面有个内核高速cache
,被监控的socket在cache里面以红黑树
的结构存储,同时,epoll还有个list链表
,存储准备就绪的事件
在调用epoll_create时,内核会建立红黑树存储socket,建立链表存储准备就绪的事件;在调用epoll_wait时,仅仅观察这个链表有没有数据,没有数据就sleep,有数据就返回,等到timeout没数据也返回;在执行epoll_ctl时,如果增加socket,则检查红黑树是否存在,存在就立即返回,不存在就添加到红黑树,然后向内核注册回调函数,用于当中断事件来临时向准备就绪的链表中插入数据。
epoll水平触发和边缘触发的实现:
epoll_wait返回用户态时是否清空链表,清空了就是边缘触发,未清空就是水平触发
select/poll和epoll的区别:
- select/poll 监控的socket句柄列表在用户态,每次调用都需要从用户态将句柄列表拷贝到内核态;但是epoll监控的句柄就是建立在内核态,减少了内核和用户态的拷贝
- select/poll 在用户态和内核之间的拷贝每次都是全部列表,同时遍历效率低;epoll则只需要将准备就绪的句柄返回即可,数量较少
异步I/O(asynchronous I/O)
用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。
最后
就好比去买一件商品
1)阻塞I/O:你自己跑去商店下单(只能一个一个来),等有了物品还要自己去拿回来
2)非阻塞I/O:你可以网上下单了,而且网上看有没有货,有的话自己去拿回来
3)I/O多路复用:和第一种情况差不多,但是这个商店下单窗口多,可以同时多个人跑来下单,谁的货到了,自己来取
4)异步I/O:你只要网上下个单,其余就不管了。商家不仅发快递,快递小哥还把商品直接送到你家里