IO多路转接 ——— select、poll、epoll

I/O多路转接之select

select初识

select是系统提供的一个多路转接接口。

  • select系统调用可以让我们的程序同时监视多个文件描述符的上的事件是否就绪。
  • select的核心工作就是等,当监视的多个文件描述符中有一个或多个事件就绪时,select才会成功返回并将对应文件描述符的就绪事件告知调用者。

select函数

select函数

select函数的函数原型如下:

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

参数说明:

  • nfds:需要监视的文件描述符中,最大的文件描述符值+1。
  • readfds:输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的读事件是否就绪,返回时内核告知用户哪些文件描述符的读事件已经就绪。
  • writefds:输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的写事件是否就绪,返回时内核告知用户哪些文件描述符的写事件已经就绪。
  • exceptfds:输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的异常事件是否就绪,返回时内核告知用户哪些文件描述符的异常事件已经就绪。
  • timeout:输入输出型参数,调用时由用户设置select的等待时间,返回时表示timeout的剩余时间。

参数timeout的取值:

  • NULL/nullptr:select调用后进行阻塞等待,直到被监视的某个文件描述符上的某个事件就绪。
  • 0:selec调用后t进行非阻塞等待,无论被监视的文件描述符上的事件是否就绪,select检测后都会立即返回。
  • 特定的时间值:select调用后在指定的时间内进行阻塞等待,如果被监视的文件描述符上一直没有事件就绪,则在该时间后select进行超时返回。

返回值说明:

  • 如果函数调用成功,则返回有事件就绪的文件描述符个数。
  • 如果timeout时间耗尽,则返回0。
  • 如果函数调用失败,则返回-1,同时错误码会被设置。

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表示的是微秒。
在这里插入图片描述

socket就绪条件

读就绪

  • socket内核中,接收缓冲区中的字节数,大于等于低水位标记SO_RCVLOWAT,此时可以无阻塞的读取该文件描述符,并且返回值大于0。
  • socket TCP通信中,对端关闭连接,此时对该socket读,则返回0。
  • 监听的socket上有新的连接请求。
  • socket上有未处理的错误。

写就绪

  • socket内核中,发送缓冲区中的可用字节数,大于等于低水位标记SO_SNDLOWAT,此时可以无阻塞的写,并且返回值大于0。
  • socket的写操作被关闭(close或者shutdown),对一个写操作被关闭的socket进行写操作,会触发SIGPIPE信号。
  • socket使用非阻塞connect连接成功或失败之后。
  • socket上有未读取的错误。

异常就绪

  • socket上收到带外数据。
注:带外数据和TCP的紧急模式相关,TCP报头当中的URG标志位和16位紧急指针搭配使用,就能够发送/接收带外数据。

select基本工作流程

如果我们要实现一个简单的select服务器,该服务器要做的就是读取客户端发来的数据并进行打印,那么这个select服务器的工作流程应该是这样的:

  • 先初始化服务器,完成套接字的创建、绑定和监听。
  • 定义一个fd_array数组用于保存监听套接字和已经与客户端建立连接的套接字,刚开始时就将监听套接字添加到fd_array数组当中。
  • 然后服务器开始循环调用select函数,检测读事件是否就绪,如果就绪则执行对应的操作。
  • 每次调用select函数之前,都需要定义一个读文件描述符集readfds,并将fd_array当中的文件描述符依次设置进readfds当中,表示让select帮我们监视这些文件描述符的读事件是否就绪。
  • 当select检测到数据就绪时会将读事件就绪的文件描述符设置进readfds当中,此时我们就能够得知哪些文件描述符的读事件就绪了,并对这些文件描述符进行对应的操作。
  • 如果读事件就绪的是监听套接字,则调用accept函数从底层全连接队列获取已经建立好的连接,并将该连接对应的套接字添加到fd_array数组当中。
  • 如果读事件就绪的是与客户端建立连接的套接字,则调用read函数读取客户端发来的数据并进行打印输出。
  • 当然,服务器与客户端建立连接的套接字读事件就绪,也可能是因为客户端将连接关闭了,此时服务器应该调用close关闭该套接字,并将该套接字从fd_array数组当中清除,因为下一次不需要再监视该文件描述符的读事件了。

说明一下:

  • 因为传入select函数的readfds、writefds和exceptfds都是输入输出型参数,当select函数返回时这些参数当中的值已经被修改了,因此每次调用select函数时都需要对其进行重新设置,timeout也是类似的道理。
  • 因为每次调用select函数之前都需要对readfds进行重新设置,所以需要定义一个fd_array数组保存与客户端已经建立的若干连接和监听套接字,实际fd_array数组当中的文件描述符就是需要让select监视读事件的文件描述符。
  • 我们的select服务器只是读取客户端发来的数据,因此只需要让select帮我们监视特定文件描述符的读事件,如果要同时让select帮我们监视特定文件描述符的读事件和写事件,则需要分别定义readfds和writefds,并定义两个数组分别保存需要被监视读事件和写事件的文件描述符,便于每次调用select函数前对readfds和writefds进行重新设置。
  • 服务器刚开始运行时,fd_array数组当中只有监听套接字,因此select第一次调用时只需要监视监听套接字的读事件是否就绪,但每次调用accept获取到新连接后,都会将新连接对应的套接字添加到fd_array当中,因此后续select调用时就需要监视监听套接字和若干连接套接字的读事件是否就绪。
  • 由于调用select时还需要传入被监视的文件描述符中最大文件描述符值+1,因此每次在遍历fd_array对readfds进行重新设置时,还需要记录最大文件描述符值。

这其中还有很多细节,下面我们就来实现这样一个select服务器。

select服务器

Socket类

首先我们可以编写一个Socket类,对套接字相关的接口进行一定程度的封装,为了让外部能够直接调用Socket类当中封装的函数,于是将这些函数定义成了静态成员函数。

代码如下:

#pragma once

#include <iostream>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <arpa/inet.h>
#include <cstring>
#include <cstdlib>

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就行了,所以类当中只包含监听套接字和端口号两个成员变量。

  • 在构造SelectServer对象时,需要指明select服务器的端口号,当然也可以在初始化select服务器的时候指明。
  • 在初始化select服务器的时候调用Socket类当中的函数,依次进行套接字的创建、绑定和监听即可。
  • 在析构函数中可以选择调用close函数将监听套接字进行关闭,但实际也可以不进行该动作,因为服务器运行后一般是不退出的。

代码如下:

#pragma once

#include "socket.hpp"
#include <sys/select.h>

#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函数,当事件就绪时对应执行某种动作即可。

  • 首先,在select服务器开始死循环调用select函数之前,需要先定义一个fd_array数组,先把数组中所有的位置初始化为无效,并将监听套接字添加到该数组当中,fd_array数组当中保存的就是需要被select监视读事件是否就绪的文件描述符。
  • 此后,select服务器就不断调用select函数监视读事件是否就绪,每次调用select函数之前都需要重新设置readfds,具体设置过程就是遍历fd_array数组,将fd_array数组当中的文件描述符添加到readfds当中,并同时记录最大的文件描述符值maxfd,因为后续调用select函数时需要将maxfd+1作为第一个参数传入。
  • 当select函数返回后,如果返回值为0,则说明timeout时间耗尽,此时直接准备进行下一次select调用即可。如果select的返回值为-1,则说明select调用失败,此时也让服务器准备进行下一次select调用,但实际应该进一步判断错误码,根据错误码来判断是否应该继续调用select函数。
  • 如果select的返回值大于0,则说明select函数调用成功,此时已经有文件描述符的读事件就绪,接下来就应该对就绪事件进行处理。

代码如下:

#pragma once

#include "socket.hpp"
#include <sys/select.h>

#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 <string>

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函数,当读事件就绪后就调用该函数进行事件处理。

  • 在进行事件处理时需要遍历fd_array数组当中的文件描述符,依次判断各个文件描述符对应的读事件是否就绪,如果就绪则需要进行事件处理。
  • 当一个文件描述符的读事件就绪后,还需要进一步判断该文件描述符是否是监听套接字,如果是监听套接字的读事件就绪,那么就应该调用accept函数将底层的连接获取上来。但是光光调用accept将连接获取上来还不够,为了下一次调用select函数时能够让select帮我们监视新连接的读事件是否就绪,在连接获取上来后还应该将该连接对应的文件描述符添加到fd_array数组当中,这样在下一次调用select函数前对readfds重新设置时就能将该文件描述符添加进去了。
  • 如果是与客户端建立的连接对应的读事件就绪,那么就应该调用read函数读取客户端发来的数据,如果读取成功则将读到的数据在服务器端进行打印。如果调用read函数读取失败或者客户端关闭了连接,那么select服务器也应该调用close函数关闭对应的连接,但此时光光关闭连接也是不够的,还应该将该连接对应的文件描述符从fd_array数组当中清除,否则后续调用的select函数还会帮我们监视该连接的读事件是否就绪,但实际已经不需要了。

代码如下:

#pragma once

#include "socket.hpp"
#include <sys/select.h>

#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数组已满
	}
};

说明一下:

  • 当调用accept函数从底层获取上来连接后,不能立即调用read函数读取该连接当中的数据,因为此时新连接当中的数据可能并没有就绪,如果直接调用read函数可能需要进行阻塞等待,我们应该将这个等待过程交给select函数来完成,因此在获取完连接后直接将该连接对应的文件描述符添加到fd_array数组当中就行了,当该连接的读事件就绪时select函数会告知我们,那个时候我们再进行数据读取就不会被阻塞住了。
  • 添加文件描述符到fd_array数组当中,本质就是遍历fd_array数组,找到一个没有被使用的位置将该文件描述符添加进去即可。但有可能fd_array数组中全部的位置都已经被占用了,那么文件描述符就会添加失败,此时就只能将刚刚获取上来的连接对应的套接字进行关闭,因为此时服务器已经没有能力处理这个连接了。

select服务器测试

至此select服务器编写完毕,重新编译后运行服务器,并用telnet工具连接我们的服务器,此时通过telnet向服务器发送的数据就能够被服务器读到并且打印输出了。
在这里插入图片描述
此外,虽然当前的select服务器是一个单进程的服务器,但它却可以同时为多个客户端提供服务,根本原因就是因为select函数调用后会告知select服务器是哪个客户端对应的连接事件就绪了,此时select服务器就可以读取对应客户端发来的数据,读取完后又会调用select函数等待某个客户端连接的读事件就绪。
在这里插入图片描述
当服务器检测到客户端退出后,也会关闭对应的连接,并将对应的套接字从fd_array数组当中清除。
在这里插入图片描述

存在的一些问题

当前的select服务器实际还存在一些问题:

  • 服务器没有对客户端发进行响应。select服务器如果要向客户端发送数据,不能直接调用write函数,因为调用write函数时实际也分为“等”和“拷贝”两步,我们也应该将“等”的这个过程交给select函数,因此在每次调用select函数之前,除了需要重新设置readfds还需要重新设置writefds,并且还需要一个数组来保存需要被监视写事件是否就绪的文件描述符,当某一文件描述符的写事件就绪时我们才能够调用write函数向客户端发送数据。
  • 没有定制协议。代码中读取数据时并没有按照某种规则进行读取,此时就可能造成粘包问题,根本原因就是因为我们没有定制协议,比如HTTP协议规定在读取底层数据时读取到空行就表明读完了一个HTTP报头,此时再根据HTTP报头当中的Content-Length属性得知正文的长度,最终就能够读取到一个完整的HTTP报文,HTTP协议通过这种方式就避免了粘包问题。
  • 没有对应的输入输出缓冲区。代码中直接将读取的数据存储到了字符数组buffer当中,这是不严谨的,因为本次数据读取可能并没有读取到一个完整的报文,此时服务器就不能进行数据的分析处理,应该将读取到的数据存储到一个输入缓冲区当中,当读取到一个完整的报文后再让服务器进行处理。此外,如果服务器要能够对客户端进行响应,那么服务器的响应数据也不应该直接调用write函数发送给客户端,应该先存储到一个输出缓冲区当中,因为响应数据可能很庞大,无法一次发送完毕,可能需要进行分批发送。

select的优点

  • 可以同时等待多个文件描述符,并且只负责等待,实际的IO操作由accept、read、write等接口来完成,这些接口在进行IO操作时不会被阻塞。
  • select同时等待多个文件描述符,因此可以将“等”的时间重叠,提高了IO的效率。

当然,这也是所有多路转接接口的优点。

select的缺点

  • 每次调用select,都需要手动设置fd集合,从接口使用角度来说也非常不便。
  • 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大。
  • 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大。
  • select可监控的文件描述符数量太少。

select可监控的文件描述符个数

调用select函数时传入的readfds、writefds以及exceptfds都是fd_set结构的,fd_set结构本质是一个位图,它用每一个比特位来标记一个文件描述符,因此select可监控的文件描述符个数是取决于fd_set类型的比特位个数的。

我们可以通过以下代码来看看fd_set类型有多少个比特位。

#include <iostream>
#include <sys/types.h>

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_DEFAULTNR_OPEN_DEFAULT的值实际就是32。

但并不意味着一个进程最多只能打开32个文件描述符,进程能打开的文件描述符个数实际是可以扩展的,比如我当前使用的云服务器默认就是把进程能打开的文件描述符设置得很高的,通过ulimit -a命令就可以看到进程能打开的文件描述符上限。
在这里插入图片描述
因此select可监控的文件描述符个数太少是一个很大的问题,比如select可监控的文件描述符个数是1024,除去其中的一个监听套接字,那么select服务器最多只能连接1023个客户端。

select的适用场景

多路转接接口select、poll和epoll,需要在一定的场景下使用,如果场景选择的不适宜,可能会适得其反。

  • 多路转接接口一般适用于多连接,且多连接中只有少部分连接比较活跃。因为少量连接比较活跃,也就意味着几乎所有的连接在进行IO操作时,都需要花费大量时间来等待事件就绪,此时使用多路转接接口就可以将这些等的事件进行重叠,提高IO效率。
  • 对于多连接中大部分连接都很活跃的场景,其实并不适合使用多路转接。因为每个连接都很活跃,也就意味着任何时刻每个连接上的事件基本都是就绪的,此时根本不需要动用多路转接接口来帮我们进行等待,毕竟使用多路转接接口也是需要花费系统的时间和空间资源的。

多连接中只有少量连接是比较活跃的,比如聊天工具,我们登录QQ后大部分时间其实是没有聊天的,此时服务器端不可能调用一个read函数阻塞等待读事件就绪。

多连接中大部分连接都很活跃,比如企业当中进行数据备份时,两台服务器之间不断在交互数据,这时的连接是特别活跃的,几乎不需要等的过程,也就没必要使用多路转接接口了。

I/O多路转接之poll

poll初识

poll也是系统提供的一个多路转接接口。

  • poll系统调用也可以让我们的程序同时监视多个文件描述符上的事件是否就绪,和select的定位是一样的,适用场景也是一样的。

poll函数

poll函数

poll函数的函数原型如下:

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

参数说明:

  • fds:一个poll函数监视的结构列表,每一个元素包含三部分内容:文件描述符、监视的事件集合、就绪的事件集合。
  • nfds:表示fds数组的长度。
  • timeout:表示poll函数的超时时间,单位是毫秒(ms)。

参数timeout的取值:

  • -1:poll调用后进行阻塞等待,直到被监视的某个文件描述符上的某个事件就绪。
  • 0:poll调用后进行非阻塞等待,无论被监视的文件描述符上的事件是否就绪,poll检测后都会立即返回。
  • 特定的时间值:poll调用后在指定的时间内进行阻塞等待,如果被监视的文件描述符上一直没有事件就绪,则在该时间后poll进行超时返回。

返回值说明:

  • 如果函数调用成功,则返回有事件就绪的文件描述符个数。
  • 如果timeout时间耗尽,则返回0。
  • 如果函数调用失败,则返回-1,同时错误码会被设置。

poll调用失败时,错误码可能被设置为:

  • EFAULT:fds数组不包含在调用程序的地址空间中。
  • EINTR:此调用被信号所中断。
  • EINVAL:nfds值超过RLIMIT_NOFILE值。
  • ENOMEM:核心内存不足。

struct pollfd结构

struct pollfd结构当中包含三个成员:

  • fd:特定的文件描述符,若设置为负值则忽略events字段并且revents字段返回0。
  • events:需要监视该文件描述符上的哪些事件。
  • revents:poll函数返回时告知用户该文件描述符上的哪些事件已经就绪。

在这里插入图片描述
events和revents的取值:

事件 描述 是否可作为输入 是否可作为输出
POLLIN 数据(包括普通数据和优先数据)可读
POLLRDNORM 普通数据可读
POLLRDBAND 优先级带数据可读(Linux不支持)
POLLPRI 高优先级数据可读,比如TCP带外数据
POLLOUT 数据(包括普通数据和优先数据)可写
POLLWRNORM 普通数据可写
POLLWRBAND 优先级带数据可写
POLLRDHUP TCP连接被对方关闭,或者对方关闭了写操作,它由GNU引入
POLLERR 错误
POLLHUP 挂起。比如管道的写端被关闭后,读端描述符上将收到POLLHUP事件
POLLNVAL 文件描述符没有打开

这些取值实际都是以宏的方式进行定义的,它们的二进制序列当中有且只有一个比特位是1,且为1的比特位是各不相同的。
在这里插入图片描述

  • 因此在调用poll函数之前,可以通过或运算符将要监视的事件添加到events成员当中。
  • 在poll函数返回后,可以通过与运算符检测revents成员中是否包含特定事件,以得知对应文件描述符的特定事件是否就绪。

poll服务器

poll的工作流程和select是基本类似的,这里我们也实现一个简单poll服务器,该服务器也只是读取客户端发来的数据并进行打印。

PollServer类

PollServer类当中也只需要包含监听套接字和端口号两个成员变量,在poll服务器绑定时直接将IP地址设置为INADDR_ANY尽即可。

  • 在构造PollServer对象时,需要指明poll服务器的端口号,当然也可以在初始化poll服务器的时候指明。
  • 在初始化poll服务器的时候调用Socket类当中的函数,依次进行套接字的创建、绑定和监听即可,这里的Socket类和之前实现的一模一样。
  • 在析构函数中可以选择调用close函数将监听套接字进行关闭,但实际也可以不进行该动作,因为服务器运行后一般是不退出的。

代码如下:

#pragma once

#include "socket.hpp"
#include <poll.h>

#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函数,当事件就绪时对应执行某种动作即可。

  • 首先,在poll服务器开始死循环调用poll函数之前,需要定义一个fds数组,该数组当中的每个位置都是一个struct pollfd结构,后续调用poll函数时会作为参数进行传入。先将fds数组当中每个位置初始化为无效,并将监听套接字添加到fds数组当中,表示服务器刚开始运行时只需要监视监听套接字的读事件。
  • 此后,poll服务器就不断调用poll函数监视读事件是否就绪。如果poll函数的返回值大于0,则说明poll函数调用成功,此时已经有文件描述符的读事件就绪,接下来就应该对就绪事件进行处理。如果poll函数的返回值等于0,则说明timeout时间耗尽,此时直接准备进行下一次poll调用即可。如果poll函数的返回值为-1,则说明poll调用失败,此时也让服务器准备进行下一次poll调用,但实际应该进一步判断错误码,根据错误码来判断是否应该继续调用poll函数。

代码如下:

#pragma once

#include "socket.hpp"
#include <poll.h>

#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<<"有事件发生..."<<std::endl;
				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服务器就应该对就绪事件进行处理了,事件处理过程如下:

  • 首先遍历fds数组中的每个struct pollfd结构,如果该结构当中的fd有效,且revents当中包含读事件,则说明该文件描述符的读事件就绪,接下来就需要进一步判断该文件描述符是监听套接字还是与客户端建立的套接字。
  • 如果是监听套接字的读事件就绪,则调用accept函数将底层建立好的连接获取上来,并将获取到的套接字添加到fds数组当中,表示下一次调用poll函数时需要监视该套接字的读事件。
  • 如果是与客户端建立的连接对应的读事件就绪,则调用read函数读取客户端发来的数据,并将读取到的数据在服务器端进行打印。
  • 如果在调用read函数时发现客户端将连接关闭或read函数调用失败,则poll服务器也直接关闭对应的连接,并将该连接对应的文件描述符从fds数组当中清除,表示下一次调用poll函数时无需再监视该套接字的读事件。

代码如下:

#pragma once

#include "socket.hpp"
#include <poll.h>

#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;
	}
};

说明一下:

  • 因为这里将fds数组的大小是固定设置的,因此在将新获取连接对应的文件描述符添加到fds数组时,可能会因为fds数组已满而添加失败,这时poll服务器只能将刚刚获取上来的连接对应的套接字进行关闭。

poll服务器测试

运行poll服务器时也需要先实例化出一个PollServer对象,对poll服务器进行初始化后就可以运行服务器了。

代码如下:

#include "poll_server.hpp"
#include <string>

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数组当中清除。
在这里插入图片描述

poll的优点

  • struct pollfd结构当中包含了events和revents,相当于将select的输入输出型参数进行分离,因此在每次调用poll之前,不需要像select一样重新对参数进行设置。
  • poll可监控的文件描述符数量没有限制。
  • 当然,poll也可以同时等待多个文件描述符,能够提高IO的效率。

说明一下:

  • 虽然代码中将fds数组的元素个数定义为1024,但fds数组的大小是可以继续增大的,poll函数能够帮你监视多少个文件描述符是由传入poll函数的第二个参数决定的。
  • 而fd_set类型只有1024个比特位,因此select函数最多只能监视1024个文件描述符。

poll的缺点

  • 和select函数一样,当poll返回后,需要遍历fds数组来获取就绪的文件描述符。
  • 每次调用poll,都需要把大量的struct pollfd结构从用户态拷贝到内核态,这个开销也会随着poll监视的文件描述符数目的增多而增大。
  • 同时每次调用poll都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大。

I/O多路转接之epoll

epoll初识

epoll也是系统提供的一个多路转接接口。

  • epoll系统调用也可以让我们的程序同时监视多个文件描述符上的事件是否就绪,与select和poll的定位是一样的,适用场景也相同。
  • epoll在命名上比poll多了一个e,这个e可以理解成是extend,epoll就是为了同时处理大量文件描述符而改进的poll。
  • epoll在2.5.44内核中被引进,它几乎具备了select和poll的所有优点,被公认为Linux2.6下性能最好的多路I/O就绪通知方法。

epoll的相关系统调用

epoll有三个相关的系统调用,分别是epoll_create、epoll_ctl和epoll_wait。

epoll_create函数

epoll_create函数用于创建一个epoll模型,该函数的函数原型如下:

int epoll_create(int size);

参数说明:

  • size:自从Linux2.6.8之后,size参数是被忽略的,但size的值必须设置为大于0的值。

返回值说明:

  • epoll模型创建成功返回其对应的文件描述符,否则返回-1,同时错误码会被设置。

注意: 当不再使用时,必须调用close函数关闭epoll模型对应的文件描述符,当所有引用epoll实例的文件描述符都已关闭时,内核将销毁该实例并释放相关资源。

epoll_ctl函数

epoll_ctl函数用于向指定的epoll模型中注册事件,该函数的函数原型如下:

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

参数说明:

  • epfd:指定的epoll模型。
  • op:表示具体的动作,用三个宏来表示。
  • fd:需要监视的文件描述符。
  • event:需要监视该文件描述符上的哪些事件。

第二个参数op的取值有以下三种:

  • EPOLL_CTL_ADD:注册新的文件描述符到指定的epoll模型中。
  • EPOLL_CTL_MOD:修改已经注册的文件描述符的监听事件。
  • EPOLL_CTL_DEL:从epoll模型中删除指定的文件描述符。

返回值说明:

  • 函数调用成功返回0,调用失败返回-1,同时错误码会被设置。

第四个参数对应的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);

参数说明:

  • epfd:指定的epoll模型。
  • events:内核会将已经就绪的事件拷贝到events数组当中(events不能是空指针,内核只负责将就绪事件拷贝到该数组中,不会帮我们在用户态中分配内存)。
  • maxevents:events数组中的元素个数,该值不能大于创建epoll模型时传入的size值。
  • timeout:表示epoll_wait函数的超时时间,单位是毫秒(ms)。

参数timeout的取值:

  • -1:epoll_wait调用后进行阻塞等待,直到被监视的某个文件描述符上的某个事件就绪。
  • 0:epoll_wait调用后进行非阻塞等待,无论被监视的文件描述符上的事件是否就绪,epoll_wait检测后都会立即返回。
  • 特定的时间值:epoll_wait调用后在直到的时间内进行阻塞等待,如果被监视的文件描述符上一直没有事件就绪,则在该时间后epoll_wait进行超时返回。

返回值说明:

  • 如果函数调用成功,则返回有事件就绪的文件描述符个数。
  • 如果timeout时间耗尽,则返回0。
  • 如果函数调用失败,则返回-1,同时错误码会被设置。

epoll_wait调用失败时,错误码可能被设置为:

  • EBADF:传入的epoll模型对应的文件描述符无效。
  • EFAULT:events指向的数组空间无法通过写入权限访问。
  • EINTR:此调用被信号所中断。
  • EINVAL:epfd不是一个epoll模型对应的文件描述符,或传入的maxevents值小于等于0。

epoll工作原理

红黑树和就绪队列

在这里插入图片描述
当某一进程调用epoll_create函数时,Linux内核会创建一个eventpoll结构体,也就是我们所说的epoll模型,eventpoll结构体当中的成员rbr和rdlist与epoll的使用方式密切相关。

struct eventpoll{
    
    
	...
	//红黑树的根节点,这棵树中存储着所有添加到epoll中的需要监视的事件
	struct rb_root rbr;
	//就绪队列中则存放着将要通过epoll_wait返回给用户的满足条件的事件
	struct list_head rdlist;
	...
}
  • epoll模型当中的红黑树本质就是告诉内核,需要监视哪些文件描述符上的哪些事件,调用epll_ctl函数实际就是在对这颗红黑树进行对应的增删改操作。
  • epoll模型当中的就绪队列本质就是告诉内核,哪些文件描述符上的哪些事件已经就绪了,调用epoll_wait函数实际就是在从就绪队列当中获取已经就绪的事件。

在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; //期待发生的事件类型
}
  • 对于epitem结构当中rbn成员来说,ffd与event的含义是,需要监视ffd上的event事件是否就绪。
  • 对于epitem结构当中的rdlink成员来说,ffd与event的含义是,ffd上的event事件已经就绪了。

说明一下:

  • 红黑树是一种二叉搜索树,因此必须有键值key,而这里的文件描述符就天然的可以作为红黑树的key值。
  • 调用epoll_ctl向红黑树当中新增节点时,如果设置了EPOLLONESHOT选项,当监听完这次事件后,如果还需要继续监听该文件描述符则需要重新将其添加到epoll模型中,本质就是当设置了EPOLLONESHOT选项的事件就绪时,操作系统会自动将其从红黑树当中删除。
  • 而如果调用epoll_ctl向红黑树当中新增节点时没有设置EPOLLONESHOT,那么该节点插入红黑树后就会一直存在,除非用户调用epoll_ctl将该节点从红黑树当中删除。

回调机制

所有添加到红黑树当中的事件,都会与设备(网卡)驱动程序建立回调方法,这个回调方法在内核中叫ep_poll_callback。

  • 对于select和poll来说,操作系统在监视多个文件描述符上的事件是否就绪时,需要让操作系统主动对这多个文件描述符进行轮询检测,这一定会增加操作系统的负担。
  • 而对于epoll来说,操作系统不需要主动进行事件的检测,当红黑树中监视的事件就绪时,会自动调用对应的回调方法,将就绪的事件添加到就绪队列当中。
  • 当用户调用epoll_wait函数获取就绪事件时,只需要关注底层就绪队列是否为空,如果不为空则将就绪队列当中的就绪事件拷贝给用户即可。

采用回调机制最大的好处,就是不再需要操作系统主动对就绪事件进行检测了,当事件就绪时会自动调用对应的回调函数进行处理。

说明一下:

  • 只有添加到红黑树当中的事件才会与底层建立回调方法,因此只有当红黑树当中对应的事件就绪时,才会执行对应的回调方法将其添加到就绪队列当中。
  • 当不断有监视的事件就绪时,会不断调用回调方法向就绪队列当中插入节点,而上层也会不断调用epoll_wait函数从就绪队列当中获取节点,这是典型的生产者消费者模型。
  • 由于就绪队列可能会被多个执行流同时访问,因此必须要使用互斥锁对其进行保护,eventpoll结构当中的lock和mtx就是用于保护临界资源的,因此epoll本身是线程安全的。
  • eventpoll结构当中的wq(wait queue)就是等待队列,当多个执行流想要同时访问同一个epoll模型时,就需要在该等待队列下进行等待。

epoll三部曲

总结一下,epoll的使用过程就是三部曲:

  • 调用epoll_create创建一个epoll模型。
  • 调用epoll_ctl,将要监控的文件描述符进行注册。
  • 调用epoll_wait,等待文件描述符就绪。

epoll服务器

为了简单演示一下epoll的使用方式,这里我们也实现一个简单的epoll服务器,该服务器也只是读取客户端发来的数据并进行打印。

EpollServer类

EpollServer类当中除了需要包含监听套接字和端口号两个成员变量之外,最好将epoll模型对应的文件描述符也作为一个成员变量。

  • 在构造EpollServer对象时,需要指明epoll服务器的端口号,当然也可以在初始化epoll服务器的时候指明。
  • 在初始化epoll服务器的时候调用Socket类当中的函数,依次进行套接字的创建、绑定和监听,此外epoll模型的创建可以在服务器初始化的时候进行。
  • 在析构函数中调用close函数,将监听套接字和epoll模型对应的文件描述符进行关闭。

代码如下:

#include "socket.hpp"
#include <sys/epoll.h>

#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函数,从就绪队列当中获取就绪事件进行处理即可。

  • 首先,在epoll服务器开始死循环调用epoll_wait函数之前,需要先调用epoll_ctl将监听套接字添加到epoll模型当中,表示服务器刚开始运行时只需要监视监听套接字的读事件。
  • 此后,epoll服务器就不断调用epoll_wait函数监视读事件是否就绪。如果epoll_wait函数的返回值大于0,则说明已经有文件描述符的读事件就绪,并且此时的返回值代表的就是有事件就绪的文件描述符个数,接下来就应该对就绪事件进行处理。
  • 如果epoll_wait函数的返回值等于0,则说明timeout时间耗尽,此时直接准备进行下一次epoll_wait调用即可。如果epoll_wait函数的返回值为-1,此时也让服务器准备进行下一次epoll_wait调用,但实际应该进一步判断错误码,根据错误码来判断是否应该继续调用epoll_wait函数。

代码如下:

#include "socket.hpp"
#include <sys/epoll.h>

#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<<"有事件发生..."<<std::endl;
				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也会一直通知用户,也就是调用epoll_wait会一直成功返回,并将就绪的事件拷贝到我们传入的数组当中。
  • 需要注意的是,所谓的事件处理并不是调用epoll_wait将底层就绪队列中的就绪事件拷贝到用户层,比如当这里的读事件就绪后,我们应该调用accept获取底层建立好的连接,或调用recv读取客户端发来的数据,这才算是将读事件处理了。
  • 如果我们仅仅是调用epoll_wait将底层就绪队列当中的事件拷贝到应用层,那么这些就绪事件实际并没有被处理掉,底层注册的回调函数会被再次调用,将就绪的事件重新添加到就绪队列当中,本质原因就是我们实际并没有对底层就绪的数据进行读取。

事件处理

如果底层就绪队列当中有就绪事件,那么调用epoll_wait函数时就会将底层就绪队列中的事件拷贝到用户提供的revs数组当中,接下来epoll服务器就应该对就绪事件进行处理了,事件处理过程如下:

  • 根据调用epoll_wait时得到的返回值,来判断操作系统向revs数组中拷贝了多少个struct epoll_event结构,进而对这些文件描述符上的事件进行处理。
  • 对于每一个拷贝上来的struct epoll_event结构,如果该结构当中的events当中包含读事件,则说明该文件描述符对应的读事件就绪,但接下来还需要进一步判断该文件描述符是监听套接字还是与客户端建立的套接字。
  • 如果是监听套接字的读事件就绪,则调用accept函数将底层建立好的连接获取上来,并调用epoll_ctl函数将获取到的套接字添加到epoll模型当中,表示下一次调用epoll_wait函数时需要监视该套接字的读事件。
  • 如果是与客户端建立的连接对应的读事件就绪,则调用recv函数读取客户端发来的数据,并将读取到的数据在服务器端进行打印。
  • 如果在调用recv函数时发现客户端将连接关闭或recv函数调用失败,则epoll服务器也直接关闭对应的连接,并调用epoll_ctl函数将该连接对应的文件描述符从epoll模型中删除,表示下一次调用epoll_wait函数时无需再监视该套接字的读事件。

代码如下:

#include "socket.hpp"
#include <sys/epoll.h>

#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 <string>

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号文件描述符就关闭了。
在这里插入图片描述

epoll的优点

  • 接口使用方便:虽然拆分成了三个函数,但是反而使用起来更方便高效。
  • 数据拷贝轻量:只在新增监视事件的时候调用epoll_ctl将数据从用户拷贝到内核,而select和poll每次都需要重新将需要监视的事件从用户拷贝到内核。此外,调用epoll_wait获取就绪事件时,只会拷贝就绪的事件,不会进行不必要的拷贝操作。
  • 事件回调机制:避免操作系统主动轮询检测事件就绪,而是采用回调函数的方式,将就绪的文件描述符结构加入到就绪队列中。调用epoll_wait时直接访问就绪队列就知道哪些文件描述符已经就绪,检测是否有文件描述符就绪的时间复杂度是 O ( 1 ) O(1) O(1),因为本质只需要判断就绪队列是否为空即可。
  • 没有数量限制:监视的文件描述符数目无上限,只要内存允许,就可以一直向红黑树当中新增节点。

注意:

  • 有人说epoll中使用了内存映射机制,内核可以直接将底层就绪队列通过mmap的方式映射到用户态,此时用户就可以直接读取到内核中就绪队列当中的数据,避免了内存拷贝的额外性能开销。
  • 这种说法是错误的,实际操作系统并没有做任何映射机制,因为操作系统是不相信任何人的,操作系统不会让用户进程直接访问到内核的数据的,用户只能通过系统调用来获取内核的数据。
  • 因此用户要获取内核当中的数据,势必还是需要将内核的数据拷贝到用户空间。

与select和poll的不同之处

  • 在使用select和poll时,都需要借助第三方数组来维护历史上的文件描述符以及需要监视的事件,这个第三方数组是由用户自己维护的,对该数组的增删改操作都需要用户自己来进行。
  • 而使用epoll时,不需要用户自己维护所谓的第三方数组,epoll底层的红黑树就充当了这个第三方数组的功能,并且该红黑树的增删改操作都是由内核维护的,用户只需要调用epoll_ctl让内核对该红黑树进行对应的操作即可。
  • 在使用多路转接接口时,数据流都有两个方向,一个是用户告知内核,一个是内核告知用户。select和poll将这两件事情都交给了同一个函数来完成,而epoll在接口层面上就将这两件事进行了分离,epoll通过调用epoll_ctl完成用户告知内核,通过调用epoll_wait完成内核告知用户。

epoll工作方式

epoll有两种工作方式,分别是水平触发工作模式和边缘触发工作模式。

水平触发(LT,Level Triggered)

  • 只要底层有事件就绪,epoll就会一直通知用户。
  • 就像数字电路当中的高电平触发一样,只要一直处于高电平,则会一直触发。

在这里插入图片描述
epoll默认状态下就是LT工作模式。

  • 由于在LT工作模式下,只要底层有事件就绪就会一直通知用户,因此当epoll检测到底层读事件就绪时,可以不立即进行处理,或者只处理一部分,因为只要底层数据没有处理完,下一次epoll还会通知用户事件就绪。
  • select和poll其实就是工作是LT模式下的。
  • 支持阻塞读写和非阻塞读写。

边缘触发(ET,Edge Triggered)

  • 只有底层就绪事件数量由无到有或由有到多发生变化的时候,epoll才会通知用户。
  • 就像数字电路当中的上升沿触发一样,只有当电平由低变高的那一瞬间才会触发。

在这里插入图片描述
如果要将epoll改为ET工作模式,则需要在添加事件时设置EPOLLET选项。

  • 由于在ET工作模式下,只有底层就绪事件无到有或由有到多发生变化的时候才会通知用户,因此当epoll检测到底层读事件就绪时,必须立即进行处理,而且必须全部处理完毕,因为有可能此后底层再也没有事件就绪,那么epoll就再也不会通知用户进行事件处理,此时没有处理完的数据就相当于丢失了。
  • ET工作模式下epoll通知用户的次数一般比LT少,因此ET的性能一般比LT性能更高,Nginx就是默认采用ET模式使用epoll的。
  • 只支持非阻塞的读写。

ET工作模式下应该如何进行读写

因为在ET工作模式下,只有底层就绪事件无到有或由有到多发生变化的时候才会通知用户,这就倒逼用户当读事件就绪时必须一次性将数据全部读取完毕,当写事件就绪时必须一次性将发送缓冲区写满,否则可能再也没有机会进行读写了。

因此读数据时必须循环调用recv函数进行读取,写数据时必须循环调用send函数进行写入。

  • 当底层读事件就绪时,循环调用recv函数进行读取,直到某次调用recv读取时,实际读取到的字节数小于期望读取的字节数,则说明本次底层数据已经读取完毕了。
  • 但有可能最后一次调用recv读取时,刚好实际读取的字节数和期望读取的字节数相等,但此时底层数据也恰好读取完毕了,如果我们再调用recv函数进行读取,那么recv就会因为底层没有数据而被阻塞住。
  • 而这里的阻塞是非常严重的,就比如我们这里写的服务器都是单进程的服务器,如果recv被阻塞住,并且此后该数据再也不就绪,那么就相当于我们的服务器挂掉了,因此在ET工作模式下循环调用recv函数进行读取时,必须将对应的文件描述符设置为非阻塞状态。
  • 调用send函数写数据时也是同样的道理,需要循环调用send函数进行数据的写入,并且必须将对应的文件描述符设置为非阻塞状态。

强调: ET工作模式下,recv和send操作的文件描述符必须设置为非阻塞状态,这是必须的,不是可选的。

对比LT和ET

  • 在ET模式下,一个文件描述符就绪之后,用户不会反复收到通知,看起来比LT更高效,但如果在LT模式下能够做到每次都将就绪的文件描述符立即全部处理,不让操作系统反复通知用户的话,其实LT和ET的性能也是一样的。
  • 此外,ET的编程难度比LT更高。

猜你喜欢

转载自blog.csdn.net/chenlong_cxy/article/details/126189616