文章目录
  1. 1. UNIX下5种可用的IO模型的区别:
    1. 1.1. 为什么boost::asio要在linux下模拟preactor模式?
    2. 1.2. 同步IO和异步IO对比:
    3. 1.3. 选择哪种IO模型:
    4. 1.4. 讨论一下同步转异步
  2. 2. int select(int maxfdpl, fd_set *readset, fd_set *write_set, fd_set *exceptset, const struct timeval *timeout)
  3. 3. poll
  4. 4. epoll
    1. 4.1. epoll的三个接口
    2. 4.2. int epoll_create(int size);
    3. 4.3. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
    4. 4.4. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout)
    5. 4.5. 千万不要忘记使用epoll_ctl删除不需要的事件
  5. 5. 拒绝服务型的攻击(Denial of Service Attack,缩写:DoS)

有关epoll来实现一个回显程序的源代码请参见SocketPool当中的gtest/unp_server和unp_client

UNIX下5种可用的IO模型的区别:

阻塞式IO
阻塞式IO

非阻塞式IO
非阻塞式IO

IO复用(select和epoll):也叫IO多路复用,也叫事件驱动。IO复用和IO多路复用的英文都是io multiplexing,这两个术语是翻译的问题。
select的io复用

信号驱动式IO
信号驱动式io

异步IO(posix的aio_系列函数):要想使用preactor模式的话,应该要用aio的函数,preactorboost::asio在linux下还是使用selectepoll来模拟异步,实际上还是阻塞的。

Linux下有两种异步IO:

  • glibc aio(aio_*)
  • kernel native aio(io_*)。

异步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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
fd_set rset;
//要使用描述符集的第一件事情就是清空描述符集合
FD_ZERO(&rset);
while (true) {
//STDIN_FILENO是标准输入stdin的描述符
FD_SET(STDIN_FILENO, &rset);
FD_SET(sockFD_, &rset);
int maxfdpl = max(STDIN_FILENO, sockFD_) + 1;
// 将timeout设为NULL,让select永远等待。
// 把rset放入read的这个描述符集的位置,而把write和except,也就是写和异常的这两个描述符集合置为NULL,因为我们只需要监听标准输入stdin和套接字sockFD_的进入信号。
select(maxfdpl, &rset, NULL, NULL, NULL);
// 使用FD_ISSET来判断sockFD_是否可读,如果可读的话那么返回true
if (FD_ISSET(sockFD_, &rset)) {
//TODO read from sockFD_
}

if (FD_ISSET(STDIN_FILENO, &rset)) {
//TODO read from stdin
}
}

有关select的最大描述符数目:以前经常在网上看到select支持最大的描述符数目是1024,但是《UNPv1》说现在的unix版本允许select使用无限数目的描述符。但是从可移植性来考虑的话,使用大描述符集合要小心。

在最后顺便再提一下pselect函数:比select函数多了一个参数const sigset_t *sigmask,这个是一个指向信号掩码的指针,就是由信号来控制pselect,这个不需要关心。

因为说实在话,现在的linux真的要使用IO多路复用的话,一般会用libevent这个库来实现,不会用到selectepoll这么底层的系统调用,就算要用到这么底层的系统调用,也不会用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
2
3
4
5
struct pollfd {
int fd;
short events;
short revents;
};

看出了什么来没有:没错!select需要使用FD_SET来设定,使用FD_ADD增加监听的描述符,而poll则是在参数当中直接使用了unsigned long nfds来定义描述符数组的长度。比起select上面说到的使用大的描述符,需要格外小心的情况,poll可以使用很大描述符。
还有一点就是select()函数需要传递读,写,异常,三个描述符集。而poll()函数的设计就是直接使用events来指定这个描述符属于读还是写,还是异常,也可以使用逻辑或符号|组合读,写,异常。这样将读,写,异常的关系直接和文件描述符挂钩,而不是与函数挂钩。

简单看一下poll的用法,这次我们使用服务器端的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
//.....
listen(listenfd, backlog);
struct pollfd client[OPEN_MAX];
client[0].fd = listenfd;
//POLLRDNORM指的是可读
client[0].events = POLLRDNORM;
// 由于接下来将遍历client数组,那么数组需要初始化一下
for (int i=1; i< OPEN_MAX; ++i) {
client[i].fd = -1;
}
//数组当中只有一个可用的描述符listenfd
maxi = 1;
while (true) {
int nready = poll(client, maxi, INFTIM);
//client[0]保存的当然是listenfd,这里判断一下返回的响应是否可读,
//如果成功说明listen有新的连接进来
if (client[0].revents & POLLRDNORM) {
struct sockaddr_in cliaddr;
socklen_t clilen = sizeof(cliaddr);
int connfd = accept(listenfd, (struct sockaddr*) &cliaddr, &clilen);
//遍历数组的目的就是把connfd放入集合当中,然后poll就能监听了
for (int i = 1; i< OPEN_MAX; ++i) {
if (client[i].fd < 0) {
client[i].fd = connfd;
break;
}
if ( i >= maxi ) {
maxi = i;
}
if (--nready <=0 ) {
continue;
}
}
}
//遍历数组找到先前的connfd,判断是否可用
for (int i = 1; i < maxi; ++i) {
if (client[i].fd < 0) {
continue;
}
if (client[i].revents & (POLLRDNORM | POLLERR)) {
//TODO receive data from client[i].fd
}
}
}

是不是感觉代码量很长,很多循环,而且可读性不好。
那么简洁的代码应该是什么样子的呢?
下面就进入重要的epoll函数:

epoll

以下是使用epoll的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62

//.......
listen(listenfd, backlog);
const int FDSIZE = 10;
const int EPOLLEVENTS = 8;
const int MAXLINE = 1024;
char buf[MAXLINE];
//创建一个epoll句柄
int efd = epoll_create(FDSIZE);
//初始化一个事件
struct epoll_event ev[EPOLLEVENTS];

//这里注册epoll事件,listener.Get()只注册读入的事件,不注册写出的事件,因为epoll只需要监视读入的事件,因为listener只会读入,不会写出
//EPOLLIN就是读入事件
struct epoll_event tmp;
tmp.events = EPOLLIN;
tmp.data.fd = listenfd;
//注册一个事件
epoll_ctl(efd, EPOLL_CTL_ADD, listenfd, &tmp); //EPOLL_CTL_ADD是加入

while (true) {
//epoll监视的只是连接而已
ret = epoll_wait(efd, ev, EPOLLEVENTS, -1); //-1这个位置设置的是一个超时值,设为-1表示永久阻塞
int ev_fd;
for (int i=0; i<ret; ++i) {
ev_fd = ev[i].data.fd;
//epoll只需要监视读入的,不需要监视写出的
if (ev_fd == listenfd) {
//说明listener有事件发生,那么这个时候就要使用accept来获取连接了
struct sockaddr_in cliaddr;
socklen_t clilen = sizeof(cliaddr);
int sockfd = accept(listenfd, (struct sockaddr*) &cliaddr, &clilen);
//从accept得到了与客户端的连接之后,也要把连接放入epoll的监听当中去
//这比select的用法可是简单多了
tmp.events = EPOLLIN;
tmp.data.fd = sockfd;
//注册一个事件
if (epoll_ctl(efd, EPOLL_CTL_ADD, sockfd, &tmp) == -1) { //EPOLL_CTL_ADD是加入
cerr << "epoll_ctl error" << endl;
}
} else if (ev[i].events & EPOLLIN) {
//TODO read from ev_fd
ssize_t n = read(ev_fd, buf, MAXLINE);
// 当从ev_fd读取失败的时候删除这个事件
if (n == -1) {
LOG(ERROR) << "read error." << endl;
tmp.events = EPOLLIN;
tmp.data.fd = ev_fd;
epoll_ctl(efd, EPOLL_CTL_DEL, ev_fd, &tmp);
} else if (n == 0) {
LOG(ERROR) << "client close." << endl;
tmp.events = EPOLLIN;
tmp.data.fd = ev_fd;
epoll_ctl(efd, EPOLL_CTL_DEL, ev_fd, &tmp);
}
write(ev_fd, buf, n);
//将buf写回到客户端的时候一定要清空buf,否则的话下次read的时候buf里面会存在客户端上次发送的残留
memset(buf, 0, sizeof(buf));
}
}
}
close(efd);

epoll的机制和poll一样,也是通过将描述符放入数据结构当中,和读,写,异常的事件挂钩,而不是和select一样,和函数挂钩。

这里代码比之上面poll的实现更加简单,原因是有epoll_ctl这个函数来把描述符加入监听当中,而不需要像poll一样先遍历数组再把描述符加入,换言之,省去了一个循环,代码可读性变得更强了。

epoll的三个接口

要使用这三个接口必须包含头文件

1
#include <sys/epoll.h>
1
2
3
4
5
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);

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
2
3
4
5
6
7
8
9
10
11
typedef union epoll_data{
void *ptr;
int fd;
__uint32_t u32;
__uint64_t u64;
} epoll_data_t;

struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};

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操作设置一个超时
文章目录
  1. 1. UNIX下5种可用的IO模型的区别:
    1. 1.1. 为什么boost::asio要在linux下模拟preactor模式?
    2. 1.2. 同步IO和异步IO对比:
    3. 1.3. 选择哪种IO模型:
    4. 1.4. 讨论一下同步转异步
  2. 2. int select(int maxfdpl, fd_set *readset, fd_set *write_set, fd_set *exceptset, const struct timeval *timeout)
  3. 3. poll
  4. 4. epoll
    1. 4.1. epoll的三个接口
    2. 4.2. int epoll_create(int size);
    3. 4.3. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
    4. 4.4. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout)
    5. 4.5. 千万不要忘记使用epoll_ctl删除不需要的事件
  5. 5. 拒绝服务型的攻击(Denial of Service Attack,缩写:DoS)