目录
一、概述
二、I/O多路转接 —— select
1. select函数的基本介绍
2. select的基本工作流程
3. 文件描述符的就绪条件
4. 基于select函数设计的服务器
1. 基本套接字的编写
2. select服务器的编写
5. select的优缺点
三、I/O多路转接 —— poll
1. poll函数的基本介绍
2. poll的基本工作流程
3. 基于poll函数设计的服务器
4. poll的优缺点
四、I/O多路转接 —— epoll
1. epoll函数的基本介绍
1. epoll_create函数
2. epoll_ctl函数
3. epoll_wait 函数
2. epoll的底层原理
3. epoll的基本工作流程
4. 基于epoll函数设计的服务器
5. epoll的优点
五、LT 和 ET模式
六、 select、poll和epoll的比较
I/O复用是的程序能够同时监听多个文件描述符,这对提高程序的性能至关重要。Linux下实现I/O复用的系统调用主要有select、poll和epoll。我们会依次对这三个系统调用进行全面的讲解,并给出实例,方便大家理解。
select系统调用的用途:在一段指定时间内,监听用户感性兴趣的文件描述符上的可读、可写和异常等事件。
1. 函数原型
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
2. 参数解读
1. nfds参数指定被监听的文件描述符的总数。它通常被设置为select监听所有文件描述符中最大值加1,因为文件描述符是从0开始计数的。
2. readfds、writefds和exceptfds参数分别指向可读、可写和异常等事件对应的文件描述符集合。应用程序调用select函数时,通过这3个参数传入自己感兴趣的文件描述符。select调用返回时,内核将修改它们来通知应用程序那些文件描述符已经就绪。这3个参数都是fd_set类型,它是一种位图结构。
图一(以readfds为例):我们这三个参数都是这样的位图结构(其实也就是数组),下标位置就是所对应的文件描述符,我们将自己想要关心的文件描述符设置到集合中(关心0、1、2),当select函数调用返回时,会将该集合中被关心的文件描述符重新设置(当该描述符读事件就绪时);如图二所示,我们可以看到,只有0和1这两个文件描述符的读事件就绪了,所以2号文件描述符又被至为-1了。
简单的来讲比特位的内容:
3. timeout参数是用来设置select函数的超时时间。它是一个timeval结构类型的指针,其定义如下:
struct timeval
{
long tv_sec; /*秒数*/
long tv_usec; /*微秒数*/
};
注:当我们给timeout设定了特定的时间后,假设是10s,如果select在10内调用返回(3s就返回了),那么timeval的成员tv_sec和tv_usec的值将会被替换为剩余的秒数。(后面代码中会介绍其总要性)
3. 返回值
select成功时返回就绪(可读、可写和异常)文件描述符的总数。如果在超时时间内没有任何文件描述符就绪,select将返回 0 。select失败时将返回 -1 并设置errno。如果在select等待期间,程序接收到信号,则select立即返回 -1 ,并设置errno为EINTR。
我们要实现一个简单的select服务器,该服务器要做的就是读取客户端发来的数据并进行打印,那么这个select服务器的工作流程应该是这样的:
-------------------------------------------------------------------------------------------------------------------------
注:
下列情况下socket可读:
下列情况下socket可写:
异常情况只有一种:
sock.hpp
#include
#include
#include
#include
#include
#include
#include
#include
using namespace std;
class Sock
{
public:
// 1.创建套接字
static int Socket()
{
int sock = socket(AF_INET, SOCK_STREAM, 0);
if(sock < 0)
{
cerr << "socket error" << endl;
exit(2);
}
return sock;
}
// 2.绑定
static void Bind(int sock, uint16_t port)
{
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = INADDR_ANY;
if(bind(sock, (struct sockaddr*)&local, sizeof(local)) < 0)
{
cerr << "bind error!" << endl;
exit(3);
}
}
// 3.设置监听套接字
static void Listen(int sock)
{
if(listen(sock, 5) < 0)
{
cerr << "listen error!" << endl;
exit(4);
}
}
// 4.获取新连接
static int Accept(int sock)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int fd = accept(sock, (struct sockaddr*)&peer, &len);
if(fd >= 0)
{
return fd;
}
return -1;
}
static void Connect(int sock, string ip, uint16_t port)
{
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(port);
server.sin_addr.s_addr = inet_addr(ip.c_str());
if(connect(sock, (struct sockaddr*)&server, sizeof(server)) == 0)
{
cout << "Connect Success!" << endl;
}
else
{
cout << "Connect failed!" << endl;
exit(5);
}
}
};
1. 利用封装好的sock类,对select服务器进行基本的套接字编写
#include
#include
#include
#include "sock.hpp"
using namespace std;
static void Usage(string proc)
{
cout << "Usage: " << proc << "port" << endl;
}
//./select_server 8080---表示如何运行这个程序(采用命令行参数的形式)
int main(int argc, char* argv[])
{
if(argc != 2)
{
Usage(argv[0]);
exit(1);
}
uint16_t port = (uint16_t)atoi(argv[1]); //将字符形式的(“8080”)端口号,转为整数形式
int listen_sock = Sock::Socket(); //创建listen_sock,用来获取新连接的套接字
Sock::Bind(listen_sock, port); //将listen_sock,绑定到端口号
Sock::Listen(listen_sock); //将listen_sock设置为,监听状态,用于客户端连接服务器
return 0;
}
2. 紧接着在使用select系统调用前,我们还需要利用到一个额外的数组。
- 从参数解读来看,三个事件都是fd_set类型的,本质上都是位图结构,首先由用户将需要关心的文件描述符添加到readfds、writefds和exceptfds中。
- 由于select调用返回时会对原先的位图结构重新调整,只返回已有事件就绪的文件描述符,未就绪事件的文件描述符会被清除(例:我让select关心1/2/3号文件描述符,它只返回了1/2号文件描述符),但是3号文件描述符本次select调用没有发生就绪事件,并不意味着下一次3号文件描述符不会有事件发生。
- 但是select在刚刚返回时就已经将3号文件描述符清除了,下次就不可能再关心3号文件描述符了。
- 所以我们就需要利用额外的数组,将需要关心的文件描述先行保存起来。
- 利用数组的好处,作为一个服务器肯定会有多个连接,每个连接都对应着一个文件描述符,我们知道文件描述符是递增的,我们后续会将accept上来的连接再次添加的数组中,每次循环调用select前都去遍历这个数组,找到最大的文件描述符,作为select系统调用的第一个参数。
#include
#include
#include
#include "sock.hpp"
using namespace std;
#define NUM (sizeof(fd_set) * 8)
int fd_array[NUM]; //内容>=0,认为是合法的fd,如果是-1,该位置没有fd
static void Usage(string proc)
{
cout << "Usage: " << proc << "port" << endl;
}
//./select_server 8080---表示如何运行这个程序(采用命令行参数的形式)
int main(int argc, char* argv[])
{
if(argc != 2)
{
Usage(argv[0]);
exit(1);
}
uint16_t port = (uint16_t)atoi(argv[1]); //将字符形式的(“8080”)端口号,转为整数形式
int listen_sock = Sock::Socket(); //创建listen_sock,用来获取新连接的套接字
Sock::Bind(listen_sock, port); //将listen_sock,绑定到端口号
Sock::Listen(listen_sock); //将listen_sock设置为,监听状态,用于客户端连接服务器
for(int i = 0; i < NUM; i++) //将数组元素全部置为 -1 ,暂时没有任何fd
{
fd_array[i] = -1;
}
fd_set rfds; //创建读事件集合
fd_array[0] = listen_sock; //将listen_sock保存到fd_array数组中
for( ; ; )
{
FD_ZERO(&rfds); //将读事件集合全部清空
int max_fd = fd_array[0];//将listen_sock设置为最大的文件描述符
for(int i = 0; i < NUM; i++)//该循环用来判断fd_array数组中的最大文件描述符,因为select第一个参数需要的就是最大文件描述符值+1;
{
if(fd_array[i] == -1) continue;
//下面的都是合法的fd
FD_SET(fd_array[i], &rfds); //所有要关心的读事件的fd,添加到rfds中
if(max_fd < fd_array[i])
{
max_fd = fd_array[i]; //更新最大fd
}
}
struct timeval timeout = {5, 0};//设置超时时间(也可以不设置)
int n = select(max_fd + 1, &rfds, nullptr, nullptr, &timeout);
}
return 0;
}
3. 对select的返回值进行判断,做出相应的操作
#include
#include
#include
#include "sock.hpp"
using namespace std;
#define NUM (sizeof(fd_set) * 8)
int fd_array[NUM]; //内容>=0,认为是合法的fd,如果是-1,该位置没有fd
static void Usage(string proc)
{
cout << "Usage: " << proc << "port" << endl;
}
//./select_server 8080---表示如何运行这个程序(采用命令行参数的形式)
int main(int argc, char* argv[])
{
if(argc != 2)
{
Usage(argv[0]);
exit(1);
}
uint16_t port = (uint16_t)atoi(argv[1]); //将字符形式的(“8080”)端口号,转为整数形式
int listen_sock = Sock::Socket(); //创建listen_sock,用来获取新连接的套接字
Sock::Bind(listen_sock, port); //将listen_sock,绑定到端口号
Sock::Listen(listen_sock); //将listen_sock设置为,监听状态,用于客户端连接服务器
for(int i = 0; i < NUM; i++) //将数组元素全部置为 -1 ,暂时没有任何fd
{
fd_array[i] = -1;
}
fd_set rfds; //创建读事件集合
fd_array[0] = listen_sock; //将listen_sock保存到fd_array数组中
for( ; ; )
{
FD_ZERO(&rfds); //将读事件集合全部清空
int max_fd = fd_array[0];//将listen_sock设置为最大的文件描述符
for(int i = 0; i < NUM; i++)//该循环用来判断fd_array数组中的最大文件描述符,因为select第一个参数需要的就是最大文件描述符值+1;
{
if(fd_array[i] == -1) continue;
//下面的都是合法的fd
FD_SET(fd_array[i], &rfds); //所有要关心的读事件的fd,添加到rfds中
if(max_fd < fd_array[i])
{
max_fd = fd_array[i]; //更新最大fd
}
}
struct timeval timeout = {5, 0};//设置超时时间(也可以不设置)
int n = select(max_fd + 1, &rfds, nullptr, nullptr, &timeout);
switch(n)
{
case -1:
cerr << "select error" << endl;//-1:表明select调用失败
break;
case 0:
cout << "select timeout" << endl;//0:表明select调用超时,没有任何文件描述符读事件就绪
break;
default:
cout << "有fd对应的事件就绪了!" << endl;//只要大于0,就是返回的已有读事件就绪的文件描述符总数
break;
}
}
return 0;
}
4. 当select成功返回时,肯定有多个文件描述符上的读事件已经就绪了,但是我们并不知道是哪一个文件描述符读事件就绪,幸好我们刚刚的fd_array数组保存了我们要关心的套接字(当然到这一步,数组中只有一个需要关心的文件描述符,就是listen_sock)。此时,我们就可以遍历这个数组中的文件描述符,对其加以判断,数组中的文件描述符有没有被设置到我们的rfds集合中:
1. 如果设置了;就会存在两种情况:
如果是监听套接字:
- 对于监听套接字而言,它的读事件就是新连接到来,此时我们应该立即accept获取新连接,并将获取到的新连接保存到fd_array数组中,用于下一次select调用时,能够关心这些文件描述符上的读事件。
在这里需要提一点,当我们在获取到新连接后,一定不能立即执行read/recv等操作。因为连接到来并不意味着这些连接上的数据就绪了,如果此时你立即执行读取操作,会存在严重的问题,例如:有人攻击你这个服务器,它给你的服务器发送大量的连接,但是从来都不传输数据,无脑的连接你。你的服务器,不断的accept获取大量的连接,然后进程不断的读取操作,但是就是没有数据,导致进程被挂起,可想而知这种危害很大。
如果是普通套接字:
- 此时我们就可以直接执行read/recv等操作。由于我们采用的是TCP协议,读取操作会存在数据粘包问题,但是我们此次代码注重理解select的工作流程,对于粘包问题,需要特定的场合。这里我们可以忽略粘包问题。
2. 如果未设置,让其再次循环select,直到文件描述符被设置(我们的代码中不做任何操作)
#include
#include
#include
#include "sock.hpp"
using namespace std;
#define NUM (sizeof(fd_set) * 8)
int fd_array[NUM]; //内容>=0,认为是合法的fd,如果是-1,该位置没有fd
static void Usage(string proc)
{
cout << "Usage: " << proc << "port" << endl;
}
//./select_server 8080---表示如何运行这个程序(采用命令行参数的形式)
int main(int argc, char* argv[])
{
if(argc != 2)
{
Usage(argv[0]);
exit(1);
}
uint16_t port = (uint16_t)atoi(argv[1]); //将字符形式的(“8080”)端口号,转为整数形式
int listen_sock = Sock::Socket(); //创建listen_sock,用来获取新连接的套接字
Sock::Bind(listen_sock, port); //将listen_sock,绑定到端口号
Sock::Listen(listen_sock); //将listen_sock设置为,监听状态,用于客户端连接服务器
for(int i = 0; i < NUM; i++) //将数组元素全部置为 -1 ,暂时没有任何fd
{
fd_array[i] = -1;
}
fd_set rfds; //创建读事件集合
fd_array[0] = listen_sock; //将listen_sock保存到fd_array数组中
for( ; ; )
{
FD_ZERO(&rfds); //将读事件集合全部清空
int max_fd = fd_array[0];//将listen_sock设置为最大的文件描述符
for(int i = 0; i < NUM; i++)//该循环用来判断fd_array数组中的最大文件描述符,因为select第一个参数需要的就是最大文件描述符值+1;
{
if(fd_array[i] == -1) continue;
//下面的都是合法的fd
FD_SET(fd_array[i], &rfds); //所有要关心的读事件的fd,添加到rfds中
if(max_fd < fd_array[i])
{
max_fd = fd_array[i]; //更新最大fd
}
}
struct timeval timeout = {5, 0};//设置超时时间(也可以不设置)
int n = select(max_fd + 1, &rfds, nullptr, nullptr, &timeout);
//int n = select(max_fd + 1, &rfds, nullptr, nullptr, NULL);
switch(n)
{
case -1:
cerr << "select error" << endl;//-1:表明select调用失败
break;
case 0:
cout << "select timeout" << endl;//0:表明select调用超时,没有任何文件描述符读事件就绪
break;
default:
cout << "有fd对应的事件就绪了!" << endl;//只要大于0,就是返回的已有读事件就绪的文件描述符总数
for(int i = 0; i < NUM; i++)
{
if(fd_array[i] == -1) //如果数组中元素为-1,表明没有文件描述符,不合法
continue;
//下面的fd都是合法的fd,合法的fd不一定就是就绪的fd
if(FD_ISSET(fd_array[i], &rfds))//判断文件描述符有没有被设置到rfds集合中
{
cout << "sock: " << fd_array[i] << " 上面有了读事件,就可以读取了" << endl;
if(fd_array[i] == listen_sock)//如果是文件描述符是监听套接字
{
cout << "listen_sock: " << listen_sock << " 有了新的连接到来" << endl;
int sock = Sock::Accept(listen_sock); //此时就应该accept获取新连接
if(sock >= 0)
{
cout << "listen_sock: " << listen_sock << " 获取新链接成功" << endl;
/*
获取成功,然后就可以recv,read了吗??绝对不可以
新连接到来,并不意味着数据的到来!!什么时候数据到来呢?不知道
但是,select知道哪些fd上面的数据可以读取了;
无法直接将fd设置进select,但是,好在我们有fd_array数组;
*/
int pos = 1;
for(; pos < NUM; pos++)//变量数组,找一个没有被使用的位置将过去上来的连接保存起来,用以select关心
{
if(fd_array[pos] == -1)
break;
}
// 1.找打了一个位置没有被使用
if(pos < NUM)
{
cout << "新链接:" << sock << " 已经被添加到数组[" << pos << "]的位置" << endl;
fd_array[pos] = sock;
}
else
{
// 2.找完了所有的fd_array[],都没有找到没有被使用的位置
// 说明服务器已经满载,无法处理新的请求
cout << "服务器满载了,关闭新的连接" << endl;
close(sock);
}
}
}
else
{
/*
普通的sock,读事件就绪了
可以进行读取啦,recv,read
可是,本次读取就一定能读完吗?读完,就一定没有所谓的数据粘包问题吗?
但是我们没法解决!我们没有场景!仅仅用来测试
*/
cout << "sock: " << fd_array[i] << " 上面有普通数据的读取" << endl;
char recv_buffer[1024] = {0};
ssize_t s = recv(fd_array[i], recv_buffer, sizeof(recv_buffer) - 1, 0);
if(s > 0)
{
recv_buffer[s] = 0;
cout << "client[" << fd_array[i] << "]#" << recv_buffer << endl;
}
else if(s == 0)
{
cout << "sock: " << fd_array[i] << "关闭了,client退出了!" << endl;
// 表明关闭了链接
close(fd_array[i]);
cout << "已经在数组下标fd_array[" << i << "中,去掉了sock:" << fd_array[i] << endl;
fd_array[i] = -1;
}
else
{
cout << "读取失败" << endl;
}
}
}
}
break;
}
}
return 0;
}
以上也是select服务器的全部代码。
优点:
缺点:
poll系统调用和select类似,也是在指定时间内轮询一定数量的文件描述符,以测试其中是否有就绪事件。
1. 函数原型:
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
2. 参数解读:
1. fds参数是一个pollfd结构体类型的数组,它指定所有我们感兴趣的文件描述符上发送的可读、可写和异常等事件。pollfd结构体定义如下:
struct pollfd
{
int fd; /*文件描述符*/
short events; /*注册的事件*/
short revents; /*实际发生的事件,由内核填充*/
};
其中,fd成员指定文件描述符;events成员告诉poll监听fd上的哪些事件(可读、可写和异常),它是一系列事件的按位或(不需要像select那样,分别传递可读、可写和异常);revents成员则是由内核进行填充,以通知用户所关心的众多fd上实际发生了哪些事件。poll支持的事件类型如下:
我们主要关注表格中标红的事件即可;
注:
2. nfds参数指定被监听事件集合fds的大小(即:数组下标)
3. timeout参数指定poll的超时时间,单位是毫秒。
3. 返回值
我们要实现一个简单的poll服务器,该服务器要做的就是读取客户端发来的数据并进行打印,那么这个poll服务器的工作流程应该是这样的:
poll的服务器设计上来讲,和select大致上差不多,只是不需要额外的数组。同样封装sock,这里不多赘述了。
#include
#include
#include
#include "sock.hpp"
using namespace std;
#define NUM 128
struct pollfd fd_array[NUM];//创建一个pollfd数组,相当于是有个集合
static void Usage(string proc)
{
cout << "Usage: " << proc << "port" << endl;
}
//./select_server 8080
int main(int argc, char* argv[])
{
if(argc != 2)
{
Usage(argv[0]);
exit(1);
}
uint16_t port = (uint16_t)atoi(argv[1]);
int listen_sock = Sock::Socket(); //创建套接字
Sock::Bind(listen_sock, port); //绑定
Sock::Listen(listen_sock); //设置监听
for(int i = 0; i < NUM; i++)//将数组全部初始化
{
fd_array[i].fd = -1; //-1代表文件描述符不合法
fd_array[i].events = 0;
fd_array[i].revents = 0;
}
fd_array[0].fd = listen_sock;//将listen_sock设置到该数组中,表明我们关心这个listen_sock
fd_array[0].events = POLLIN; //关心的事件就是读事件 如果你想同时关心写事件可以POLLIN | POLLOUT 这样一来就不像select那样麻烦了。
fd_array[0].revents = 0; //这里是有内核进行填充的,设置为0即可
//事件循环
for( ; ; )
{
int timeout = -1;
/*
-1:阻塞式等待
0:立即返回(轮询检测)
1000:1000毫秒内时阻塞式等待,1000毫秒以外超时返回。
*/
int n = poll(fd_array, NUM, timeout);
switch(n)
{
case -1:
cerr << "select error" << endl;
break;
case 0:
cout << "select timeout" << endl;
break;
default:
cout << "有fd对应的事件就绪了!" << endl;
for(int i = 0; i < NUM; i++)//遍历数组,检测文件描述上的事件
{
if(fd_array[i].revents & POLLIN)//如果该文件描述符存在读事件
{
cout << "sock: " << fd_array[i].fd << " 上面有了读事件,就可以读取了" << endl;
if(fd_array[i].fd == listen_sock)//如果是监听套接字,需要accept
{
cout << "listen_sock: " << listen_sock << " 有了新的连接到来" << endl;
int sock = Sock::Accept(listen_sock);
if(sock >= 0)
{
cout << "listen_sock: " << listen_sock << " 获取新链接成功" << endl;
int pos = 1;
for(; pos < NUM; pos++)
{
if(fd_array[pos].fd == -1)
break;
}
if(pos < NUM)
{
cout << "新链接:" << sock << " 已经被添加到数组[" << pos << "]的位置" << endl;
//将获取上的连接设置到数组中,填好需要关心的事件,这里关心读事件
fd_array[pos].fd = sock;
fd_array[pos].events = POLLIN;
fd_array[pos].revents = 0;
}
else
{
cout << "服务器满载了,关闭新的连接" << endl;
close(sock);
}
}
}
else
{
cout << "sock: " << fd_array[i].fd << " 上面有普通数据的读取" << endl;
char recv_buffer[1024] = {0};
ssize_t s = recv(fd_array[i].fd, recv_buffer, sizeof(recv_buffer) - 1, 0);
if(s > 0)
{
recv_buffer[s] = 0;
cout << "client[" << fd_array[i].fd << "]#" << recv_buffer << endl;
}
else if(s == 0)
{
cout << "sock: " << fd_array[i].fd << "关闭了,client退出了!" << endl;
// 表明关闭了链接
close(fd_array[i].fd);
cout << "已经在数组下标fd_array[" << i << "中,去掉了sock:" << fd_array[i].fd << endl;
fd_array[i].fd = -1;
}
else
{
cout << "读取失败" << endl;
}
}
}
}
break;
}
}
return 0;
}
优点:
不同与select使用三个位图来表示三个fdset的方式,poll使用一个pollfd的指针实现。
缺点:
poll中监听的文件描述符数目增多时
我们可以发现从select到poll是一个提升,那么epoll就是对poll的一个更大的提升。
epoll是Linux特有的I/O复用函数。它在实现和使用上与select和poll有很大的差异。首先,epoll不再是单独使用一个函数来完成任务,而是使用一组函数。其次epoll把用户关心的文件描述符上的事件放在了内核里的一个事件表中。但epoll需要使用一个额外的文件描述符来唯一标识内核中的这个事件表。(这部分内容会在epoll的底层原理中介绍,大概了解即可)
epoll有三个相关的系统调用,分别是epoll_create、epoll_ctl和epoll_wait。
1. 函数原型
//创建一个epoll模型
int epoll_create(int size);
2. 参数介绍
size参数自从Linux2.6.8之后,size参数是被忽略的,但size的值必须设置为大于0的值。size只是给内核一个提示,告诉它事件需要多大。仅仅只是给内核提一个建议,具体多大还是操作系统说了算。
3. 返回值
接下来的函数用来操控内核事件表
1. 函数原型
//向指定的epoll模型中注册事件
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
2. 参数介绍
1. epfd参数就是刚刚epoll_create创建出来的文件描述符(对应着一个内核事件表)
2. fd参数是要操作的文件描述符,op参数则是指定操作类型。 操作类型有如下三种:
3. event参数是指定需要关心的事件,它是epoll_event结构指针类型。epoll_event的定义如下:
struct epoll_event
{
__uint32_t events; /*epoll事件*/
epoll_data_t data; /*用户数据*/
};
其中events成员描述的是事件类型。epoll支持的事件类型和poll基本相同。就是多了一个“E”:
其中data成员用于存储用户数据,其定义如下:
struct union epoll_data
{
void* ptr;
int fd;
uint32_t u32;
uint64_t u64;
}epoll_data_t;
epoll_data_t是一个联合体,其4个成员使用最多的是fd,它指定事件所从属的目标文件描述符。ptr成员可以用来指定与fd相关的用户数据。但是由于epoll_data_t是一个联合体,我们不同使用ptr和fd。如果要将文件描述符和用户数据关联起来,已实现数据快速访问,只能使用其他手段,比如放弃使用epoll_data_t的fd成员,而在ptr指向的用户数据中包含fd。(这里只要大致知道fd和ptr具体的含义即可,后续代码也只是涉及使用fd)
3. 返回值
epoll_ctl成功时返回0,失败则返回-1并设置errno。
1. 函数原型
//用于收集监视的事件中已经就绪的事件
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
2. 参数介绍
1. epfd参数就是刚刚epoll_create创建出来的文件描述符(对应着一个内核事件表)
2. timeout参数的含义和poll接口的timeout相同
3. maxevents参数指定最多监听多少个事件,它必须大于0。
4. events参数:它其实是一个数组。epoll_wait函数如果检测到事件,就将所有就绪的事件从内核事件表(就是epfd所指向的事件表)中复制到它的第二个参数events指向的数组中。这个数组指用于输出epoll_wait检测到的就绪事件,而不像select和poll的数组那样既作用于传入用户注册事件,又用于输出内核检测到的就绪事件。这样极大的提高了应用程序索引就绪文件描述符的效率。
3. 返回值
该函数成功时返回就绪的文件描述符个数,失败则返回-1并设置errno。
首先,我们在创建好监听套接字后,开始调用epoll_create函数,它的返回值也是一个文件描述符(epoll_fd),该文件描述符(epoll_fd)就会对应内核中的事件表,本质就是在内核中创建出一棵红黑树和一个就绪队列。
- 红黑树就是epoll用来存储用户所关心的文件描述符(key值)和所关心的事件(value值)。
- 就绪队列就是用来存储已经发生事件就绪的文件描述符和相应的就绪事件。
紧接着我们继续调用epoll_ctl函数,这个函数是用来将用户所关心的文件描述符添加到刚刚内核所创建出来的红黑树节点当中(图中就是将listen_fd添加到红黑树中,当然所关心的事件也是设置好的,假设就是读事件),这个函数除了进行添加操作,还做了一件很重要的事情,那就是为添加到红黑树节点中的文件描述符都设置了相应的回调函数。
- 回调函数:首先,数据会来自不同的设备,每个设备都会与相应的文件描述符关联起来,也就是通过回调函数参数关联。回调函数的作用在于,当红黑树节点中的文件描述符有事件就绪时,就会通过回调函数,将就绪事件(及相应的fd)添加到就绪队列当中。
然后,读取数据的时候就是从就绪队列当中把数据经由内核缓冲区拷贝到用户缓冲区。
对于监听套接字而言,它accept上来的新连接还需要再次添加到红黑树当中,由epoll关心,用户就通过这样的内核处理方法,就能够获取到每个连接上的数据。
注:底层原理只是示意图,不一定很详细,但是能够说明问题。
epoll的工作流程,在看完底层原理后,也差不多就是epoll的工作流程。但我还是再详细的说一下其编码上的流程。
基本的套接字和之前一样,这里就不多说了。直接来看epoll的代码。
#include
#include
#include
#include
#include
#include
#include "sock.hpp"
#define SIZE 128
#define NUM 64
using namespace std;
static void Usage(string proc)
{
cout << "Usage: " << proc << " port" << endl;
}
// ./epoll_server port
int main(int argc, char* argv[])
{
if(argc != 2)
{
Usage(argv[0]);
exit(1);
}
// 1.建立TCP 监听socket
uint16_t port = (uint16_t)atoi(argv[1]);
int listen_sock = Sock::Socket();
Sock::Bind(listen_sock, port);
Sock::Listen(listen_sock);
// 2.创建epoll模型,获得epfd(文件描述符)
int epfd = epoll_create(SIZE);
// 3.添加listen_sock和它所关心的事件添加到内核
struct epoll_event ev;
ev.events = EPOLLIN;//默认LT模式
//ev.events = EPOLLIN | EPOLLET;//ET模式
ev.data.fd = listen_sock;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_sock, &ev);
// 4.事件循环
volatile bool quit = false;
struct epoll_event revs[NUM];//这个数组,会由内核进行填充,存储就绪事件
while(!quit)
{
int timeout = -1;
// 这里传入的数组,仅仅是尝试从内核中拿回来已经就绪的事件
int n = epoll_wait(epfd, revs, NUM, timeout);
switch(n)
{
case 0:
cout << "time out ..." << endl;
break;
case -1:
cerr << "epoll error ..." << endl;
break;
default:
cout << "有事件就绪了!" << endl;
// 5.处理就绪事件
for(int i = 0; i < n; i++)
{
int sock = revs[i].data.fd;
cout << "文件描述符:[" << sock << "] 上面有事件就绪了" << endl;
if(revs[i].events & EPOLLIN)
{
cout << "文件描述符:[" << sock << "] 有读事件就绪" << endl;
if(sock == listen_sock)
{
cout << "文件描述符:[" << sock << "] 链接事件就绪" << endl;
// 5.1处理链接事件
int fd = Sock::Accept(listen_sock);
if(fd >= 0)
{
cout << "获取新链接成功啦:" << fd << endl;
// 能不能立即读取呢??不能
struct epoll_event _ev;
_ev.events = EPOLLIN;
_ev.data.fd = fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &_ev);//将新的fd交给epoll管理
cout << "已经将" << fd<< "托管给epoll啦" << endl;
}
else
{
cout << "accept failed!" << endl;
}
}
else
{
// 5.2正常的读取处理
cout << "文件描述符:[" << sock << "] 正常事件就绪" << endl;
char buffer[1024];
ssize_t s = recv(sock, buffer, sizeof(buffer) - 1, 0);
if(s > 0)
{
buffer[s] = 0;
cout << "client [" << sock << "]#" << buffer << endl;
}
else if(s == 0)
{
// 对端关闭链接
cout << "client quit " << sock << endl;
close(sock);
epoll_ctl(epfd, EPOLL_CTL_DEL, sock, nullptr);
cout << "sock: " << "delete from epoll success" << endl;
}
else
{
// 读取失败
cout << "read error! " << endl;
close(sock);
epoll_ctl(epfd, EPOLL_CTL_DEL, sock, nullptr);
cout << "sock: " << "delete from epoll success" << endl;
}
}
}
else if(revs[i].events & EPOLLOUT)
{
// 处理写事件
// 写事件相当复杂一下,这部分代码并不会影响整个程序的运行
}
else
{
// 其他
}
}
break;
}
}
close(epfd); //结束后,切记不要忘了close
close(listen_sock);
return 0;
}
优点:
首先,select、poll和epoll的工作模式默认情况下都是LT模式。但是epoll的工作模式有两种,分别为LT模式(水平触发模式)和ET模式(边缘触发模式)。
LT模式和ET模式相比:
ET模式是epoll的高效工作模式,或许你会觉得一直通知的方式,不是更好吗?但是你考虑一个问题,以刚刚的例子来说,小王的方式就是LT模式,他作为快递员,今天什么事都没干,基本上所有的时间都是在通知张三拿快递,他只是通知一个人,但是快递站有很多人的快递;但是小李就是以ET模式工作的,他虽然只通知了一次,但是他一天就可以通知很多人,相比之下,ET的效率更高。
代码中如何体现ET或LT呢?
首先epoll默认是LT模式,我们要将其改为ET模式,只需要将其事件或上一个EPOLLET,在刚才的代码中有体现,可以尝试运行此服务器,测试其效果(我们测试时,只要关心监听套接字就足够了,将其accept函数部分注释掉。然后LT模式下,就会发现,当监听套接字有读事件就绪后,会不断的触发提示,告诉你有事件就绪;ET模式只会通知一次)
使用ET模式的注意点:ET模式下的文件描述符必须设置为非阻塞
首先,epoll在ET模式下,只会通知一次,这样就会倒逼着程序员必须一次性将数据读取完毕。
假设有这样一种场景:假设有320个字节的数据要读取,现在是ET模式,如何才能保证数据被全部读取完呢?只能循环读取。假设你设置读取的字节数是一次性读取100字节,当进程读取第一次时还剩220,第二次还剩120,第三次还剩下20,第四次读取只读到了20个字节,我们可以发现,前面在读取的时候,都能个最大限度的满足读取要求,因此就能继续读取,当要读取100个字节的数据时,只读到了20个字节,就表明没有数据了,因此不会继续读取,这样数据就能够全部读取完了。
但是如果只有300个字节的数据呢?读完第三次后,依然会继续读取,因为上一次读取能读到100个字节,进程认为还有数据会继续recv,但实际上已经没有数据了,文件描述符不设置为非阻塞,recv调用就会阻塞,进而导致进程被挂起。如果有多个这样的进程挂起,后果可想而知。
为了解决这样的问题,就必须将文件描述符设置为非阻塞。
select、poll和epoll三种I/O多路转接的系统调用,都能够同时监听多个文件描述符。他们都由timeout参数指定超时时间,直到有一个或多个文件描述符上有事件发生时返回,返回值就是文件描述符的数量。返回0表示没有事件发生。
相同点: 这3组函数都是通过某种结构体变量来告诉内核关心哪些文件描述符上的哪些事件,并使用该结构体类型的参数来获取内核的处理结果。
不同点: