Linux下 多路转接select详解

欢迎来到我的频道!【点击跳转专栏】

本文所有代码已托管至码云:【点此转跳】

文章目录

  • [1. I/O多路转接之select](#1. I/O多路转接之select)
    • [1.1 初识select](#1.1 初识select)
    • [1.2 select函数原型](#1.2 select函数原型)
    • [1.3 文件fd的事件](#1.3 文件fd的事件)
    • [1.4 fd_set(内核级类型)](#1.4 fd_set(内核级类型))
  • [1.4 select 原理](#1.4 select 原理)
  • [2. 使用 select 实现简易服务器](#2. 使用 select 实现简易服务器)
    • [2.1 SelectServer 类成员](#2.1 SelectServer 类成员)
    • [2.2 Main.cc:创建并启动服务器](#2.2 Main.cc:创建并启动服务器)
    • [2.3 SelectServer 初始化](#2.3 SelectServer 初始化)
    • [2.4 Loop():使用 select 等待事件](#2.4 Loop():使用 select 等待事件)
    • [2.5 Dispatcher():分发就绪事件](#2.5 Dispatcher():分发就绪事件)
    • [2.6 Accepter():处理新连接](#2.6 Accepter():处理新连接)
    • [2.7 Recvr():处理客户端数据](#2.7 Recvr():处理客户端数据)
    • [2.8. 整体工作流程](#2.8. 整体工作流程)
    • [2.9 完整代码](#2.9 完整代码)
  • 3.select的优缺点
    • [3.1 优点](#3.1 优点)
    • [3.2 缺点](#3.2 缺点)
    • 3.3总结

1. I/O多路转接之select

1.1 初识select

众所周知,IO = 等 + 拷贝 ,select就是负责里面等的环节,一次可以等待多个fd是否可读、可写。select的核心工作就是:等!目标是:检测fd中的事件是否就绪,即 缓冲区是否有数据!从而解放read、recv、send...函数等待的过程!

系统提供select函数来实现多路复用输入/输出模型.

  • select系统调用是用来让我们的程序监视多个文件描述符的状态变化的;
  • 程序会停在select这里等待,直到被监视的文件描述符有一个或多个发生了状态改变;

1.2 select函数原型

select() 是一种 I/O 多路复用函数,可以同时监视多个文件描述符(包括 socket),等待它们变得可读、可写或发生异常。这样程序就不必为每个连接单独阻塞等待。

c 复制代码
#include <sys/select.h>

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

参数大意:

  • nfds:要检查的最大文件描述符加 1;
  • readfds:关注哪些描述符变得可读;
  • writefds:关注哪些描述符变得可写;
  • exceptfds:关注异常情况;
  • timeout:最多等待多久;传 NULL 表示一直等,传零表示立即检查。
    返回值 表示 就绪的描述符数量 ; 没有事件、超时返回 0,出错返回 -1。调用后,fd_set 会被改成"已就绪"的描述符集合,所以下次调用前通常要重新设置。

参数timeout取值:

  • NULL: 则表示select()没有timeout,select将一直被阻塞,直到某个文件描述符上发生了事件;
  • 0: 仅检测描述符集合的状态,然后立即返回,并不等待外部事件的发生。
  • 特定的时间值: 如果在指定的时间段里没有事件发生,select将超时返回。

struct timeval结构:

timeval结构⽤于描述⼀段时间⻓度,如果在这个时间内,需要监视的描述符没有事件发⽣则函数返回,返回值为0

cpp 复制代码
struct timeval 
{
    long tv_sec;   // 秒数(自 Unix 纪元起)
    long tv_usec;  // 微秒数(0~999999)
};

返回值:

  • 执行成功则返回文件描述词状态已改变的个数
  • 如果返回0代表在描述词状态改变前已超过timeout时间,没有返回
  • 当有错误发生时则返回-1,错误原因存于errno,此时参数readfds,writefds, exceptfds和timeout的值变成不可预测。

错误值可能为:

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

⚠️:fd_set参数后面单独介绍

1.3 文件fd的事件

一直说事件就绪,那么事件到底是啥!

  1. 读事件就绪,即接收缓冲区有数据
  2. 写事件就绪,即发送缓冲区有空间
  3. 异常事件就绪,如 信号中断、内存不足、fd被关闭、传参错误等

关于socket的读就绪

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

1.4 fd_set(内核级类型)

fd_set叫 文件描述符集! 是内核级类型,只要是类型,就一定有它的大小!

大小为128byte!这个文件描述符集,底层其实就是一个位图!128byte,即 1024bit,也就是fd能等待的上限是1024个!


这三个参数,都是输入输出型参数!我们以readfds举例:

  1. 作为输入时: 本质是用户在告诉内核,比特位的位置,标识比特位的标号,比特位的内容(1位是,0位否)标识是否关心对应位置fd的 读事件是否就绪!
  2. 作为输出是: 内核通知用户,哪些fd读就绪了,其中比特位的位置,标识fd的编号,比特位的内容表示 事件是否发生!(1为是,0为否)

就绪描述符,永远是设置的描述符的子集或者相同!也就是你标识了1、2、7,4号描述符就绪了,输出时4号bit位依然是0!


Linux 提供了⼀组操作fd_set的接⼝, 来⽐较⽅便的操作位图.

cpp 复制代码
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的全部位

补充:一些细节问题

  1. 我们一般不会同时关心读写,我们一般比较关心先读后写!
  2. 因为 对任意一个fd来说,读事件一般都是默认不就绪的(一开始fd没有数据),写事件一般是默认就绪的(对于一个任意空fd来说,发送缓冲区一般一直默认有空间)
  3. 也就是说 写事件一般不会常设,而是按需设制!因为只有不就绪的事件才会需要select帮忙!后续我们暂时只关心读事件(对于不关心的事件直接设置成NULL即可!)

1.4 select 原理

select底层是通过采用遍历的方式进行测fd是否就绪的!所以为什么select第一个参数必须要最大fd值+1【比如最大fd是9,那么就必须要遍历到<10,遍历的时候约定俗成左闭右开,懂得都懂】,因为我们都懂,文件描述符本质就是数组下标

遍历的时候每个文件都有个对应的struct file,为了实现一切皆文件,每个文件里面有个函数指针表(ops表),里面有个函数方法:(*poll)();用于检测 一个fd是否就绪!

这个poll(这个poll不会阻塞进程)在网络中就表明检测 你接收队列是否有数据、发送队列是否有空间,或者是全连接队列(accept 队列)等中是否有建立好的链接,它都能检测!

select会先然后根据位图,遍历位图上的每个fd,底层调用poll方法时,当发现有fd就绪时,对应的fd_set位图就置1,反之置0,遍历完结束后,只要有至少一个就绪,select就立刻返回就绪的fd的数量!

如果fd_set位图全部置0,内核会阻塞当前进程 ,将进程置入每一个文件的等待队列中, 也就是所谓的 阻塞住了

等待队列,就是挂在某个内核对象(socket、fd、文件)上的一个链表,放着所有正在阻塞等待这个 fd 事件的进程 / 任务file结构体(其实放在inode、socket里)里面,都自带一个等待队列头 wait_queue_head_t


那么什么时候进程被唤醒呢?

  1. 网卡收到数据包 → 硬件中断
  2. 中断处理函数把数据放到 socket 内核缓冲区
  3. 同时,该函数会遍历这个 socket fd 对应的等待队列 ,把队列上所有等待读事件的进程唤醒(调用wake_up)
  4. 进程唤醒后, 同时把进程从所有 fd 等待队列上摘除,然后再次一轮遍历全部 fd,检查哪些真正就绪,生成结果位图返回给用户

问题:上一轮遍历已经把 fd_set(用户级别的输入输出参数) 清 0 了,阻塞被唤醒之后哪来的 fd 集合继续遍历?

select本质是系统调用,所以肯定要陷入内核,用户态 fd_set 和 内核里保存一份「本次 select 关心哪些 fd」的备份(它属于这次 select 系统调用上下文,不会销毁),清零的是用户态的fd_set,跟我内核里的备份有毛关系!

但是若是轮询式检测: select 每次调用,内核临时拷贝传入的 fd_set,调用结束后内核就丢弃这份拷贝,不会留存

2. 使用 select 实现简易服务器

套接字部分已经封装过了 关于内容感兴趣的可以参考【点击转跳】

2.1 SelectServer 类成员

cpp 复制代码
class SelectServer
{
private:
    uint16_t _port;              // 服务器监听端口

    // 监听 socket,负责接收客户端连接
    std::unique_ptr<Socket> _listensock;

    // 保存监听 socket 和客户端 socket
    // -1 表示当前位置没有使用
    int _rfdset[gfdsum];

    // 控制服务器是否退出事件循环
    bool _quit;
};

各成员作用:

  1. _port: 保存服务器监听端口,例如 8080。
  2. _listensock: 指向监听 socket,主要用于执行 accept(),获取新的客户端连接。
  3. _rfdset: 文件描述符数组,用来保存所有需要监听的 socket; 数组中既有监听 socket,也有客户端 socket。
  4. _quit: 控制服务器是否继续运行, _quit 为 false 时,服务器持续处理事件。
  5. gfdsum: 表示文件描述符数组的最大容量,也就是数组最多可以保存多少个 socket。

2.2 Main.cc:创建并启动服务器

cpp 复制代码
int main(int argc, char* argv[])
{
    uint16_t port = std::stoi(argv[1]);

    // 创建 SelectServer,并完成监听 socket 初始化
    std::unique_ptr<SelectServer> svr =
        std::make_unique<SelectServer>(port);

    // 进入 select 事件循环
    svr->Loop();

    return 0;
}

2.3 SelectServer 初始化

cpp 复制代码
// 创建 TCP 监听 socket,内部依次执行:
// socket()、bind()、listen()
_listensock->BuildTcpSocketMethod(port);

// 初始化文件描述符数组
// -1 表示该位置没有保存 socket
for (int i = 0; i < gfdsum; ++i)
{
    _rfdset[i] = -1;
}

// 保存监听 socket
// 后续 select 会监听它是否有新连接
_rfdset[0] = _listensock->Sockfd();

2.4 Loop():使用 select 等待事件

cpp 复制代码
void Loop()
{
    while (!_quit)
    {
        fd_set rfds;
        FD_ZERO(&rfds);

        int maxfd = -1;

        // 将所有有效 socket 加入读事件集合
        for (int i = 0; i < gfdsum; ++i)
        {
            if (_rfdset[i] == -1)
            {
                continue;
            }

            FD_SET(_rfdset[i], &rfds);

            // select 的第一个参数要求传入最大 fd + 1
            if (maxfd < _rfdset[i])
            {
                maxfd = _rfdset[i];
            }
        }

        // 阻塞等待:
        // 监听 socket 可读,表示有新连接;
        // 客户端 socket 可读,表示有数据或连接断开。
        // nullptr 表示不监听写事件、异常事件和超时。
        int n = select(
            maxfd + 1,
            &rfds,
            nullptr,
            nullptr,
            nullptr
        );

        if (n < 0)
        {
            continue;
        }

        // 判断具体是哪个 socket 就绪
        Dispatcher(rfds);
    }
}

2.5 Dispatcher():分发就绪事件

cpp 复制代码
void Dispatcher(fd_set& rfds)
{
    for (int i = 0; i < gfdsum; ++i)
    {
        if (_rfdset[i] == -1)
        {
            continue;
        }

        // 判断当前 socket 是否在 select 返回的就绪集合中
        if (!FD_ISSET(_rfdset[i], &rfds))
        {
            continue;
        }

        if (_rfdset[i] == _listensock->Sockfd())
        {
            // 监听 socket 就绪,说明有新的客户端连接
            Accepter();
        }
        else
        {
            // 客户端 socket 就绪,读取客户端数据
            Recvr(i);
        }
    }
}

2.6 Accepter():处理新连接

cpp 复制代码
void Accepter()
{
    InetAddr clientaddr;

    // accept() 获取新的客户端 socket
    int sockfd = _listensock->Accepter(clientaddr);

    if (sockfd < 0)
    {
        return;
    }

    // 找到数组中的空闲位置,保存客户端 socket
    for (int i = 0; i < gfdsum; ++i)
    {
        if (_rfdset[i] == -1)
        {
            _rfdset[i] = sockfd;
            break;
        }
    }

    // 下一轮 select 会开始监听这个客户端 socket
}

2.7 Recvr():处理客户端数据

cpp 复制代码
void Recvr(int i)
{
    char buffer[1024];

    // recv() 读取客户端发送的数据
    ssize_t n = recv(
        _rfdset[i],
        buffer,
        sizeof(buffer) - 1,  // 预留一个字节存放 '\0'
        0
    );

    if (n > 0)
    {
        buffer[n] = '\0';

        // 收到数据后,拼接 Echo 响应并发送回客户端
        std::string echo_string = "echo #";
        echo_string += buffer;

        send(
            _rfdset[i],
            echo_string.c_str(),
            echo_string.size(),
            0
        );
    }
    else
    {
        // n == 0:客户端主动断开
        // n < 0:读取发生错误
        // 两种情况都需要关闭并移除 socket
        close(_rfdset[i]);
        _rfdset[i] = -1;
    }
}

select 服务器的核心流程就是:

text 复制代码
创建监听 socket
        ↓
select 等待事件
        ↓
监听 socket 就绪?──是──> accept 新连接
        │
        否
        ↓
recv 读取客户端数据并响应
        ↓
继续 select 等待下一轮事件

2.8. 整体工作流程

text 复制代码
创建监听 socket
        ↓
将监听 socket 加入数组
        ↓
select() 等待事件
        ↓
判断就绪 socket
   ┌────┴────┐
   ↓         ↓
新连接     客户端数据
accept()   recv()
   ↓         ↓
保存 fd    send() 回显

2.9 完整代码

SelectServer.hpp

cpp 复制代码
#pragma once
#include "InetAddr.hpp"
#include "Logger.hpp"
#include "Socket.hpp"
#include <bits/types/struct_timeval.h>
#include <cstddef>
#include <cstdint>
#include <iostream>
#include <memory>
#include <string>
#include <sys/select.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>

using namespace NS_LOG_MODULE;
using namespace NS_SOCKET_MODULE;

static const int gfdsum = sizeof(fd_set) * 8;
static const int gdefaultfd = -1;

class SelectServer
{
  public:
    SelectServer(uint16_t port = 8080)
        : _port(port), _listensock(std::make_unique<TcpSocket>()), _quit(false)
    {
        _listensock->BuildTcpSocketMethod(port);
        LOG(LogLevel::DEBUG)
            << "create listensock success,fd: " << _listensock->Sockfd();

        for (int i = 0; i < gfdsum; ++i)
        {
            _rfdset[i] = gdefaultfd;
        }
        //默认直接把刚开始的fd,直接添加到数组中
        _rfdset[0] = _listensock->Sockfd();
    }

    void Accepter()
    {
        //监听套接字就绪
        LOG(LogLevel::INFO) << "listensockfd event ready!";
        InetAddr clientaddr;
        int sockfd = _listensock->Accepter(clientaddr);
        LOG(LogLevel::INFO)
            << "get a new link,sockfd is :" << sockfd << clientaddr.ToString();
        //此时链接已经获得了,能直接读嘛?不能直接读,而因该托管给select!!如何托管?只要把这个fd添加到辅助数组中去就可以了
        int pos = 0;
        //找第一个空位
        for (; pos < gfdsum; pos++)
        {
            if (_rfdset[pos] == gdefaultfd)
            {
                break;
            }
        }
        if (pos == gfdsum)
        {
            LOG(LogLevel::WARNING) << "server is full!";
            close(sockfd);
        }
        else
        {
            _rfdset[pos] = sockfd;
        }
    }

    void Recvr(int i)
    {
        //普通文件描述服就绪
        char buffer[1024];
        //只限于读这一次不会阻塞
        ssize_t n = recv(_rfdset[i], buffer, sizeof(buffer), 0);
        //此时直接读取不会再造成阻塞了!
        if (n > 0)
        {
            buffer[n] = 0;
            LOG(LogLevel::DEBUG) << "buffer : " << buffer;
            //后面对于报文处理分析粘包问题解决就是自定义协议
            //序列反序列化的工作了 我这为了简化就直接发了
            std::string echo_string = "echo #";
            echo_string += buffer;
            //我们可以直接发送数据吗?一般写事件是默认就绪的,不过没发完一般就是发送缓冲区满了也需要添加select写事件,这里就不写了
            send(_rfdset[i], echo_string.c_str(), echo_string.size(), 0);
        }
        else if (n == 0)
        {
            LOG(LogLevel::INFO) << "client quit: " << _rfdset[i];
            // 0.先关闭fd
            close(_rfdset[i]);
            // 1. 从select中移除fd
            _rfdset[i] = gdefaultfd;
        }
        else
        {
            LOG(LogLevel::WARNING) << "recv error:" << _rfdset[i];
            // 0.先关闭fd
            close(_rfdset[i]);
            // 1. 从select中移除fd
            _rfdset[i] = gdefaultfd;
        }
    }

    void Dispatcher(fd_set &rfds)
    {
        for (int i = 0; i < gfdsum; i++)
        {
            //判断文件描述符是否合法
            if (_rfdset[i] == gdefaultfd)
            {
                continue;
            }
            else
            {
                //判读是否就绪
                if (FD_ISSET(_rfdset[i], &rfds))
                {
                    //判读是不是listen套接字
                    if (_rfdset[i] == _listensock->Sockfd())
                    {
                        //链接管理器
                        Accepter();
                    }
                    else
                    {
                        //IO处理器
                        Recvr(i);
                    }
                    //将来这两个模块可以单独开一个文件写一个类,让业务代码进行分层解耦
                }
            }
        }
    }
    void PrintFd()
    {
        std::cout << "select fd list: ";
        for (int i = 0; i < gfdsum; i++)
        {
            if (_rfdset[i] == gdefaultfd)
            {
                continue;
            }
            std::cout << _rfdset[i] << " ";
        }
        std::cout << std::endl;
    }
    void Loop()
    {
        while (!_quit)
        {
            //一个tcp服务器,最开始的时候,只有一个fd,就是监听socket,随着链接的增多,fd才会增多!
            // listen sockfd,我们直接调用accept = 等待 + 获取链接 ,
            // 所以我们应该把等待的工作,交给select 针对listen套接字
            //我们只关心读事件就绪!
            fd_set rfds; // read fd set
            FD_ZERO(&rfds);
            int maxfd = gdefaultfd;
            // FD_SET(_listensock->Sockfd(), &rfds);
            // struct timeval timeout = {3, 0};
            for (int i = 0; i < gfdsum; i++)
            {
                if (_rfdset[i] == gdefaultfd)
                {
                    continue;
                }
                //一定是一个合法的fd
                // 1.吧fd添加到rfds位图中,重置位图
                FD_SET(_rfdset[i], &rfds);

                // 2.更新最大的fd
                if (maxfd < _rfdset[i])
                {
                    maxfd = _rfdset[i];
                }
            }
            //只负责等待 把这个理解成事件监听器
            int n = select(maxfd + 1, &rfds, NULL, NULL, NULL);
            PrintFd();
            switch (n)
            {
            case 0:
                LOG(LogLevel::INFO) << "time out";
                break;
            case -1:
                LOG(LogLevel::WARNING) << "select error";
                break;
            default:
                LOG(LogLevel::INFO) << "event ready n= " << n;
                //把这个理解成事件派发器
                Dispatcher(rfds); //不仅仅处理新链接,还需要处理普通额IO事件。
                break;
            }
        }
    }
    ~SelectServer()
    {
    }

  private:
    uint16_t _port;
    std::unique_ptr<Socket> _listensock;
    bool _quit;
    // select服务器,往往需要结合一个辅助数组,才能完成工作
    int _rfdset[gfdsum]; //保存所有合法文件fd
};
//这种代码方式称为事件式编程

Main.cc

cpp 复制代码
#include "Logger.hpp"
#include "SelectServer.hpp"
#include <iostream>
#include <memory>
using namespace NS_LOG_MODULE;

int main(int argc, char *argv[])
{
    if (argc!=2)
    {
        std::cout << "Usage:"<<argv[0]<<"port"<<std::endl;
        return 1;
    }
    ENABLE_CONSOLE_LOG_STRATEGY();
    
    uint16_t port =std::stoi(argv[1]);
    
    std::unique_ptr<SelectServer> svr = std::make_unique<SelectServer>(port);
    svr->Loop();
    return 0;
}

3.select的优缺点

3.1 优点

使用单进程就可以同时处理多文件描述符的io的问题, 可以聚合fd,减少等的比重,提高IO效率。

3.2 缺点

  • 每次调用select,都需要手动设置fd集合,从接口使用角度来说也非常不便.
  • 每次调用select,都需要把fd集合从用户态拷贝到内核态,算是一个额外内存开销
  • 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时很大
  • 每次使用select时,在用户层面设置参数和查看参数的时候,用户也要遍历,时间复杂度高
  • select支持的文件描述符数量太小.

3.3总结

select可以提高IO效率,但是因为支持的文件描述符数量太小,增幅是有明显的上限!因为select在内核和用户层面要遍历。即使文件描述符无上限,fd越多其固定遍历周期的时间也就越长!反而有可能会适得其反,将iO效率降低!

问题这么多,那么select还有学习的意义吗?其实select带来的最大意义: 其实是帮我们程序员基于多路转接实现一些就绪事件的通知机制!

相关推荐
无足鸟ICT1 小时前
【RHCA+】RPM包命名规则
linux·运维·服务器
Zucker n2 小时前
一款适合研究生科研工作安排的日历
linux·数据库·python
爱吃香菜的初学者2 小时前
十六.Linux——多线程(2)
linux·运维·开发语言
‎ദ്ദിᵔ.˛.ᵔ₎4 小时前
Linux:对有关 SIGCHLD 的深层理解
linux·运维·服务器
aichitang20244 小时前
QCU 2.0.0:补齐 Windows、Linux 与 GNOME Wayland
linux·运维·windows·ai·agent·ai编程
Lv_沐曦4 小时前
Linux 上的Web文件管理器-filebrowser
linux·运维·服务器
闲云自留地4 小时前
CentOS 搬 Ubuntu 24.04:yum 变 apt、防火墙、SELinux、root 全差异避坑
linux·ubuntu·centos
逐流人5 小时前
Redis内存数据库:从数据类型到集群部署
linux·运维·数据库·redis·缓存·云计算