Linux 多路转接 —— select

目录

    • 传统艺能
    • select
    • fd_set 结构
      • timeval 结构
    • socket 就绪条件
      • 读条件
      • 写就绪
      • 异常就绪
    • select 工作流程
    • select 服务器实现
      • socket 类
      • SelectServer 类
      • 运行服务器
    • 事件处理
    • 一些问题
    • select 优点
    • select 缺点
    • 适用场景

传统艺能

小编是双非本科大二菜鸟不赘述,欢迎米娜桑来指点江山哦
在这里插入图片描述
1319365055

非科班转码社区诚邀您入驻
小伙伴们,满怀希望,所向披靡,打码一路向北
一个人的单打独斗不如一群人的砥砺前行
这是和梦想合伙人组建的社区,诚邀各位有志之士的加入!!
社区用户好文均加精(“标兵”文章字数2000+加精,“达人”文章字数1500+加精)
直达: 社区链接点我


Linux 多路转接 —— select_第1张图片

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 的取值:

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

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

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

EBADF:文件描述符为无效的或该文件已关闭
EINTR:此调用被信号所中断
EINVAL:nfds 参数为负值
ENOMEM:核心内存不足

fd_set 结构

fd_set 结构与 sigset_t 结构类似,fd_set 本质也是一个位图,用位图中对应的位来表示要监视的文件描述符:

在这里插入图片描述

Linux 多路转接 —— select_第2张图片
调用 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 就绪条件

读条件

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

写就绪

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

异常就绪

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

select 工作流程

如果想实现一个 select 服务器,那么我们可以大概知道他的工作流程:

  1. 先初始化服务器,创建套接字,绑定和监听套接字
  2. 定义一个 fd_array 数组来保存监听套接字以及已经和客户端建立链接的套接字,监听套接字一开始就要加入数组
  3. 服务器循环进行 select 监测读事件是否就绪,如果就绪就可以执行对应操作
  4. 在 select 之前还应该创建一个 readfds 文件描述符集,将 fd_array 的文件描述符放入 readfds ,select 就会对这些文件描述符对应的读事件进行监视,在最后就会看到 在 readfds 里面会有一条条的记录
  5. select 检测到读事件就绪就会将其对应的文件描述符放进 readfds ,我们就能知道哪些事件已经就绪
  6. 如果是读事件的监听套接字就绪了,就用 accept 从底层全连接队列获取已建立的连接,并将对应连接的套接字添加到 fd_array 里
  7. 如果是读事件和客户端建立连接的套接字,就调用 read 将读取到的信息打印出来
  8. 如果读事件是和客户端建立连接的套接字就绪,也可能是因为客户端关闭了连接,此时服务器应该调用 close 关闭套接字,并将该套接字从 fd_array 数组中清除,因为下一次不需要再监视该读事件了。

因为传入 select 的 readfds、writefds 和 exceptfds都是输入输出型参数,当 select 返回时这些参数中的值已经修改了,因此每次调用 select 都需要重新设置,timeout 也是同理。

进行重新设置,就需要定义一个 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 服务器实现

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 类

因为当前使用的是云服务器,所以编写的 select 服务器在绑定时不需要显示绑定IP地址,直接将IP地址设置为 INADDR_ANY 就行了,所以类当中只包含监听套接字和端口号两个成员变量。

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

#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 服务器开始死循环调用 select 函数,需要先定义一个 fd_array 数组,先把所有位置初始化为无效,并将监听套接字添加到该数组当中,fd_array 数组中保存的就是需要被select监视读事件是否就绪的文件描述符

此后,服务器就不断调用 select 监视读事件是否就绪,每次调用 select 之前都需要重新设置 readfds,具体设置过程就是遍历 fd_array 数组的文件描述符添加到 readfds 中,并同时记录最大的文件描述符值 maxfd,因为后续调用 select 时需要将maxfd+1作为第一个参数传入。

当 select 函后,如果返回值为0,则说明 timeout 时间耗尽,直接进行下一次 select 调用即可;如果返回值为 -1,则说明 select 调用失败,此时让服务器准备进行下一次 select 调用,但实际应该进一步判断错误码,根据错误码来判断是否应该继续调用 select 。如果 select 返回值大于 0,则说明 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;
		}
	}
};

事件处理

当 select 检测到文件描述符的读事件就绪并成功返回后,就应该对事件进行处理了,这里编写一个 HandlerEvent 函数

在处理时需要遍历 fd_array 数组的文件描述符,依次判断各个文件描述符对应的读事件是否就绪,如果就绪则需要进行事件处理。当一个读事件就绪后,还需要进一步判断该文件描述符是否是监听套接字,如果是监听套接字就应该调用 accept 函数将底层的连接获取上来。但是只调用 accept 将连接获取上来还不够,为了下一次调用 select 时能让 select 帮我们监视新连接的读事件是否就绪,在连接获取上来后还应该将该连接对应的文件描述符添加到 fd_array 数组当中,这样在下一次调用 select 前对 readfds 重新设置时就能将该文件描述符添加进去了。

如果是与客户端建立连接的读事件就绪,那么就应该调用 read 函数读取客户端发来的数据,读取成功则进行打印。如果读取失败或者客户端关闭连接,那么 select 服务器应该调用 close 函数关闭对应连接,但此时只关闭连接也是不够的,还应该将该连接对应的文件描述符从 fd_array 数组当中清除,否则后续调用的 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 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;
				}
			}
		}
	}
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 服务器编写完毕,用 telnet 工具连接我们的服务器,此时通过 telnet 向服务器发送的数据就能够被服务器读到并且打印输出了。
此外,虽然当前 select 服务器是一个单进程的服务器,但它却可以同时为多个客户端提供服务,根本原因就是因为 select 函数调用后会告知 select 服务器哪个客户端对应的连接事件就绪了,此时 select 服务器就可以读取发来的数据,读取完后又会调用 select 等待某个客户端连接的读事件就绪。

一些问题

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

select 服务器如果要向客户端发送数据,不能直接调用write函数,因为调用write函数时实际也分为 “等” 和“拷贝” 两步,我们也应该将 “等” 的这个过程交给 select ,因此在每次调用 select 之前,除了需要重新设置 readfds 还需要重新设置writefds,并且还需要一个数组来保存需要被监视写事件是否就绪的文件描述符,就绪时我们才能够调用 write 向客户端发送数据。

其次没有定制协议。代码中读取数据时并没有按照某种规则进行读取,此时就可能造成粘包问题,根本原因就是因为我们没有定制协议,比如HTTP协议规定在读取底层数据时读取到空行就表明读完了一个HTTP报头,此时再根据HTTP报头当中的 Content-Length 属性得知正文的长度,最终就能够读取到一个完整的HTTP报文。

没有对应的输入输出缓冲区。代码中直接将读取的数据存储到了字符数组 buffer 当中,这是不严谨的,因为本次数据读取可能并没有读取到一个完整的报文,此时服务器就不能进行数据的分析处理,应该将读取到的数据存储到一个输入缓冲区当中,当读取到一个完整的报文后再让服务器进行处理。此外服务器的响应数据也不应该直接调用 write 发送给客户端,应该先存储到一个输出缓冲区当中,因为数据可能很庞大,无法一次发送完毕,可能需要进行分批发送。

select 优点

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

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

select 缺点

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

这里我细嗦一下 select 的文件描述符数量,调用 select 时的 readfds、writefds以及exceptfds 都是fd_set结构,fd_set 结构本质是一个位图,它用每一个比特位来标记一个文件描述符,因此 select 可监控的文件描述符个数是取决于 fd_set 的比特位个数。

我们可以计算一下 fd_size 的比特位大小:

#include 
#include 

int main()
{
	std::cout << sizeof(fd_set)* 8 << std::endl;
	return 0;
}

结果很明显,可监控的文件描述符数量为 1024 个 \color{red} {结果很明显,可监控的文件描述符数量为 1024 个} 结果很明显,可监控的文件描述符数量为1024

Linux 多路转接 —— select_第3张图片

适用场景

多路转接接口的 select,poll epoll 都有自己适用的场景,在不恰当的场合使用某个接口只会适得其反。

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

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

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

你可能感兴趣的:(Linux,C++,linux,服务器,c++,select)