Linux网络:多路转接 select

发布于:2024-12-18 ⋅ 阅读:(71) ⋅ 点赞:(0)


select是最早的多路转接方式,它允许用户同时监听多个文件的状态变化,当一个或多个文件状态发生变化,select就会通知用户。

看似这是对文件的管理,实际上更多用于网络,因为Linux中网络是当作文件管理的,用户操作每个socket都是通过操作文件描述符fd。因此select更多的是对网络的IO进行处理,用于同时监听多个网络套接字。


系统调用

Linux提供的系统调用函数就叫做select,需要包含头文件<sys/select.h>,函数声明如下:

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

此处引入了两个新的类型fd_setstruct timeval,它们是select所需的配置项。

fd_set表示一个文件描述符的集合,定义如下:

typedef struct {
	unsigned long fds_bits[__FDSET_LONGS];
} __kernel_fd_set;

这个结构体内部,存储了一个unsigned long的数组,其实这是一张位图,位图的大小由__FDSET_LONGS这个宏决定。可以把多个文件的描述符fd添加到这张位图中,就可以形成一个文件描述符的集合。

struct timeval表示一个时间,它用于设定select的返回时间,定义如下:

struct timeval{
	time_t tv_sec;
	susseconds_t tu_usec;
};

这个结构体的第一个变量tv_sec指定秒数,第二个变量tu_usec指定微秒数,两个数值结合,就得到一个指定的时间段。

简单了解了这两个结构体的功能后,再看看select的五个参数:

  • nfds:监听的所有文件描述符中,最大的描述符的值 +1
  • readfds:文件描述符集指针,可以为NULL,集合内包含要监听读事件的文件
  • writefds:文件描述符集指针,可以为NULL,集合内存包含要监听写事件的文件
  • exceptfds:文件描述符集指针,可以为NULL,集合内包含要监听异常事件的文件
  • timeout:指向struct timeval的指针,表示这个select的超时时间
    • NULL:传入空指针,如果没有文件就绪,就一直阻塞等待
    • 非空指针:如果等待指定的时间,还没有文件就绪,直接返回

再解释一下三个文件描述符集合的事件:

  • 读事件:这个文件内有数据到来了,可以进行读取
  • 写事件:这个文件内有空位置了,可以进行写入
  • 异常事件:这个文件发生了异常,需要进行异常处理

在网络的IO模型中,最常用的就是readfds读事件,因为不确定网络报文何时到达,也就是文件内何时有数据,此时就可以让select代管。当select检测到某一个网络文件内有数据到来了,本质就是有网络报文到达,就会触发读事件,表示这个文件内有数据可读了。

文件描述符集合fd_set不能直接操作,操作系统提供了四个函数专门操作fd_set

// 把 fd 从 fd_set 中移除
void FD_CLR(int fd, fd_set *set);

// 把 fd 添加到 fd_set 中
void FD_SET(int fd, fd_set *set);

// 检测 fd 是否在 fd_set 中
int  FD_ISSET(int fd, fd_set *set);

// 把 fd_set 清空
void FD_ZERO(fd_set *set);

刚刚提到,fd_set本质是一个位图,它的大小由__FDSET_LONGS控制。而这个宏由操作系统维护,如果想要修改,需要重新编译内核,非常麻烦。所以select有一个重要的特性:

select能够同时监听的文件描述符fd是有上限的。

这其实是select的一个缺点,但是也不算很大的缺点。

另一个缺点才是最麻烦的:

每次select返回,三张fd_set集合全部都会被重新设置

select中,三个fd_set传入的都是指针,这其实是一个输入输出型参数,输入时fd_set存储要监听事件的文件描述符,而返回时,三个fd_set都会被重新设置,表示这次调用select触发事件的文件描述符

比如说一开始准备监听读事件,在readfds中设置五个文件描述符:

5 6 7 8 9

假设78中有网络报文到达,当select返回时readfds内的文件描述符变成:

7 8

这说明78这两个文件描述符对应的网络套接字有数据到达了。这个过程可以反映出两个事情:

  1. 每次select返回,都会清空没有触发事件的描述符,下一次调用select要把所有文件描述符重新设置一遍
  2. 用户等到select返回后,要遍历整个fd_set才知道哪些文件描述符就绪了

除此之外timeval也是一个输入输出型参数,它的返回值表示剩余的时间。一个select不一定等到timeval设置的事件结束才返回,有可能某些文件就绪就提前返回了,此时timeval会被设置成剩余时间。

最后select本身还有一个int的返回值,这表示本次select中,有多少个文件描述符就绪了。

总结一下select的四个返回值:

  • 函数返回值:表示本次调用select有几个文件就绪
  • readfds:读事件就绪的文件描述符集合
  • writefds:写事件就绪的文件描述符集合
  • exceptfds:异常事件就绪的文件描述符集合
  • timeout:返回时的剩余时间

echo server

接下来使用select系统调用,实现一个简单的echo server

总代码地址:[多路转接Select-EchoServer]

SelectServer类

首先是一个错误类型的枚举,它用于在遇到错误时进行简单的报错。

enum
{
    SOCKET_ERROR = 1, // 套接字错误
    BIND_ERROR,       // 绑定错误
    ACCEPT_ERROR,     // 接收连接错误
    SELECT_ERROR      // 多路转接错误
};

SelectServer类结构如下:

class SelectServer
{
    static const int MAX_SZ = sizeof(fd_set) * 8; // select可以监听的最大文件描述符数量
public:
    SelectServer(uint16_t port); // 构造函数
    
    void start() // 开启网络服务

private:
    void handlerEvent(fd_set& fds); // 事件派发
    
    void acceptClient(); // 接收客户端连接

    void serviceIO(int pos); // 处理客户端数据

private:
    int _listenfd;             // 监听套接字
    std::vector<int> _sockfds; // 客户端套接字数组
};

SelectSerer类中,包含两个成员变量:

  • _listenfdTCP的监听套接字的文件描述符,用于监听到来的客户端连接
  • _sockfds:套接字文件描述符数组,表示当前建立连接的客户端的文件描述符

除此之外,还维护了一个常量MAX_SZ ,这是select可以监听的套接字的最大数量。sizeof(fd_set)fd_set的字节数,每个字节8 bit,所以MAX_SZ = sizeof(fd_set) * 8

构造函数:

SelectServer(uint16_t port); // 构造函数

构造函数接收一个端口号,表示这个服务监听的端口。

开启服务:

void start() // 开启网络服务

这个函数用于进行死循环,每一轮循环进行一次select的调用,监听所有套接字,并处理数据。

事件派发:

void handlerEvent(fd_set& fds); // 派发事件

这个函数用于进行事件派发,接受一个fd_set,因为套接字包含两种类型:listensockfd用于接收客户端连接,以及一般的客户端套接字sockfd用于完成echo server。这需要两个不同的函数进行处理。

事件处理:

void acceptClient(); // 接收客户端连接

void serviceIO(int pos); // 处理客户端数据

刚刚提到不同套接字处理事件的方式不同,此处两个函数分别用用于处理不同的事件。acceptClient用于接收客户端连接,而serviceIO用于与客户端通信,serviceIO接受一个参数pos,表示客户端的套接字在_sockfds数组的下标。


构造函数

构造函数代码如下:

SelectServer(uint16_t port)
    : _sockfds(MAX_SZ, -1) // 初始化_sockfds
{
	// 创建套接字
    _listenfd = socket(AF_INET, SOCK_STREAM, 0); 
    if (_listenfd < 0)
    {
        std::cerr << "socket error!" << std::endl;
        exit(SOCKET_ERROR);
    }

	// 创建套接字地址
    struct sockaddr_in addr; 
    bzero(&addr, sizeof(addr));
    addr.sin_family  = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(port);

	// 绑定地址
    int n = bind(_listenfd, (struct sockaddr*)&addr, sizeof(addr)); 
    if (n < 0)
    {
        std::cerr << "bind error!" << std::endl;
        exit(BIND_ERROR);
    }

	// 把listensockfd添加到数组头部
    _sockfds[0] = _listenfd;
    
    // 开始监听
    listen(_listenfd, 16);
}

初始化列表中_sockfds(MAX_SZ, -1)把数组的长度设置为MAX_SZ,值全部初始化为-1表示这个位置没有值。

随后其实就是Linux中很标准的创建TCP服务的流程:创建套接字绑定套接字监听套接字

唯一不同的是添加了一步_sockfds[0] = _listenfd,因为最后_sockfds内的文件描述符要被select监听,listensockfd同样要被监听,所以一开始就要把这个监听套接字加入到数组中。


事件循环

事件循环流程如下:

void start()
{
    while(true)
    {
    ·	// 设置 set_fd

        // 设置 timeval
        
        // select 监听
        	
        // 处理结果
    }
}

整个事件循环分为如上四步。

  • 设置set_fd
void start()
{
    while(true)
    {
        // 设置 set_fd
        fd_set fds; // 创建一个fd_set 
        FD_ZERO(&fds); // 清空这个fd_set 
        int max_fd = _listenfd; // 初始化最大的fd
        
        // 把所有数组元素(套接字)加入到文件集
        for (int i = 0; i < MAX_SZ; i++)
        {
            if (_sockfds[i] == -1)
                continue;

            FD_SET(_sockfds[i], &fds);
            max_fd = std::max(max_fd, _sockfds[i]);
        }


        // 设置 timeval
        
        // select 监听
        	
        // 处理结果
    }
}

首先创建一个集合fd_set,此处只监听读事件,只创建一个集合。FD_ZERO(&fds)把整个集合清空,防止有之前残留的数据。

此处的max_fd,是因为select的第一个参数是所有文件描述符中最大的那个+1,所以要提前统计最大的文件描述符。

随后进入一个for循环,遍历_sockfds,只要_sockfds[i] != -1说明这是一个有效的套接字,把这个通过FD_SET添加到fd_set中。顺便统计当前最大的max_fd

此处注意fds这个变量,每轮循环都要重新设置一次,因为select会改动传入的fd_set,这在之前讲过。

  • 设置timeval
void start()
{
    while(true)
    {
    ·	// 设置 set_fd

        // 设置 timeval
        struct timeval timeout = { 1, 0 }; 
        
        // select 监听
        	
        // 处理结果
    }
}

此处直接把timeval的时间设置为1 s

  • 开启select
void start()
{
    while(true)
    {
    ·	// 设置 set_fd

        // 设置 timeval
        
        // select 监听
		int ret = select(max_fd + 1, &fds, nullptr, nullptr, &timeout);
		
        // 处理结果
    }
}

此时正式开始调用select函数,开始多路转接。第一个参数传入max_fd + 1,也就是最大的文件描述符加一。读事件传入&fds,剩下两个集合传入nullptr,最后再把超时时间传进去。

ret变量接收返回值。

  • 处理返回值
void start()
{
    while(true)
    {
    ·	// 设置 set_fd

        // 设置 timeval
        
        // select 监听
        	
        // 处理结果
            switch (ret)
            {
            case 0:
                std::clog << "timeout..." << std::endl;
                break;
            case -1:
                std::cerr << "select error..." << std::endl;
                exit(SELECT_ERROR);
                break;
            default:
                std::clog << "event happen..." << std::endl;
                handlerEvent(fds);
            }
    }
}

如果返回值是0,说明这次没有任何文件描述符就绪,进入下一轮循环。如果ret == -1,说明出现错误,直接退出。

如果ret > 0,说明有事件就绪了,调用handlerEvent开始进行事件派发。


事件派发

事件派发很简单,就是判断文件描述符是_listenfd还是普通的sockfd,调用不同的函数进行处理。

void handlerEvent(fd_set& fds)
{
    for (int i = 0; i < MAX_SZ; i++)
    {
        if (_sockfds[i] == -1 || !FD_ISSET(_sockfds[i], &fds))
            continue;

        if (_sockfds[i] == _listenfd)
            acceptClient();
        else
            serviceIO(i);
    }
}

遍历整个_sockfds数组,判断_sockfds[i]在不在集合fds中,如果在说明这个套接字有数据到来可以读取了。

随后进行判断,如果是_listenfd就执行acceptClient,反之执行serviceIO


事件处理

  • 处理listenfd
void acceptClient()
{
    // 接收连接
    // 把套接字插入 _sockfds 数组中
}

监听套接字只需要两部:先通过accept接收这个连接,随后把这个套接字加入到_sockfds数组中,下一轮select会自动对_sockfds内的套接字进行监听。

接收请求:

void acceptClient()
{
	 // 接收连接
    struct sockaddr_in peer;    // 预设客户端的地址结构体
    bzero(&peer, sizeof(peer));
    socklen_t len;

    int clientfd = accept(_listenfd, (sockaddr*)&peer, &len); // 接收客户端连接
    if (clientfd < 0)
    {
        std::cerr << "accept error!" << std::endl;
        exit(ACCEPT_ERROR);
    }

    // 把套接字插入 _sockfds 数组中
}

这也是LinuxTCP的基础操作,通过accept接受一个TCP连接。

添加到_sockfds

void acceptClient()
{
    // 接收连接

    // 把套接字插入 _sockfds 数组中
    int pos = 0;
    for (; pos < MAX_SZ; pos++)  // 找到合适的位置进行插入
    {
        if (_sockfds[pos] == -1)
            break;
    }

    if (pos == MAX_SZ) // 监听的套接字达到上限
    {
        std::clog << "select if full..." << std::endl;
        close(clientfd);
    }
    else // 还可以插入
    {
        _sockfds[pos] = clientfd;
        std::clog << clientfd << " add to sockfds" << std::endl;
    }
}

首先通过循环遍历,找到第一个值为-1的位置,记录下pos

如果pos的值到达了最大值,说明数组满了,此时无法接收更多连接,直接关闭。反之则把这个新的套接字添加到_sockfds[pos]位置。

  • 处理客户端echo server
void serviceIO(int pos)
{
    // 接收数据
    char buffer[1024];
    int n = recv(_sockfds[pos], buffer, sizeof(buffer) - 1, 0);

    // 处理数据
    if (n > 0)
    {
        buffer[n] = '\0';
        std::cout << "message: " << buffer << std::endl;

        std::string ret = "echo: " + (std::string)buffer;
        send(_sockfds[pos], ret.c_str(), ret.size(), 0);
    }
    else if (n == 0)
    {
        std::clog << _sockfds[pos] << " exit..." << std::endl;
        close(_sockfds[pos]);
        _sockfds[pos] = -1;
    }
    else
    {
        std::cerr << _sockfds[pos] << " error..." << std::endl;
        close(_sockfds[pos]);
        _sockfds[pos] = -1;
    }
}

首先通过recv接收来自客户端的数据,最后进行数据的处理。

如果n > 0,那么说明收到的数据,直接把数据通过send返回客户端。

如果n == 0,说明客户端发起了关闭连接的请求,或者n < 0说明发生一次,此时close关闭连接,并把_sockfds[pos]设为-1


测试

最后只需要通过一个main函数启动这个SelectServer即可:

#include <iostream>
#include <memory>

#include "SelectServer.hpp"

// ./selectServer port 
int main(int argc, char* argv[])
{
    if (argc != 2)
    {
        std::cout << "Usage: " << argv[0] << " port" << std::endl;
        exit(-1);
    }

    uint16_t port = std::stoi(argv[1]);
    SelectServer svr(port); // 启动SelectServer
    svr.start();  // 开启事件循环

    return 0;
}

运行效果:

在这里插入图片描述

左侧是SelectServer,右侧是telnet客户端。可以看到,起初没有数据到来,一直触发timeout,当telnet发起连接,此时触发listenfd的事件,4 add to sockfds表示新的连接建立成功,并被select开始监听了。

随后用户发送helloworld都正常得到了响应,message: hello表示成功处理了普通的客户端请求。


总结

在刚才的代码中可以看出select的特性,每次循环都要重新设置所有的套接字。当select返回后,要遍历所有的套接字,来判断哪个套接字可以进行读取了。所以select是一个比较麻烦的多路转接策略。

而当select返回后,还要依据不同的套接字类型,来进行不同的事件处理。这也就是基本的多路转接使用流程:事件循环事件派发事件处理



网站公告

今日签到

点亮在社区的每一天
去签到