
🔥草莓熊Lotso: 个人主页
❄️个人专栏: 《C++知识分享》 《Linux 入门到实践:零基础也能懂》
✨生活是默默的坚持,毅力是永久的享受!
🎬 博主简介:

文章目录
- 前言
- [一. 多路转接的本质:把 "等" 和 "拷贝" 分开](#一. 多路转接的本质:把 "等" 和 "拷贝" 分开)
-
- [1.1 什么是 "条件就绪"](#1.1 什么是 "条件就绪")
- [1.2 常见多路复用方案对比](#1.2 常见多路复用方案对比)
- [二. select 函数详解](#二. select 函数详解)
-
- [2.1 参数详解](#2.1 参数详解)
- [2.2 返回值](#2.2 返回值)
- [三. socket 就绪条件详解](#三. socket 就绪条件详解)
-
- [3.1 读就绪](#3.1 读就绪)
- [3.2 写就绪](#3.2 写就绪)
- [3.3 异常就绪(选学)](#3.3 异常就绪(选学))
- [四. select 的编程特点与缺点](#四. select 的编程特点与缺点)
-
- [4.1 select 的编程特点](#4.1 select 的编程特点)
- [4.2 select 的缺点](#4.2 select 的缺点)
- [五. 实战:从零实现 select 服务器](#五. 实战:从零实现 select 服务器)
-
- [5.1 基础组件回顾](#5.1 基础组件回顾)
- [5.2 select 服务器核心实现](#5.2 select 服务器核心实现)
- [5.3 主函数](#5.3 主函数)
- [5.4 代码解读](#5.4 代码解读)
- [5.5 编译运行](#5.5 编译运行)
- 结尾:
前言
在上一篇文章中,我们详细讲解了 Linux 下的五种 IO 模型,其中IO 多路复用 是目前高并发服务器的基石。它用一个线程就能同时处理成百上千个连接,彻底解决了阻塞 IO"一个连接一个线程" 的性能瓶颈。而
select,作为 Linux 系统上第一个实现 IO 多路复用的系统调用,虽然现在已经逐渐被更高效的epoll取代,但它是理解所有多路复用技术的基础。几乎所有操作系统都实现了select接口,掌握它不仅能帮你理解多路复用的核心思想,也是面试中绕不开的考点。本文将从最底层的多路转接本质讲起,带你彻底搞懂select函数的每一个参数,最后通过实战代码从零实现一个基于select的 TCP 服务器。

一. 多路转接的本质:把 "等" 和 "拷贝" 分开
在讲select之前,我们必须先回顾一个核心结论:任何 IO 操作都可以拆分为两个步骤:等 + 拷贝。
我们之前用的read、recv这些系统调用,其实是把这两件事一起做了:先等数据就绪,再把数据从内核拷贝到用户空间。而多路转接技术的核心思想,就是把这两步彻底分开:
- 等 :由
select、poll、epoll这类专门的函数来做,一次可以同时等多个文件描述符(fd) - 拷贝 :还是由原来的
read、recv、write、send来做,只有当select告诉我们某个 fd 就绪了,我们才去调用这些函数
换句话说,多路转接就是一个就绪事件的通知机制。它告诉我们哪个 fd 已经准备好了,可以进行读写操作了,而我们只需要在收到通知后,去执行实际的拷贝操作即可。
1.1 什么是 "条件就绪"
select等多路复用函数,本质上就是在等待 fd 的 "条件就绪"。条件就绪分为两种:
- 读就绪:内核接收缓冲区中有数据可读,或者有新的连接请求到来
- 写就绪:内核发送缓冲区中有空闲空间,可以写入数据
当某个 fd 满足以上任意一个条件时,select就会返回,告诉我们这个 fd 已经就绪,可以进行对应的 IO 操作了。

1.2 常见多路复用方案对比
Linux 系统提供了三种主流的多路复用实现,它们的核心思想一致,但在性能和使用方式上有很大区别:
| 方案 | 支持系统 | 数据结构 | 连接数上限 | 效率 |
|---|---|---|---|---|
| select | POSIX 兼容(Linux/Windows/macOS) | 位图 | 固定(默认 1024) | 低,内核需线性遍历所有 fd |
| poll | Linux/macOS | 链表 | 无固定上限(受内存限制) | 中,仍需线性遍历 |
| epoll | 仅 Linux | 红黑树 + 就绪链表 | 无固定上限 | 高,仅通知活跃 fd,无需遍历 |
虽然epoll是目前性能最好的,但select作为最基础、最通用的实现,是我们必须掌握的。

二. select 函数详解
select函数的原型如下:
c
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds,
struct timeval *timeout);
这个函数看起来参数很多,但其实逻辑非常清晰。下面我们逐个拆解每一个参数的含义。
2.1 参数详解
1. nfds:最大文件描述符 + 1
这是最容易被误解的一个参数。很多人以为它是要监听的 fd 的个数,其实不是。nfds 的值应该是你要监听的所有 fd 中,最大的那个 fd 的值再加 1。
比如你要监听 fd=3、fd=5、fd=7 这三个 fd,那么nfds应该设置为7+1=8。这样内核只需要遍历 0 到 7 这 8 个 fd,而不需要遍历整个 1024 位的位图,提高了效率。
2. readfds、writefds、exceptfds:三个文件描述符集
这三个参数分别对应我们要监听的读事件 、写事件 和异常事件 的 fd 集合。它们的类型都是fd_set,本质上是一个位图结构。
fd_set结构体的定义大致如下:
c
typedef struct {
long int fds_bits[__FD_SETSIZE / __NFDBITS];
} fd_set;
其中__FD_SETSIZE默认是 1024,所以select最多只能同时监听 1024 个 fd,这是它最致命的缺点之一。
为了方便操作这个位图,系统提供了四个宏:
c
// 清空fd_set中的所有位
void FD_ZERO(fd_set *set);
// 将fd添加到fd_set中
void FD_SET(int fd, fd_set *set);
// 将fd从fd_set中移除
void FD_CLR(int fd, fd_set *set);
// 测试fd是否在fd_set中被置位
int FD_ISSET(int fd, fd_set *set);
非常重要的一点:这三个参数都是输入输出型参数。
- 输入时:用户通过这三个参数告诉内核,我们关心哪些 fd 上的哪些事件
- 输出时:内核通过这三个参数告诉用户,哪些 fd 上的哪些事件已经就绪了
也就是说,每次调用select之后,这三个fd_set都会被内核修改。未就绪的 fd 对应的位会被清空,只有就绪的 fd 对应的位会被保留。因此,每次调用 **select**之前,都必须重新设置这三个参数。
3. timeout:超时时间
这个参数用来设置select的等待时间,类型是struct timeval:
c
struct timeval {
time_t tv_sec; // 秒
suseconds_t tv_usec; // 微秒
};
timeout有三种取值:
- NULL :
select会永久阻塞,直到有 fd 就绪 {0, 0}:select会立即返回,不阻塞,这是非阻塞模式- 特定时间值 :
select会阻塞指定的时间,如果在这段时间内没有 fd 就绪,就返回 0
注意 :当select因为有 fd 就绪而返回时,timeout结构体的值会被修改为剩余的时间。比如你设置了 5 秒超时,结果 2 秒后就有 fd 就绪了,那么返回时timeout.tv_sec会变成 3。
2.2 返回值
select的返回值有三种情况:
- > 0:成功,返回就绪的 fd 的个数
- = 0:超时,在指定时间内没有任何 fd 就绪
- < 0 :出错,错误码存放在
errno中


三. socket 就绪条件详解
很多人写select代码时,不知道什么时候 fd 才算就绪。这里我们详细讲解一下 TCP socket 的就绪条件,这是正确使用select的前提。
3.1 读就绪
当满足以下任意一个条件时,socket 就处于读就绪状态:
- 内核接收缓冲区中的字节数大于等于低水位标记
SO_RCVLOWAT(默认是 1),此时可以无阻塞地读取数据,并且返回值大于 0 - TCP 通信中,对端关闭了连接,此时对该 socket 读会返回 0
- 监听的 socket 上有新的连接请求到来(这是服务器最核心的事件)
- socket 上有未处理的错误
3.2 写就绪
当满足以下任意一个条件时,socket 就处于写就绪状态:
- 内核发送缓冲区中的可用字节数大于等于低水位标记
SO_SNDLOWAT(默认是 1),此时可以无阻塞地写入数据,并且返回值大于 0 - socket 的写操作被关闭(调用了
close或shutdown),对这样的 socket 写会触发SIGPIPE信号 - 非阻塞
connect连接成功或失败之后 - socket 上有未读取的错误
3.3 异常就绪(选学)
当 socket 上收到带外数据(TCP 紧急数据)时,就处于异常就绪状态。这在实际开发中很少用到,了解即可。
四. select 的编程特点与缺点
4.1 select 的编程特点
通过上面的讲解,我们可以总结出select编程的几个核心特点:
- 需要辅助数组保存所有要监听的 fd :因为
select返回后会清空未就绪的 fd,所以我们需要一个额外的数据结构(通常是数组)来保存所有要监听的 fd,每次调用select前,都要从这个数组中把 fd 重新添加到fd_set中 - 每次调用前必须重置
fd_set:由于fd_set是输入输出型参数,每次调用后都会被内核修改,所以必须在每次循环开始时,重新调用FD_ZERO清空,然后从辅助数组中重新添加 fd - 需要手动计算最大 fd :每次添加或删除 fd 时,都要重新计算当前最大的 fd,作为
nfds参数的值
4.2 select 的缺点
select虽然简单通用,但它的缺点也非常明显:
- 支持的 fd 数量有限:默认最多只能监听 1024 个 fd,虽然可以通过重新编译内核来修改,但这会带来兼容性问题
- 用户态与内核态拷贝开销大 :每次调用
select,都需要把整个fd_set从用户态拷贝到内核态,当 fd 很多时,这个开销会非常大 - 内核需要线性遍历所有 fd:内核每次都要遍历所有传入的 fd,检查是否有事件就绪,当 fd 很多时,效率会急剧下降
- 接口使用不便 :每次调用前都要手动重置
fd_set,计算最大 fd,非常繁琐
五. 实战:从零实现 select 服务器
讲完了理论,我们来动手实现一个基于select的 TCP 服务器。这个服务器目前只处理新连接的建立,后续我们会继续完善,添加客户端数据的读写功能。
5.1 基础组件回顾
我们会复用之前封装好的几个基础组件(想看具体实现的,可以翻翻我之前的博客,比如网络版本计算器这个里面好像就有):
InetAddr.hpp:封装了网络地址的转换Socket.hpp:封装了 TCP socket 的创建、绑定、监听、接受连接等操作Logger.hpp:线程安全的日志系统Mutex.hpp:互斥锁和 RAII 锁守卫
这里重点看一下Socket.hpp中新增的地址和端口复用设置:
c
void CreateSocketOrDie() override
{
_sockfd = socket(AF_INET, SOCK_STREAM, 0);
if(_sockfd < 0)
{
LOG(LogLevel::FATAL) << "create socket error";
exit(SOCKET_ERROR);
}
int opt = 1;
// 设置地址复用
setsockopt(_sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 设置端口复用
setsockopt(_sockfd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));
LOG(LogLevel::INFO) << "create socket success";
}
添加这两个选项可以解决服务器重启时 "地址已被占用" 的问题,是服务器开发中的最佳实践。
5.2 select 服务器核心实现
我们的selectServer类的核心代码如下:
cpp
#ifndef __SELECTSERVER__HPP
#define __SELECTSERVER__HPP
#include "Logger.hpp"
#include "InetAddr.hpp"
#include "Socket.hpp"
#include <bits/types/struct_timeval.h>
#include <cstdint>
#include <memory>
#include <sys/select.h>
// [核心解析]:sizeof(fd_set) 通常是 128 字节,128 * 8 = 1024。这是 select 底层位图能表示的最大比特位数。
// 换句话说,这也直接锁死了我们这个服务器最多只能同时托管 1024 个文件描述符(包含监听套接字)。
#define NUM (sizeof(fd_set) * 8)
using namespace LogModule;
const int gdefaultfd = -1;
class selectServer
{
public:
selectServer(uint16_t port)
:_port(port)
,_listenfd(std::make_unique<TcpSocket>())
{
_listenfd->BulidSocketMethod(_port);
// 初始化
// [核心解析]:辅助数组的作用是在用户态(应用层)保存所有需要被 select 监控的 fd。
// 初始时全部置为 -1 (gdefaultfd),表示该槽位目前为空闲状态,没有分配给任何连接。
for(int i = 0; i < NUM; i++)
{
arr_fds[i] = gdefaultfd;
}
// 首先把_litenfd 放入数组里面
// [核心解析]:服务器刚启动时,唯一存在的文件描述符就是监听套接字 (_listenfd)。
// 必须把它作为第一个"火种"放入辅助数组的 0 号位,让 select 跑起来去感知第一批新连接的到来。
arr_fds[0] = _listenfd->Socketfd();
}
void Start()
{
fd_set rfds; // read fd set
while(true)
{
// rfds 参数重置
// [核心解析]:【高频考点】因为 select 返回时,内核会把"未就绪"的 fd 从 rfds 中清零(输入输出型参数的特性)。
// 如果不每次清空并重新设置,下一次循环系统就再也不会去监听那些曾经未就绪的 fd 了。
FD_ZERO(&rfds);
// [核心解析]:select 的第一个参数要求传入最大 fd 的值。
// 因为随着客户端的连接和断开,fd 是动态变化的,所以我们必须在每次循环中重新计算出当前托管的 max_fd。
int max_fd = gdefaultfd;
for(int i = 0; i < NUM; i++)
{
if(arr_fds[i] == gdefaultfd)
continue;
// 把新的添加进去
FD_SET(arr_fds[i], &rfds);
if(max_fd < arr_fds[i]) max_fd = arr_fds[i]; // 更新最大文件描述符
}
// struct timeval timeout = {5,0}; // 我们今天这里先不使用这个
// int n = select(max_fd + 1, &rfds, nullptr, nullptr, &timeout);
// [核心解析]:第一个参数必须是 max_fd + 1,这是操作系统底层轮询位图的边界要求。
// 最后一个参数设为 nullptr,表示采用纯阻塞等待模式,没有 fd 就绪就死等,不消耗 CPU 资源。
int n = select(max_fd + 1, &rfds, nullptr, nullptr, nullptr);
switch (n)
{
case 0:
LOG(LogLevel::DEBUG) << "time out...";
break;
case -1:
LOG(LogLevel::DEBUG) << "select error...";
break;
default:
LOG(LogLevel::DEBUG) << "事件就绪...: n: " << n;
// [核心解析]:【重要提示】目前代码的逻辑是只要有事件就绪(n > 0),就统统交由 HandlerEvent 去处理新连接。
// 这是一个简化版的骨架。实际上在完整的逻辑中,我们还需要在这里遍历 arr_fds,
// 利用 FD_ISSET() 去精准判断:到底是 _listenfd 就绪了(来新连接了),还是普通的普通通信 fd 就绪了(有数据发来了)。
HandlerEvent();
break;
}
}
}
~selectServer()
{}
private:
void HandlerEvent()
{
InetAddr clientaddr;
// [核心解析]:这里的 Accept 绝对不会发生阻塞!
// 因为正是外层的 select 已经帮我们"等"到了底层三次握手完成(读事件就绪),所以我们一调 Accept 就能瞬间拿到新连接。
int fd = _listenfd->Accepter(&clientaddr);
LOG(LogLevel::INFO) << "get a new link...";
// 你得到了一个新的连接,这个连接怎么处理??
// recv(fd)?? 等 + 拷贝, 不能!!
// fd -> 托管给select-> 只有select具有"等"的能力!-> 如何托管?? -> 只要把fd添加的辅助数组即可!
if(fd >= 0)
{
// 找一个空闲的位置
// [核心解析]:由于不能立刻 recv,我们必须把这个刚刚拿到的 fd 存进辅助数组里。
int pos = 0;
for(;pos < NUM; pos++)
{
if(arr_fds[pos] == gdefaultfd)
break;
}
if(pos == NUM)
{
// 遍历完了找不到
// [核心解析]:数组满了,说明服务器达到了 1024 个并发连接的物理上限。
// 此时心有余而力不足,只能无情地关闭掉这个新建立的连接,保护服务器不崩溃。
close(fd);
}
else
{
// 把这个新获取到的加进辅助数组即可
// 下一轮循环的时候, 会关心上的
// [核心解析]:逻辑完美闭环!将新 fd 填入数组的空闲槽位。
// 当此函数执行完毕,代码流会重返外层 while(true) 的起点。
// 下一次遍历辅助数组时,这个崭新的 fd 就会自然而然地被 FD_SET 加入位图,正式移交内核的 select 托管!
arr_fds[pos] = fd;
}
}
}
private:
uint16_t _port;
std::unique_ptr<Socket> _listenfd;
// 需要一个辅助数组
int arr_fds[NUM];
};
#endif
5.3 主函数
主函数非常简单,只需要创建一个selectServer对象并启动即可:
cpp
#include "selectServer.hpp"
#include <cstdint>
#include <memory>
// 定义服务器绑定的默认端口号
const uint16_t gport = 8080;
int main()
{
// [代码解析]:对象的实例化与生命周期管理
// 这里使用了 C++11 引入的 std::unique_ptr 智能指针来在堆区创建服务器对象。
// 采用智能指针的核心优势是 RAII(资源获取即初始化)机制:
// 即使服务器在未来运行中抛出异常导致意外退出,unique_ptr 也会在出作用域时
// 自动调用 selectServer 的析构函数,确保底层的文件描述符等系统资源被安全释放。
std::unique_ptr<selectServer> select_server = std::make_unique<selectServer>(gport);
// [代码解析]:启动事件循环(Event Loop)
// 调用 Start() 后,主线程的代码执行流将进入 selectServer 类内部的 while(true) 死循环。
// 服务器从此刻起正式开始运转,持续不断地执行 "重置位图 -> select等待 -> 处理就绪事件" 的闭环逻辑。
// 除非收到中断信号(如 Ctrl+C)或内部发生致命错误调用 exit(),否则程序将永远阻塞运行在此处。
select_server->Start();
return 0;
}
5.4 代码解读
- 辅助数组的作用 :
arr_fds数组是整个select服务器的核心。它保存了所有我们要监听的 fd,包括监听 socket 和所有客户端连接的 socket。每次调用select前,我们都要从这个数组中把所有有效 fd 重新添加到fd_set中。 - 为什么 accept 不会阻塞 :因为
select返回时,已经告诉我们监听 socket 上有新连接到来了。此时调用accept,内核中已经有完成三次握手的连接在全连接队列中等待了,所以accept会立即返回,不会阻塞。 - 新连接的处理 :当我们得到一个新的连接 fd 后,只需要把它添加到辅助数组中即可。下一次循环时,这个 fd 就会被自动添加到
select的监听集合中,select就会开始监听这个 fd 上的读事件。
5.5 编译运行
编译运行这个服务器,然后用多个 telnet 客户端连接,你会看到服务器成功接受了所有连接:

结尾:
html
🍓 我是草莓熊 Lotso!若这篇技术干货帮你打通了学习中的卡点:
👀 【关注】跟我一起深耕技术领域,从基础到进阶,见证每一次成长
❤️ 【点赞】让优质内容被更多人看见,让知识传递更有力量
⭐ 【收藏】把核心知识点、实战技巧存好,需要时直接查、随时用
💬 【评论】分享你的经验或疑问(比如曾踩过的技术坑?),一起交流避坑
🗳️ 【投票】用你的选择助力社区内容方向,告诉大家哪个技术点最该重点拆解
技术之路难免有困惑,但同行的人会让前进更有方向~愿我们都能在自己专注的领域里,一步步靠近心中的技术目标!
结语:本文从多路转接的本质出发,详细讲解了select函数的每一个参数和工作原理,并通过实战代码实现了一个能够接受多个连接的select服务器。目前这个服务器还只是一个骨架,它只能接受新连接,还不能处理客户端发送的数据。在下一篇文章中,我们会继续完善这个服务器,添加客户端数据的读写功能,同时讲解如何处理客户端断开连接的情况,以及如何优化select的事件处理逻辑。虽然select有很多缺点,但它是理解 IO 多路复用的基础。掌握了select,你再去学习poll和epoll就会非常轻松。如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、关注,我们下期再见!
✨把这些内容吃透超牛的!放松下吧✨ ʕ˘ᴥ˘ʔ づきらど
