socket连接池SocketPool分析(三):《UNPv1》复习(下):IO多路复用
有关epoll
来实现一个回显程序的源代码请参见SocketPool当中的gtest/unp_server和unp_client
UNIX下5种可用的IO模型的区别:
阻塞式IO
非阻塞式IO
IO复用(select和epoll):也叫IO多路复用,也叫事件驱动。IO复用和IO多路复用的英文都是io multiplexing,这两个术语是翻译的问题。
信号驱动式IO
异步IO(posix的aio_系列函数):要想使用preactor
模式的话,应该要用aio
的函数,preactor
的boost::asio
在linux下还是使用select
和epoll
来模拟异步,实际上还是阻塞的。
Linux下有两种异步IO:
- glibc aio(aio_*)
- kernel native aio(io_*)。
我在网上看到过一个比喻,觉得蛮有意思的:
有“阻塞IO”,“非阻塞IO”,“IO多路复用”,“异步IO”四个人在钓鱼:
“阻塞IO”用的是最老式的鱼竿,所以呢,得一直守着,等到鱼上钩了再拉杆;
“非阻塞IO”的鱼竿有个功能,能够显示是否有鱼上钩,所以呢,B就和旁边的MM聊天,隔会再看看有没有鱼上钩,有的话就迅速拉杆;
“IO复用”用的鱼竿和B差不多,但他想了一个好办法,就是同时放好几根鱼竿,然后守在旁边,一旦有显示说鱼上钩了,它就将对应的鱼竿拉起来;
“异步IO”是个有钱人,干脆雇了一个人帮他钓鱼,一旦那个人把鱼钓上来了,就给“异步IO”发个短信。
为什么boost::asio要在linux下模拟preactor模式?
是基于在windows和linux操作系统之间可移植的考虑,参照知乎上陈硕老师的答案:
windows有自己的一套高效异步IO模型(几乎等同于Preactor),同时支持文件IO和网络IO;但 Linux 只有高效的网络同步IO(epoll 之类的 io multiplexing是同步的Reactor,且不支持磁盘文件),二者的高效IO编程模型从根本上不兼容。因此,ASIO 要想高效且跨平台,只能用 Preactor 模型了。不可避免地会在 Linux 上损失一点儿效率。
同步IO和异步IO对比:
同步IO:导致请求进程阻塞,直到IO操作完成;
异步IO:不导致请求IO阻塞
这里的请求IO当然就是recvfrom这个系统调用了,所以以上的阻塞式IO,非阻塞式IO,IO复用,信号驱动式IO这四个都是同步的IO
选择哪种IO模型:
在已经知道了当客户端会阻塞在一个描述符上的问题的时候,接下来就需要选择解决这个问题的IO模型了:
1,阻塞式IO加上fork子进程。使用子进程阻塞在一个描述符上,多个子进程阻塞在多个描述符上。这种方法的问题是:操作什么时候终止?如果子进程终止的话,父进程是会收到SIGCHLD信号的。但是如果父进程终止,父进程应该如何通知子进程终止呢?
2,使用阻塞式IO,就是一个循环,访问第一个描述符,如果没就绪,马上返回。接着访问第二个描述符,没就绪的话马上返回,再又访问第一个描述符。缺点是:太浪费CPU的时间了
3,异步IO,就是告诉内核,当一个描述符已经准备好的可以进行IO的时候,用一个信号通知它。但是问题有2个:a,并非所有的系统都支持这种机制。 b,这种通知信号对每个进程而言只有1个,如果这个信号对两个描述符都起作用,那么进程无法判断该用哪一个。
4,多线程模型:缺点是:内存问题是多线程模型的软肋。因为多线程模型默认情况下,(在Linux)每个线程会开8M的栈空间,假定有10000个连接,开这么多个线程需要10000*8M=80G的内存空间!即使调整每个线程的栈空间,也很难满足更多的需求。甚至攻击者可以利用这一点发动DDoS,只要一个连接连上服务器什么也不做,就能吃掉服务器几M的内存。解决方法就是不让客户端使用长连接,仅仅使用短连接来做。
总结:在linux操作系统当中,选择最多的是多线程模型和事件驱动模型。
讨论一下同步转异步
我觉得其实要实现异步接口,接口里面的代码还是同步的。比如说我写一个函数function()。这个函数监听全局变量x的值,当x的值改变的时候,打印一条字符串。那么我可以使用一个线程来启动function(),线程当中function不断轮询全局变量x,这就是同步,但是对于主线程来说,它就是异步的,因为主线程完全可以不管function()继续做其他事情,当function()完成后,可以发送字符串给主线程。
所以function()对于x是同步的,但是主线程对于x就是异步的。
这就是同步转异步,利用多线程来实现。
int select(int maxfdpl, fd_set *readset, fd_set *write_set, fd_set *exceptset, const struct timeval *timeout)
这个函数最后一个参数timeout是告诉内核等待所指定描述符中的任何一个就绪可花费多长事件
- 把timeout设为NULL的时候,select将永远等待下去,仅仅在有一个描述符准备好IO的时候才返回。
- 把timeout设置为0,就是根本不等待,select将不断轮询,得到一个描述符。
- 把timeout设为一个普通值,在有一个描述符准备好时候IO返回,但是不超过这个时间
中间的三个参数readset,writeset和exceptset指定我们要让内核测试读写和异常条件的描述符。
select使用描述符集,通常是一个整形数组。
使用
1 | void FD_SET(int fd, fd_set *fd_set) |
来将fd这个描述符放入描述符集合fd_set当中
使用
1 | int FD_ISSET(int fd, fd_set *fd_set) |
来判断描述符集合fd_set当中的这个描述符fd是否已经就绪
注意:使用IO多路复用要和多线程编程的模型相区别开来。IO复用是在一个线程当中使用select监听多个描述符。而多线程IO是每个线程阻塞在一个描述符上,使用多个线程来监听多个描述符。
使用方法,看看客户端的使用:
1 | fd_set rset; |
有关select的最大描述符数目:以前经常在网上看到select支持最大的描述符数目是1024,但是《UNPv1》说现在的unix版本允许select使用无限数目的描述符。但是从可移植性来考虑的话,使用大描述符集合要小心。
在最后顺便再提一下pselect
函数:比select函数多了一个参数const sigset_t *sigmask
,这个是一个指向信号掩码的指针,就是由信号来控制pselect
,这个不需要关心。
因为说实在话,现在的
linux
真的要使用IO多路复用的话,一般会用libevent
这个库来实现,不会用到select
和epoll
这么底层的系统调用,就算要用到这么底层的系统调用,也不会用select
,而是用更强的epoll
,因为现在用的Linux内核都是2.6以上的版本,所以肯定支持epoll
,最关键的一点是select
不能解决C10k problem,而epoll
就是为了解决C10k problem而生的。
我在这里写一下select
的原因是为了与下文的epoll
做比较,为了在比较当中更好地学习epoll
,所以pselect
这个过期的函数我在这里只是附带提一下有这么个概念,甚至连函数的式子都没写出来。
poll
进入epoll
函数之前也简短介绍一下它的原型poll
函数吧。
1 | int poll(struct pollfd *fdarray, unsigned long nfds, int timeout); |
第一个参数是指向一个结构数组第一个元素的指针,每个数组元素都是一个pollfd结构(要测试的条件由events指定,revents成员中返回描述符的状态。这样做的意义是为了避免值–结果参数):
1 | struct pollfd { |
看出了什么来没有:没错!select需要使用FD_SET来设定,使用FD_ADD增加监听的描述符,而poll则是在参数当中直接使用了unsigned long nfds来定义描述符数组的长度。比起select上面说到的使用大的描述符,需要格外小心的情况,poll可以使用很大描述符。
还有一点就是select()
函数需要传递读,写,异常,三个描述符集。而poll()
函数的设计就是直接使用events
来指定这个描述符属于读还是写,还是异常,也可以使用逻辑或符号|
组合读,写,异常。这样将读,写,异常的关系直接和文件描述符挂钩,而不是与函数挂钩。
简单看一下poll
的用法,这次我们使用服务器端的代码:
1 | //..... |
是不是感觉代码量很长,很多循环,而且可读性不好。
那么简洁的代码应该是什么样子的呢?
下面就进入重要的epoll
函数:
epoll
以下是使用epoll
的实现:
1 |
|
epoll
的机制和poll
一样,也是通过将描述符放入数据结构当中,和读,写,异常的事件挂钩,而不是和select
一样,和函数挂钩。
这里代码比之上面poll
的实现更加简单,原因是有epoll_ctl
这个函数来把描述符加入监听当中,而不需要像poll
一样先遍历数组再把描述符加入,换言之,省去了一个循环,代码可读性变得更强了。
epoll的三个接口
要使用这三个接口必须包含头文件
1 | #include <sys/epoll.h> |
1 | int epoll_create(int size); |
int epoll_create(int size);
创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大。当创建好epoll柄后,它也会占用一个fd值。所以在使用完epoll后,必须调用close()关闭。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
第一个参数表示使用epoll_create
创建的epoll描述符。
第二个参数表示动作,用三个宏来表示:
- EPOLL_CTL_ADD:注册新的fd到epfd中;
- EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
- EPOLL_CTL_DEL:从epfd中删除一个fd;
第三个参数是需要监听的描述符(socket, STDIN_FILENO等等)
第四个参数是告诉内核需要监听的事件。原型就是poll()
函数的pollfd
结构。
1 | typedef union epoll_data{ |
events可以是以下几个宏的集合:
- EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
- EPOLLOUT:表示对应的文件描述符可以写;
- EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
- EPOLLERR:表示对应的文件描述符发生错误;
- EPOLLHUP:表示对应的文件描述符被挂断;
- EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
- EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket.在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的,所以,这种模式编程出错误可能性要小一点。传统的select/poll都是这种模型的代表。
ET (edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once),不过在TCP协议中,ET模式的加速效用仍需要更多的benchmark确认。
ET和LT的区别就在这里体现,LT事件不会丢弃,而是只要读buffer里面有数据可以让用户读,则不断的通知你。而ET则只在事件发生之时通知。
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout)
类似与阻塞的select()
函数或者poll()
函数,这个函数也是等待事件发生。maxevents告之内核这个events有多大,这个 maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。
千万不要忘记使用epoll_ctl删除不需要的事件
想象一下这样子的场景:
客户端和服务器都通过read()
和write()
函数来发送和接受消息。并且当read()
和write()
函数返回0或者-1的时候(当然,也就是函数失败的时候),向终端打印出一行”read|write error”。
当客户端和服务器已经连接上了,发送完了信息,然后我随意对服务器按下ctrl+c。会发生什么?
结论是客户端将不断在终端上打印出”read error”,用不停止。
这个问题花了我一个下午的时间找原因,要解释这个问题,首先让我们回到TCP的四次挥手上面来。
首先是linux内核由于接受到我按下的ctrl+c,将发送一个终止信号给我的服务器程序,然后服务器调用close()
关闭连接,并向客户端发送一个FIN。
关键就在这里:这个FIN当然是通过已经连接的sockFD_发送的,但是客户端的epoll正在监听sockFD_,而且这个FIN将被epoll认为是EPOLLIN状态而捕获,从而出发sockFD_绑定的函数,在这里就是read()
函数。但是由于根本不可能通过read()
函数从sockFD_读取到任何东西,所以将返回0,那么将往终端上打印出”read error”,然后再次进入“while (true)”循环,这个时候FIN还没有消失!怎么可能消失,因为客户端还没给服务器相应ACK呢!所以又一次被epoll通知sockFD_上有事件发生,又一次进入read()
函数,又一次打印”read error”,又一次进入“while (true)”循环。
所谓的西西弗斯的石头就是用来形容这种情况的吧。
所以绝对不能把sockfd的EPOLLIN的监听一直打开着
我发现这个问题的时间比较晚,在我的文章《socket连接池SocketPool分析(五)::Accept函数,进退维谷的困境》当中是由于遇到了另外的问题才发现了这个问题。
拒绝服务型的攻击(Denial of Service Attack,缩写:DoS)
考虑一下这样的一种情况:一个恶意的客户连接到服务器,发送一个字节的数据之(不是换行符)后睡眠,服务器将调用read()
函数,它从客户端读入这个单字节的数据,然后阻塞于read调用,等待来自客户端的其他数据。服务器于是因为这么一个客户而被阻塞,不能够再为其他任何客户提供服务,直到那个恶意客户发送一个换行符或者终止符为止。这里的一个基本概念就是:当一个服务器在处理多个客户时,绝对不能阻塞于只与单个客户相关的某个函数调用,否则可能导致服务器被挂起,拒绝为所有其他客户提供服务,这就是所谓的拒绝服务攻击。目的在于使目标电脑的网络或系统资源耗尽,使服务暂时中断或停止,导致其对目标客户不可用。现在更常见的是DDoS,(Distributed Denial of Service,分布式拒绝服务型的攻击)。
这就是针对服务器的攻击,解决方案:
- 1,使用非阻塞式的IO
- 2,让每个客户由单独的控制线程来提供服务(这就是IO复用和多线程的一个大的区分,多线程不会有这样的问题,只有IO复用会有这样的问题)
- 3,对IO操作设置一个超时