C/C++ Linux网络编程7 - epoll解决客户端并发连接问题
Linux中,select和poll虽然能够处理并发连接问题。但是它们仍有自己的缺点:需要不断在用户态-内核态转换和拷贝fd进入内核数据开销大,需要线性遍历所有关心的fd,如果连接数过大,性能降低。
而epoll就是用于解决这些痛点。
目录
一. epoll 接口
1.1 epoll_create
//所需头文件
#include <sys/epoll.h>
int epoll_create(int size);
//用于创建一个epoll句柄
//返回值
返回一个文件描述符,失败返回-1
epoll_create的参数size现在已经被弃用了。只要大于0即可。返回的fd需要在不使用的时候使用close 关闭!
1.2 epoll_ctl
//所需头文件
#include <sys/epoll.h>
int epollctl(int epfd, int op, int fd, struct epoll_event *event);
//参数说明
epfd : epoll_creat创建的epfd
op : 用于增加,修改,删除 某一个文件描述符的事件
fd : 用户需要epoll关心的文件描述符
event : 用户需要epoll关心的文件描述符中的什么事件
0表示成功,-1表示错误,并且设置错误码
op传入的宏有:
EPOLL_CTL_ADD:表示添加新的fd到epfd中
EPOLL_CTL_MOD:表示修改epfd中注册的fd的监听事件
EPOLL_CTL_DEL:从epfd中删除一个文件描述符fd,不再关心这个事件
使用epollctl关心的fd,由epoll内部自行维护。后续用户无需重新在事件就绪后重新拷贝进入内核。只要我们不去删除这个关心fd,epoll就会自动维护fd。
epoll_event 结构体
这个结构体中包含了这个关心fd的部分信息,fd,读写关心。

其中 events是一个位图,用户可以通过宏定义来设置我们关心的事件。
EPOLLIN 表示写事件关心
EPOLLOUT 表示读事件关心
EPOLLERR 表示关心异常事件
EPOLLPRI 表示有紧急数据可读(比如TCP中的带外数据)
EPOLLHUB 表示对应fd被中断
EPOLLET 表示设置为边缘触发ET模式
EPOLLONESHOT 表示只监听一次
1.3 epoll_wait
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
//返回值:和select/poll一摸一样
0 表示超时了
<0 表示出错了
>0 表示有多少个文件描述符就绪了
参数
epfd:我们epoll_creat创建的文件描述符
events:是用户关心就绪事件的集合,大小和关心事件以及fd数据由用户定义。 就绪事件是否就绪由内核自行填充
maxevents:表示事件集合的最大数量
timeout 表示等待方式 < 0 阻塞等地 = 0非阻塞等待 > 0 表示每隔多少毫米以内进行阻塞,n毫秒时返回0表示进行一次timeout
epoll唤醒就绪事件后,就会通过epoll_wait返回就绪事件。我们通过返回的就绪事件和对应的events以及回调函数,就能处理事件
二. epoll底层原理⭐
2.1 原理说明
用户 -> 内核:
epoll在内部自动维护了一颗红黑树(key 是 fd,value 是 events),当用户使用epoll_ctl进行管理fd时候其实就是操作这颗红黑树的节点。由于红黑树的特性,epoll保证了增删查改的效率都为 log(n) 。并且由内核自行维护这些数据,用户后续无需不断进入内核进行拷贝数据
这样大大减少了select/poll 频繁向内核拷贝数据的性能开销,同时提高了性能
内核 -> 用户:
epoll的每一个节点都设置好了回调机制,,当底层数据就绪后会通过回调函数返回。并且epoll内核会维护一个就绪队列,事件就绪的节点被放入就绪队列。这样我们就不需要去遍历红黑树来判断哪些事件就绪,而是通过就绪队列返回真正就绪的事件。
这样就避免了select/poll去线性遍历所有的关心fd
总结一下就是:epoll通过红黑树管理fd + 就绪队列 + 回调机制 直接返回真正就绪的事件。这样就解决了select/poll的两个痛点。大大提高效率
2.2 总结
epoll_create 用于创建epoll模型(红黑树,就绪队列等结构)并初始化,同时返回一个文件描述符epfd指向epoll模型。用于后续的控制和等待
epoll_ctl 通过epfd文件描述符来控制epoll模型,比如告诉内核我需要关心的文件描述符fd和该fd的某事件,epoll模型会将该fd和其事件插入到红黑树中,
epoll_wait:当epoll模型底层收到数据的时候,通过回调函数的方式将就绪的fd和其事件连入就绪队列 epoll_ctl通过返回值的形式来告诉用户某一个fd的某事件就绪了。
2.3 epoll优点
1 通过红黑树,使用空间换时间。避免内核拷贝
2 回调机制和就绪队列。避免无效线性遍历
3 mmap等共享机制,实现零拷贝
4 epoll没有fd限制,增删查改由红黑树自行维护。编码方便
三. epoll服务器
流程为 epoll_create socket bind listen epoll_ctl 循环(epoll_wait accept/recv/send close) close
3.1 socket.hpp
这里将socket相关的接口进行封装,方便我们服务器使用。
#pragma once
#include <iostream>
#include <unistd.h>
#include <assert.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <arpa/inet.h>
#include <cstring>
const int gbacklog = 5;
class mySocket
{
public:
// 1.构建tcp socketfd
static int creatSockfd()
{
// 创建socketfd
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
assert(sockfd > 0);
return sockfd;
}
// 2.bind绑定端口
static void Bind(int sockfd, int port)
{
struct sockaddr_in serveraddr;
memset(&serveraddr, 0, sizeof(serveraddr));
// 设置地址的信息(协议,ip,端口)
serveraddr.sin_family = AF_INET;
serveraddr.sin_addr.s_addr = htonl(INADDR_ANY); // 绑定任意网卡ip,通常我们访问某一个IP地址是这个服务器的公网网卡IP地址
serveraddr.sin_port = htons(port); // 注意端口16位,2字节需要使用htons。不可使用htonl
if (bind(sockfd, (const sockaddr *)(&serveraddr), sizeof(serveraddr)) < 0)
{
perror("sock bind err");
exit(-1);
}
std::cout << "sock bind success" << std::endl;
}
// 3. listen监听,让打开的sock这个"文件"去监听来自网络的请求。用于获取新的网络连接
static void Listen(int sockfd, int n)
{
if (listen(sockfd, n) == -1)
{
perror("sock listen err");
exit(-1);
}
std::cout << "sock listen success" << std::endl;
}
// 4 accept创建sockfd用于传输数据
static int Accept(int listenfd, std::string &clientIp, uint16_t &clientPort)
{
// 获取新fd用于通信
struct sockaddr_in clientaddr;
memset(&clientaddr, 0, sizeof(clientaddr));
socklen_t len = sizeof(clientaddr);
int sockfd = accept(listenfd, (struct sockaddr *)&clientaddr, &len);
if (sockfd < 0)
{
perror("sock accept err");
exit(-1);
}
else
{
// 成功了,可以获取对方的ip和端口
clientIp = inet_ntoa(clientaddr.sin_addr);
clientPort = ntohs(clientaddr.sin_port);
}
return sockfd;
}
};
3.2 tcpServer.hpp
服务器代码框架如下:
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
#include <cstring>
#include <functional>
#include <thread>
#include "socket.hpp"
const int fdnums = 100000;
namespace YZC
{
// 设置默认端口和最大backlog
const int defaultPort = 8080;
const int maxBacklog = 128;
// 设置回调函数
using func_t = std::function<int(int)>;
class tcpServer
{
public:
tcpServer(func_t func, int port = defaultPort)
: _port(port), _epfd(-1), _callback(func) {}
void init()
{
}
void run()
{
}
private:
int _listensock; // 监听sock
int _port; // 端口port
int _epfd; // epoll fd
struct epoll_event *_events; // epoll返回数组
func_t _callback; // fd对应回调函数
};
}
init 函数
这个函数是用于初始化服务器的部分功能,包含epoll_create socket bind listen。由于我们之前封装了socket,这部分代码很快就能实现
void init()
{
// epoll_create创建epollfd,初始化返回epoll_event数组
_epfd = epoll_create(1);
if (_epfd < 0)
{
printf("epoll_create err\n");
exit(errno);
}
_events = new struct epoll_event[fdnums];
// socket
_listensock = mySocket::creatSockfd();
// bind
mySocket::Bind(_listensock, _port);
// listen
mySocket::Listen(_listensock, maxBacklog);
// epollctl关心 listensock,注意要设置好event
struct epoll_event ev;
ev.data.fd = _listensock;
ev.events = EPOLLIN;
epoll_ctl(_epfd, EPOLL_CTL_ADD, _listensock, &ev);
}
run 函数
这部分是服务器的主要运行代码,循环处理epoll_wait返回事件。包含accept/read/send
void run()
{
while (true)
{
// epoll_wait唤醒就绪事件
int n = epoll_wait(_epfd, _events, fdnums, -1);
// 遍历_events执行fd对应方法
for (int i = 0; i < n; i++)
{
int fd = _events[i].data.fd;
int event = _events[i].events;
if (fd == _listensock)
{
// 说明监听事件就绪了,需要accept新连接
std::string ip;
uint16_t port;
int clientsock = mySocket::Accept(_listensock, ip, port);
// 然后epoll_ctl注册该事件
struct epoll_event ev;
ev.data.fd = clientsock;
ev.events = EPOLLIN;
epoll_ctl(_epfd, EPOLL_CTL_ADD, clientsock, &ev);
}
else if (event & EPOLLIN)
{
// 读事件就绪,这里直接使用回调函数简单处理
_callback(fd);
if (n == 0)
{
epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr);
close(fd);
}
}
else if (event & EPOLLOUT)
{
// 写事件就绪
}
else // 其他事件就绪
;
}
}
}
和前几篇文章一样,回调函数是下面这两个函数
int serviceIO(int sockfd)
{
// 这里仅做简单的数据收发
char buffer[128] = {0};
int count = recv(sockfd, buffer, sizeof(buffer) - 1, 0);
if (count < 0)
{
std::cerr << "recv err" << std::endl;
exit(-1);
}
if (count == 0)
{
// 对方关闭
return 0;
}
printf("client --> server:%s\n", buffer);
send(sockfd, buffer, strlen(buffer), 0);
return count;
}
int serviceHTTP(int sockfd)
{
// 这里仅做简单的数据收发
char buffer[128] = {0};
int count = recv(sockfd, buffer, sizeof(buffer) - 1, 0);
if (count < 0)
{
std::cerr << "recv err" << std::endl;
exit(-1);
}
if (count == 0)
{
// 对方关闭
return 0;
}
printf("client --> server:%s\n", buffer);
std::string outbuffer;
std::string body = "<h1>hello world</h1>";
outbuffer =
"HTTP/1.1 200 OK\r\n"
"Content-Type: text/html; charset=utf-8\r\n"
"Content-Length: " +
std::to_string(body.size()) + "\r\n"
"Server: Apache/2.4.41\r\n"
"Date: Mon, 18 Dec 2023 08:32:10 GMT\r\n"
"X-Frame-Options: DENY\r\n"
"X-Content-Type-Options: nosniff\r\n"
"Referrer-Policy: strict-origin-when-cross-origin\r\n"
"\r\n" // 空行分隔头部和正文
+ body;
// 无脑向客户端发送一个简单http响应
send(sockfd, outbuffer.c_str(), outbuffer.size(), 0);
return count;
}
3.3 tcpServer.cc
#include "tcpServer.hpp"
#include <iostream>
#include <memory>
using namespace std;
// tcp 服务器,启动方式与udp server一样
//./tcpServer + local_port //我们将本主机的所有ip与端口绑定
static void Usage(string proc)
{
cout << "\nUsage:\n\t" << proc << " lock_port\n\n";
}
int main(int argc, char *argv[])
{
if (argc != 2)
{
Usage(argv[0]);
}
uint16_t serverport = atoi(argv[1]);
unique_ptr<YZC::tcpServer> tsvr(new YZC::tcpServer(YZC::serviceIO, serverport));
tsvr->init();
tsvr->run();
return 0;
}
运行结果如下:

四. 性能测试与总结
首先我们拿回上篇文章的测试结果:

4.1 性能测试
同理使用wrk这个工具来测试QPS,记得回调函数使用 serviceHTTP
对照测试结果如下
10线程 1000并发连接
[yzc@study wrk]$ ./wrk -c 1000 -d 10s -t 10 http://47.105.37.157:8081
Running 10s test @ http://47.105.37.157:8081
10 threads and 1000 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 122.71ms 228.09ms 2.00s 91.04%
Req/Sec 1.75k 1.23k 11.60k 71.05%
170430 requests in 10.10s, 43.07MB read
Socket errors: connect 0, read 0, write 0, timeout 131
Requests/sec: 16879.76
Transfer/sec: 4.27MB
10线程 10000并发连接
[yzc@study wrk]$ ./wrk -c 10000 -d 10s -t 10 http://47.105.37.157:8082
Running 10s test @ http://47.105.37.157:8082
10 threads and 10000 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 134.98ms 254.30ms 2.00s 91.48%
Req/Sec 1.68k 1.53k 9.37k 71.85%
132351 requests in 10.10s, 33.45MB read
Socket errors: connect 0, read 4, write 0, timeout 1528
Requests/sec: 13105.75
Transfer/sec: 3.31MB
50线程 25000连接
[root@study wrk]# ./wrk -c 25000 -d 10s -t 50 http://47.105.37.157:8081
Running 10s test @ http://47.105.37.157:8081
50 threads and 25000 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 474.14ms 315.52ms 2.00s 71.26%
Req/Sec 507.49 0.94k 14.61k 92.93%
106198 requests in 33.63s, 26.84MB read
Socket errors: connect 0, read 56, write 0, timeout 1145
Requests/sec: 3158.14
Transfer/sec: 817.29KB
100线程 55555连接
[root@study wrk]# ./wrk -c 55555 -d 10s -t 100 http://47.105.37.157:8080
Running 10s test @ http://47.105.37.157:8080
100 threads and 55555 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 714.98ms 598.22ms 2.00s 58.87%
Req/Sec 338.24 711.24 12.42k 92.09%
253134 requests in 1.10m, 63.97MB read
Socket errors: connect 9581, read 2237, write 20959, timeout 42049
Requests/sec: 3829.26
Transfer/sec: 0.97MB
与之前数据一起制作成表格如下:
| 并发数 | 架构 | 线程数 | QPS | 总请求数 | 平均延迟 | 吞吐量 | 错误数 | 错误类型 | 测试状态 | 数据来源 |
|---|---|---|---|---|---|---|---|---|---|---|
| 1,000 | 多进程 | 10 | 7,281 | 73,625 | 100ms | 1.84MB/s | 482 | 482超时 | ✅ 正常 | 原表 |
| 1,000 | 多线程 | 10 | 8,650 | 87,421 | 126ms | 2.19MB/s | 73 | 73超时 | ✅ 最佳 | 原表 |
| 1,000 | select | 10 | 15,965 | 160,346 | 48ms | 4.03MB/s | 286 | 286超时 | 🎯 优异 | 原表 |
| 1,000 | poll | 10 | 16,844 | 170,114 | 134ms | 4.26MB/s | 391 | 391超时 | 🎯 优异 | 本次测试 |
| 1,000 | epoll | 10 | 16,880 | 170,430 | 123ms | 4.27MB/s | 131 | 131超时 | 🎯 优异 | 本次测试 |
| 10,000 | 多进程 | 10 | 5,522 | 55,745 | 102ms | 1.40MB/s | 433 | 123读+310超时 | ✅ 正常 | 原表 |
| 10,000 | 多线程 | 10 | 7,375 | 74,453 | 194ms | 1.86MB/s | 353 | 107读+246超时 | ✅ 最佳 | 原表 |
| 10,000 | poll | 10 | 14,940 | 151,096 | 80ms | 3.78MB/s | 902 | 902超时 | 🎯 优异 | 本次测试 |
| 10,000 | epoll | 10 | 13,106 | 132,351 | 135ms | 3.31MB/s | 1,532 | 4读+1528超时 | 🎯 优异 | 本次测试 |
| 25,000 | 多进程 | 50 | 1,042 | 35,604 | 420ms | 270KB/s | 10,972 | 77读+8932写+1963超时 | ▲ 高压稳定 | 原表 |
| 25,000 | 多线程 | 50 | 313 | 24,298 | 205ms | 81KB/s | 953 | 691读+262超时 | ▲ 性能衰减 | 原表 |
| 25,000 | poll | 50 | 2,476 | 80,420 | 173ms | 640KB/s | 1,136 | 442读+694超时 | ▲ 性能衰减 | 本次测试 |
| 25,000 | epoll | 50 | 3,158 | 106,198 | 474ms | 817KB/s | 1,201 | 56读+1145超时 | ▲ 高压稳定 | 本次测试 |
| 55,555 | 多进程 | 100 | 0 | 0 | 0us | 0B/s | 37,170 | 37170写错误 | ❌ 崩溃 | 原表 |
| 55,555 | 多线程 | 100 | N/A | N/A | N/A | N/A | N/A | 测试被终止 | ❌ 崩溃 | 原表 |
| 55,555 | poll | 100 | 1,514 | 152,163 | 402ms | 392KB/s | 29,912 | 15515连接+2144读+394写+9859超时 | ❌ 严重过载 | 本次测试 |
| 55,555 | epoll | 100 | 3,829 | 253,134 | 715ms | 0.97MB/s | 74,826 | 9581连接+2237读+20959写+42049超时 | ❌ 严重过载 | 本次测试 |
可以看到epoll对比poll在高压下还是更稳定的,由于我的服务器配置为 2G2核。是无法完全体现epoll的优势的。配置提高epoll和poll的性能都会有所上升
epoll虽然提高了性能,但是有没有更好的代码架构来处理?可以使用reactor模式,我们上面的代码是基于fd处理的,并没有完全面向事件。
reactor模式是面向事件的,当事件触发后,我们通过回调函数来处理对应事件。并且使用ET模式来减少epoll_wait唤醒,一次性读取更多数据。从而提高服务器的性能
更多推荐



所有评论(0)