参考博客:https://blog.csdn.net/sjsjnsjnn/article/details/128371848
一、多路复用介绍
在网络编程中,多路复用(I/O Multiplexing) 是一种高效处理多个 I/O 通道的技术。它允许单线程或单进程同时监控多个文件描述符(如套接字、文件等),当其中任何一个或多个描述符变为可读、可写或发生异常时,程序能够立即感知并进行相应处理。这种机制特别适合构建高并发的网络服务器,避免了为每个客户端连接创建单独线程或进程的开销。
为什么需要多路复用?
传统的网络编程模型中,处理多个客户端连接通常有两种方式:
-
阻塞 I/O + 多线程 / 多进程:为每个客户端连接创建一个独立的线程或进程。这种方式实现简单,但随着并发连接数的增加,系统资源(如内存、CPU 调度开销)会迅速耗尽,扩展性较差。
-
非阻塞 I/O + 轮询:程序不断主动查询每个文件描述符的状态。这种方式虽然避免了线程创建的开销,但会导致 CPU 资源的浪费(大量无效轮询)。
而多路复用技术通过系统提供的特定机制(如 select
、poll
、epoll
等),让内核代为监控多个文件描述符,只有当真正有事件发生时才通知程序处理,既减少了线程 / 进程的创建,又避免了无效轮询,显著提高了系统的并发处理能力。
二、 select 机制详解
select
是最早实现的多路复用机制之一,几乎被所有操作系统支持。它的核心思想是:将需要监控的文件描述符集合告诉内核,内核在这些描述符中有事件发生时返回,并通知程序哪些描述符就绪。
2.1 select 函数原型
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
参数说明:
-
nfds参数指定被监听的文件描述符的总数。它通常被设置为select监听所有文件描述符中最大值加1,因为文件描述符是从0开始计数的。
-
readfds、writefds和exceptfds参数分别指向可读、可写和异常等事件对应的文件描述符集合。应用程序调用select函数时,通过这3个参数传入自己感兴趣的文件描述符。select调用返回时,内核将修改它们来通知应用程序那些文件描述符已经就绪。这3个参数都是fd_set类型,它是一种位图结构。
图一(以readfds为例):我们这三个参数都是这样的位图结构(其实也就是数组),下标位置就是所对应的文件描述符,我们将自己想要关心的文件描述符设置到集合中(关心0、1、2),当select函数调用返回时,会将该集合中被关心的文件描述符重新设置(当该描述符读事件就绪时);如图二所示,我们可以看到,只有0和1这两个文件描述符的读事件就绪了,所以2号文件描述符又被至为-1了。
简单的来讲比特位的内容**:**
-
输入时:用户告诉内核,你要帮我关心这个集合中的哪些文件描述符。
-
输出时:内核告诉用户,你关心的那些文件描述符上的读事件,有哪些已经就绪了。
-
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_set
是 select
用于存储文件描述符集合的数据结构,本质上是一个位图(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服务器的工作流程应该是这样的:
-
首先完成基本的套接字的创建、绑定和监听。
-
定义一个fd_array数组用于保存监听套接字和已经与客户端建立连接的套接字,刚开始时就将监听套接字添加到fd_array数组当中。
-
然后服务器开始循环调用select函数,检测读事件是否就绪,如果就绪则执行对应的操作。
-
每次调用select函数之前,都需要定义一个读文件描述符集readfds,并将fd_array当中的文件描述符依次设置进readfds当中,表示让select帮我们监听这些文件描述符的读事件是否就绪。
-
当select检测到数据就绪时会将读事件就绪的文件描述符设置进readfds当中,此时我们就能够得知哪些文件描述符的读事件就绪了,并对这些文件描述符进行对应的操作。
-
如果读事件就绪的是监听套接字,则调用accept函数从底层全连接队列获取已经建立好的连接,并将该连接对应的套接字添加到fd_array数组当中。
-
如果读事件就绪的是与客户端建立连接的套接字,则调用read函数读取客户端发来的数据并进行打印输出。
-
当然,服务器与客户端建立连接的套接字读事件就绪,也可能是因为客户端将连接关闭了,此时服务器应该调用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可读:
- socket内核接收缓冲区中的字节数大于或等于其低水位标记SO_RCVLOWAT。此时我们可以无阻塞的读该socket,并且读操作返回的字节数大于0。
- socket通信的对方关闭连接。此时对该socket的读操作将返回0。
- 监听socket上有新的连接请求。
- socket上有未处理的错误。
下列情况下socket可写:
- socket内核发送缓冲区中的可用字节数大于等于低水位标记SO_SNDLOWAT,此时可以无阻塞的写,并且返回值大于0。
- socket的写操作被关闭(close),对写操作被关闭的socket进行写操作,会触发SIGPIPE信号。
- socket使用非阻塞connect连接成功或失败之后。
- socket上有未处理的错误。
异常情况只有一种:
- 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
。