select在C语言Socket中如何高效使用?

99ANYc3cd6
预计阅读时长 27 分钟
位置: 首页 C语言 正文

什么是 I/O 多路复用?

在传统的网络编程中,如果服务器需要同时处理多个客户端连接,通常有以下几种模式:

c语言 socket select
(图片来源网络,侵删)
  1. 阻塞 I/O 模型:为每个客户端创建一个线程,如果一个线程在 read 时阻塞,不会影响其他线程,但这种方法会创建大量线程,消耗大量内存和 CPU 上下文切换开销,扩展性差。
  2. 非阻塞 I/O 模型:将所有 socket 都设置为非阻塞,然后在一个循环中依次对每个 socket 进行 readwrite,这种方法虽然避免了线程开销,但会浪费大量 CPU 时间在无效的系统调用上(当一个 socket 还没数据时,read 会立即返回错误)。

I/O 多路复用就是为了解决上述问题而生的,它的核心思想是:用一个线程来监视多个文件描述符(File Descriptor,简称 FD,在 socket 编程中通常指 socket),当任何一个 FD 准备好进行 I/O 操作(有数据可读、可写、或发生异常)时,select 函数才会返回,告诉应用程序哪些 FD 已经就绪。

这样,应用程序就无需盲目地轮询,而是可以高效地处理已经准备好的 I/O 事件。


select 函数详解

select 函数是 POSIX 标准定义的,其原型如下:

#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

参数解析

  1. nfds:

    c语言 socket select
    (图片来源网络,侵删)
    • 类型:int
    • 含义:需要监视的 文件描述符的最大值 + 1select 内部会遍历 0 到 nfds-1 的所有 FD,你不需要传递一个 FD 集合,而是传递一个上限值。
    • 如果你要监视的 FD 是 3, 5, 10,nfds 就应该是 10 + 1 = 11
  2. readfds, writefds, exceptfds:

    • 类型:fd_set *
    • 含义:指向文件描述符集合的指针,它们分别用于监视:
      • readfds: 可读 的 FD,当这些 FD 中有数据可读时,select 会返回。
      • writefds: 可写 的 FD,当这些 FD 的发送缓冲区有空间可以写入数据时,select 会返回。
      • exceptfds: 异常 的 FD,当这些 FD 上发生“带外数据”(Out-of-Band data)等错误条件时,select 会返回。
    • 你可以只关心其中一种或几种,将不关心的参数设为 NULL
  3. timeout:

    • 类型:struct timeval *
    • 含义:select 函数的阻塞超时时间。
      • timeoutNULLselect永远阻塞,直到有 FD 就绪。
      • timeout 指向一个 timeval 结构体,并且其成员 tv_sec(秒)和 tv_usec(微秒)都为 0,select立即返回,不阻塞。
      • timeout 指向一个有效的 timeval 结构体,select 将最多阻塞指定的时间,如果超时,即使没有 FD 就绪,select 也会返回 0。
    • 结构体定义:
      struct timeval {
          long tv_sec;  // 秒
          long tv_usec; // 微秒
      };

返回值

  • 成功:返回就绪 FD 的总数(0, 1, 2, ...)。
  • 超时:返回 0,表示在指定的 timeout 时间内,没有任何 FD 就绪。
  • 出错:返回 -1,并设置 errno,当 nfds 无效或被信号中断时。

fd_set 操作宏

fd_set 是一个内部结构,我们不应该直接操作它,标准库提供了一组宏来方便地操作这个集合:

  1. FD_ZERO(fd_set *set): 清空一个文件描述符集合,在每次调用 select 之前,都必须先调用它来初始化集合。
  2. FD_SET(int fd, fd_set *set): 将一个文件描述符 fd 添加到集合 set 中。
  3. FD_CLR(int fd, fd_set *set): 将一个文件描述符 fd 从集合 set 中移除。
  4. FD_ISSET(int fd, fd_set *set): 检查文件描述符 fd 是否在集合 set 中,当 select 返回后,使用此宏来检查某个 FD 是否就绪。

select 的完整工作流程(以服务器为例)

这是一个使用 select 实现的简单回显服务器的完整流程:

c语言 socket select
(图片来源网络,侵删)
  1. 创建监听 socket (socket, bind, listen)。
  2. 初始化 fd_set:使用 FD_ZERO 清空 readfds
  3. 将监听 socket 添加到 readfds:使用 FD_SET
  4. 进入主循环: a. 备份 readfdsselect 函数在返回时会修改 readfds 集合,只保留就绪的 FD,每次循环开始前,都需要将原始的 readfds(包含监听 socket 和所有客户端 socket)重新设置到 readfds 中。 b. 调用 select:传入备份后的 readfdsnfds(监听 FD + 1)和 timeout。 c. 处理返回值
    • 如果返回值 > 0,表示有 FD 就绪。
    • 如果返回值 == 0,表示超时,可以执行一些其他任务(如定时器),然后继续循环。
    • 如果返回值 < 0,检查 errno,如果是 EINTR(被信号中断),可以继续循环;否则是严重错误,应退出。 d. 遍历所有可能的 FD:从 0 到 nfds-1,使用 FD_ISSET 检查每个 FD 是否在 readfds 中。 e. 处理就绪的 FD
    • 如果就绪的 FD 是监听 socket:说明有新的客户端连接,调用 accept 接受连接,将返回的新客户端 socket 也添加到 readfds 集合中(别忘了更新 nfds)。
    • 如果就绪的 FD 是客户端 socket:说明该客户端有数据可读,调用 read 从该 socket 读取数据。read 返回 0,表示客户端已断开连接,需要关闭该 socket,并将其从 readfds 集合中移除(FD_CLR)。read 返回正常数据,将其回写给客户端。
  5. 循环直到程序结束

完整代码示例

下面是一个简单的回显服务器,它使用 select 来处理一个监听 socket 和多个客户端 socket。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/select.h>
#include <errno.h>
#define PORT 8080
#define MAX_CLIENTS 30
#define BUFFER_SIZE 1024
int main() {
    int server_fd, new_socket;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);
    char buffer[BUFFER_SIZE] = {0};
    // 1. 创建 socket
    if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
        perror("socket failed");
        exit(EXIT_FAILURE);
    }
    // 设置 socket 选项,允许地址重用
    if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt))) {
        perror("setsockopt");
        exit(EXIT_FAILURE);
    }
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(PORT);
    // 2. 绑定
    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
        perror("bind failed");
        exit(EXIT_FAILURE);
    }
    // 3. 监听
    if (listen(server_fd, 3) < 0) {
        perror("listen");
        exit(EXIT_FAILURE);
    }
    printf("Server listening on port %d...\n", PORT);
    // 4. 初始化 fd_set
    fd_set read_fds;
    int max_sd; // 记录最大的文件描述符
    // 客户端 socket 数组,初始化为 -1
    int client_sockets[MAX_CLIENTS];
    for (int i = 0; i < MAX_CLIENTS; i++) {
        client_sockets[i] = -1;
    }
    while (1) {
        // 清空文件描述符集合
        FD_ZERO(&read_fds);
        // 添加监听 socket 到集合
        FD_SET(server_fd, &read_fds);
        max_sd = server_fd;
        // 添加所有客户端 socket 到集合
        for (int i = 0; i < MAX_CLIENTS; i++) {
            int sd = client_sockets[i];
            if (sd > 0) {
                FD_SET(sd, &read_fds);
                if (sd > max_sd) {
                    max_sd = sd;
                }
            }
        }
        // 5. 调用 select
        // max_sd + 1 是 nfds
        // timeout 设为 NULL,表示一直阻塞直到有事件发生
        int activity = select(max_sd + 1, &read_fds, NULL, NULL, NULL);
        if (activity < 0 && errno != EINTR) {
            perror("select error");
        }
        // 6. 处理就绪的 FD
        // 检查是否有新的连接
        if (FD_ISSET(server_fd, &read_fds)) {
            if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0) {
                perror("accept");
                continue;
            }
            printf("New connection, socket fd is %d, ip is : %s, port : %d\n",
                   new_socket, inet_ntoa(address.sin_addr), ntohs(address.sin_port));
            // 将新的 socket 添加到客户端数组
            for (int i = 0; i < MAX_CLIENTS; i++) {
                if (client_sockets[i] == -1) {
                    client_sockets[i] = new_socket;
                    break;
                }
            }
        }
        // 检查所有客户端 socket
        for (int i = 0; i < MAX_CLIENTS; i++) {
            int sd = client_sockets[i];
            if (sd > 0 && FD_ISSET(sd, &read_fds)) {
                // 读取数据
                int valread = read(sd, buffer, BUFFER_SIZE);
                if (valread == 0) {
                    // 客户端断开连接
                    printf("Client disconnected, socket fd %d\n", sd);
                    close(sd);
                    client_sockets[i] = -1; // 从数组中移除
                } else {
                    // 回显数据
                    buffer[valread] = '\0';
                    printf("Message from client %d: %s", sd, buffer);
                    send(sd, buffer, valread, 0);
                }
            }
        }
    }
    return 0;
}

select 的优缺点

优点

  1. 跨平台:几乎所有支持 sockets 的操作系统都实现了 select
  2. 实现简单:API 和概念相对简单,容易理解和使用。

缺点

  1. 性能瓶颈
    • 文件描述符数量限制fd_set 的大小通常是有限的(在 Linux 上默认是 1024),虽然可以通过 FD_SETSIZE 宏调整,但这是一个编译时限制。
    • 线性扫描select 内部是通过遍历 fd_set 来检查 FD 状态的,当监视的 FD 数量很大时(例如数千个),每次调用 select 都要进行线性扫描,效率会急剧下降,时间复杂度是 O(n)。
  2. 集合修改select 会修改传入的 fd_set,每次循环都需要重新设置,增加了编程的复杂性。
  3. 无法得知具体事件select 只能告诉你某个 FD “就绪了”,但无法像 epoll 那样告诉你具体是“可读”、“可写”还是“异常”,你需要自己再次检查。

select 是 I/O 多路复用的入门和基石,它通过让一个线程监视多个 FD,极大地提高了服务器的并发处理能力,由于其固有的性能限制(线性扫描和 FD 数量限制),在需要处理成千上万个连接的高性能服务器场景中,现代 Linux 系统更倾向于使用 epoll,而 BSD 系统则使用 kqueue,但对于中小型应用、学习或跨平台需求不高的场景,select 仍然是一个简单有效的选择。

-- 展开阅读全文 --
头像
dede5.7免登录接口,如何实现文章通用模型?
« 上一篇 12-07
织梦include里面的文件夹功能
下一篇 » 12-07

相关文章

取消
微信二维码
支付宝二维码

目录[+]