欢迎光临
我们一直在努力

【Linux】从零开始使用多路转接IO --- select

在这里插入图片描述

碌碌无为,则余生太长;

欲有所为,则人生苦短。

— 中岛敦 《山月记》—


从零开始认识五种IO模型

  • 1 前言
  • 2 认识多路转接select
  • 3 多路转接select等待连接
  • 4 完善代码
  • 5 总结

1 前言

上一篇文章我们讲解了五种IO模型的基本概念,并通过系统调用使用了非阻塞IO。 一般的服务器不会使用非阻塞IO,因为非阻塞IO非常耗费CPU资源,导致CPU发热效率下降!非阻塞IO只有在特定情况下才比较好用!

今天我们来学习多路转接select。

我们知道IO = 等 + 拷贝。拷贝的前提是底层有数据,没有数据的时候就需要进行等待。为了提高效率可以等待多个文件描述符。多路转接就是等待文件描述符上的新事件,等到就可以通知程序员事件已经就绪,可以进行拷贝!

这个事件可以是:

  • 读事件就绪:OS底层有数据了
  • 写事件就绪:OS底层有空间了

今天我们要学习的就是多路转接select

2 认识多路转接select

我们先来看其作用与定位:

  • select的定位是:只在IO中只负责等待,不进行拷贝! 并且select可以等待多个文件描述符,有新事件就进行通知。

来看select系统调用:

SELECT(2) Linux Programmer's Manual SELECT(2)

NAME
select, pselect, FD_CLR, FD_ISSET, FD_SET, FD_ZERO synchronous I/O multiplexing

SYNOPSIS
#include <sys/select.h>

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

void FD_CLR(int fd, fd_set *set);
int FD_ISSET(int fd, fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);

int pselect(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, const struct timespec *timeout,
const sigset_t *sigmask);

Feature Test Macro Requirements for glibc (see feature_test_macros(7)):

pselect(): _POSIX_C_SOURCE >= 200112L

select函数中有5个参数,都是用来干什么的呢?

  • int nfds:输入性参数 ,表示等待的多个文件描述符最大值 加 1。比如等待1 2 5 6 99 这几个文件描述符,那么就要传入100。注意不是文件描述符的个数!
  • struct timeval *timeout:输入输出性参数 ,这是一个结构体表示微秒级别的时间戳,其中有两个参数分别表示秒和微秒。这个参数告诉select在这个时间戳内进行阻塞式select,超出时间就进行一次返回。如果时间以内等到了新事件,就返回,并把剩余时间返回。传入{0,0}就是非阻塞轮询了。传入nullptr表示一直阻塞等待事件。 在这里插入图片描述
  • 那么现在我们知道了两个参数,我们探索一下返回值:

    • 大于0:有几个就绪了
    • 等于0:超时返回了
    • 小于0:select出错了

    那么其他三个参数呢?首先fd_set代表文件描述符集,是用位图进行维护的!位图下标表示文件描述符,该比特位的内容表示对应信息!一共1024比特位,可以表示1024个文件描述符,下面我们就来了解一下这三个参数: 这三个参数都是fd_set,是输入输出参数,分别对应读事件,写事件,异常事件。通过这三个位图的设置,我们就可以对一个文件描述符的操作指明清楚。今天我们以读事件为例进行讲解:

    • 输入时:传入一个读事件文件描述符,就是告诉OS要帮我们关心fd_set集合中的所有fd的读事件。这里比特位的位置表示文件描述符的编号,比特位的内容表示是否关心fd的读事件!
    • 输出时:OS会返回一个读事件文件描述符,表示你让我关心的文件描述符集中哪些已经就绪了!这里比特位的位置表示文件描述符的编号,比特位的内容表示事件是否发生!

    OK,现在我们了解了select的基本参数,下面我们就开始使用select进行编程

    3 多路转接select等待连接

    我们首先把之前的套接字基础的类拷贝过来:

  • class Socket:实现套接字的创建工作,并进入监听模式。
  • class InetAddr:网络套接字基本信息类,用于进行网络套接字传参工作。
  • class Log:进行日志信息的打印,便于调试
  • 然后我们就来设计Selectsever类:

    • 成员变量需要端口号,TcpSocket套接字类
    • 构造函数中进行端口号的初始化,并创建套接字,设置为监听模式
    • 循环函数中不能直接进行accept获取连接,因为底层不一定有数据,直接进行会阻塞式等待。所以我们可以把accept看做IO函数,将等的任务交给select函数。
    • select函数需要对监听套接字进行等待

    #pragma once

    #include "Socket.hpp"
    #include <sys/select.h>
    #include "Log.hpp"

    using namespace socket_ns;
    using namespace log_ns;

    class SelectServer
    {
    public:
    SelectServer(uint16_t port) : _port(port),_listensock(std::make_unique<TcpSocket>())
    {
    // 建立监听套接字
    _listensock->BuildListenSocket(_port);
    }
    ~SelectServer()
    {};
    void Initserver()
    {

    }
    void Loop()
    {
    //进入服务
    while(true)
    {
    //不能直接进行accept 因为底层不一定建立了连接,所以需要等待底层就绪
    //等待过程交给select
    //_listensock->Accepter();

    //创建fd_set
    fd_set rfds ;
    FD_ZERO(&rfds);
    //加入监听套接字文件描述符
    FD_SET( _listensock->GetSockfd() , &rfds);
    //创建timeout
    struct timeval timeout = {3 , 0};
    //进行select
    int n = ::select(_listensock->GetSockfd() + 1 , &rfds , nullptr , nullptr , &timeout);

    switch (n)
    {
    case 0:
    //超时
    LOG(DEBUG , "timeout : %d.%d\\n" , timeout.tv_sec , timeout.tv_usec);
    break;
    case 1:
    //出错了
    LOG(ERROR, "select error\\n");
    break;
    default:
    //正常
    LOG(INFO, "have event ready: n = %d\\n" , n);
    //执行任务
    HandlerEvent(rfds);
    break;
    }

    }
    }

    private:
    uint16_t _port;
    std::unique_ptr<Socket> _listensock;
    };

    我们运行程序来看等待效果: 在这里插入图片描述 可以正常的进行等待,当我们进行连接时: 在这里插入图片描述 select函数就能告诉我们有哪些文件描述符就绪,可以进行拷贝。这里可以得到一个现象:

    • 如果事件就绪,但是不处理,select就会一直通知我们,直到我们处理这个事件。

    当我们知道底层就绪时,我们就可以进行"拷贝"了:

    void HandlerEvent(fd_set& rfds)
    {
    //判断是否是套接字就绪
    if(FD_ISSET(_listensock->GetSockfd() , &rfds))
    {
    //连接事件就绪
    //那么这里我们可以进行accept吗?
    InetAddr addr;
    int sockfd = _listensock->Accepter(&addr);//已经就绪 ,不会阻塞
    //这时会得到一个新连接
    if(sockfd > 0)
    {
    LOG(DEBUG ,"get a new link , client info %s:%d\\n" , addr.Ip().c_str() ,addr.Port());
    //TODO
    }
    else
    {
    return ;
    }
    }
    }

    但是有几个问题:

  • 在上面的handler函数中,我们已经获取到了连接,那么下面敢不敢直接进行读取呢? 当然不能,因为建立连接并不代表会有请求传过来!所以还需要等待请求!
  • 那么怎么知道底层有没有就绪呢? 还是通过select进行等待,想办法将新的fd添加给select,进行统一管理!
  • 那么这样select等待的fd不就越来越多,这要怎么进行维护呢? 通过辅助数据结构进行维护!由于select接口的参数是输入输出性,无法保存文件描述符,所以必然需要额外的数据结构进行维护文件描述符!
  • 4 完善代码

    针对上面的三个问题,我们首先要做的就是想办法通过一个数据结构维护需要进行select的文件描述符。每次进入循环进行select时,就要通过这个数据结构初始化rfds!然后在通过对返回值的rfds与辅助数据结构中的文件描述符进行比对,对有新事件的文件描述符进行处理!

    对于这个数据结构我们选择最简单的一维C风格数组即可!进行初始化时都设置为默认值-1

    const static int gnum = sizeof(fd_set) * 8;
    const static int gdefault = 1;
    //…
    void Initserver()
    {
    // 对数组进行初始化
    for (int i = 0; i < gnum; i++)
    {
    fd_array[i] = gdefault;
    }
    // 加入监听套接字
    fd_array[0] = _listensock->GetSockfd();
    }
    //…
    // 辅助数组
    int fd_array[gnum];

    通过这个数组,当我们进行循环时,每次就都需要通过这个数组进行初始化rfds。

    void Loop()
    {
    // 进入服务
    while (true)
    {
    // 创建fd_set
    fd_set rfds;
    FD_ZERO(&rfds);
    int max_fd = 0;
    // 首先根据fd_array将合法fd加入到rfds
    for (int i = 0; i < gnum; i++)
    {
    if (fd_array[i] == gdefault)
    continue;
    // 加入合法的文件描述符
    FD_SET(fd_array[i], &rfds);
    // 维护一个文件描述符最大值
    if (fd_array[i] > max_fd)
    max_fd = fd_array[i];
    }
    // 创建timeout
    struct timeval timeout = {30, 0};
    // 进行select
    int n = ::select(max_fd + 1, &rfds, nullptr, nullptr, &timeout);
    switch (n)
    {
    case 0:
    // 超时
    LOG(DEBUG, "timeout : %d.%d\\n", timeout.tv_sec, timeout.tv_usec);
    break;
    case 1:
    // 出错了
    LOG(ERROR, "select error\\n");
    break;
    default:
    // 正常
    LOG(INFO, "have event ready: n = %d\\n", n);
    // 处理事件
    HandlerEvent(rfds);
    PrintDebug();
    break;
    }
    }
    }

    接下来我们来看handlerevent函数,进行select之后,如果有事件就绪,程序就会进入handlerevent函数。那么我们要如何判断是哪一个文件操作符的事件就绪了呢?

    • 直接遍历数组,进行FD_ISSET,通过对每一个合法fd进行判断,我们就能够知道是哪一个文件操作符有事件就绪!
    • 如果是listenfd就绪,说明有新连接,需要进行accepter获取新连接的fd,将其存入到文件描述符数组中!
    • 如果是普通fd就绪,我们进行读写操作即可,如果有连接退出了,要及时更新数组。

    void Accepter()
    {
    // 连接事件就绪
    InetAddr addr;
    int sockfd = _listensock->Accepter(&addr); // 已经就绪 ,不会阻塞
    // 这时会得到一个新连接
    if (sockfd > 0)
    {
    LOG(DEBUG, "get a new link , client info %s:%d\\n", addr.Ip().c_str(), addr.Port());
    // 将新获取的fd加入到数组中
    LOG(INFO, "get new fd :%d\\n", sockfd);
    bool flag = false;
    for (int i = 0; i < gnum; i++)
    {
    if (fd_array[i] == gdefault)
    {
    flag = true;
    fd_array[i] = sockfd;
    break;
    }
    else
    continue;
    }
    if (flag == false)
    {
    LOG(WARNING, "fd_array have fill!\\n");
    }
    }
    }
    void HandlerIO(int &fd)
    {
    char buffer[1024];
    int n = ::recv(fd, buffer, sizeof(buffer) 1, 0);
    if (n > 0)
    {
    // 读取到了数据
    buffer[n] = 0;
    std::string echo_str = "[client say]#";
    echo_str += buffer;
    std::cout << echo_str << std::endl;
    // 返回一个报文
    std::string content = "<html><body><h1>hello bite</h1></body></html>";
    std::string ret_str = "HTTP/1.0 200 OK\\r\\n";
    ret_str += "Content-Type: text/html\\r\\n";
    ret_str += "Content-Length: " + std::to_string(content.size()) + "\\r\\n\\r\\n";
    ret_str += content;
    // echo_str += buffer;
    ::send(fd, ret_str.c_str(), ret_str.size(), 0); // 临时方案
    }
    else if (n == 0)
    {
    // 此时fd退出了
    LOG(INFO, "fd:%d quit!\\n", fd);
    ::close(fd);
    fd = gdefault;
    }
    else
    {
    LOG(ERROR, "recv error! errno:%d\\n", errno);
    ::close(fd);
    fd = gdefault;
    }
    }
    void HandlerEvent(fd_set &rfds)
    {
    // 遍历fd_array判断是否有就绪的新事件
    for (int i = 0; i < gnum; i++)
    {
    if (fd_array[i] == gdefault)
    continue;
    // 如果有新事件
    if (FD_ISSET(fd_array[i], &rfds))
    {
    // 进行判断是scokfd 还是普通fd
    if (fd_array[i] == _listensock->GetSockfd())
    {
    Accepter();
    }
    // 普通fd 进行正常读写
    else
    {
    HandlerIO(fd_array[i]);
    }
    }
    }
    }

    这样就使用select完成了对连接的获取读取工作!来看效果: 在这里插入图片描述 可以看到,我们的数组中的有效fd随着客户端连接与中断会动态变化!

    5 总结

    根据上面的代码,我们可以总结出select的一些优缺点:

  • 每次调用 select,都需要手动设置 fd 集合, 从接口使用角度来说也非常不便.
  • 每次调用 select, 都需要把 fd 集合从用户态拷贝到内核态, 这个开销在 fd 很多时会很大。这个是多路转接IO无法避免的问题!
  • 同时每次调用 select 都需要在内核遍历传递进来的所有 fd,这个开销在 fd 很多时很大。
  • select 支持的文件描述符数量太小!虽然操作系统中文件描述符也有限制,但是这是操作系统的缺陷。同样select也是缺点
  • 这里不断的要进行循环遍历数组,造成的性能开销是比较大的!所以就有了其他两种多路转接方案:poll与epoll

    赞(0)
    未经允许不得转载:171主机测评 » 【Linux】从零开始使用多路转接IO --- select
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址