【Linux】Linux 多路复用-select

参考博客:https://blog.csdn.net/sjsjnsjnn/article/details/128371848

一、多路复用介绍

在网络编程中,多路复用(I/O Multiplexing) 是一种高效处理多个 I/O 通道的技术。它允许单线程或单进程同时监控多个文件描述符(如套接字、文件等),当其中任何一个或多个描述符变为可读、可写或发生异常时,程序能够立即感知并进行相应处理。这种机制特别适合构建高并发的网络服务器,避免了为每个客户端连接创建单独线程或进程的开销。

为什么需要多路复用?

传统的网络编程模型中,处理多个客户端连接通常有两种方式:

  1. 阻塞 I/O + 多线程 / 多进程:为每个客户端连接创建一个独立的线程或进程。这种方式实现简单,但随着并发连接数的增加,系统资源(如内存、CPU 调度开销)会迅速耗尽,扩展性较差。

  2. 非阻塞 I/O + 轮询:程序不断主动查询每个文件描述符的状态。这种方式虽然避免了线程创建的开销,但会导致 CPU 资源的浪费(大量无效轮询)。

而多路复用技术通过系统提供的特定机制(如 selectpollepoll 等),让内核代为监控多个文件描述符,只有当真正有事件发生时才通知程序处理,既减少了线程 / 进程的创建,又避免了无效轮询,显著提高了系统的并发处理能力。

二、 select 机制详解

select 是最早实现的多路复用机制之一,几乎被所有操作系统支持。它的核心思想是:将需要监控的文件描述符集合告诉内核,内核在这些描述符中有事件发生时返回,并通知程序哪些描述符就绪

2.1 select 函数原型

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

参数说明

  1. nfds参数指定被监听的文件描述符的总数。它通常被设置为select监听所有文件描述符中最大值加1,因为文件描述符是从0开始计数的。

  2. readfds、writefds和exceptfds参数分别指向可读、可写和异常等事件对应的文件描述符集合。应用程序调用select函数时,通过这3个参数传入自己感兴趣的文件描述符。select调用返回时,内核将修改它们来通知应用程序那些文件描述符已经就绪。这3个参数都是fd_set类型,它是一种位图结构。

在这里插入图片描述

图一(以readfds为例):我们这三个参数都是这样的位图结构(其实也就是数组),下标位置就是所对应的文件描述符,我们将自己想要关心的文件描述符设置到集合中(关心0、1、2),当select函数调用返回时,会将该集合中被关心的文件描述符重新设置(当该描述符读事件就绪时);如图二所示,我们可以看到,只有0和1这两个文件描述符的读事件就绪了,所以2号文件描述符又被至为-1了。

简单的来讲比特位的内容**:**

  1. 输入时:用户告诉内核,你要帮我关心这个集合中的哪些文件描述符。

  2. 输出时:内核告诉用户,你关心的那些文件描述符上的读事件,有哪些已经就绪了。

  3. timeout参数是用来设置select函数的超时时间。它是一个timeval结构类型的指针,其定义如下:

struct timeval
{
    long tv_sec;   /*秒数*/
    long tv_usec;  /*微秒数*/
};
  • 当timeout变量的tv_sec成员和tv_usec成员都传递0时,表明不设置超时时间,只要事件不就续,select就会立即返回。
  • 当timeout变量设为NULL时,只要事件不就续,select就会一直阻塞。
  • 特定的时间值:select调用后在指定的时间内进行阻塞等待,如果被监视的文件描述符上一直没有事件就绪,则在该时间后select进行超时返回。

返回值

  • select成功时返回就绪(可读、可写和异常)文件描述符的总数。
  • 如果在超时时间内没有任何文件描述符就绪,select将返回 0 。
  • select失败时将返回 -1 并设置errno。如果在select等待期间,程序接收到信号,则select立即返回 -1 ,并设置errno为EINTR。
fd_set 数据结构

fd_setselect 用于存储文件描述符集合的数据结构,本质上是一个位图(bitmap),每一位代表一个文件描述符。例如,如果第 3 位被设置为 1,则表示文件描述符 3 正在被监控。

操作 fd_set 的主要函数有:

  • FD_ZERO(fd_set *set):清空集合。
  • FD_SET(int fd, fd_set *set):将文件描述符 fd 添加到集合中。
  • FD_CLR(int fd, fd_set *set):将文件描述符 fd 从集合中移除。
  • FD_ISSET(int fd, fd_set *set):检查文件描述符 fd 是否在集合中且就绪。

2.2 select的基本工作流程

我们要实现一个简单的select服务器,该服务器要做的就是读取客户端发来的数据并进行打印,那么这个select服务器的工作流程应该是这样的:

  1. 首先完成基本的套接字的创建、绑定和监听。

  2. 定义一个fd_array数组用于保存监听套接字和已经与客户端建立连接的套接字,刚开始时就将监听套接字添加到fd_array数组当中。

  3. 然后服务器开始循环调用select函数,检测读事件是否就绪,如果就绪则执行对应的操作。

  4. 每次调用select函数之前,都需要定义一个读文件描述符集readfds,并将fd_array当中的文件描述符依次设置进readfds当中,表示让select帮我们监听这些文件描述符的读事件是否就绪。

  5. 当select检测到数据就绪时会将读事件就绪的文件描述符设置进readfds当中,此时我们就能够得知哪些文件描述符的读事件就绪了,并对这些文件描述符进行对应的操作。

  6. 如果读事件就绪的是监听套接字,则调用accept函数从底层全连接队列获取已经建立好的连接,并将该连接对应的套接字添加到fd_array数组当中。

  7. 如果读事件就绪的是与客户端建立连接的套接字,则调用read函数读取客户端发来的数据并进行打印输出。

  8. 当然,服务器与客户端建立连接的套接字读事件就绪,也可能是因为客户端将连接关闭了,此时服务器应该调用close关闭该套接字,并将该套接字从fd_array数组当中清除,因为下一次不需要再监视该文件描述符的读事件了。

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

因为每次调用select函数之前都需要对readfds进行重新设置,所以需要定义一个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进行重新设置时,还需要记录最大文件描述符值。

2.3 文件描述符的就绪条件

下列情况下socket可读:

  1. socket内核接收缓冲区中的字节数大于或等于其低水位标记SO_RCVLOWAT。此时我们可以无阻塞的读该socket,并且读操作返回的字节数大于0。
  2. socket通信的对方关闭连接。此时对该socket的读操作将返回0。
  3. 监听socket上有新的连接请求。
  4. socket上有未处理的错误。

下列情况下socket可写:

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

异常情况只有一种:

  1. socket上接收到了带外数据。

在网络编程中,带外数据(Out-of-Band Data,OOB) 是一种特殊的机制,允许发送方通过紧急通道快速传输少量关键数据,这些数据能够 “插队” 到普通数据流之前被接收方优先处理。这种机制主要用于在紧急情况下传递重要信息,而不必等待普通数据的传输完成。

2.4 基于select函数设计的服务器

2.4.1 封装socket接口

sock.hpp这个文件主要是封装Linux下的网络socket接口

#include <iostream>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <cstring>
#include <netinet/in.h>

class Sock
{

public:
    static int Socket(){
        int sock = socket(AF_INET,SOCK_STREAM,0);
        if(sock < 0){
            std::cerr << "socket error" << std::endl;
            exit(1);
        }

        return sock;
    }

    static void Bind(int sock,uint16_t port){
        struct sockaddr_in local;
        memset(&local,0,sizeof(local));
        
        local.sin_family = AF_INET;
        local.sin_addr.s_addr = INADDR_ANY;
        local.sin_port = htons(port);

        if(bind(sock,(struct sockaddr*)&local,sizeof(local)) < 0){
            std::cout << "bind error !" << std::endl;
            exit(2);
        }
    }

    static void Listen(int sock){
        if(listen(sock,5) < 0){
            std::cerr << "listen error" << std::endl;
            exit(3);
        }
    }

        static int Accept(int sock){
        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);

        int fd = accept(sock,(struct sockaddr*)&peer,&len);
        if(fd >= 0){
            return fd;
        }
        else{
            return -1;
        }

    }

    static void Connect(int sock,std::string ip,uint16_t port){
        struct sockaddr_in server;
        memset(&server,0,sizeof(server));

        server.sin_family = AF_INET;
        server.sin_port = htons(port);
        server.sin_addr.s_addr = inet_addr(ip.c_str());

        if(connect(sock,(struct sockaddr*)&server,sizeof(server) == 0)){
            std::cout << "connect sucess" << std::endl;
        }
        else{
            std::cout << "connect failed" << std::endl;
            exit(4);
        }
    }
};

2.4.2 select服务器的编写

利用封装好的sock类,对select服务器进行基本的套接字编写

#include "sock.hpp"
#include <sys/select.h>

using namespace std;

#define NUM (sizeof(fd_set) * 8)

int fd_array[NUM];
int listen_sock = 0;

void handle(int index, fd_set &rfds)
{
    if (FD_ISSET(fd_array[index], &rfds))
    { // 文件描述符在读事件集合中
        std::cout << "sock:" << fd_array[index] << " connection is ready !" << std::endl;

        if (fd_array[index] == listen_sock)
        { // 连接事件就绪
            std::cout << "listen_sock:" << listen_sock << " get new connection !" << std::endl;

            int sock = Sock::Accept(listen_sock);
            if (sock >= 0)
            {

                int pos = 1;
                for (; pos < NUM; ++pos)
                {
                    if (fd_array[pos] == -1)
                        break;
                }

                if (pos < NUM)
                {
                    std::cout << "new connection has been added to fd_array[" << pos << "] !" << std::endl;
                    fd_array[pos] = sock;
                }
                else
                {
                    std::cout << "server connections has been full !" << std::endl;
                    close(sock);
                }
            }
        }
        else
        { // 读事件就绪
            std::cout << "sock:" << fd_array[index] << " read is ready !" << std::endl;

            char buffer[1024] = {0};
            ssize_t len = recv(fd_array[index], buffer, sizeof(buffer) - 1, 0);
            if (len < 0)
            {
                std::cout << "recv failed !" << std::endl;
                exit(11);
            }
            else if (len == 0)
            {
                std::cout << "sock:" << fd_array[index] << " has closed !" << std::endl;
                close(fd_array[index]);
                std::cout << "fd_array[" << index << "] has been set -1 !" << std::endl;
                fd_array[index] = -1;
            }
            else
            {
                buffer[len] = '\0';
                std::cout << "sock:" << fd_array[index] << " recv:" << buffer << std::endl;
            }
        }
    }
}

int main(int argc, char *argv[])
{
    if (argc < 2)
    {
        std::cerr << "argc < 2" << std::endl;
        return 1;
    }

    uint16_t port = (uint16_t)atoi(argv[1]); // 端口
    listen_sock = Sock::Socket();
    Sock::Bind(listen_sock, port);
    Sock::Listen(listen_sock);

    for (int i = 0; i < NUM; ++i)
    {
        fd_array[i] = -1;
    }

    fd_set rfds; // 读事件集合
    fd_array[0] = listen_sock;

    std::cout << "server is listening on port " << port << std::endl;
    while (true)
    {
        FD_ZERO(&rfds);           // 清空读事件集合
        int max_fd = fd_array[0]; // 最大文件描述符
        for (int i = 0; i < NUM; ++i)
        {
            if (fd_array[i] == -1)
                continue;

            FD_SET(fd_array[i], &rfds);             // 将有效的描述符fd,添加到读事件集合
            max_fd = std::max(max_fd, fd_array[i]); // 更新最大描述符
        }

        struct timeval timeout = {5, 0}; // 超时时间

        int ret = select(max_fd + 1, &rfds, nullptr, nullptr, &timeout);

        if (ret == 0)
        {
            std::cout << "select timeout !" << std::endl;
            continue;;
        }
        else if (ret == -1)
        {
            std::cout << "select error !" << std::endl;
            exit(10);
        }
        else
        {
            std::cout << "select fd ready !" << std::endl;
            for (int i = 0; i < NUM; ++i)
            {
                if (fd_array[i] == -1)
                {
                    continue;
                }
                else
                {
                    handle(i, rfds);
                }
            }
        }
    }

    for(int i = 0 ; i < NUM ; ++i){
        if(fd_array[i] != -1){
            close(fd_array[i]);
            fd_array[i] = -1;
        }
    }

    return 0;
}
  • 从参数解读来看,三个事件都是fd_set类型的,本质上都是位图结构,首先由用户将需要关心的文件描述符添加到readfds、writefds和exceptfds中。

  • 由于select调用返回时会对原先的位图结构重新调整,只返回已有事件就绪的文件描述符,未就绪事件的文件描述符会被清除(例:我让select关心1/2/3号文件描述符,它只返回了1/2号文件描述符),但是3号文件描述符本次select调用没有发生就绪事件,并不意味着下一次3号文件描述符不会有事件发生。

  • 但是select在刚刚返回时就已经将3号文件描述符清除了,下次就不可能再关心3号文件描述符了。

  • 所以我们就需要利用额外的数组,将需要关心的文件描述先行保存起来。

  • 利用数组的好处,作为一个服务器肯定会有多个连接,每个连接都对应着一个文件描述符,我们知道文件描述符是递增的,我们后续会将accept上来的连接再次添加的数组中,每次循环调用select前都去遍历这个数组,找到最大的文件描述符,作为select系统调用的第一个参数。

对select的返回值进行判断,做出相应的操作

当select成功返回时,肯定有多个文件描述符上的读事件已经就绪了,但是我们并不知道是哪一个文件描述符读事件就绪,幸好我们刚刚的fd_array数组保存了我们要关心的套接字(当然到这一步,数组中只有一个需要关心的文件描述符,就是listen_sock)。此时,我们就可以遍历这个数组中的文件描述符,对其加以判断,数组中的文件描述符有没有被设置到我们的rfds集合中:

1. 如果设置了;就会存在两种情况:

如果是监听套接字:

  • 对于监听套接字而言,它的读事件就是新连接到来,此时我们应该立即accept获取新连接,并将获取到的新连接保存到fd_array数组中,用于下一次select调用时,能够关心这些文件描述符上的读事件。

  • 在这里需要提一点,当我们在获取到新连接后,一定不能立即执行read/recv等操作。因为连接到来并不意味着这些连接上的数据就绪了,如果此时你立即执行读取操作,会存在严重的问题,例如:有人攻击你这个服务器,它给你的服务器发送大量的连接,但是从来都不传输数据,无脑的连接你。你的服务器,不断的accept获取大量的连接,然后进程不断的读取操作,但是就是没有数据,导致进程被挂起,可想而知这种危害很大。

如果是普通套接字:

  • 此时我们就可以直接执行read/recv等操作。由于我们采用的是TCP协议,读取操作会存在数据粘包问题,但是我们此次代码注重理解select的工作流程,对于粘包问题,需要特定的场合。这里我们可以忽略粘包问题。

2. 如果未设置,让其再次循环select,直到文件描述符被设置(我们的代码中不做任何操作)

2.5 服务器测试

可以使用下面的Makefile进行编译

CXX = g++

CXXFLAGS = -Wall -std=c++14

SRCS = main.cpp 

OBJS = $(SRCS:.cpp=.o)

TARGET = server

all:$(TARGET)

$(TARGET):$(OBJS)
	$(CXX) $(CXXFLAGS) -o $(TARGET) $(OBJS)
	
%.o:%.cpp
	$(CXX) $(CXXFLAGS) -c $< -o $@
		
clean:
	rm -f $(OBJS) $(TARGET)
	
.PHONY:all clean	

编译

在这里插入图片描述

运行服务器

./server 8080

在这里插入图片描述

客户端代码

#include <iostream>
#include <cstring>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <thread>

#define PORT 8080
#define BUFFER_SIZE 1024

// 接收服务器消息
void receive_messages(int socket) {
    char buffer[BUFFER_SIZE];

    while (true) {
        memset(buffer, 0, sizeof(buffer));
        ssize_t bytes_received = recv(socket, buffer, BUFFER_SIZE, 0);

        if (bytes_received <= 0) {
            std::cout << "服务器断开连接。" << std::endl;
            close(socket);
            return;
        }

        std::cout << "收到消息: " << buffer << std::endl;
    }
}

int main() {
    int client_socket;
    struct sockaddr_in server_addr;

    // 创建客户端套接字
    client_socket = socket(AF_INET, SOCK_STREAM, 0);
    if (client_socket < 0) {
        std::cerr << "套接字创建失败。" << std::endl;
        return -1;
    }

    // 初始化服务器地址
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(PORT);
    server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");  // 服务器 IP

    // 连接到服务器
    if (connect(client_socket, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
        std::cerr << "连接服务器失败。" << std::endl;
        return -1;
    }

    std::cout << "成功连接服务器。" << std::endl;

    // 创建线程接收服务器消息
    std::thread receive_thread(receive_messages, client_socket);
    receive_thread.detach();  // 分离线程以便独立运行

    // 发送消息给服务器
    char message[BUFFER_SIZE];
    while (true) {
        std::cin.getline(message, BUFFER_SIZE);
        send(client_socket, message, strlen(message), 0);
    }

    close(client_socket);
    return 0;
}

运行客户端

连接到服务器,效果如下

在这里插入图片描述

第二个客户端连接到服务器

在这里插入图片描述

发送消息到服务器

在这里插入图片描述

客户端退出
在这里插入图片描述

三、总结

select 的优缺点

优点

  • 跨平台支持:几乎所有操作系统都支持 select
  • 简单易用:API 设计简洁,易于理解和实现。

缺点

  • 文件描述符数量限制:通常受 FD_SETSIZE 限制(默认值为 1024),难以处理大量并发连接。
  • 线性扫描效率低:每次 select 返回后,需要遍历所有可能的描述符检查就绪状态,时间复杂度为 O (n)。
  • 内核与用户空间数据复制开销:每次调用 select 都需要将描述符集合从用户空间复制到内核空间。
适用场景

由于 select 的局限性,它适用于以下场景:

  • 并发连接数较少的应用。
  • 跨平台兼容性要求较高的应用。
  • 简单的网络程序或学习目的。

对于高并发场景,现代操作系统通常提供更高效的替代方案,如 Linux 的 epoll、BSD/macOS 的 kqueue 和 Windows 的 IOCP

更多资料:https://github.com/0voice

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值