前言
在网络编程领域,IO 模型是支撑高效通信的核心基础之一。当需要让单个进程或线程同时处理多个网络连接的 IO 事件时,'IO 多路复用(多路转接)' 技术成为了关键解法 —— 它能让程序通过少量进程 / 线程,高效监控并处理多个 IO 事件,极大提升系统对 IO 资源的利用效率。
IO 多路复用是网络编程核心基础,select 作为经典实现支持单进程监控多连接。文章详解 select 接口参数及 fd_set 操作,通过封装 Socket 类构建服务器,展示初始化、事件分发及主循环流程。对比阻塞与非阻塞模型,分析 select 在文件描述符数量限制、用户态内核拷贝开销等方面的优缺点,为学习 poll 和 epoll 奠定基础。

在网络编程领域,IO 模型是支撑高效通信的核心基础之一。当需要让单个进程或线程同时处理多个网络连接的 IO 事件时,'IO 多路复用(多路转接)' 技术成为了关键解法 —— 它能让程序通过少量进程 / 线程,高效监控并处理多个 IO 事件,极大提升系统对 IO 资源的利用效率。
select作为 IO 多路复用模型中经典且具有代表性的实现,是开发者接触'多路转接'的重要入门点。尽管随着技术演进,它逐渐显现出一些局限性,但深入理解 select的工作机制、使用逻辑及其优缺点,不仅能帮助我们掌握'单进程管理多连接'的核心思路,更是学习更先进多路复用技术(如 poll、epoll)的重要前提。
本文将围绕'select 实现多路转接'展开,从 select接口的基本定义入手,逐步讲解基于 select的多路转接服务器实现(包含套接字封装、初始化流程、fd_set对象操作及服务器主循环设计等),最后剖析 select自身的优势与不足。希望通过对这些内容的梳理,能让读者清晰把握 select在多路 IO 转接中的核心作用,为后续 IO 模型学习与网络编程实践筑牢基础。
在介绍 select这三种多路转接的 IO 模型之前,有必要先介绍以下 5 种 IO 模型分别是哪几种。
我们在操作系统中直接调用,read && write将数据读取上来,其本质就是将数据从用户层拷贝到操作系统中/从操作系统中拷贝到用户层——就是'拷贝';
因此在进行拷贝之前,必须先判断条件是否成立,也就是读写事件是否就绪。
我们通常定义高效 IO 指的是:单位时间内,IO 过程中,等的比重越小,效率越高。
下面介绍五种 IO 模型:
EAGAIN或EWOULDBLOCK 错误;SIGIO信号通知进程来拿取数据;下面介绍实现多路转接 IO 的 3 种方式。
关于 select 实现多路转接,此处将分为两部分进行介绍:
select可以一次等待多个文件,当有一个文件就绪了就返回,这样可以一次性等待多个文件,提高了等待的效率。
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
该接口就是 select的等待接口:
nfds:标识等待的文件描述符中最大的 + 1;fd_set是内核提供的一种数据结构,其本质是一张位图,记录着要关心的文件描述符。
2. 参数二 readfds:是一个结构体,记录要关心读事件就绪的文件描述符;
3. 参数三 writefds:记录要关心写事件就绪的文件描述符;
4. 参数四 exceptfds:记录要关心异常事件的文件描述符;
struct timeval也是内核提供的一种结构体,用于记录 select要进行等待的事件:
struct timeval {
time_t tv_sec; /* seconds */
suseconds_t tv_usec; /* microseconds */
};
当时间到达/有文件读写事件就绪就会进行返回。
timeval:标识 select等待的时间,如果等待时间到了,还没有一个文件读写事件就绪 select也会进行返回,传 nullptr标识阻塞式的等待;上面的 fd_set是操作系统提供给我们的数据结构,我们不能直接对该数据结构进行操作,而应该使用操作系统提供的接口来进行操作:
void FD_ZERO(fd_set *set):将位图全部清零,用于初始化;void FD_SET(int fd, fd_set *set):将 fd 文件描述符添加到位图中;void FD_CLR(int fd, fd_set *set):将 fd 文件描述符从位图中移除;void FD_ISSET(int fd, fd_set *set):检查 fd 文件描述符是否在位图中。如果有文件描述符就绪,操作系统怎么告诉我们是那些文件就绪了???
为了让操作系统能够通知我们,select接口的后 4 个参数被设计为输入输出型参数。
readfds输出来告诉,那些文件描述符的读事件已经就绪;writefds和 exceptfds也一样;timeval告诉我们,距离规定的返回时间还剩余多久。select使用的是内核提供的现成的数据结构 fd_set,因此这也就意味着其可以监视的文件描述符的数量是有限的,可以通过 sizeof(fd_set)*8来计算出来。
为了方便理解,我们实现一个简单的服务器,将用户发送过来的数据在前面添加一个 server got a message后直接进行返回。
首先我们先对网络套接字的接口进行封装:创建套接字,绑定,监听;关于这方面的知识可以查看之前的 TCP 相关内容,此时就直接贴实现方法:
const std::string defaultip_ = "0.0.0.0";
enum SockErr { SOCKET_Err, BIND_Err, };
class Sock {
public:
Sock(uint16_t port) : port_(port), listensockfd_(-1) {}
void Socket() {
listensockfd_ = socket(AF_INET, SOCK_STREAM, 0);
if (listensockfd_ < 0) {
Log(Fatal) << "socket fail";
exit(SOCKET_Err);
}
Log(Info) << "socket success";
}
void Bind() {
struct sockaddr_in server;
server.sin_family = AF_INET;
server.sin_port = htons(port_);
inet_pton(AF_INET, defaultip_.c_str(), &server.sin_addr);
if (bind(listensockfd_, (struct sockaddr*)&server, sizeof(server)) < 0) {
Log(Fatal) << "bind fail";
exit(BIND_Err);
}
Log(Info) << "bind success";
}
void Listen() {
if (listen(listensockfd_, 10) < 0) {
Log(Warning) << "listen fail";
}
Log(Info) << "listen success";
}
int Accept() {
struct sockaddr_in client;
socklen_t len = sizeof(client);
int fd = accept(listensockfd_, (sockaddr*)&client, &len);
if (fd < 0) {
Log(Warning) << "accept fail";
}
return fd;
}
int Accept(std::string& ip, uint16_t& port) {
struct sockaddr_in client;
socklen_t len = sizeof(client);
int fd = accept(listensockfd_, (sockaddr*)&client, &len);
if (fd < 0) {
Log(Warning) << "accept fail";
}
port = ntohs(client.sin_port);
char bufferip[64];
inet_ntop(AF_INET, &client.sin_addr, bufferip, sizeof(bufferip)-1);
ip = bufferip;
return fd;
}
int Get_fd() { return listensockfd_; }
~Sock() { close(listensockfd_); }
private:
uint16_t port_;
int listensockfd_;
};
下面就来实现 selectserver服务器:
首先就是构造出 Selectserver类来对服务器进行管理:
Sock对象,进行 TCP 通信;fd_set能够等待的文件个数;writefds的位图了,理论上是要进行设置的,大家可以自行实现以下;const int fds_num_max = sizeof(fd_set) * 8;
const int defaultfd = -1;
class Selectserver {
public:
Selectserver(uint16_t port) : _sock_ptr(new Sock(port)) {
for (int i = 0; i < fds_num_max; i++) {
_fds_array[i] = defaultfd;
}
}
private:
std::shared_ptr<Sock> _sock_ptr;
int _fds_array[fds_num_max]; // 该数组用来存储 select 要进行等待的文件描述符,初始值为 -1
};
下一步就是进行初始化:
初始化一共就分为 4 个步骤:
_fd_array数组中。对于前三个步骤在前面我们已经进行封装过来,因此,此处可以直接进行调用。
Sock指向的套接字文件,因此也应该使用 select进行等待。以下是具体实现:
void AddToArray(int fd) {
int pos = 0;
for (; pos < fds_num_max && _fds_array[pos] != defaultfd; pos++);
if (pos == fds_num_max) {
// select 已经到达监听极限了,不能再添加要进行监听的文件了
// 1. 关闭文件
// 2. 打印日志
close(fd);
Log(Warning) << "select is full";
} else {
// 1. 有位置直接进行添加
_fds_array[pos] = fd;
Log(Info) << "add a new fd : " << fd;
}
}
void Init() {
// 1. 创建套接字
// 2. 绑定
// 3. 设置监听
// 4. 将套接字描述符加入到_fds_array 数组中
_sock_ptr->Socket();
_sock_ptr->Bind();
_sock_ptr->Listen();
AddToArray(_sock_ptr->Get_fd());
}
我们此处设计的 select接口并不考虑 writefds和 exceptfds,因此我们只需要实现初始化传入的 readfds接口即可,我们需要有一个已经设置好了的 fd_set,以及一个其中最大的文件描述符,因此此处使用一个 pair作为返回值。
std::pair<fd_set, int> Get_readfds() {
// 1. 对位图进行初始化
// 2. 循环遍历_fds_array 数组,将要进行等待的文件描述符添加到位图中
int max_num = 0;
fd_set readfds;
FD_ZERO(&readfds);
for (int i = 0; i < fds_num_max; i++) {
if (_fds_array[i] == -1) continue;
FD_SET(_fds_array[i], &readfds);
max_num = std::max(max_num, _fds_array[i]);
}
return std::make_pair(readfds, max_num);
}
当 select等待后,存在文件描述符就绪,就需要将这些文件描述符对应的数据拿上来。
而文件描述符又分为两种:
// 是套接字就绪
void Sockfd_Ready() {
// 1. 将套接字中建立好的连接拿上来
// 2. 将拿上来的文件描述符加入到_fds_array 中,等到客户端发送消息过来
int fd = _sock_ptr->Accept();
AddToArray(fd);
}
// fd 表示文件描述符,i 表示在数组中的位置
void Normalfd_Ready(int fd, int i) {
// 1. 读取文件描述符中的数据
// 2. 将数据简单处理后,进行返回(此处假设 TCP 接收的报文是完整的)
char inbuffer[1024];
int n = read(fd, inbuffer, sizeof(inbuffer)-1);
if (n > 0) {
inbuffer[n] = 0;
std::string ret = "server got a message : ";
ret += inbuffer;
write(fd, ret.c_str(), ret.size());
} else if (n == 0) {
// 对方已经关闭文件
// 1. 将在_fds_array 中的对应位置设为 -1 表示已经被移除了,不需要再进行等待
// 2. 关闭文件描述符
_fds_array[i] = defaultfd;
close(fd);
} else {
// 出错了
Log(Error) << "read fail";
}
}
void Dispather(fd_set* fdreads) {
int listensock = _sock_ptr->Get_fd();
for (int i = 0; i < fds_num_max; i++) {
if (_fds_array[i] == defaultfd || !FD_ISSET(_fds_array[i], fdreads)) continue;
if (_fds_array[i] == listensock) {
Sockfd_Ready();
} else {
Normalfd_Ready(_fds_array[i], i);
}
}
}
void Run() {
while (true) {
auto [fdreads, max_num] = Get_readfds();
int n = select(max_num + 1, &fdreads, nullptr, nullptr, nullptr);
if (n > 0) {
// 有事件就绪,进行任务的派发
Dispather(&fdreads);
} else if (n == 0) {
Log(Info) << "no file";
} else {
Log(Error) << "select fail";
}
}
}
以上就是整个 selectserver类的实现了。
优点:
select来做,只要有读事件就绪就通知上层来将数据取走;缺点:
fd_set,等待的文件描述符的数量是有限的;select的时候都要进行重新设置;fd_set从用户层拷贝到内核中,又要拷贝回来,拷贝数据频繁;后续文章中我们将讲解 select的替代方案:poll和 epoll.

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online