网络编程:Linux下高性能TCP网络服务器(代码完整版)
网络编程:Linux下高性能TCP网络服务器(代码完整版)
高性能服务器的核心在于使用非阻塞I/O和事件驱动模型。在Linux上,这通常通过 epoll 系统调用来实现。下面是一个简化的、基于Reactor模式的回显(Echo)服务器,它会接收客户端发来的数据并原样返回。
C++17 实现代码
#include <iostream>
#include <string>
#include <vector>
#include <cstring>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <sys/epoll.h>
const int MAX_EVENTS = 1024;
const int PORT = 8080;
// 设置套接字为非阻塞模式
void set_non_blocking(int sockfd) {
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);
}
int main() {
// 1. 创建监听套接字
int listen_sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_sockfd < 0) {
perror("socket creation failed");
return 1;
}
// 设置地址复用,防止重启时端口被占用
int opt = 1;
if (setsockopt(listen_sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) {
perror("setsockopt failed");
return 1;
}
// 2. 绑定地址和端口
struct sockaddr_in server_addr{};
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(PORT);
if (bind(listen_sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
perror("bind failed");
return 1;
}
// 3. 开始监听
if (listen(listen_sockfd, 10) < 0) {
perror("listen failed");
return 1;
}
set_non_blocking(listen_sockfd); // 将监听套接字也设为非阻塞
std::cout << "Server listening on port " << PORT << std::endl;
// 4. 创建epoll实例
int epoll_fd = epoll_create1(0);
if (epoll_fd < 0) {
perror("epoll_create1 failed");
return 1;
}
// 5. 将监听套接字添加到epoll监控
struct epoll_event event{};
event.events = EPOLLIN; // 监控可读事件
event.data.fd = listen_sockfd;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_sockfd, &event) < 0) {
perror("epoll_ctl add listen failed");
return 1;
}
// 6. 事件循环
struct epoll_event events[MAX_EVENTS];
while (true) {
// 等待事件发生
int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
if (nfds < 0) {
perror("epoll_wait failed");
break;
}
for (int i = 0; i < nfds; ++i) {
if (events[i].data.fd == listen_sockfd) {
// --- 处理新连接 ---
struct sockaddr_in client_addr{};
socklen_t client_len = sizeof(client_addr);
int conn_sockfd = accept(listen_sockfd, (struct sockaddr*)&client_addr, &client_len);
if (conn_sockfd < 0) {
perror("accept failed");
continue;
}
std::cout << "New connection from " << inet_ntoa(client_addr.sin_addr) << ":" << ntohs(client_addr.sin_port) << std::endl;
set_non_blocking(conn_sockfd); // 新连接的套接字也必须是非阻塞的
// 将新连接的套接字加入epoll监控
event.events = EPOLLIN | EPOLLET; // 使用边缘触发(ET)模式
event.data.fd = conn_sockfd;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_sockfd, &event) < 0) {
perror("epoll_ctl add client failed");
close(conn_sockfd);
}
} else {
// --- 处理客户端数据 ---
int conn_sockfd = events[i].data.fd;
char buffer[1024];
ssize_t count = read(conn_sockfd, buffer, sizeof(buffer));
if (count < 0) {
// 非阻塞模式下,EAGAIN或EWOULDBLOCK表示数据已读完
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// std::cerr << "Read finished for fd " << conn_sockfd << std::endl;
} else {
perror("read failed");
}
} else if (count == 0) {
// 客户端关闭连接
std::cout << "Client " << conn_sockfd << " disconnected." << std::endl;
epoll_ctl(epoll_fd, EPOLL_CTL_DEL, conn_sockfd, nullptr);
close(conn_sockfd);
} else {
// 收到数据,回显给客户端
// 注意:在实际高性能场景中,write也可能需要处理EAGAIN
if (write(conn_sockfd, buffer, count) < 0) {
perror("write failed");
}
}
}
}
}
close(listen_sockfd);
close(epoll_fd);
return 0;
}
关键点解析
- 非阻塞I/O:通过
fcntl将套接字设置为非阻塞模式。这样,read和accept等系统调用在没有数据或连接时会立即返回,而不是让线程挂起,从而允许一个线程处理成千上万个连接。 epoll事件驱动:epoll是Linux下高效的I/O多路复用机制。服务器通过epoll_wait等待多个套接字上的事件(如可读、可写),当有事件发生时,内核会通知程序,程序再进行处理。这避免了为每个连接创建线程的巨大开销。- 边缘触发(ET)模式:代码中使用了
EPOLLET。这意味着只有当套接字的状态发生变化时(例如,从无数据到有数据),epoll_wait才会通知一次。这要求程序必须一次性将缓冲区的数据读完(或写入直到EAGAIN),性能通常比水平触发(LT)模式更高。 - C++17特性:代码中使用了C++17的类模板参数推导(CTAD),例如
struct sockaddr_in server_addr{};,可以自动推导类型并进行值初始化,使代码更简洁。
epoll 单线程支撑十万级并发连接、处理数据的核心原理
我用通俗原理 + 和传统select/poll对比 + 关键底层机制,把核心逻辑讲透,全是面试&底层本质。
一、先给结论:能扛十万级并发,靠这5个核心
- epoll 内核就绪队列 + 事件通知模型,不用遍历所有连接
- 文件描述符采用红黑树管理,增删查都是 (O(\log n))
- 边缘触发 EPOLLET + 非阻塞IO,极致性能、无多余事件通知
- 一次 epoll_wait 批量返回就绪事件,用户态内核态拷贝开销极小
- 单线程事件循环,无线程切换开销,内存、CPU 消耗极低
二、逐个拆解核心原理
1. 传统 select/poll 为什么扛不住10万连接?
致命缺点:
- 你要把所有10万个fd都传给内核
- 内核遍历全部10万fd挨个检查有没有事件
- 检查完再把10万fd拷贝回用户态
- 用户态还要再遍历10万fd找哪个有事
连接越多,遍历开销线性暴涨,1万连接就卡了,更别说10万。
时间复杂度
select/poll:(O(n)),n是总连接数。
2. epoll 核心机制1:事件驱动,只返回就绪的fd
epoll 设计思路完全反过来:
- 你提前把所有fd注册到内核epoll红黑树
- 内核全程帮你监控每个fd状态
- 只有有事件就绪(新连接/有数据/断开)的fd,才会被内核放入就绪链表
epoll_wait只把就绪的少量fd拷贝回用户态
👉 用户态只遍历「有事件的几十个fd」,不是遍历十万个
时间复杂度:(O(1)) 就绪事件数,和总连接数无关
这是能撑十万级空闲长连接的最关键原因。
3. epoll 核心机制2:内核用红黑树管理所有监听fd
- 所有要监听的 socket fd,在内核用红黑树组织
epoll_ctl ADD/DEL/MOD增删改:(O(\log n))- 10万级连接,增删依然极快,不会卡顿
select 是数组、poll 是链表,海量连接下查找、删除都很慢。
4. 核心机制3:EPOLLET 边缘触发 + 全非阻塞
水平触发 LT(默认)
只要缓冲区有数据,每次 epoll_wait 都反复通知
浪费CPU,容易重复处理。
边缘触发 ET(你代码里用的 EPOLLIN|EPOLLET)
只在状态发生变化的一瞬间通知一次:
- 新连接到达 瞬间 通知一次
- 客户端发数据 从无到有 瞬间 通知一次
- 不会重复刷屏通知
配合每个socket都设为非阻塞:
- 一次事件触发,循环read直到读空缓冲区
- 不丢失数据、不重复触发、CPU利用率拉满
十万连接大部分是空闲长连接,ET模式完全不浪费CPU在空闲fd上。
5. 核心机制4:用户态/内核态 拷贝开销极小
- select:每次调用都要整表拷贝所有fd进内核、再拷回来
- epoll:
- 注册只在
epoll_ctl时一次 epoll_wait只拷贝少量就绪事件- 十万长连接绝大部分没事,几乎无拷贝开销
- 注册只在
6. 核心机制5:单线程事件循环,无线程调度开销
普通多线程模型:
- 1个连接开1个线程
- 10万连接就要10万线程
- 线程栈内存爆炸、内核线程调度CPU疯狂上下文切换,直接崩
epoll 模型:
- 一个线程就能管理10万+连接
- 没有线程创建、销毁、切换开销
- 只循环处理有事件的连接,空闲连接完全不占CPU
三、十万级连接能稳定运行的本质总结
- 不遍历全部连接,只处理就绪连接(和总连接数无关)
- 内核红黑树管理海量fd,增删高效 (O(\log n))
- 边缘触发ET + 非阻塞,空闲连接零CPU消耗
- 仅就绪事件拷贝,用户态内核态开销极低
- 单线程事件循环,无线程上下文切换、无内存爆炸
四、一句话极简概括
epoll 能撑十万级并发,是因为它不轮询所有连接,而是内核主动只把「有事的连接」通知给程序,再加上红黑树管理、边缘触发、非阻塞IO、单线程事件循环,做到十万空闲连接几乎不耗CPU和内存。
select / poll / epoll 并发模型对比图解 + 核心差异
用最直白的模型对比,一眼看懂为什么只有 epoll 能扛十万级并发。
一、先统一场景
假设:总连接 100000 个,同一时刻只有 5 个有数据/新连接。
1. Select 模型(古董级)
工作流程
- 用户态每次把全部 10 万个 fd 拷贝传给内核
- 内核遍历全部 10 万 fd,逐个检查有没有事件
- 标记就绪的 fd,再把整个集合拷贝回用户态
- 用户态再遍历全部 10 万 fd,挨个判断谁有事
致命缺点
- 每次都全量拷贝 + 全量内核遍历 + 全量用户遍历
- 时间复杂度:O(n)
- 有最大 fd 数量限制(默认 1024)
- 连接越多,越卡,撑不住上万连接
形象比喻
老板把10万份文件全部交给保安,保安从头到尾翻一遍找出有问题的,再把整摞还给老板,老板再从头翻一遍找问题。
2. Poll 模型(改良版,本质没变)
工作流程
和 select 逻辑几乎一样:
- 把全部 10 万 fd 数组传给内核
- 内核遍历全部 fd 检测事件
- 拷贝回用户态,用户再遍历全部 fd
改进
- 去掉了 1024 最大限制
没解决的核心问题
- 依然全量拷贝、全量遍历 O(n)
- 十万连接下 CPU 开销依然爆炸
形象比喻
还是翻完整摞文件,只是文件数量不限制了,但还是要全翻。
3. Epoll 模型(现代高性能)
工作流程
- 一开始用
epoll_ctl把 10万 fd 注册到内核红黑树,只注册一次 - 内核全程后台自动监控所有 fd,无事完全不管
- 只有有事件就绪的 fd,内核放进就绪链表
epoll_wait只把就绪的 5 个 fd 拷贝回用户态- 用户态只遍历这 5 个就绪 fd 处理即可
核心优势
- 不用每次传全部 fd
- 内核不用遍历所有连接
- 用户态只遍历就绪少量连接
- 时间复杂度:O(1)(只跟就绪数有关,跟总连接数无关)
- 红黑树管理 fd:增删改 O(logn)
- 配合 ET边缘触发 + 非阻塞,空闲 10万连接几乎 0 CPU 消耗
形象比喻
老板提前把 10万份文件编号交给保安保管,保安平时自动盯着;
只有出问题的那几份,单独拿给老板,老板只处理这几份,不用翻全部。
二、三者核心对比表
| 特性 | select | poll | epoll |
|---|---|---|---|
| 遍历方式 | 全量遍历所有fd | 全量遍历所有fd | 只遍历就绪fd |
| 时间复杂度 | O(n) | O(n) | O(就绪数) |
| 每次拷贝 | 全量fd拷贝 | 全量fd拷贝 | 只拷贝就绪事件 |
| fd上限 | 限制1024 | 无限制 | 无限制 |
| 内核管理结构 | 位图 | 链表 | 红黑树+就绪链表 |
| 适合并发量 | 几百~一千 | 几千 | 十万~百万级 |
| 触发模式 | 水平触发LT | 水平触发LT | LT / ET边缘触发 |
三、为什么你的代码能扛十万级连接?
- epoll 不用遍历全部 10万 连接,只处理有事的几个
- 内核红黑树管海量 fd,增删高效不卡顿
- EPOLLET 边缘触发 + 全非阻塞,空闲连接不浪费 CPU
- 单线程事件循环,无线程上下文切换开销
- 只拷贝就绪事件,用户态/内核态开销极低
四、一句总结
- select/poll:人少还行,人多全员挨个点名
- epoll:不用点名,谁有事主动举手,只处理举手的人
这就是 epoll 能轻松支撑十万、百万级长连接的根本原理。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐



所有评论(0)