典型IO模型
IO的种类
IO模型根据特性可以分为以下几个种类:阻塞IO,非阻塞IO,信号驱动IO,异步IO,多路转接IO。
阻塞IO
为了IO发起IO调用,若IO条件不满足则一直等待,直到条件具备。
非阻塞IO
为了IO发起IO调用,若条件不满足则直接报错返回,执行其他指令。之后再次发起IO调用,条件不满足则继续报错返回,条件满足则直接进行数据拷贝后调用返回。
阻塞与非阻塞的区别在与发起一个调用是否能够立即返回。
信号驱动IO
自定义IO信号,如果IO条件具备则发送IO信号,收到信号后则打断其他操作进行信号处理,执行IO操作进行数据拷贝,结束后调用返回。
异步IO
自定义IO信号,发起IO调用,然后让操作系统进行等待条件满足,满足后操作系统进行数据拷贝,拷贝完后通知进程,进程收到后直接处理数据。
与之对应的是同步的操作同步与异步的区别在于功能的完成是否由自身完成。
那么是同步好还是异步好呢?答案是视使用场景而定。同步的流程控制更加简单,但是不管是否阻塞都会浪费CPU资源,因此对CPU的利用率不足。而异步对CPU的利用率更高,但是流程控制更加复杂,并且IO调用越多,同一时间占用的空间资源越多。
从以上IO种类来看,IO效率越来越高,但是流程控制越来越复杂,资源占用也越来越多。
多路转接IO
多路转接IO对大量描述符进行事件监控,能够让用户只对事件就绪的描述符进行操作。在网络通信中,如果用户仅仅对就绪的描述符进行操作,则流程在一个执行流中就不会阻塞,可以实现在一个执行流中对多个描述符进行并发操作。多路转接IO的实现主要是通过几种多路转接模型实现:select/poll/epoll。
select模型
工作原理
select模型对大量描述符进行几种事件监控,让用户能够仅仅针对事件就绪的描述符进行操作,对就绪事件的判断主要有以下几个标准:
1、可读事件:接收缓冲区中数据大小大于等于低水位标记(默认一个字节)。
2、可写事件:发送缓冲区中空闲空间的大小大于等于低水位标记(默认一个字节)。
3、异常事件:描述符是否发生了某些异常。
实现流程
1、用户首先定义事件集合,一共三种事件集合可读/可写/异常,每一个集合实际是一个位图,将某个事件集合中描述符对应的位置1
用于标记用户关心该描述符的某些事件。
2、将集合拷贝到内核进行监控。对集合中所有描述符进行遍历判断,判断是否事件就绪。
3、若有某个描述符就绪了用户关心的事件,则该返回给用户结果了。返回的时候分别从各个事件集合中将没有就绪该事件的描述符对应的位置0
,返回给用户三种表示就绪的描述符事件集合。
4、用户拿到就绪描述符事件集合后,通过分别遍历三种事件集合找到哪些描述符还在集合中,来判断哪些描述符已经就绪了指定事件,然后进行操作。
接口
1 | int select(int nfds, fd_set *readfds, fd_set *writefds, |
用select模型实现tcp服务器
按照以下步骤实现:
1、搭建tcp服务器。
2、在accept之前创建select监控集合,并且将监听socket添加到可读事件集合中。
3、进行select监控,当select返回并且有就绪描述符。
4、若有事件就绪,判断就绪的描述符是否是监听socket,如果是则accept
新的socket
添加监控,否则recv
数据。
每处理完一遍之后,都要再次对所有描述符进行监控,而每次系统都会将没有就绪的描述符剔除,因此每次监控前我们都得先重新添加一遍所有要监控的描述符。
1 | //用select模型实现并发服务器 |
以上的实现用到了我们之前实现的tcp_socket.hpp
,并将其类中的析构函数自动关闭套接字去掉。之后再用之前实现的tcp_cli.cpp
即tcp客户端进行连接测试。
1 | 服务端: |
select优缺点分析
缺点:
1、select所能监控的描述符数量是有上线的,FD_SETSIZE = 1024
。
2、每次监控都需要将监控集合拷贝到内核中。
3、在内核中进行轮询遍历查询,随着描述符的增多性能降低。
4、返回的是就绪集合,需要用户自己进行判断才能对描述符进行操作。
5、每次返回都会清空未就绪描述符,因此每次监控都需要重新添加到集合中。
优点:
1、POSIX标准
,支持跨平台。
poll模型
工作原理
poll
采用事件结构的方式对描述符进行事件监控,只需要一个事件结构数组,将要响应的描述符提交到数组的每一个结构节点的fd中,以及将用户关心的事件添加到响应节点events
中。即可进行监控。
接口
1 | int poll(struct pollfd *fds, nfds_t nfds, int timeout); |
使用流程
1、用户定义一个事件结构数组,然后将关心的描述符以及事件添加到数组中。
2、将数组拷贝到内核进行监控。在内核中会轮询遍历监控。
3、当事件数组中有描述符事件就绪/等待超时则poll返回,poll返回时将每个描述符就虚的事件添加到相应节点revents
中。
4、用户对数组中的每个节点的revents
进行判断之后进行操作。
优缺点分析
缺点:
1、无法跨平台。
2、依然需要将监控的描述符事件数组拷贝到内核中。
3、
在内核中同样是轮询遍历监控,性能会随着描述符的增多而下降。
4、只是将就绪的事件放到了结构的revents
中,需要用户轮询查找就绪的描述符。
优点:
1、没有描述符监控的数量上限。
2、采用事件结构进行监控,简化了select
三种事件集合的操作流程。
3、不需要每次重新向集合中添加事件数组。
epoll模型
epoll
模型是为了处理大批量句柄而做了改进的poll
,它几乎具备了所需的一切优点,是Linux下性能最好的多路I/O模型。
接口
1 | int epoll_create(int size); |
工作原理
epoll
监控是一个异步阻塞操作。对描述符的监控由操作系统完成,当描述符就绪之后,则将就绪的描述符对应的epoll_event
结构添加到双向链表list
中,而当前进程只是每隔一段时间判断以下list
是否为空,即可知道是否有描述符就绪。
操作系统完成监控,对于每一个描述符所关心的事件都定义了一个事件回调,当描述符就绪事件的时候就会调用回调函数,这个回调函数负责将事件结构信息即struct epoll_event
添加到双向链表中。
epoll_wait
会自动检测list
双向链表,检测到链表list
不为空,表示有就绪事件,则将这个链表中这些epoll_event
放到用户的events
数组中返回出去。
用epoll模型实现tcp服务器
1 | #include <iostream> |
在事件结构体中events
字段还有另外几种事件触发模式EPOLLLT
水平触发,EPOLLET
边缘触发。
水平触发对于可读事件来说,只要接收缓冲区中的数据大小高于低水位标记就会触发事件。对于可写事件来说,只要发送缓冲区中剩余空间高于低水位标记时就会触发事件。总结来说就是只要缓冲区满足可读或可写要求就会触发事件。举个例子,如果你在读取缓冲区,一次没有读完,只要缓冲区中还有数据那么下一次epoll
会继续触发事件告诉你有东西可读。
边缘触发对于可读和可写事件来说都是只有当缓冲区中内容改变即新数据接收到接收缓冲区或发送缓冲区数据被发送走时才会触发时间。同样举个例子,如果想要读取接收缓冲区的内容,一次没有读取完,虽然缓冲区中还有数据但是下一次epoll
不会触发事件让你继续读取,知道有新的数据接收到接收缓冲区中,此时你可以连着上次的数据和新数据一起读取。
水平触发和边缘触发没有明确的性能差距,但是边缘触发一次读写只触发一次,确实比水平触发要触发的次数少,系统就不会触发一些你不关心的就绪文件描述符。因此有时使用边缘触发更好一些,但是为了一次触发就能把缓冲区中数据全部读完需要循环读取数据,知道读完位置,又为了不造成阻塞要将描述符设置为非阻塞,然后循环读取数据直到EAGAIN
错误就表示一次读完了。其实实际使用中都是非阻塞循环进行数据读取,毕竟在不清楚数据到底有多少的情况下,要想一次读完就只能采用以上方法。
epoll优缺点分析
优点:
1、采用事件结构方式监控,简化多个监控集合的操作流程。
2、没有所能监控的描述符数量上限。
3、epoll监控的事件只需要向内核拷贝一次,不需要每次都拷贝。
4、监控采用异步阻塞,在内核中进行事件回调方式监控,因此性能不会随着描述符的增多而降低。
5、直接返回就绪事件结构,用户可以通过就绪事件结构中的描述符直接操作,不需要进行遍历判断。
缺点:
1、不支持跨平台。
多路转接IO适用场景
多路转接模型适用于对大量描述符进行监控,但是同一时间只有少量活跃的场景。多线程/多进程的并发处理时比较高效且公平的。但是多路转接模型的并发是轮询处理的,一个处理完才会处理下一个,如果活跃描述符很多,则会导致后面的描述符等待时间过长。
因此使用epoll
往往是用其进行活跃判断,当描述符活跃再将其放到线程池中进行公平处理。这样的搭配才是较为完美的。