梅州市网站建设_网站建设公司_博客网站_seo优化
2026/1/3 1:55:01 网站建设 项目流程

一、 为什么需要 IO 多路转接?

在传统的网络编程中,如果服务器要处理成千上万个连接,使用多线程(每个连接一个线程)会导致资源耗尽。

IO 多路复用(IO Multiplexing)允许我们只用一个线程,就能同时监听多个文件描述符(FD)的状态(读、写、异常)。一旦某个 FD 就绪,就通知应用程序进行处理 。


二、 Select:老牌机制(位图管理)

select是最早的解决方案,它使用一个位图(Bitmap)来管理需要监听的 FD。

1. 核心原理与数据结构
  • fd_set位图:select的核心数据结构是fd_set

它本质上是一个long类型的数组,被当作位图使用 。

    • 位图中的每一位(bit)对应一个文件描述符。
    • 例如:要监听fd=5,就把第 5 个 bit 置为 1 。

函数原型:

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
    • nfds: 监听的最大文件描述符值 + 1 。
    • readfds/writefds: 分别是读、写事件的位图集合 。
2. 工作流程(“填表-拷贝-轮询”循环)

Select 的执行过程非常“笨重” :

  1. 用户态填表:使用FD_ZERO清空位图,然后用FD_SET把需要监听的 FD(如 fd=1, fd=2)加进去。
  2. 拷贝到内核:调用select(),将整个fd_set从用户态拷贝到内核态 。
  3. 内核轮询:内核遍历这个位图,查看是否有 FD 就绪。如果没有,进程进入睡眠等待。
  4. 返回结果:当有事件发生(或超时),内核修改fd_set(只保留就绪的 bit,其他的清除),然后返回就绪的数量。
  5. 用户态遍历:select返回后,用户并不知道具体是谁就绪了,必须遍历所有 FD,使用FD_ISSET一个个检查 。
3. Select 的三大硬伤

Select 有明显的缺点:

  • 数量限制:fd_set的大小是固定的(通常是 1024),这意味着单个进程最多只能监听 1024 个连接 。
  • 维护成本高:每次调用select前,都需要重新设置fd_set(因为内核会修改它),且每次都要把集合在用户态和内核态之间拷贝 。
  • O(N) 轮询:无论内核检查就绪,还是用户检查结果,都需要遍历整个集合。当 FD 数量很大时,性能会线性下降 。

三、示例代码

#include <iostream> #include <sys/socket.h> #include <netinet/in.h> #include <sys/select.h> #include <cstring> #include <unistd.h> #include <algorithm> #define BUF_SIZE 1024 #define PORT 8080 int main() { int listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd < 0) { perror("创建套接字失败"); return -1; } // 设置端口复用 int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); // 设置字段信息 sockaddr_in serv_addr; memset(&serv_addr, 0, sizeof(serv_addr)); serv_addr.sin_family = AF_INET; serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); serv_addr.sin_port = htons(PORT); int ret = bind(listen_fd, (sockaddr *)&serv_addr, sizeof(serv_addr)); if (ret < 0) { perror("绑定套接字失败"); return -1; } // 监听套接字 ret = listen(listen_fd, 5); if (ret < 0) { perror("监听套接字失败"); return -1; } std::cout << "select server start...." << PORT << "......" << std::endl; // select初始化 fd_set global_fds; // 用于保存我们想要监听的所有fd FD_ZERO(&global_fds); // 清空位图 // 将listen_fd加入监听集合 FD_SET(listen_fd, &global_fds); // max_fd当前最大的fd值 int max_fd = listen_fd; // 用于在用户态记录已经连接的客户端fd std::vector<int> client_fds; while (true) { // 位图拷贝,select是破坏性的,会修改global_fds,我们需要拷贝一个临时的tmp_fds fd_set tmp_fds = global_fds; std::cout << "waiting for select......" << std::endl; int activity = select(max_fd + 1, &tmp_fds, NULL, NULL, NULL); if (activity < 0) { perror("Select error"); break; } // 处理Listen socket的新链接,如果listen_fd在位图中标记为1,说明有新链接 if (FD_ISSET(listen_fd, &tmp_fds)) { sockaddr_in client_addr; socklen_t addr_len = sizeof(client_addr); int new_sock = accept(listen_fd, (sockaddr *)&client_addr, &addr_len); if (new_sock >= 0) { std::cout << "new client connected: FD: " << new_sock << std::endl; // 将新链接加入global_fds,以便下一次监听 FD_SET(new_sock, &global_fds); // 更新max_fd max_fd = std::max(max_fd, new_sock); // 记录到vector client_fds.push_back(new_sock); } } // 处理client sockets for (auto it = client_fds.begin(); it != client_fds.end(); it++) { int fd = *it; // 判断是否就绪 if (FD_ISSET(fd, &tmp_fds)) { char buffer[BUF_SIZE] = {0}; int valread = read(fd, buffer, BUF_SIZE); if (valread == 0) { std::cout << "client disconnected: fd= " << fd << std::endl; close(fd); // 避免迭代器失效 it = client_fds.erase(it); continue; } else if (valread > 0) { std::cout << "[Msg from " << fd << "]: " << buffer << std::endl; send(fd, buffer, valread, 0); } else { perror("读取错误"); } } } } close(listen_fd); return 0; }
A. 位图的“重置” (Resetting)

在代码中,我们定义了两个fd_setglobal_fdstmp_fds

C++

fd_set tmp_fds = global_fds; // 每次循环都要拷贝 select(..., &tmp_fds, ...);
  • 原理:select是一个**“入参即出参”**的函数。你传入10010(表示监听 fd 1 和 4),如果只有 fd 4 就绪,内核会把这个集合修改为00010并返回。
  • 佐证:select返回后会把以前加入但无事件发生的 fd 清空,所以每次开始select前都要重新设置 。如果不拷贝,第二次循环时,你监听的那些还没动静的 FD 就丢失了。
B. 笨重的遍历 (Traversal)
for (auto it = client_fds.begin(); it != client_fds.end(); ) { if (FD_ISSET(fd, &tmp_fds)) { ... } }
  • 原理:select返回后,只告诉你“有 x 个连接有动静”,但没告诉你具体是。你必须遍历你手里所有的连接,用FD_ISSET一个个去问位图。
  • 文档佐证:文档提到,select返回后,需要 array 作为源数据和fd_set进行FD_ISSET判断 。
C. Max FD 的维护

C++

if (new_sock > max_fd) max_fd = new_sock; select(max_fd + 1, ...);
  • 原理:内核需要知道扫描位图扫到哪里截止。nfds参数必须是最大文件描述符 + 1。
  • 文档佐证:参数nfds是需要监视的最大的文件描述符值+1 。
D. 1024 限制

虽然代码里看不出硬限制,但fd_set本质是一个固定大小的结构体。

  • 原理:FD_SET宏操作的是一个固定长度的数组。
  • 文档佐证:fd_set是一个位图,sizeof(fd_set)通常是 512 字节,每 bit 表示一个 fd,最大支持 4096 (或者更常见的 1024) 。

Accept 讲解

在代码中,这一行是:

C++

int new_sock = accept(listen_fd, (struct sockaddr*)&client_addr, &addr_len);

accept返回的是一个全新的文件描述符(File Descriptor, FD),通常称为“连接套接字” (Connected Socket)。

下面详细讲解这个返回值的含义、作用以及它与listen_fd的区别进行说明:

1. 返回值的具体含义
  • 成功时:返回一个非负整数(例如 4, 5, 6...)。这个整数是内核分配给当前这个特定客户端连接的唯一标识符 。
  • 失败时:返回-1,并设置errno变量来指示具体的错误原因(如资源不足等)。
2. 这个“新 FD”是用来干嘛的?

这是理解网络编程最关键的一点:区分“监听”和“服务”

  • listen_fd(传入参数):
    • 角色:它是**“门童”“迎宾员”**。
    • 作用:它只负责站在门口等待新的客人(连接请求)。它永远只在监听端口(如 8080)上工作。
    • 寿命:服务器运行期间一直存在,专门用来生成新连接。
  • new_sock(返回值):
    • 角色:它是**“专属服务员”**。
    • 作用:它是专门为了服务刚刚进来的那一位客人而生成的。
    • 关键点:后续所有的数据传输(read,write,send,recv),必须使用这个new_sock,而不能用listen_fd。因为listen_fd根本不知道具体是哪个客户端,只有new_sock绑定了特定的客户端 IP 和端口。
4. 形象的比喻

想象你去一家餐厅吃饭:

  1. 门迎(listen_fd):站在门口喊“欢迎光临”。你进门后,门迎并不会跟着你进包间服务,他会继续站在门口等下一位客人。
  2. accept 动作:门迎把你领进去,指派了一位**服务员(new_sock)**专门负责你这一桌。
  3. 后续交互:你点菜、倒水(read/write),都是跟这位服务员说,而不是跑去门口跟门迎喊。
5. 在 Select 代码中的处理

这也是为什么在代码中通过accept拿到new_sock后,必须立刻做两件事:

  1. FD_SET(new_sock, &global_fds): 告诉 Select,“以后也要帮我盯着这个新服务员(客户端)有没有动静”。
  2. client_fds.push_back(new_sock): 把它记在一个小本子上,方便后续遍历检查

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询