上篇文章:C/C++ Linux网络编程6 - poll解决客户端并发连接问题-CSDN博客

代码仓库:橘子真甜 (yzc-YZC) - Gitee.com

        Linux中,select和poll虽然能够处理并发连接问题。但是它们仍有自己的缺点:需要不断在用户态-内核态转换和拷贝fd进入内核数据开销大,需要线性遍历所有关心的fd,如果连接数过大,性能降低。

        而epoll就是用于解决这些痛点。

目录

一. epoll 接口

1.1 epoll_create

1.2 epoll_ctl

1.3 epoll_wait

二. epoll底层原理⭐

2.1 原理说明

2.2 总结

2.3 epoll优点

三. epoll服务器

3.1 socket.hpp

3.2 tcpServer.hpp

3.3 tcpServer.cc

四. 性能测试与总结

4.1 性能测试


一. 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唤醒,一次性读取更多数据。从而提高服务器的性能

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐