在网络编程中,处理多个并发连接是每个开发者必须面对的挑战。相比于传统的阻塞式I/O,多路复用技术能让我们用单线程高效管理大量连接。poll作为select的改进版,解决了文件描述符数量限制的问题,是Linux系统编程中的重要工具。
poll的核心优势在于:
在实际项目中,poll特别适合以下场景:
我们的PollServer类采用了现代C++的RAII风格,确保资源安全。核心成员包括:
cpp复制class PollServer {
const static int size = 4096; // 最大监控fd数量
const static int defaultfd = -1; // 无效fd标记
private:
std::unique_ptr<Socket> _listensock; // 监听socket智能指针
bool _isrunning; // 服务器运行状态
struct pollfd _fds[size]; // pollfd结构数组
};
关键设计决策:
构造函数中的初始化逻辑值得注意:
cpp复制PollServer(int port) : _listensock(std::make_unique<TcpSocket>()), _isrunning(false) {
_listensock->BuildTcpSocketMethod(port); // 创建监听socket
// 初始化pollfd数组
for(int i = 0; i < size; i++) {
_fds[i].fd = defaultfd;
_fds[i].events = 0;
_fds[i].revents = 0;
}
// 设置监听socket的pollfd
_fds[0].fd = _listensock->Fd();
_fds[0].events = POLLIN; // 监听读事件
}
这里有几个专业技巧:
Start()方法实现了服务器的主事件循环:
cpp复制void Start() {
_isrunning = true;
int timeout = 1000; // 1秒超时
while(_isrunning) {
int n = poll(_fds, size, timeout);
switch(n) {
case -1:
LOG(LogLevel::ERROR) << "poll error";
break;
case 0:
LOG(LogLevel::INFO) << "time out...";
break;
default:
LOG(LogLevel::DEBUG) << "有事件就绪了... n:" << n;
Dispatcher();
break;
}
}
}
关键参数说明:
Dispatcher()方法负责处理就绪事件:
cpp复制void Dispatcher() {
for(int i = 0; i < size; i++) {
if(_fds[i].fd == defaultfd) continue;
if(_fds[i].revents & POLLIN) { // 检查读事件
if(_fds[i].fd == _listensock->Fd()) {
Accepter(); // 处理新连接
} else {
Recver(i); // 处理客户端数据
}
}
}
}
性能优化点:
Accepter()方法展示了专业级的连接管理:
cpp复制void Accepter() {
InetAddr client;
int sockfd = _listensock->Accept(&client);
if(sockfd >= 0) {
LOG(LogLevel::INFO) << "Get new link success, kfd: " << sockfd
<< " client:" << client.StringAddr();
// 查找空闲位置
int pos = 0;
for(; pos < size; pos++) {
if(_fds[pos].fd == defaultfd) break;
}
if(pos == size) { // 数组已满
LOG(LogLevel::WARNING) << "poll server full";
close(sockfd); // 必须关闭无法处理的socket
} else {
_fds[pos].fd = sockfd;
_fds[pos].events = POLLIN; // 监听读事件
_fds[pos].revents = 0;
}
}
}
连接管理要点:
Recver()方法展示了健壮的数据处理:
cpp复制void Recver(int pos) {
char buffer[1024];
ssize_t n = recv(_fds[pos].fd, buffer, sizeof(buffer)-1, 0);
if(n > 0) { // 正常数据
buffer[n] = 0; // 添加字符串终止符
std::cout << "client say: " << buffer << std::endl;
}
else if(n == 0) { // 客户端关闭连接
LOG(LogLevel::INFO) << "client quit...";
CloseFd(pos); // 清理资源
}
else { // 错误情况
LOG(LogLevel::ERROR) << "recv error";
CloseFd(pos);
}
}
// 封装资源清理逻辑
void CloseFd(int pos) {
close(_fds[pos].fd);
_fds[pos].fd = defaultfd;
_fds[pos].events = 0;
_fds[pos].revents = 0;
}
错误处理经验:
示例中使用固定大小数组,实际项目建议动态扩容:
cpp复制// 在类定义中替换固定数组
std::vector<struct pollfd> _fds;
// 修改Accepter中的查找逻辑
int pos = FindEmptySlot();
if(pos == -1) {
_fds.resize(_fds.size() * 1.5); // 1.5倍扩容
pos = _fds.size() - 1;
}
// 辅助方法
int FindEmptySlot() {
for(size_t i = 0; i < _fds.size(); ++i) {
if(_fds[i].fd == defaultfd) return i;
}
return -1;
}
扩容策略考量:
虽然poll本身是单线程的,但可以结合线程池提高吞吐量:
cpp复制// 添加线程池成员
ThreadPool _pool{4}; // 4个工作线程
// 修改Recver处理
void Recver(int pos) {
char buffer[1024];
ssize_t n = recv(_fds[pos].fd, buffer, sizeof(buffer), 0);
if(n > 0) {
_pool.enqueue([buffer, n] {
// 复杂处理放入线程池
ProcessData(buffer, n);
});
}
// ...错误处理不变
}
线程整合要点:
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| poll立即返回-1 | 被信号中断 | 检查errno,EINTR时重试 |
| 接收数据不完整 | 缓冲区太小 | 增大缓冲区或循环接收 |
| 客户端连接被拒绝 | 文件描述符耗尽 | 检查ulimit -n设置 |
| CPU占用过高 | timeout设置过小 | 适当增大超时时间 |
cpp复制void PrintActiveFds() {
for(int i = 0; i < size; i++) {
if(_fds[i].fd != defaultfd) {
std::cout << "Active fd: " << _fds[i].fd
<< ", events: " << _fds[i].events
<< ", revents: " << _fds[i].revents << std::endl;
}
}
}
bash复制strace -f ./poll_server
bash复制watch -n 1 'ls /proc/<pid>/fd | wc -l'
| 特性 | select | poll | epoll |
|---|---|---|---|
| 最大连接数 | 1024 | 无限制 | 无限制 |
| 时间复杂度 | O(n) | O(n) | O(1) |
| 内存使用 | 固定位图 | 动态数组 | 内核红黑树 |
| 触发模式 | 水平触发 | 水平触发 | 支持边缘触发 |
| 适用场景 | 低并发兼容 | 中等并发 | 高并发 |
在实际项目中,我通常会根据这样的评估来选择多路复用方案。对于大多数中等规模的网络应用,poll提供了良好的平衡点——比select更强大,比epoll更简单。特别是在需要支持多种Unix-like系统的场景下,poll的兼容性优势更加明显。