Linux I/O 多路复用实战:Select/Poll 编程指南

前言:本文将详细解析 select 和 poll 系统调用的工作原理与性能瓶颈。由于 epoll 内核机制比较复杂(包含红黑树、就绪队列、回调机制及 LT/ET 模式等),内容量大,将为其单独撰写一篇文章,敬请关注后续更新!

文章目录

  • 一、什么是IO多路复用?
  • 二、select
    • [1. select参数介绍](#1. select参数介绍)
    • [2. select程序编写](#2. select程序编写)
    • [3. select性能总结](#3. select性能总结)
  • 三、poll
    • [1. poll参数介绍](#1. poll参数介绍)
    • [2. poll程序编写](#2. poll程序编写)
    • [3. poll性能总结](#3. poll性能总结)

一、什么是IO多路复用?

IO多路复用的本质是使用一个执行流同时等待多个文件描述符就绪。它解决了阻塞IO中"一个连接需要一个线程"导致的资源消耗过大问题,也解决了非阻塞IO需要不断轮询导致的CPU利用率低的问题。

实现IO多路复用的常用三种方法:select/poll/epoll,接下来我们一一进行学习:

二、select

我们知道IO = 等+拷贝,而select只负责''这个步骤,一次可以等待多个fd,有任意一个或多个fd就绪了告诉用户可以IO了。

select的本质:通过等待多个fd的一种就绪事件通知机制。

  • 什么是可读?底层(比如接收缓冲区)有数据,读事件就绪。
  • 什么是可写?底层(比如发送缓冲区)有空间,写事件就绪。

在默认情况下,接收缓冲区和发送缓冲区都是空的,因此默认情况下,读事件通常不就绪,而写事件通常就绪(因为发送缓冲区有空间)。接下来我们以等待读事件就绪为例子讲解select:

1. select参数介绍

输入以下指令可查看select使用手册:

shell 复制代码
man select

头文件:#include <sys/select.h>(该头文件声明了select系统调用,表明其为内核提供的系统级接口)

select接口:

c 复制代码
int select(int nfds, fd_set *readfds, fd_set *writefds,
	fd_set *exceptfds, struct timeval *timeout);

select参数:

  • nfds:传入所有需要等待的文件描述符中的最大文件描述符加1(内核通过该值确定需遍历的fd范围,避免无效遍历)

  • timeout:这是一个输入输出型参数,struct timeval类型成员如下:

    c 复制代码
    struct timeval {
    int	tv_sec;		/* seconds */
    int	tv_usec;	/* microseconds */
    };
    • tv_sec:表示阻塞等待的秒数
    • tv_usec:表示阻塞等待的微妙数。
    • 最终等待阻塞时间为tv_sec+tv_usec[1](#1)
    • timeout作为输出型参数时表示的是剩余的时间。比如timeout传入的是5秒,而只等了2秒就有文件就绪并进行返回,那么返回的剩余的时间就是3秒。
  • readfds/writefds/exceptfds
    这三个参数都是fd_set类型的输入输出型参数,用法是一样的,这里就以readfds为例进行讲解

    • readfds:只关心读事件。
    • writefds:只关心写事件。
    • exceptfds:只关心异常事件。

select是管理多个描述符的,怎么传入多个描述符?

首先我们需要清楚fd_set类型,这是一个文件描述符集合,是内核提供给用户的数据结构,我们需要向fd_set里添加需要监控的fd ,而fd本质是数组下标(即0,1,2,3...),什么结数据结构可以表示这些信息呢?所以fd_set是位图结构,内存紧凑、操作高效。

fd_set位图是怎么表示某个描述符是否被关心呢?

  • 比特位的编号:从右到左分别表示文件描述符0,1,2,3...
  • 比特位的内容
    • 作为输入型参数:表示该文件描述符是否被关心。(0不关心,1关心)
    • 作为输出型参数:表示该文件描述符是否已就绪。(0不就绪,1就绪)

比如这样一段比特位:0000 1000,作为输入型参数表示3号文件描述符被关心;作为输出型参数表示3号文件描述符已就绪。

细节:

  1. 位图是输入输出型参数,所以位图一定会频繁变更。如果下次还需要关心该描述符,需要我们频繁去修改位图。
  2. fd_set是数据类型,那么它就有固定的大小,也就是可关心的文件描述符是有上限的,上限是多少呢?每个系统内核的值不同,我们使用sizeof(fd_set)*8可以查看,通常是1024。虽然select可关心的描述符有上限,但有的老内核只支持select,select有很好的跨平台性。

select返回值:

  • 大于0:这个值是多少就表示有多少个描述符就绪。
  • 等于0:表示超时,只有当timeout设为非nullptr才会有该情况。
  • 小于0:表示select执行出错,比如有非法描述符等。

2. select程序编写

这里我们仅仅讲解核心代码部分,突出重点,如下:

c 复制代码
class SelectServer
{
public:
    //完成初始化,打开套接字,端口绑定,打开监听...
    void Start()
    {
        while(true)
        {
            //是否进行accept?
        }
    }
    //......
private:
    int _listenfd;
    //......
};

注意这里监听描述符_listenfd也是文件描述符,需要我们用select进行管理,而不是直接accept

服务器在刚启动时,默认只有一个fd,accept本质是阻塞IO。accept是一个IO,只不过不是用来传输数据的,而它关心的是_listenfd的读事件。我们需要将_listenfd添加到select函数中,让select帮我关心读事件就绪。

  • 结论:新连接到来,读事件就绪。

首先定义一个fd_set位图,把_listenfd添加到位图里,然后把该位图作为readfds参数传入select中。注意:我们不能自己使用位操作把_listenfd添加到fd_set位图,而是使用OS提供的相应的接口,如下:

  • void FD_CLR(int fd, fd_set* set):清除指定描述符。
  • int FD_ISSET(int fd, fd_set* set):判断fd是否在fd_set集合里。
  • void FD_SET(int fd, fd_set* set):设置fd到fd_set集合里
  • void FD_ZERO(fd_set* set):清空fd_set集合。

即:

c 复制代码
fd_set rfds;
FD_ZERO(&rfds);
FD_SET(_listenfd, &rfds);

注意:这里没有设置到内核里,只是在用户栈上。

因为在此时只关心_listenfd的读事件,所以select的第一个参数只用填_listenfd+1,writefdsexceptfds部分填nullptr即可。这里我们使用非阻塞模式 ,即timeoutnullptr

示例:

c 复制代码
class SelectServer
{
public:
    //完成初始化(创建套接字、绑定端口、开启监听)...
    //......
    void Start()
    {
        while(true)
        {
            //如果直接用accept会直接阻塞,我们使用select检测_listenfd读事件是否就绪
            //1.定义rfds文件描述符集。
            fd_set rfds;
            FD_ZERO(&rfds);
            FD_SET(_listenfd, &rfds);
        
            //2.执行select,把rfds设置到内核。
            int n = select(_listenfd+1, &rfds, nullptr, nullptr, nullptr);
            
            //3.处理select返回值
            switch(n)
            {
            case -1:
                std::cout<<"select fail"<<std::endl;
                break;
            case 0:
                std::cout<<"time out..."<<std::endl;
                break;
            default:
                std::cout<<"事件就绪..."<<std::endl;
                //处理事件
                //......
                break;
            }
        }
    }
    //......
private:
    int _listenfd;
    //......
};

如上代码如果事件就绪后不进行处理会出现死循环打印 "事件就绪..."

当有事件就绪,需要处理就绪事件,通常调用事件处理函数。比如以上场景我们需要做的就是进行accept,示例:

c 复制代码
//调用事件处理函数:
HandlerEvent()
{
	//调用accpet获取用户fd
}
  • 问题1:这里accept会不会阻塞?不会,因为上层已经告诉我有连接就绪了。
  • 问题2:获取到用户fd能直接读吗?不能,因为如果用户没有发数据,那么程序就会被阻塞在这里,其他用户来访问了也不会去处理。
  • 问题3:用户fd不能直接读,那什么时候读?有数据就绪时再读就不会阻塞。怎么知道它有没有数据就绪?可以通过select。总结:accept获取到的fd需要进行select管理。select管理的fd多起来的原因就是通过拿到新的用户fd。

当select管理的fd越来越多,有会带来新的问题。因为select返回时rfds已经被内核修改,那么下次再设置rfds时怎么历史管理过那些fd呢?所以需要我们把受到管理的fd记录下来,这里就要用到一个辅助数组(其他数据结构也可以),辅助下一次设置rfds。

  • 添加成员int _fd_array[FDSIZE],这里把FDSIZE设为1024
  • 初始化_fd_array:将数组初始化为全-1,然后把_fd_array[0]设置为_listenfd

注意:select第一个参数是被管理的文件描述符中最大值加1,所以需要从_fd_array中取到最大fd。

文件描述符集rfds的填写示例:

c 复制代码
fd_set rfds;
FD_ZERO(&rfds);
int maxfd = -1;//存取最大fd
for (int i = 0; i < FDSIZE; i++)//遍历辅助数组
{
	if (_fd_array[i] != -1)
		FD_SET(_fd_array[i], &rfds);
	maxfd = max(maxfd, _fd_array[i]);//找到最大fd
}

那么我们怎么把新获取到的userfd(accept获取到的用户fd)托管给select呢?

只需要把userfd给辅助数组即可。如下:

  1. 找到_fd_array中的空位置。
  2. 如果没有空位置了(服务器被打满),则关闭userfd;如果有则将空位置设置为userfd

示例:

c 复制代码
for (int i = 0; i < FDSIZE; i++)//遍历辅助数组
{
	if (_fd_array[i] == -1)  //当有空位置时,把userfd添加上
    {
    	_fd_array[i] = userfd;
    	std::cout<<"accept success fd = "<<userfd<<std::endl;
        break;
    }
    else if (i == FDSIZE - 1) //如果不是空位置,而且遍历到底了,则关闭userfd,退出循环
    {
    	std::cout<< "服务器繁忙..."<<std::endl;
        close(userfd);
        break;
    }
}

当select管理的fd变多,我们可以通过返回值知道有多少个fd就绪,但并不知道是那个fd就绪,是读就绪还是写就绪。所以在事件处理函数HandlerEvent中我们还要判断,那些fd就绪?读就绪还是写就绪或者是异常?(这里只考虑读就绪)。

其次不同文件描述符就绪的处理方式不同,比如listenfd读就绪就要进行accept获取userfd,如果是userfd读就绪则需要读取接收缓冲区数据。需要针对不同描述符就绪做不同处理,所以需要我们重新设计HandlerEvent,示例:

c 复制代码
void HandlerEvent(fd_set& rfds/*, fd_set& wfds*/)
{
	for(int i=0; i<FDSIZE; i++)
	{
		//如果_fd_array[i]不合法则continue
		if (_fd_array[i] == -1) continue;
		//接下来判断是否读就绪
		if(FD_ISSET(_fd_array[i], &rfds))
		{
			//能确定读就绪,接下来根据不同的描述符做不同处理。
			if(_fd_array[i] == _listenfd)
			{
				//调用自定义Accept()......
			}
			else
			{
				//调用Read()......
			}
		}
	}
}

注意:

  • Accept中需要完成把新的userfd托管给select的操作。
  • Read 中当判断用户把连接断开后要把对应的userfd从_fd_array中移除(即将_fd_array中值为userfd的位置改为值-1),然后再关闭userfd

注意:调用Read时就证明读就绪了,不会阻塞。但不能在Read循环读,而是只读一次。数据没读完还会触发就绪,会再次调用Read。

到这里程序的核心逻辑就完成了,没有多进程,没有多线程,却能同时处理多个IO请求,做出了多执行流的效果。没有进程/线程切换成本,也没有内核调度成本。

3. select性能总结

特点:

  • 可监控描述符有上限。
  • 需要辅助数组保存文件描述符,两个作用:
    • 在select返回后readfds/writefds/exceptfds作为源借助辅助数组判定fd是否就绪
    • select调用后内核会把原文件描述符集更改为以就绪的文件描述符集,需要借助辅助数组重置文件描述符集。

缺点:

  • 需要各种遍历,select本身也遍历文件描述符表(select第一个参数就是用来确定遍历到那个文件描述符的),所以比较慢。
  • 每次都要对文件描述符集重置,很繁琐。
  • select支持的文件描述符数量有限。

三、poll

poll的作用和效果与select类似,但其接口设计更简单,在某些场景下也更高效。

1. poll参数介绍

输入以下指令可查看poll使用手册:

shell 复制代码
man poll

头文件:#include <poll.h>

poll接口:

c 复制代码
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

poll参数:

  • timeout:和select中的timeout参数的作用相同,这里的timeout做了简化,是int类型,单位是毫秒。
    • -1(小于0):阻塞
    • 等于0:非阻塞
    • 大于0:阻塞timeout毫秒后返回
  • fds一个struct pollfd类型数组的起始地址
  • nfds数组元素个数

poll返回值(同select):

  • 大于0:这个值是多少就表示有多少个描述符就绪。
  • 等于0:表示"超时"或"非阻塞时无就绪事件。
  • 小于0:表示poll执行出错,比如有非法描述符等。

关于struct pollfd类型,成员如下:

c 复制代码
struct pollfd{
	int   fd;
	short events;
	short revents;
}
  • fd:文件描述符
  • events:输入型参数。用位图的思想标记需要关心的该fd的什么事件。
  • revents:输出型参数。内核给用户返回已经就绪的事件。

poll与select最大的区别就是把输入型参数和输出型参数分开了,不用繁琐的重置文件描述符集。

可关心的事件:

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

如上事件的本质是比特位为1的宏(即都是2的次方数),所以可以通过位操作设置到events中。这里我们只用重点关注POLLIN(读事件)POLLOUT(写事件)即可。

poll调用时,fd和events为有效输入,用户通过这两个字段告诉内核需关心该fd上的events事件(使用"|"运算符把事件添加到events中即可)。poll成功返回时fd和revents有效,内核告诉用户哪些fd上面的revents事件就绪(拿着revents使用"&"运算符去匹配事件即可)。

2. poll程序编写

  1. 加头文件#include<poll.h>

  2. 定义数组,这里就用固定大小,即struct pollfd _fds[FDSIZE]FDSIZE设为4096。(也可以使用数组指针动态开辟内存大小)。

  3. 初始化数组(注意fd为-1时内核并不会关心该文件描述符,所以把数组fd字段全初始化为-1),如下:

    c 复制代码
    for(int i=0; i<FDSIZE; i++)
    {
    	_fds[i].fd = -1;
    	_fds[i].events = 0;
    	_fds[i].revents = 0;
    }
    _fds[0].fd = _listenfd;
    _fds[0].events = POLLIN;

Start函数:

c 复制代码
void Start()
{
    while(true)
    {
        int n = poll(&_fds, FDSIZE, 0);
        //处理poll返回值
        switch(n)
        {
        case -1:
            std::cout<<"select fail"<<std::endl;
            break;
        case 0:
            std::cout<<"time out..."<<std::endl;
            break;
        default:
            std::cout<<"事件就绪..."<<std::endl;
            //处理事件
            HandlerEvent();
            //......
            break;
        }
    }
}

事件处理(可在select基础上修改):

c 复制代码
void HandlerEvent()
{
	for(int i=0; i<FDSIZE; i++)
	{
		//如果_fds[i].fd不合法则continue
		if (_fds[i].fd == -1) continue;
		//接下来判断是否读就绪
		if(_fds[i].revents&POLLIN)
		{
			//能确定读就绪,接下来根据不同的描述符做不同处理。
			if(_fds[i].fd == _listenfd)
			{
				//调用Accept()......
			}
			else
			{
				//调用Read()......
			}
		}
	}
}

在Accept中要把新连接userfd托管给poll,只需要把userfd给_fds数组即可。如下:

  1. 找到_fds中的空位置。(即fd为-1的位置)
  2. 如果没有空位置了(服务器被打满),则关闭userfd或给数组扩容;如果有则将空位置fd设置为userfd并设置events。

示例:

c 复制代码
for (int i = 0; i < FDSIZE; i++)
{
	if (_fds[i].fd == -1)
    {
    	_fds[i].fd  = userfd;
    	_fds[i].events = POLLIN;
    	std::cout<<"accept success fd = "<<userfd<<std::endl;
        break;
    }
    else if (i == FDSIZE - 1)
    {
    	std::cout<< "服务器繁忙..."<<std::endl;
        close(userfd);
        break;
    }
}

在Read()中如果用户断开连接需要把userfd关闭,然后从_fds中移除(即把fd设为-1,events和revents设为0)。

3. poll性能总结

解决了select什么问题:

  1. 将输入和输出参数分离,不用在每次poll之前进行文件描述符集重置。
  2. 可管理的fd没有上限(由数组大小决定,无限制)。

缺点:

  1. 和select一样,poll返回后,需要轮询fd来获取就绪的描述符。
  2. 同时连接的大量客户端在一段时间可能很少处于就绪状态(即大量用户活跃度低),因此随着监视描述符数量增长,其效率也会线性下降。

非常感谢您能耐心读完这篇文章。倘若您从中有所收获,还望多多支持呀!


  1. 1 秒 = 10 3 毫秒 1秒=10^3毫秒 1秒=103毫秒, 1 秒 = 10 6 微妙 1秒=10^6微妙 1秒=106微妙 ↩︎
相关推荐
苦学编程的谢6 分钟前
Linux
linux·运维·服务器
G_H_S_3_13 分钟前
【网络运维】Linux 文本处理利器:sed 命令
linux·运维·网络·操作文本
Linux运维技术栈24 分钟前
多系统 Node.js 环境自动化部署脚本:从 Ubuntu 到 CentOS,再到版本自由定制
linux·ubuntu·centos·node.js·自动化
long_run25 分钟前
C++之auto 关键字
c++
拾心2141 分钟前
【运维进阶】Linux 正则表达式
linux·运维·正则表达式
疯狂的代M夫1 小时前
C++对象的内存布局
开发语言·c++
Gss7772 小时前
源代码编译安装lamp
linux·运维·服务器
444A4E2 小时前
深入理解Linux进程管理:从创建到替换的完整指南
linux·c语言·操作系统
重启的码农2 小时前
llama.cpp 分布式推理介绍(4) RPC 服务器 (rpc_server)
c++·人工智能·神经网络
重启的码农2 小时前
llama.cpp 分布式推理介绍(3) 远程过程调用后端 (RPC Backend)
c++·人工智能·神经网络