1、基本概念
Level_triggered(水平触发):当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据一次性全部读写完(如读写缓冲区太小),那么下次调用 epoll_wait()时,它还会通知你在上没读写完的文件描述符上继续读写,当然如果你一直不去读写,它会一直通知你
Edge_triggered(边缘触发):当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你
阻塞IO:当你去读一个阻塞的文件描述符时,如果在该文件描述符上没有数据可读,那么它会一直阻塞(通俗一点就是一直卡在调用函数那里),直到有数据可读。当你去写一个阻塞的文件描述符时,如果在该文件描述符上没有空间(通常是缓冲区)可写,那么它会一直阻塞,直到有空间可写。以上的读和写我们统一指在某个文件描述符进行的操作,不单单指真正的读数据,写数据,还包括接收连接accept(),发起连接connect()等操作
非阻塞IO:当你去读写一个非阻塞的文件描述符时,不管可不可以读写,它都会立即返回,返回成功说明读写操作完成了,返回失败会设置相应errno状态码,根据这个errno可以进一步执行其他处理。它不会像阻塞IO那样,卡在那里不动
2、关于在socket和EPOLL中的阻塞与非阻塞
关于socket中的阻塞与非阻塞,首先明白的是,阻塞与非阻塞是文件(文件描述符)的性质,而不是函数的性质
socket通信中用到的三个文件描述符
客户端:
- connfd:客户端创建socket时候得到的文件描述符。connect使用这个描述符主动发起链接。
服务端:
- listenfd:创建socket得到的文件描述符,同时bind和listen使用的也是这个文件描述符
- clientfd:调用accept得到的文件描述符,也就是用于通信的文件描述符
注意:
accept函数并不参与三次握手过程,accept函数将建立好的连接从全连接队列中移除,并返回clientfd,随后客户端和服务店通过connfd和clientfd进行通信。
socket通信中相关的文件描述符是否设置为阻塞模式对下列api造成的影响
1、当connfd被设置为阻塞模式的时候(默认),connect函数会一直阻塞到连接成功或超时出错,超时值需要修改内核参数
(注意:connfd的阻塞与否影响的不仅是connect,还有客户端的read以及write(send、recv等)函数族)
2、当connfd被设置成非阻塞模式,无论连接是否成功,connect都会立刻返回
调用connect建立连接成功,返回0,失败则返回-1并设置对应的errno
例如:
- errno为EINPROGRESS:这表示连接仍在进行中,需要进一步等待。非阻塞式connect才会出现
- EACCES:拒绝连接,通常是由于权限问题
- EADDRINUSE:地址已经在使用中,无法建立连接
- ECONNREFUSED:远程主机拒绝连接
- ETIMEDOUT:连接超时,远程主机没有在指定的时间内响应
- EHOSTUNREACH:无法到达远程主机
3、当listenfd设置成阻塞模式的时候(默认,无需设置),如果连接全连接队列中有需要处理的连接,accet函数会立即返回,否则会一直阻塞下去,直到新的连接到来
(也就是说,listenfd的阻塞与否影响的是accept,而不会影响bind和listen)
4、当listenfd设置成非阻塞的时候,无论连接全连接队列是否有连接,accpet都会立即返回,不会阻塞。如果有连接,则accept返回对应的socket。如果没有连接,accept返回值小于0,并设置对应的errno为EAGAIN或EWOULDBLOCK
5、当connfd或clientfd设置为阻塞模式的时候(默认),send会尝试发数据,如果对端因为TCP窗口太小导致本段无法发送出去,send函数会一直阻塞到对端TCP窗口变大足以发送数据或者超时;recv则相反,如果此时没有数据可获取,recv函数会一直阻塞直到收取到数据或者超时,有的话,读到数据后返回。send和recv函数的超时时间可以分别使用SO_SNDTIMEO和SO_RCTIMEO两个套接字选项来设置
6、当connfd和clientfd设置成非阻塞模式的时候,send和recv函数都会立即返回,send函数即使因为对端TCP窗口太小发送不出去也会立即返回,recv函数如果无数据可收也会立即返回,此时这两个函数的返回值都是-1,错误码都是EAGIN。这种情况下,send和recv函数的返回值有三种情况,分别是大于0,等于0,小于0。总结 如下:
返回值 | 返回值含义 |
---|---|
大于0 | 成功发送(send)或收取(recv) n 个字节 |
0 | 对端关闭连接 |
-1 | 返回值为-1并且errno为EAGAIN或EWOULDBLOCK:表示写操作暂时不可用,即套接字当前不可写,需要稍后重试。errno为其他值:表示写操作发生错误,具体的错误原因可以通过查看errno的值来确定。 |
3、几种IO模型的触发方式
考虑服务端
这里我们要探讨epoll()的水平触发(LT)和边缘触发(ET),以及阻塞IO和非阻塞IO对它们的影响
对于监听的socket文件描述符我们用listenfd代替,对于accept()返回的文件描述符(即要读写的文件描述符)用connfd代替
验证内容如下:
- 水平触发的非阻塞listenfd
- 边缘触发的非阻塞listenfd
- 水平触发的阻塞connfd
- 水平触发的非阻塞connfd
- 边缘触发的阻塞connfd
- 边缘触发的非阻塞connfd
以上没有验证阻塞的listenfd,因为最开始将listenfd添加到epoll中,调用epoll_wait()返回后必定是已就绪的连接,设不设置阻塞accept()都会立即返回
4、代码验证
#include <sys/epoll.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
// events数组的大小
const int max_epoll_events = 10;
// buffer缓冲区的大小
const int buffer_size = 5;
// listen的第二个参数,全连接队列的大小
const int listen_size = 10;
// LT模式
const int epoll_lt = 0;
// ET模式
const int epoll_et = 1;
// 文件描述符为阻塞
const int block = 0;
// 文件描述符为非阻塞
const int noblock = 1;
// 设置文件描述符为阻塞
void SetNoblock(int fd)
{
int old_flag = fcntl(fd, F_GETFL);
fcntl(fd, F_SETFL, old_flag | O_NONBLOCK);
}
// 注册文件描述符到epoll中,并设置其事件为EPOLLIN(可读事件)
void AddfdToEollp(int epollfd, int fd, int epoll_type, int block_type)
{
struct epoll_event ev;
ev.data.fd = fd;
ev.events = EPOLLIN;
// 如果是ET模式,则设置EPOLL_ET
if (epoll_type == epoll_et)
{
ev.events |= EPOLLET;
}
// 是否设置阻塞
if (block_type == block)
{
SetNoblock(fd);
}
epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &ev);
}
// LT处理流程
void EpollLT(int fd)
{
char buffer[buffer_size];
int size = 0;
bzero(buffer, buffer_size);
printf("read begin\n");
if ((size = read(fd, buffer, buffer_size)) > 0)
{
printf("收到消息:%s\n", buffer);
}
else if (size == 0)
{
printf("客户端关闭连接\n");
close(fd);
}
}
// 带循环的ET流程
void EpollETLoop(int fd)
{
char buffer[buffer_size];
int size = 0;
bzero(buffer, buffer_size);
printf("带循环的ET开始读取数据\n");
while (true)
{
bzero(buffer, buffer_size);
size = read(fd, buffer, buffer_size);
if (size > 0)
{
printf("收到消息:%s", buffer);
}
else if (size == 0)
{
printf("客户端关闭连接\n");
close(fd);
break;
}
else
{
if (errno == EAGAIN || errno == EWOULDBLOCK)
{
printf("循环读取数据结束\n");
break;
}
}
}
printf("带循环的ET处理结束\n");
}
// 不带循环的ET流程
void EpollETNoLoop(int fd)
{
char buffer[buffer_size];
int size = 0;
bzero(buffer, buffer_size);
printf("不带循环的ET开始读取数据\n");
size = read(fd, buffer, buffer_size);
if (size > 0)
{
printf("收到消息:%s", buffer);
}
else if (size == 0)
{
printf("客户端关闭连接\n");
close(fd);
}
printf("不带循环的ET处理结束\n");
}
void EpollProcess(int epollfd, struct epoll_event* events, int number, int listenfd, int epoll_type, int block_type)
{
for(int i = 0; i < number; ++i)
{
int fd = events[i].data.fd;
//监听套接字有事件发生,一般都是新连接到来
if(fd == listenfd)
{
printf("=============================新一轮accept()=============================\n");
printf("accept()开始\n");
//休眠3秒,模拟服务器很繁忙,不能立刻处理accept连接
printf("服务器繁忙...\n");
sleep(3);
printf("服务器繁忙结束...\n");
struct sockaddr_in client_addr;
socklen_t client_addr_len = sizeof(client_addr);
int connfd = accept(listenfd, (struct sockaddr*)&client_addr, &client_addr_len);
//int connfd = accept4(listenfd, (struct sockaddr *)&clientaddr, &len, SOCK_NONBLOCK);
AddfdToEollp(epollfd, connfd, epoll_type, block_type);
printf("client ip is %s, client port is %d, accept fd is %d\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port), connfd);
printf("accept 结束, fd is %d\n", connfd);
}
else if(events[i].events & EPOLLIN)
{
if(epoll_type == epoll_lt)
{
printf("水平触发开始...\n");
EpollLT(fd);
}
else if(epoll_type == epoll_et)
{
printf("边缘触发开始...\n");
//不带循环的水平触发
EpollETLoop(fd);
//带循环的水平触发
EpollETNoLoop(fd);
}
}
}
}
// 创建监听socket
int CreateListenSocket(const char *ip, const int port)
{
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
//int listensock = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
if (listenfd < 0)
{
fprintf(stderr, "socket error, error code is %d\n", errno);
}
struct sockaddr_in servaddr;
bzero(&servaddr, sizeof(servaddr));
// memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = inet_addr(ip);
servaddr.sin_port = htons(port);
// 设置地址复用
int on = 1;
if (setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)) == -1)
{
fprintf(stderr, "SO_REUSEADDR error, error code is %d\n", errno);
exit(1);
}
// 绑定
if (bind(listenfd, (struct sockaddr *)&(servaddr), sizeof(servaddr)) == -1)
{
fprintf(stderr, "bind error, error code is %d\n", errno);
exit(2);
}
if (listen(listenfd, 5) == -1)
{
fprintf(stderr, "listen error, error code is %d\n", errno);
exit(3);
}
return listenfd;
}
int main(int argc, char *argv[])
{
if (argc < 3)
{
fprintf(stderr, "usage:%s ip_address port_number\n", argv[0]);
exit(4);
}
int listenfd, epollfd, number;
listenfd = CreateListenSocket(argv[1], atoi(argv[2]));
if ((epollfd = epoll_create1(0)) == -1)
{
fprintf(stderr, "epoll_create1 error, error code is%d", errno);
}
struct epoll_event events[max_epoll_events];
//listenfd:非阻塞的LT
//AddfdToEollp(epollfd,listenfd, epoll_lt, noblock);
//listenfd:非阻塞的ET
AddfdToEollp(epollfd,listenfd, epoll_et, noblock);
while(true)
{
number = epoll_wait(epollfd, events, max_epoll_events, -1);//时间参数为0表示立即返回,为-1表示无限等待,大于0表示阻塞多少毫秒
if(number > 0)
{
//connfd:阻塞的LT模式
//EpollProcess(epollfd, events, number, listenfd, epoll_lt, block);
//connfd:非阻塞的LT模式
EpollProcess(epollfd, events, number, listenfd, epoll_lt, noblock);
//connfd:阻塞的ET模式
//EpollProcess(epollfd, events, number, listenfd, epoll_et, block);
//connfd:非阻塞的ET模式
//EpollProcess(epollfd, events, number, listenfd, epoll_et, noblock);
}
}
return 0;
}
水平触发的非阻塞listenfd
放开AddfdToEollp(epollfd,listenfd, epoll_lt, noblock); 和 EpollProcess(epollfd, events, number, listenfd, epoll_lt, block); 然后编译运行:
代码里面休眠了3秒,模拟繁忙服务器不能很快处理accept()请求。这里,我们开另一个终端快速用5个连接连到服务器:
我们再看看服务器的反映,可以看到5个终端连接都处理完成了,返回的新connfd依次为5,6,7,8,9:
测试完毕,批量kill掉那5个客户端:
for i in {1..5};do kill %$i;done
边缘触发的非阻塞listenfd
放开打开AddfdToEollp(epollfd,listenfd, epoll_et, noblock); 和 EpollProcess(epollfd, events, number, listenfd, epoll_lt, block);
然后编译运行,采用同样的方式,快速创建5个客户端连接。再看服务器的反映,5个客户端只处理了3个。说明高并发时,会出现客户端连接不上的问题:
后面4个测试等待listenfd都采用水平触发,后面就不重复写了
水平触发的阻塞connfd
放开EpollProcess(epollfd, events, number, listenfd, epoll_lt, noblock);
编译运行,用一个客户端连接,并发送1-9这几个数字:
再看服务器的反映,可以看到水平触发触发了2次。因为我们代码里面设置的缓冲区是5字节,处理代码一次接收不完,水平触发一直触发,直到数据全部读取完毕:
水平触发的非阻塞connfd
放开EpollProcess(epollfd, events, number, listenfd, epoll_lt, noblock);
编译运行,用一个客户端连接,并发送一段数据:
再看服务器的反映,可以看到水平触发触发了2次。跟水平触发的阻塞connfd一模一样
边缘触发的阻塞connfd
放开EpollProcess(epollfd, events, number, listenfd, epoll_et, block); 和 EpollETLoop(fd);
先测试不带循环的ET模式(即不循环读取数据,跟水平触发读取一样),编译运行后,开启一个客户端连接,并发送1-9这几个数字,再看看服务器的反映,可以看到边缘触发只触发了一次,只读取了5个字节:
我们继续在刚才的客户端发送一个字符a,告诉epoll_wait(),有新的可读事件发生:
这个时候,如果继续在刚刚的客户端再发送一个a,客户端这个时候就会读取上次没读完的a加上次的回车符,2个字节,还剩3个字节的缓冲区就可以读取本次的a加本次的回车符共4个字节:
我们可以看到,阻塞的边缘触发,如果不一次性读取一个事件上的数据,会干扰下一个事件!!!
接下来,我们就一次性读取数据,即带循环的ET模式。注意:我们这里测试的还是边缘触发的阻塞connfd,只是换个读取数据的方式。
放开EpollETLoop(fd);
编译运行,依然用一个客户端连接,发送1-9。看看服务器,可以看到数据全部读取完毕:
细心的朋友肯定发现了问题,程序没有输出"带循环的ET处理结束",是因为程序一直卡在了read()函数上,因为是阻塞IO,如果没数据可读,它会一直等在那里,直到有数据可读。如果这个时候,用另一个客户端去连接,服务器不能受理这个新的客户端!!!
边缘触发的非阻塞connfd
不带循环的ET测试同上面一样,数据不会读取完。这里我们就只需要测试带循环的ET处理,即正规的边缘触发用法。
放开EpollProcess(epollfd, events, number, listenfd, epoll_et, noblock);
编译运行,用一个客户端连接,并发送1-9。再观测服务器的反映,可以看到数据全部读取完毕,处理函数也退出了,因为非阻塞IO如果没有数据可读时,会立即返回,并设置error,这里我们根据EAGAIN和EWOULDBLOCK来判断数据全部读取完毕了,可以退出循环了:
这个时候,我们用另一个客户端去连接,服务器依然可以正常接收请求:
5、总结
-
对于监听的 listenfd,一般设置成非阻塞。最好使用水平触发模式,边缘触发模式会导致高并发情况下,有的客户端会连接不上。如果非要使用边缘触发,可以用 while 来循环 accept()。
-
对于读写的 connfd,水平触发模式下,阻塞和非阻塞效果都一样,只要是调用了read读取数据,那么就一定能读取到数据。不过还是建议设置为非阻塞。
-
对于读写的 connfd,边缘触发模式下,必须使用非阻塞 IO,并要求一次性地完整读写全部数据。
如果需要及时处理所有就绪事件,尤其是在高并发的情况下,可以选择 ET 模式。
如果应用程序的处理逻辑较为复杂,可能会花费较长的时间处理每个事件,或者存在一些短暂的阻塞情况,可以选择 LT 模式。