select是系统提供的一个多路转接接口。
select函数
select函数的函数原型如下:
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
参数说明:
参数timeout的取值:
返回值说明:
select调用失败时,错误码可能被设置为:
EBADF
:文件描述符为无效的或该文件已关闭。EINTR
:此调用被信号所中断。EINVAL
:参数nfds为负值。ENOMEM
:核心内存不足。fd_set结构
fd_set结构与sigset_t结构类似,fd_set本质也是一个位图,用位图中对应的位来表示要监视的文件描述符。
调用select函数之前就需要用fd_set结构定义出对应的文件描述符集,然后将需要监视的文件描述符添加到文件描述符集当中,这个添加的过程本质就是在进行位操作,但是这个位操作不需要用户自己进行,系统提供了一组专门的接口,用于对fd_set类型的位图进行各种操作。
如下:
void FD_CLR(int fd, fd_set *set); //用来清除描述词组set中相关fd的位
int FD_ISSET(int fd, fd_set *set); //用来测试描述词组set中相关fd的位是否为真
void FD_SET(int fd, fd_set *set); //用来设置描述词组set中相关fd的位
void FD_ZERO(fd_set *set); //用来清除描述词组set的全部位
timeval结构
传入select函数的最后一个参数timeout,就是一个指向timeval结构的指针,timeval结构用于描述一段时间长度,该结构当中包含两个成员,其中tv_sec表示的是秒,tv_usec表示的是微秒。
读就绪
SO_RCVLOWAT
,此时可以无阻塞的读取该文件描述符,并且返回值大于0。写就绪
SO_SNDLOWAT
,此时可以无阻塞的写,并且返回值大于0。异常就绪
注:带外数据和TCP的紧急模式相关,TCP报头当中的URG标志位和16位紧急指针搭配使用,就能够发送/接收带外数据。
如果我们要实现一个简单的select服务器,该服务器要做的就是读取客户端发来的数据并进行打印,那么这个select服务器的工作流程应该是这样的:
说明一下:
这其中还有很多细节,下面我们就来实现这样一个select服务器。
Socket类
首先我们可以编写一个Socket类,对套接字相关的接口进行一定程度的封装,为了让外部能够直接调用Socket类当中封装的函数,于是将这些函数定义成了静态成员函数。
代码如下:
#pragma once
#include
#include
#include
#include
#include
#include
#include
class Socket{
public:
//创建套接字
static int SocketCreate()
{
int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0){
std::cerr << "socket error" << std::endl;
exit(2);
}
//设置端口复用
int opt = 1;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
return sock;
}
//绑定
static void SocketBind(int sock, int 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;
socklen_t len = sizeof(local);
if (bind(sock, (struct sockaddr*)&local, len) < 0){
std::cerr << "bind error" << std::endl;
exit(3);
}
}
//监听
static void SocketListen(int sock, int backlog)
{
if (listen(sock, backlog) < 0){
std::cerr << "listen error" << std::endl;
exit(4);
}
}
};
SelectServer类
现在编写SelectServer类,因为我当前使用的是云服务器,所以编写的select服务器在绑定时不需要显示绑定IP地址,直接将IP地址设置为INADDR_ANY
就行了,所以类当中只包含监听套接字和端口号两个成员变量。
代码如下:
#pragma once
#include "socket.hpp"
#include
#define BACK_LOG 5
class SelectServer{
private:
int _listen_sock; //监听套接字
int _port; //端口号
public:
SelectServer(int port)
: _port(port)
{}
void InitSelectServer()
{
_listen_sock = Socket::SocketCreate();
Socket::SocketBind(_listen_sock, _port);
Socket::SocketListen(_listen_sock, BACK_LOG);
}
~SelectServer()
{
if (_listen_sock >= 0){
close(_listen_sock);
}
}
};
运行服务器
服务器初始化完毕后就应该周期性的执行某种动作了,而select服务器要做的就是不断调用select函数,当事件就绪时对应执行某种动作即可。
代码如下:
#pragma once
#include "socket.hpp"
#include
#define BACK_LOG 5
#define NUM 1024
#define DFL_FD - 1
class SelectServer{
private:
int _listen_sock; //监听套接字
int _port; //端口号
public:
void Run()
{
fd_set readfds; //读文件描述符集
int fd_array[NUM]; //保存需要被监视读事件是否就绪的文件描述符
ClearFdArray(fd_array, NUM, DFL_FD); //将数组中的所有位置设置为无效
fd_array[0] = _listen_sock; //将监听套接字添加到fd_array数组中的第0个位置
for (;;){
FD_ZERO(&readfds); //清空readfds
//将fd_array数组当中的文件描述符添加到readfds当中,并记录最大的文件描述符
int maxfd = DFL_FD;
for (int i = 0; i < NUM; i++){
if (fd_array[i] == DFL_FD) //跳过无效的位置
continue;
FD_SET(fd_array[i], &readfds); //将有效位置的文件描述符添加到readfds当中
if (fd_array[i] > maxfd) //更新最大文件描述符
maxfd = fd_array[i];
}
switch (select(maxfd + 1, &readfds, nullptr, nullptr, nullptr)){
case 0:
std::cout<<"timeout..."<<std::endl;
break;
case -1:
std::cerr << "select error" << std::endl;
break;
default:
//正常的事件处理
std::cout<<"有事件发生..."<<std::endl;
//HandlerEvent(readfds, fd_array, NUM);
break;
}//end switch
}//end for
}
private:
void ClearFdArray(int fd_array[], int num, int default_fd)
{
for (int i = 0; i < num; i++){
fd_array[i] = default_fd;
}
}
};
说明一下: 为了测试timeout不同取值时的不同效果,当有事件就绪时这里先只打印一句提示语句。
timeout测试
在运行服务器时需要先实例化一个SelectServer类对象,对select服务器进行初始化后就可以调用Run成员函数运行服务器了。
代码如下:
#include "select_server.hpp"
#include
static void Usage(std::string proc)
{
std::cerr << "Usage: " << proc << " port" << std::endl;
}
int main(int argc, char* argv[])
{
if (argc != 2){
Usage(argv[0]);
exit(1);
}
int port = atoi(argv[1]);
SelectServer* svr = new SelectServer(port);
svr->InitSelectServer();
svr->Run();
return 0;
}
由于当前服务器调用select函数时直接将timeout设置为了nullptr,因此select函数调用后会进行阻塞等待。而服务器在第一次调用select函数时只让select监视监听套接字的读事件,所以运行服务器后如果没有客户端发来连接请求,那么读事件就不会就绪,而服务器则会一直在第一次调用的select函数中进行阻塞等待。
当我们借助telnet工具向select服务器发起连接请求后,select函数就会立马检测到监听套接字的读事件就绪,此时select函数便会成功返回,并将我们设置的提示语句进行打印输出,因为当前程序并没有对就绪事件进行处理,此后每次select函数一调用就会检测到读事件就绪并成功返回,因此会看到屏幕不断打印输出提示语句。
如果服务器在调用select函数时将timeout的值设置为0,那么select函数调用后就会进行非阻塞等待,无论被监视的文件描述符上的事件是否就绪,select检测后都会立即返回。
此时如果select监视的文件描述符上有事件就绪,那么select函数的返回值就是大于0的,如果select监视的文件描述符上没有事件就绪,那么select的返回值就是等于0的。
struct timeval timeout = { 0, 0 }; //每隔0秒timeout一次
switch (select(maxfd + 1, &readfds, nullptr, nullptr, &timeout)){
case 0:
std::cout << "timeout..." << std::endl;
break;
case -1:
std::cerr << "select error" << std::endl;
break;
default:
//正常的事件处理
std::cout << "有事件发生..." << std::endl;
//HandlerEvent(readfds, fd_array, NUM);
break;
}
运行服务器后如果没有客户端发来连接请求,那么select服务器就会一直调用select函数进行轮询检测,但每次检测时读事件都不就绪,因此每次select函数的返回值都是0,因此就会不断打印“timeout…”提示语句。
当有客户端发来连接请求后,select在某次轮询检测时就会检测到监听套接字的读事件就绪,此时select函数便会成功返回,并将我们设置的提示语句进行打印输出。
如果服务器在调用select函数时将timeout的值设置为特定的时间值,比如我们这里将timeout的值设置为5秒,那么select函数调用后的5秒内会进行阻塞等待,如果5秒后依旧没有读事件就绪,那么select函数将会进行超时返回。
我们可以将select函数超时返回和成功返回时timeout的值进行打印,以验证timeout是一个输入输出型参数。
struct timeval timeout = { 5, 0 }; //每隔5秒timeout一次
switch (select(maxfd + 1, &readfds, nullptr, nullptr, &timeout)){
case 0:
std::cout << "timeout: " << timeout.tv_sec << std::endl;
break;
case -1:
std::cerr << "select error" << std::endl;
break;
default:
//正常的事件处理
std::cout << "有事件发生... timeout: " << timeout.tv_sec << std::endl;
//HandlerEvent(readfds, fd_array, NUM);
break;
}
运行服务器后如果没有客户端发来连接请求,那么每次select函数调用5秒后都会进行超时返回,并且每次打印输出timeout的值都是0,也就意味着timeout的时间是被耗尽了的。
当有客户端发来连接请求后,在某次调用select函数时就会检测到监听套接字的读事件就绪,此时select函数便会成功返回,并将我们设置的提示语句进行打印输出。
因为当前程序并没有对就绪事件进行处理,因此在第一次select检测到读事件就绪后,之后每次select函数一调用就会检测到读事件就绪并成功返回,因此会看到屏幕不断打印输出提示语句,并且后续打印输出timeout的值都是4,表示本次select检测到读事件就绪时timeout的剩余时间为4秒。
因为timeout和readfds、writefds与exceptfds一样,它们都是输入输出型参数,因此如果要使用timeout参数,那么在每次调用select函数之前也都需要对timeout的值进行重新设置。
事件处理
当select检测到有文件描述符的读事件就绪并成功返回后,接下来就应该对就绪事件进行处理了,这里编写一个HandlerEvent函数,当读事件就绪后就调用该函数进行事件处理。
代码如下:
#pragma once
#include "socket.hpp"
#include
#define BACK_LOG 5
#define NUM 1024
#define DFL_FD - 1
class SelectServer{
private:
int _listen_sock; //监听套接字
int _port; //端口号
public:
void HandlerEvent(const fd_set& readfds, int fd_array[], int num)
{
for (int i = 0; i < num; i++){
if (fd_array[i] == DFL_FD){ //跳过无效的位置
continue;
}
if (fd_array[i] == _listen_sock&&FD_ISSET(fd_array[i], &readfds)){ //连接事件就绪
//获取连接
struct sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);
if (sock < 0){ //获取连接失败
std::cerr << "accept error" << std::endl;
continue;
}
std::string peer_ip = inet_ntoa(peer.sin_addr);
int peer_port = ntohs(peer.sin_port);
std::cout << "get a new link[" << peer_ip << ":" << peer_port << "]" << std::endl;
if (!SetFdArray(fd_array, num, sock)){ //将获取到的套接字添加到fd_array当中
close(sock);
std::cout << "select server is full, close fd: " << sock << std::endl;
}
}
else if (FD_ISSET(fd_array[i], &readfds)){ //读事件就绪
char buffer[1024];
ssize_t size = read(fd_array[i], buffer, sizeof(buffer)-1);
if (size > 0){ //读取成功
buffer[size] = '\0';
std::cout << "echo# " << buffer << std::endl;
}
else if (size == 0){ //对端连接关闭
std::cout << "client quit" << std::endl;
close(fd_array[i]);
fd_array[i] = DFL_FD; //将该文件描述符从fd_array中清除
}
else{
std::cerr << "read error" << std::endl;
close(fd_array[i]);
fd_array[i] = DFL_FD; //将该文件描述符从fd_array中清除
}
}
}
}
private:
bool SetFdArray(int fd_array[], int num, int fd)
{
for (int i = 0; i <num; i++){
if (fd_array[i] == DFL_FD){ //该位置没有被使用
fd_array[i] = fd;
return true;
}
}
return false; //fd_array数组已满
}
};
说明一下:
select服务器测试
至此select服务器编写完毕,重新编译后运行服务器,并用telnet工具连接我们的服务器,此时通过telnet向服务器发送的数据就能够被服务器读到并且打印输出了。
此外,虽然当前的select服务器是一个单进程的服务器,但它却可以同时为多个客户端提供服务,根本原因就是因为select函数调用后会告知select服务器是哪个客户端对应的连接事件就绪了,此时select服务器就可以读取对应客户端发来的数据,读取完后又会调用select函数等待某个客户端连接的读事件就绪。
当服务器检测到客户端退出后,也会关闭对应的连接,并将对应的套接字从fd_array数组当中清除。
存在的一些问题
当前的select服务器实际还存在一些问题:
当然,这也是所有多路转接接口的优点。
select可监控的文件描述符个数
调用select函数时传入的readfds、writefds以及exceptfds都是fd_set结构的,fd_set结构本质是一个位图,它用每一个比特位来标记一个文件描述符,因此select可监控的文件描述符个数是取决于fd_set类型的比特位个数的。
我们可以通过以下代码来看看fd_set类型有多少个比特位。
#include
#include
int main()
{
std::cout << sizeof(fd_set)* 8 << std::endl;
return 0;
}
运行代码后可以看到,其实select可监控的文件描述符个数就是1024个。
因此我们实现的select服务器当中将fd_array数组的大小设置为1024是足够的,因为readfds当中最多就只能添加1024个文件描述符,但不同环境下fd_set的大小可能是不同的,并且fd_set的大小也是可以调整的(涉及重新编译内核),因此之前select服务器当中对NUM的宏定义正确写法应该是这样的。
#define NUM (sizeof(fd_set)*8)
一个进程能打开的文件描述符个数
进程控制块task_struct当中有一个files指针,该指针指向一个struct files_struct结构,进程的文件描述符表fd_array就存储在该结构当中,其中文件描述符表fd_array的大小定义为NR_OPEN_DEFAULT
,NR_OPEN_DEFAULT
的值实际就是32。
但并不意味着一个进程最多只能打开32个文件描述符,进程能打开的文件描述符个数实际是可以扩展的,比如我当前使用的云服务器默认就是把进程能打开的文件描述符设置得很高的,通过ulimit -a
命令就可以看到进程能打开的文件描述符上限。
因此select可监控的文件描述符个数太少是一个很大的问题,比如select可监控的文件描述符个数是1024,除去其中的一个监听套接字,那么select服务器最多只能连接1023个客户端。
多路转接接口select、poll和epoll,需要在一定的场景下使用,如果场景选择的不适宜,可能会适得其反。
多连接中只有少量连接是比较活跃的,比如聊天工具,我们登录QQ后大部分时间其实是没有聊天的,此时服务器端不可能调用一个read函数阻塞等待读事件就绪。
多连接中大部分连接都很活跃,比如企业当中进行数据备份时,两台服务器之间不断在交互数据,这时的连接是特别活跃的,几乎不需要等的过程,也就没必要使用多路转接接口了。
poll也是系统提供的一个多路转接接口。
poll函数
poll函数的函数原型如下:
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
参数说明:
参数timeout的取值:
返回值说明:
poll调用失败时,错误码可能被设置为:
EFAULT
:fds数组不包含在调用程序的地址空间中。EINTR
:此调用被信号所中断。EINVAL
:nfds值超过RLIMIT_NOFILE值。ENOMEM
:核心内存不足。struct pollfd结构
struct pollfd结构当中包含三个成员:
事件 | 描述 | 是否可作为输入 | 是否可作为输出 |
---|---|---|---|
POLLIN | 数据(包括普通数据和优先数据)可读 | 是 | 是 |
POLLRDNORM | 普通数据可读 | 是 | 是 |
POLLRDBAND | 优先级带数据可读(Linux不支持) | 是 | 是 |
POLLPRI | 高优先级数据可读,比如TCP带外数据 | 是 | 是 |
POLLOUT | 数据(包括普通数据和优先数据)可写 | 是 | 是 |
POLLWRNORM | 普通数据可写 | 是 | 是 |
POLLWRBAND | 优先级带数据可写 | 是 | 是 |
POLLRDHUP | TCP连接被对方关闭,或者对方关闭了写操作,它由GNU引入 | 是 | 是 |
POLLERR | 错误 | 否 | 是 |
POLLHUP | 挂起。比如管道的写端被关闭后,读端描述符上将收到POLLHUP事件 | 否 | 是 |
POLLNVAL | 文件描述符没有打开 | 否 | 是 |
这些取值实际都是以宏的方式进行定义的,它们的二进制序列当中有且只有一个比特位是1,且为1的比特位是各不相同的。
poll的工作流程和select是基本类似的,这里我们也实现一个简单poll服务器,该服务器也只是读取客户端发来的数据并进行打印。
PollServer类
PollServer类当中也只需要包含监听套接字和端口号两个成员变量,在poll服务器绑定时直接将IP地址设置为INADDR_ANY尽即可。
代码如下:
#pragma once
#include "socket.hpp"
#include
#define BACK_LOG 5
class PollServer{
private:
int _listen_sock; //监听套接字
int _port; //端口号
public:
PollServer(int port)
: _port(port)
{}
void InitPollServer()
{
_listen_sock = Socket::SocketCreate();
Socket::SocketBind(_listen_sock, _port);
Socket::SocketListen(_listen_sock, BACK_LOG);
}
~PollServer()
{
if (_listen_sock >= 0){
close(_listen_sock);
}
}
};
运行服务器
服务器初始化完毕后就可以开始运行了,而poll服务器要做的就是不断调用poll函数,当事件就绪时对应执行某种动作即可。
代码如下:
#pragma once
#include "socket.hpp"
#include
#define BACK_LOG 5
#define NUM 1024
#define DFL_FD - 1
class PollServer{
private:
int _listen_sock; //监听套接字
int _port; //端口号
public:
void Run()
{
struct pollfd fds[NUM];
ClearPollfds(fds, NUM, DFL_FD); //清空数组中的所有位置
SetPollfds(fds, NUM, _listen_sock); //将监听套接字添加到数组中,并关心其读事件
for (;;){
switch (poll(fds, NUM, -1)){
case 0:
std::cout << "timeout..." << std::endl;
break;
case -1:
std::cerr << "poll error" << std::endl;
break;
default:
//正常的事件处理
//std::cout<<"有事件发生..."<
HandlerEvent(fds, NUM);
break;
}
}
}
private:
void ClearPollfds(struct pollfd fds[], int num, int default_fd)
{
for (int i = 0; i < num; i++){
fds[i].fd = default_fd;
fds[i].events = 0;
fds[i].revents = 0;
}
}
bool SetPollfds(struct pollfd fds[], int num, int fd)
{
for (int i = 0; i < num; i++){
if (fds[i].fd == DFL_FD){ //该位置没有被使用
fds[i].fd = fd;
fds[i].events |= POLLIN; //添加读事件到events当中
return true;
}
}
return false; //fds数组已满
}
};
事件处理
当poll检测到有文件描述符的读事件就绪,就会在其对应的struct pollfd结构中的revents成员中添加读事件并返回,接下来poll服务器就应该对就绪事件进行处理了,事件处理过程如下:
代码如下:
#pragma once
#include "socket.hpp"
#include
#define BACK_LOG 5
#define NUM 1024
#define DFL_FD - 1
class PollServer{
private:
int _listen_sock; //监听套接字
int _port; //端口号
public:
void HandlerEvent(struct pollfd fds[], int num)
{
for (int i = 0; i < num; i++){
if (fds[i].fd == DFL_FD){ //跳过无效的位置
continue;
}
if (fds[i].fd == _listen_sock&&fds[i].revents&POLLIN){ //连接事件就绪
struct sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);
if (sock < 0){ //获取连接失败
std::cerr << "accept error" << std::endl;
continue;
}
std::string peer_ip = inet_ntoa(peer.sin_addr);
int peer_port = ntohs(peer.sin_port);
std::cout << "get a new link[" << peer_ip << ":" << peer_port << "]" << std::endl;
if (!SetPollfds(fds, NUM, sock)){ //将获取到的套接字添加到fds数组中,并关心其读事件
close(sock);
std::cout << "poll server is full, close fd: " << sock << std::endl;
}
}
else if (fds[i].revents&POLLIN){ //读事件就绪
char buffer[1024];
ssize_t size = read(fds[i].fd, buffer, sizeof(buffer)-1);
if (size > 0){ //读取成功
buffer[size] = '\0';
std::cout << "echo# " << buffer << std::endl;
}
else if (size == 0){ //对端连接关闭
std::cout << "client quit" << std::endl;
close(fds[i].fd);
UnSetPollfds(fds, i); //将该文件描述符从fds数组中清除
}
else{
std::cerr << "read error" << std::endl;
close(fds[i].fd);
UnSetPollfds(fds, i); //将该文件描述符从fds数组中清除
}
}
}
}
private:
bool SetPollfds(struct pollfd fds[], int num, int fd)
{
for (int i = 0; i < num; i++){
if (fds[i].fd == DFL_FD){ //该位置没有被使用
fds[i].fd = fd;
fds[i].events |= POLLIN; //添加读事件到events当中
return true;
}
}
return false; //fds数组已满
}
void UnSetPollfds(struct pollfd fds[], int pos)
{
fds[pos].fd = DFL_FD;
fds[pos].events = 0;
fds[pos].revents = 0;
}
};
说明一下:
poll服务器测试
运行poll服务器时也需要先实例化出一个PollServer对象,对poll服务器进行初始化后就可以运行服务器了。
代码如下:
#include "poll_server.hpp"
#include
static void Usage(std::string proc)
{
std::cerr << "Usage: " << proc << " port" << std::endl;
}
int main(int argc, char* argv[])
{
if (argc != 2){
Usage(argv[0]);
exit(1);
}
int port = atoi(argv[1]);
PollServer* svr = new PollServer(port);
svr->InitPollServer();
svr->Run();
return 0;
}
因为我们编写的poll服务器在调用poll函数时,将timeout的值设置成了-1,因此运行服务器后如果没有客户端发来连接请求,那么服务器就会在调用poll函数后进行阻塞等待。
当我们用telnet工具连接poll服务器后,poll服务器调用的poll函数在检测到监听套接字的读事件就绪后就会调用accept获取建立好的连接,并打印输出客户端的IP和端口号,此时客户端发来的数据也能够成功被poll服务器收到并进行打印输出。
此外,poll服务器也是一个单进程服务器,但是它也可以同时为多个客户端提供服务。
当服务器端检测到客户端退出后,也会关闭对应的连接,并将对应的套接字从fds数组当中清除。
说明一下:
epoll也是系统提供的一个多路转接接口。
epoll有三个相关的系统调用,分别是epoll_create、epoll_ctl和epoll_wait。
epoll_create函数
epoll_create函数用于创建一个epoll模型,该函数的函数原型如下:
int epoll_create(int size);
参数说明:
返回值说明:
注意: 当不再使用时,必须调用close函数关闭epoll模型对应的文件描述符,当所有引用epoll实例的文件描述符都已关闭时,内核将销毁该实例并释放相关资源。
epoll_ctl函数
epoll_ctl函数用于向指定的epoll模型中注册事件,该函数的函数原型如下:
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
参数说明:
第二个参数op的取值有以下三种:
EPOLL_CTL_ADD
:注册新的文件描述符到指定的epoll模型中。EPOLL_CTL_MOD
:修改已经注册的文件描述符的监听事件。EPOLL_CTL_DEL
:从epoll模型中删除指定的文件描述符。返回值说明:
第四个参数对应的struct epoll_event结构如下:
struct epoll_event结构中有两个成员,第一个成员events表示的是需要监视的事件,第二个成员data是一个联合体结构,一般选择使用该结构当中的fd,表示需要监听的文件描述符。
events的常用取值如下:
EPOLLIN
:表示对应的文件描述符可以读(包括对端SOCKET正常关闭)。EPOLLOUT
:表示对应的文件描述符可以写。EPOLLPRI
:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)。EPOLLERR
:表示对应的文件描述符发送错误。EPOLLHUP
:表示对应的文件描述符被挂断,即对端将文件描述符关闭了。EPOLLET
:将epoll的工作方式设置为边缘触发(Edge Triggered)模式。EPOLLONESHOT
:只监听一次事件,当监听完这次事件之后,如果还需要继续监听该文件描述符的话,需要重新将该文件描述符添加到epoll模型中。这些取值实际也是以宏的方式进行定义的,它们的二进制序列当中有且只有一个比特位是1,且为1的比特位是各不相同的。
epoll_wait函数
epoll_ctl函数用于收集监视的事件中已经就绪的事件,该函数的函数原型如下:
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
参数说明:
参数timeout的取值:
返回值说明:
epoll_wait调用失败时,错误码可能被设置为:
EBADF
:传入的epoll模型对应的文件描述符无效。EFAULT
:events指向的数组空间无法通过写入权限访问。EINTR
:此调用被信号所中断。EINVAL
:epfd不是一个epoll模型对应的文件描述符,或传入的maxevents值小于等于0。红黑树和就绪队列
当某一进程调用epoll_create函数时,Linux内核会创建一个eventpoll结构体,也就是我们所说的epoll模型,eventpoll结构体当中的成员rbr和rdlist与epoll的使用方式密切相关。
struct eventpoll{
...
//红黑树的根节点,这棵树中存储着所有添加到epoll中的需要监视的事件
struct rb_root rbr;
//就绪队列中则存放着将要通过epoll_wait返回给用户的满足条件的事件
struct list_head rdlist;
...
}
在epoll中,对于每一个事件都会有一个对应的epitem结构体,红黑树和就绪队列当中的节点分别是基于epitem结构中的rbn成员和rdllink成员的,epitem结构当中的成员ffd记录的是指定的文件描述符值,event成员记录的就是该文件描述符对应的事件。
struct epitem{
struct rb_node rbn; //红黑树节点
struct list_head rdllink; //双向链表节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所属的eventpoll对象
struct epoll_event event; //期待发生的事件类型
}
说明一下:
EPOLLONESHOT
选项,当监听完这次事件后,如果还需要继续监听该文件描述符则需要重新将其添加到epoll模型中,本质就是当设置了EPOLLONESHOT
选项的事件就绪时,操作系统会自动将其从红黑树当中删除。EPOLLONESHOT
,那么该节点插入红黑树后就会一直存在,除非用户调用epoll_ctl将该节点从红黑树当中删除。回调机制
所有添加到红黑树当中的事件,都会与设备(网卡)驱动程序建立回调方法,这个回调方法在内核中叫ep_poll_callback。
采用回调机制最大的好处,就是不再需要操作系统主动对就绪事件进行检测了,当事件就绪时会自动调用对应的回调函数进行处理。
说明一下:
epoll三部曲
总结一下,epoll的使用过程就是三部曲:
为了简单演示一下epoll的使用方式,这里我们也实现一个简单的epoll服务器,该服务器也只是读取客户端发来的数据并进行打印。
EpollServer类
EpollServer类当中除了需要包含监听套接字和端口号两个成员变量之外,最好将epoll模型对应的文件描述符也作为一个成员变量。
代码如下:
#include "socket.hpp"
#include
#define BACK_LOG 5
#define SIZE 256
class EpollServer{
private:
int _listen_sock; //监听套接字
int _port; //端口号
int _epfd; //epoll模型
public:
EpollServer(int port)
: _port(port)
{}
void InitEpollServer()
{
_listen_sock = Socket::SocketCreate();
Socket::SocketBind(_listen_sock, _port);
Socket::SocketListen(_listen_sock, BACK_LOG);
//创建epoll模型
_epfd = epoll_create(SIZE);
if (_epfd < 0){
std::cerr << "epoll_create error" << std::endl;
exit(5);
}
}
~EpollServer()
{
if (_listen_sock >= 0){
close(_listen_sock);
}
if (_epfd >= 0){
close(_epfd);
}
}
};
运行服务器
服务器初始化完毕后就可以开始运行了,而epoll服务器要做的就是不断调用epoll_wait函数,从就绪队列当中获取就绪事件进行处理即可。
代码如下:
#include "socket.hpp"
#include
#define BACK_LOG 5
#define SIZE 256
#define MAX_NUM 64
class EpollServer{
private:
int _listen_sock; //监听套接字
int _port; //端口号
int _epfd; //epoll模型
public:
void Run()
{
AddEvent(_listen_sock, EPOLLIN); //将监听套接字添加到epoll模型中,并关心其读事件
for (;;){
struct epoll_event revs[MAX_NUM];
int num = epoll_wait(_epfd, revs, MAX_NUM, -1);
if (num < 0){
std::cerr << "epoll_wait error" << std::endl;
continue;
}
else if (num == 0){
std::cout << "timeout..." << std::endl;
continue;
}
else{
//正常的事件处理
//std::cout<<"有事件发生..."<
HandlerEvent(revs, num);
}
}
}
private:
void AddEvent(int sock, uint32_t event)
{
struct epoll_event ev;
ev.events = event;
ev.data.fd = sock;
epoll_ctl(_epfd, EPOLL_CTL_ADD, sock, &ev);
}
};
说明一下:
事件处理
如果底层就绪队列当中有就绪事件,那么调用epoll_wait函数时就会将底层就绪队列中的事件拷贝到用户提供的revs数组当中,接下来epoll服务器就应该对就绪事件进行处理了,事件处理过程如下:
代码如下:
#include "socket.hpp"
#include
#define BACK_LOG 5
#define SIZE 256
#define MAX_NUM 64
class EpollServer{
private:
int _listen_sock; //监听套接字
int _port; //端口号
int _epfd; //epoll模型
public:
void HandlerEvent(struct epoll_event revs[], int num)
{
for (int i = 0; i < num; i++){
int fd = revs[i].data.fd; //就绪的文件描述符
if (fd == _listen_sock&&revs[i].events&EPOLLIN){ //连接事件就绪
struct sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);
if (sock < 0){ //获取连接失败
std::cerr << "accept error" << std::endl;
continue;
}
std::string peer_ip = inet_ntoa(peer.sin_addr);
int peer_port = ntohs(peer.sin_port);
std::cout << "get a new link[" << peer_ip << ":" << peer_port << "]" << std::endl;
AddEvent(sock, EPOLLIN); //将获取到的套接字添加到epoll模型中,并关心其读事件
}
else if (revs[i].events&EPOLLIN){ //读事件就绪
char buffer[64];
ssize_t size = recv(fd, buffer, sizeof(buffer)-1, 0);
if (size > 0){ //读取成功
buffer[size] = '\0';
std::cout << "echo# " << buffer << std::endl;
}
else if (size == 0){ //对端连接关闭
std::cout << "client quit" << std::endl;
close(fd);
DelEvent(fd); //将文件描述符从epoll模型中删除
}
else{
std::cerr << "recv error" << std::endl;
close(fd);
DelEvent(fd); //将文件描述符从epoll模型中删除
}
}
}
}
private:
void AddEvent(int sock, uint32_t event)
{
struct epoll_event ev;
ev.events = event;
ev.data.fd = sock;
epoll_ctl(_epfd, EPOLL_CTL_ADD, sock, &ev);
}
void DelEvent(int sock)
{
epoll_ctl(_epfd, EPOLL_CTL_DEL, sock, nullptr);
}
};
epoll服务器测试
运行epoll服务器时需要先实例化出一个EpollServer对象,对epoll服务器进行初始化后就可以运行服务器了。
代码如下:
#include "epoll_server.hpp"
#include
static void Usage(std::string proc)
{
std::cout << "Usage: " << proc << " port" << std::endl;
}
int main(int argc, char* argv[])
{
if (argc != 2){
Usage(argv[0]);
exit(1);
}
int port = atoi(argv[1]);
EpollServer* svr = new EpollServer(port);
svr->InitEpollServer();
svr->Run();
return 0;
}
因为编写epoll服务器在调用epoll_wait函数时,我们将timeout的值设置成了-1,因此运行服务器后如果没有客户端发来连接请求,那么服务器就会在调用epoll_wait函数后进行阻塞等待。
当我们用telnet工具连接epoll服务器后,epoll服务器调用的epoll_wait函数在检测到监听套接字的读事件就绪后就会调用accept获取建立好的连接,并打印输出客户端的IP和端口号,此时客户端发来的数据也能够成功被epoll服务器收到并进行打印输出。
此外,我们这里编写的也是一个单进程的epoll服务器,但是它可以同时为多个客户端提供服务。
我们可以用ls /proc/PID/fd
命令,查看当前epoll服务器的文件描述符的使用情况。其中文件描述符0、1、2是默认打开的,分别对应的是标准输入、标准输出和标准错误,3号文件描述符对应的是监听套接字,4号文件描述符对应的是服务器创建的epoll模型,5号和6号文件描述符对应的分别是正在访问服务器的两个客户端。
当服务器端检测到客户端退出后,也会关闭对应的连接,此时epoll服务器对应的5号和6号文件描述符就关闭了。
注意:
与select和poll的不同之处
epoll有两种工作方式,分别是水平触发工作模式和边缘触发工作模式。
水平触发(LT,Level Triggered)
边缘触发(ET,Edge Triggered)
如果要将epoll改为ET工作模式,则需要在添加事件时设置EPOLLET
选项。
ET工作模式下应该如何进行读写
因为在ET工作模式下,只有底层就绪事件无到有或由有到多发生变化的时候才会通知用户,这就倒逼用户当读事件就绪时必须一次性将数据全部读取完毕,当写事件就绪时必须一次性将发送缓冲区写满,否则可能再也没有机会进行读写了。
因此读数据时必须循环调用recv函数进行读取,写数据时必须循环调用send函数进行写入。
强调: ET工作模式下,recv和send操作的文件描述符必须设置为非阻塞状态,这是必须的,不是可选的。