(四)IO多路复用:select
服务器通常需要处理大量客户端的连接请求,为了高效地处理这些请求,可以使用多线程设计服务器。然而,多线程的每个线程都会消耗一定的系统资源,包括内存和CPU时间。当线程数量过多时,这些资源的消耗会变得非常显著,可能导致系统性能下降。此外,CPU在进行线程之间的上下文切换时也会有一定的开销,这进一步增加了系统的负担。
如何使用单线程高效处理多个客户端的请求呢?
在Linux系统中,一切皆文件,每个客户端连接都是以一个文件描述符(File Descriptor,FD)表示。最简单粗暴的方法是遍历多个客户端的文件描述符集合,如果有数据则处理;并通过非阻塞套接字来避免当前客户端阻塞其他客户端的通信:
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <fcntl.h> // 非阻塞
#include <vector>
#include <algorithm>
// 设置文件描述符为非阻塞模式
void set_non_blocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
// 接收和处理客户端数据
void handle_client(int& fd) {
char buf[1024]; // 共享缓冲区
int len = recv(fd, buf, sizeof(buf) - 1, 0); // 从客户端读取数据,注意缓冲区大小减1以留出空间给'\0'
if (len == 0) {
// 客户端关闭连接
std::cout << "fd" << fd << "的客户端已经关闭连接..." << std::endl;
close(fd); // 关闭套接字
fd = -1; // 标记为已关闭
} else if (len < 0) {
// 接收失败
if (errno != EAGAIN && errno != EWOULDBLOCK) { // 只处理非阻塞的错误
std::cerr << "接收数据失败..." << std::endl;
close(fd); // 关闭通信套接字
fd = -1; // 标记为已关闭
}
} else {
buf[len] = '\0';
// 打印客户端发送的消息
std::cout << "fd" << fd << "的客户端: " << buf << std::endl;
// 回应客户端
snprintf(buf, sizeof(buf), "你好, 客户端\n"); // 使用 snprintf 以避免缓冲区溢出
send(fd, buf, strlen(buf), 0); // 发送回应
}
}
int main() {
// 创建用于监听的套接字
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd == -1) {
perror("socket");
exit(EXIT_FAILURE);
}
// 设置监听套接字为非阻塞模式
set_non_blocking(listen_fd);
// 绑定套接字
sockaddr_in addr;
addr.sin_family = AF_INET; // ipv4
addr.sin_port = htons(10000); // 设置端口号并转换为网络字节序
inet_pton(AF_INET, "172.31.108.107", &addr.sin_addr.s_addr); // 指定IP地址
int ret = bind(listen_fd, (sockaddr*)&addr, sizeof(addr));
if (ret == -1) {
perror("bind");
close(listen_fd); // 关闭套接字
exit(EXIT_FAILURE);
}
// 设置监听
ret = listen(listen_fd, 100);
if (ret == -1) {
perror("listen");
close(listen_fd); // 关闭套接字
exit(EXIT_FAILURE);
}
std::vector<int> client_fds; // 用于存储客户端信息的向量
while (true) {
// 接受新客户端连接
sockaddr_in client_addr;
socklen_t len = sizeof(client_addr);
int conn_fd = accept(listen_fd, (sockaddr*)&client_addr, &len);
if (conn_fd != -1) {
// 设置新连接为非阻塞模式
set_non_blocking(conn_fd);
std::cout << "客户端连接,fd: " << conn_fd << std::endl;
// 将新连接添加到 clients 向量
client_fds.push_back(conn_fd);
}
// 处理客户端套接字
for (auto& fd : client_fds) {
if (fd != -1) {
handle_client(fd); // 处理客户端数据
}
}
// 移除已关闭的客户端
client_fds.erase(std::remove_if(client_fds.begin(), client_fds.end(), [](int fd) { return fd == -1; }), client_fds.end());
}
// 关闭监听套接字
close(listen_fd);
return 0;
}
这种方法必须对客户端套接字进行逐个轮询,无法同时监控多个客户端的状态,效率较低。此外,使用非阻塞套接字避免了阻塞带来的问题,但会导致 CPU 占用高、编程复杂度增加、错误处理变得更复杂,以及可能出现数据传输不完整的情况。
针对这个问题,出现了IO多路复用方法。IO指的是文件、网络等的输入/输出操作;多路指的是同时监听多个网络连接或文件描述符(即多个IO通道);复用指的是允许同一个线程处理多个IO操作,避免了为每个IO操作创建独立线程,从而实现了资源的复用。IO多路复用可以监听多个套接字,保证accept
只有在能够成功执行时才会被调用,从而避免不必要的阻塞。
IO多路复用方法主要包括 select,poll,epoll。
【并发】IO多路复用select/poll/epoll介绍
select、poll、epoll 这三种IO多路复用技术的执行原理
IO多路复用到底是不是异步的?
select
<select.h>介绍:https://subingwen.cn/linux/select/
select 简介:select 通过文件描述符集合 fd_set
来存储和监视多个文件描述符的状态。fd_set
是一个位图(bitmap),通常由 128 个字节(1024 个标志位)组成。调用 select 时,将这个位图传递给内核,以判断哪些文件描述符是就绪的。
使用select实现TCP服务端处理多个客户端的连接请求和通信:
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/select.h> // fd_set, select, FD_SET, FD_CLR, FD_ISSET, FD_ZERO
#include <sys/socket.h>
#include <vector>
#include <algorithm>
// 接收和处理客户端数据
void handle_client(int fd, fd_set& master_set) {
char buf[1024]; // 共享缓冲区
int len = recv(fd, buf, sizeof(buf) - 1, 0); // 从客户端读取数据,注意缓冲区大小减1以留出空间给'\0'
if (len == 0) {
// 客户端关闭连接
std::cout << "fd为" << fd << "的客户端已经关闭连接..." << std::endl;
FD_CLR(fd, &master_set); // 从 master_set 中移除
close(fd); // 关闭套接字
} else if (len > 0) {
// cout遇到'\0'时,将其视为字符串结束。避免输出缓冲区的旧数据或乱码。
buf[len] = '\0';
// 打印客户端发送的消息
std::cout << "fd为" << fd << "的客户端: " << buf << std::endl;
// 回应客户端
snprintf(buf, sizeof(buf), "你好, 客户端\n"); // 使用 snprintf 以避免缓冲区溢出
send(fd, buf, strlen(buf), 0); // 发送回应
} else {
if (errno != EAGAIN && errno != EWOULDBLOCK) {
std::cerr << "接收数据失败..." << std::endl;
FD_CLR(fd, &master_set); // 从 master_set 中移除
close(fd); // 关闭套接字
}
}
}
int main() {
// 创建用于监听的套接字
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd == -1) {
perror("socket");
exit(EXIT_FAILURE);
}
// 绑定套接字
sockaddr_in addr;
addr.sin_family = AF_INET; // ipv4
addr.sin_port = htons(10000); // 设置端口号并转换为网络字节序
inet_pton(AF_INET, "172.31.108.107", &addr.sin_addr.s_addr); // 指定IP地址
int ret = bind(listen_fd, (sockaddr*)&addr, sizeof(addr));
if (ret == -1) {
perror("bind");
close(listen_fd); // 关闭套接字
exit(EXIT_FAILURE);
}
// 设置监听
ret = listen(listen_fd, 100);
if (ret == -1) {
perror("listen");
close(listen_fd); // 关闭套接字
exit(EXIT_FAILURE);
}
fd_set master_set, read_set;
// master_set:用作主集合,包含所有需要监视的文件描述符。
// read_set:就绪的文件描述符。
FD_ZERO(&master_set); // 清空主集合
FD_SET(listen_fd, &master_set); // 添加监听文件描述符到集合
int max_fd = listen_fd; // 最大的文件描述符
while (true) {
// 每次调用select前重新初始化read_set为master_set
read_set = master_set; // 复制主集合到读集合
// select 会修改read_set ,只保留就绪的文件描述符。
int ready = select(max_fd + 1, &read_set, nullptr, nullptr, nullptr); // 阻塞等待
if (ready == -1) {
perror("select");
break;
}
// 判断监听套接字是否就绪
if (FD_ISSET(listen_fd, &read_set)) {
sockaddr_in client_addr;
socklen_t len = sizeof(client_addr);
// 接受新客户端连接
int conn_fd = accept(listen_fd, (sockaddr*)&client_addr, &len);
if (conn_fd == -1) {
perror("accept");
continue;
}
std::cout << "客户端连接,fd: " << conn_fd << std::endl;
// 将新连接的文件添加到 master_set
FD_SET(conn_fd, &master_set);
if (conn_fd > max_fd) {
max_fd = conn_fd; // 更新最大文件描述符
}
}
// 处理客户端套接字
for (int fd = 0; fd <= max_fd; fd++) {
if (fd != listen_fd && FD_ISSET(fd, &read_set)) {
handle_client(fd, master_set); // 处理客户端数据
}
}
}
// 关闭监听套接字
close(listen_fd);
return 0;
}