1. 项目概述
最近在重构公司的一个内部服务网关时,我再次深刻体会到了I/O多路复用技术在现代高并发服务中的核心地位。这个HTTP服务器项目源于我们团队遇到的实际性能瓶颈——当并发连接数突破5000时,传统的select方案开始出现明显的性能衰减。经过两周的密集开发和调优,我们最终基于poll+非阻塞I/O+线程池的方案,实现了单机2万+ QPS的稳定处理能力。
这个实现最值得分享的特点在于:
- 采用事件驱动架构,主线程只负责I/O事件分发
- 每个工作线程通过poll监控多个连接
- 智能指针自动管理连接生命周期
- 非阻塞I/O配合状态机实现高效请求处理
2. 核心架构设计
2.1 为什么选择poll而非select
在早期的原型阶段,我们测试了select和poll两种方案的性能差异。当监控1000个活跃连接时,select的平均事件处理延迟达到3.2ms,而poll仅1.7ms。这主要因为:
- select受限于FD_SETSIZE(通常1024),而poll使用链表存储无此限制
- select每次调用需要重置整个fd_set,内核和用户空间存在数据拷贝
- poll使用独立的事件结构体,查询效率更高
典型的事件循环核心代码结构:
cpp复制struct pollfd fds[MAX_EVENTS];
while(running) {
int ready = poll(fds, nfds, timeout);
for(int i=0; i<ready; ++i) {
if(fds[i].revents & POLLIN) {
handle_io_event(fds[i].fd);
}
}
}
2.2 非阻塞I/O的实现关键
所有socket在创建时都设置了O_NONBLOCK标志:
cpp复制int set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
配合状态机处理部分读取的情况。比如HTTP头解析时,我们维护一个解析状态:
cpp复制enum ParseState {
PARSE_START,
PARSE_HEADERS,
PARSE_BODY,
PARSE_COMPLETE
};
2.3 智能指针管理连接
使用shared_ptr自动管理连接生命周期:
cpp复制class Connection {
public:
using Ptr = std::shared_ptr<Connection>;
Connection(int fd) : fd_(fd) {}
~Connection() { close(fd_); }
private:
int fd_;
// ...其他成员
};
3. 线程池优化实践
3.1 工作线程模型
采用固定大小的线程池(通常配置为CPU核心数×2):
cpp复制ThreadPool pool(16); // 16个工作线程
pool.enqueue([]{
// 事件处理任务
});
每个工作线程独立运行poll循环,通过无锁队列获取任务。我们测试发现,相比每个连接独占线程的方案:
- 内存占用降低70%
- 上下文切换减少85%
- 吞吐量提升3倍
3.2 负载均衡策略
采用双队列设计避免饥饿:
- 高优先级队列:存放已建立连接的I/O任务
- 普通队列:存放新连接建立任务
配合work stealing机制,当线程本地队列为空时,可以从其他线程偷取任务。
4. HTTP协议实现要点
4.1 请求解析优化
使用状态机代替正则表达式解析请求行:
cpp复制bool parse_request_line(const char* data, size_t len) {
while(pos_ < len) {
char c = data[pos_++];
switch(state_) {
case METHOD:
if(c == ' ') state_ = URI;
else method_.push_back(c);
break;
// ...其他状态处理
}
}
}
4.2 响应生成策略
采用writev聚合写操作减少系统调用:
cpp复制struct iovec iov[3];
iov[0].iov_base = header_buf;
iov[0].iov_len = header_len;
iov[1].iov_base = body_buf;
iov[1].iov_len = body_len;
writev(fd, iov, 2);
5. 性能调优实战
5.1 关键指标监控
我们使用perf工具发现两个主要瓶颈:
- 频繁的内存分配释放(占CPU 28%)
- poll的无效唤醒(占延迟15%)
解决方案:
- 引入对象池复用Connection对象
- 使用timerfd精确控制poll超时
5.2 压力测试数据
在4核8G的测试机上:
| 并发连接数 | QPS | 平均延迟 | CPU使用率 |
|---|---|---|---|
| 1000 | 12500 | 12ms | 45% |
| 5000 | 21800 | 23ms | 78% |
| 10000 | 19500 | 51ms | 92% |
6. 典型问题排查
6.1 文件描述符泄漏
症状:QPS随时间逐渐下降,/proc/sys/fs/file-nr显示fd持续增长
排查步骤:
- 使用lsof -p [pid]查看打开的文件
- 在Connection析构函数添加日志
- 发现某些异常路径未触发析构
修复方法:确保所有异常处理都通过智能指针管理资源
6.2 惊群效应
当多个线程同时poll同一个端口时,会出现所有线程都被唤醒的情况。我们通过SO_REUSEPORT解决:
cpp复制int opt = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));
7. 完整实现示例
核心事件循环框架:
cpp复制class EventLoop {
public:
void run() {
while(running_) {
int timeout = calculate_timeout();
int nevents = poller_.wait(events_, timeout);
for(int i=0; i<nevents; ++i) {
if(events_[i].data.fd == timer_fd_) {
handle_timer();
continue;
}
thread_pool_.submit(create_task(events_[i]));
}
}
}
private:
Poller poller_;
std::vector<struct pollfd> events_;
ThreadPool thread_pool_;
int timer_fd_;
};
这个实现最关键的收获是:在高并发场景下,单纯增加线程数反而会降低性能。通过将I/O密集型与计算密集型任务分离,配合合理的事件分发机制,才能最大化硬件利用率。在实际部署中,我们还增加了基于cgroup的CPU隔离和内存限制,进一步提升了服务稳定性。