I/O多路复用(select/poll/epoll)
Socket编程
服务端
- 服务端首先调用
socket()
函数,创建网络协议为 IPv4,以及传输协议为 TCP 的 Socket ,接着调用bind()
函数,给这个 Socket 绑定一个 IP 地址和端口
- 调用
listen()
函数进行监听,此时对应 TCP 状态图中的listen
,如果我们要判定服务器中一个网络程序有没有启动,可以通过netstat
命令查看对应的端口号是否有被监听。
- 服务端进入了监听状态后,通过调用
accept()
函数,来从内核获取客户端的连接,如果没有客户端连接,则会阻塞等待客户端连接的到来。
客户端
-
创建好 Socket 后,调用
connect()
函数发起连接,该函数的参数要指明服务端的 IP 地址和端口号。 -
TCP三次连接。没完全建立连接的队列,称为 TCP 半连接队列,这个队列都是没有完成三次握手的连接,此时服务端处于
syn_rcvd
的状态;建立连接的队列,称为 TCP 全连接队列,这个队列都是完成了三次握手的连接,此时服务端处于established
状态; -
TCP 全连接队列不为空后,服务端的
accept()
函数,就会从内核中的 TCP 全连接队列里拿出一个已经完成连接的 Socket 返回应用程序,后续数据传输都用这个 Socket。
I/O模式
-
阻塞式:等待数据准备好(文件描述符可读/写)才继续执行
-
非阻塞式:轮询文件描述符,不等待,通过返回错误值提示未就绪,会消耗CPU资源
-
I/O复用(select、poll、epoll) :阻塞在select、poll、epoll上,等待多个描述符就绪,返回其中一个
-
信号(SIGIO) :首先开启套接字的信号驱动式I/O功能(并通过sigaction系统调用安装一个信号处理函数。该系统调用将立即返回,进程继续工作,也就是说它没有被阻塞。当数据报准备好读取时,内核就为该进程产生一个SIGIO信号。随后既可以在信号处理函数中调用recvfrom读取数据报,并通知主循环数据已准备好待处理,也可以立即通知主循环,让它读取数据报。无论如何处理SIGIO信号,这种模型的优势在于等待数据报到达期间进程不被阻塞。主循环可以继续执行,只要等待来自信号处理函数的通知:既可以是数据已准备好被处理,也可以是数据报已准备好被读取。
-
异步I/O:上述I/O都是同步I/O。异步I/O工作机制是:告知内核启动某个操作,并让内核在整个操作(包括将数据从内核复制到用户空间缓冲区)完成后通知。这种模型与信号驱动模型的主要区别在于:信号驱动式I/O是由内核通知何时可以启动一个I/O操作,而异步I/O模型是由内核通知I/O操作何时完成
同步I/O和异步I/O对比
- 同步I/O操作(synchronous I/O opetation)导致请求进程阻塞,直到I/O操作完成;
- 异步I/O操作(asynchronous I/O opetation)不导致请求进程阻塞。
select/poll
select 实现多路复用的方式是,将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写, 接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。
所以,对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合,一次是在内核态里,一个次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。
select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024
,只能监听 0~1023 的文件描述符。
poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。
但是 poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。
epoll
epoll 通过两个方面,很好解决了 select/poll 的问题。
- epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过
epoll_ctl()
函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删查一般时间复杂度是O(logn)
,通过对这棵黑红树进行操作,这样就不需要像 select/poll 每次操作时都传入整个 socket 集合,只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。
- epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用
epoll_wait()
函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。
epoll 支持两种事件触发模式,分别是边缘触发(edge-triggered,ET) 和水平触发(level-triggered,LT) 。
- 使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完;
- 使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取;
select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式。
多路复用 API 返回的事件并不一定可读写的,如果使用阻塞 I/O, 那么在调用 read/write 时则会发生程序阻塞,因此最好搭配非阻塞 I/O,以便应对极少数的特殊情况。
select实现
该函数允许进程指示内核等待多个事件中的任何一个发生,并只在有一个或多个事件发生或经历一段指定的时间后才唤醒它。
# include <sys/select.h>
# include <sys/time.h>
struct timeval{
long tv_sec; /*seconds*/
long tv_usec; /*microseconde */
};
int select(int maxfdp1, fdset *readset, fdset *writeset, fd_set *exceptset, const struct timeval *timeout);
-
返回值:
- 跨所有描述符集的已就绪的总位数。
- 如果在任何描述符就绪之前定时器到时,那么返回0。
- 返回-1表示出错(这是可能发生的,譬如本函数被一个所捕获的信号中断)。
-
参数
-
timeout指定超时时间,timeval结构指定秒数和微秒。设为
NULL
永远等待;设为0不等待,轮询;其他大于0值,等待一段时间。 -
中间的三个参数readset、writeset和exceptset指定让内核测试读、写和异常条件的描述符。select函数的中间三个参数readset、writeset和exceptset中,如果对某一个的条件不感兴趣,就可以把它设为空指针。
void FD_ZERO(fd_set *fdset); /* clear all bits in fdset */ void FD_SET(int fd,fd_set *fdset); /* turn on the bit for fd in fdset */ void FD_CLR(int fd,fd_set *fdset); /* trun off the bit for fd in fdset */ int FD_ISSET(int fd,fd_set *flset); /* is the bit for fd on in fdset? */
-
maxfdp1参数指定待测试的描述符个数,它的值是待测试的最大描述符加1,描述符0,1,2 ... 一直到maxfdp1-1均将被测试。
-
描述符就绪条件
-
读就绪
- 套接字接收缓冲区有数据,对这样的套接字执行读操作不会阻塞并将返回一个大于0的值(读入的字节数)。
- 该连接的读半部关闭(也就是接收了FIN的TCP连接)。对这样的套接字的读操作将不阻塞并返回0(也就是返回EOF)。
- 该套接字是一个监听套接字且已完成的连接数不为0,accept不会阻塞。
- 其上有一个套接字错误待处理。对这样的套接字的读操作将不阻塞并返回-1(也就是返回一个错误),同时把
errno
设置成确切的错误条件。这些待处理错误(pending error)也可以通过指定SO_ERROR套接字选项调用getsockopt获取并清除。
-
写就绪
- 套接字发送缓冲区有数据,写操作不阻塞且返回一个大于0的值(写入的字节数)
- 该连接的写半部关闭。对这样的套接字的写操作将产生SIGPIPE信号
- 使用非阻塞式connect的套接字已建立连接,或者connect已经以失败告终
- 其上有一个套接字错误待处理。
select处理对端TCP
- 如果对端TCP发送数据,那么该套接字变为可读,并且read返回一个大于0的值(即读入数据的字节数)。
- 如果对端TCP发送一个FIN(对端进程终止),那么该套接字变为可读,并且read返回0 (EOF)。
- 如果对端TCP发送一个RST(对端主机崩溃并重新启动),那么该套接字变为可读,并且read返回-1,而errno中含有确切的错误码。
参考资料
《Unix网络编程卷1》