linux下 C++ 使用 epoll 多路复用 实现高性能的tcpserver

linux系统中,实现socket多路复用的技术有selectpollepoll 等多种方式。这些不同方式个有优缺点和适用场景,这不是本文讨论的重点,又兴趣的可以自己搜索学习一下。但是在高并发场景下, epoll 性能是最高的, Nginx 都听说过吧,大名鼎鼎的Nginx 底层用的就是 epoll

这篇文章主要是写怎么用 epoll,而不是原理分析。这篇文章不是最全的,也不是最深入的,但是绝对是一篇能让普通人看懂的,看完能自己用epoll写出一个tcpserver的文章。全废话不多说,直接开始搞

首先明确一点,epoll 是linux系统提供的系统调用,也就说,epoll 在Windows系统上是没法使用的,相应的代码也是没法编译的。如果有人知道怎么在Windows中编译,请赐教。

工具

文中使用的开发环境

  1. 系统: Debian GNU/Linux 10 (buster)
  2. linux内核: 4.19.0-14
  3. gcc版本: 8.3.0

准备知识

epoll是linux内核提供的功能,这个功能对外提供系统调用,在C/C++中通过三个函数对用户提供功能

  1. epoll_create(int __size) 创建一个epoll,_size 参数在linux2.6内核之后就没有什么作用了, 但是要>0,一般直接填 1 就好了。函数返回创建的epoll的文件描述符,如果创建失败,会返回 -1。

  2. epoll_ctl(nt __epfd, int __op, int __fd,struct epoll_event *__event) 操作已有的epoll,epfdepoll的文件描述符;op操作方式,有添加、删除、修改等等;_fd 要操作对象的描述符,如果是操作tcp连接,也会就是这个连接的描述符。_event epoll 的响应事件,当epoll管理的tcp连接有事件发生时,会通过 _event 这个对象传递出来,所以在添加连接时,要把这个连接包装成一个 epoll_event 对象 </br>

    • op 类型
    • EPOLL_CTL_ADD 添加一个描述符
    • EPOLL_CTL_DEL 删除一个描述符
    • EPOLL_CTL_MOD 修改一个描述符
  3. epoll_wait(int __epfd, struct epoll_event *__events,int __maxevents, int __timeout) 当epoll管理的连接中有响应事件发生时,会回调这个函数。epfdepoll的文件描述符;__events 可以操作的连接数组;__maxevents 一个可以处理的最大事件数量;__timeout 超时时间(单位毫秒),如果填-1,会直到有可操作事件发生时才会返回,因为C++不支持函数多返回值,像Go可以直接返回所有事件和数量了 (╥╯^╰╥)。

    events 中的常用的类型:

    • EPOLLIN :表示对应的文件描述符可以读(SOCKET正常关闭)
    • EPOLLOUT:表示对应的文件描述符可以写
    • EPOLLPRI:表示对应的文件描述符有紧急的数据可读(表示有带外数据到来)
    • EPOLLERR:表示对应的文件描述符发生错误(默认注册)
    • EPOLLHUP:表示对应的文件描述符被挂断(默认注册)
    • EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的
    • EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
      是不是很惊奇,这么牛逼的epoll就三个函数,第一次看到的时候我也觉得很奇怪,三个函数就能搞定那么复杂的事情。不过想想也是,把复杂的东西简化,才能体现出大神的实力
epoll的两种模式

epoll 事件有两种模型 Level Triggered (LT) 和 Edge Triggered (ET):</br>

  • LT(level triggered,水平触发模式)是默认的工作方式,并且同时支持 block 和 non-block socket。在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的,所以,这种模式编程出错误可能性要小一点。

  • ET(edge-triggered,边缘触发模式)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,等到下次有新的数据进来的时候才会再次出发就绪事件。

把socket设置为非阻塞模式的方法

int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

需要的头文件 #include <fcntl.h>

epoll原理

简单通过画图解释一下epoll的工作原理


这里没有涉及太底层的东西,因为太底层的我也没研究过,不敢乱讲。知之为知之,不知为不知。</br>
epoll可以看做是一个由操作系统提供的容器,这个容器管理了一些 epoll_event (图中我画成单向链表了,实际上用的是红黑树,因为画树太麻烦了),这个event是我们添加进去的,event中设置了要响应的事件类型,当epoll 检测到具体的 event 有对应的事件发生时,会通过epoll_wait() 通知。

简单的epoll实现

#include <iostream>//控制台输出
#include <sys/socket.h>//创建socket
#include <netinet/in.h>//socket addr
#include <sys/epoll.h>//epoll
#include <unistd.h>//close函数
#include <fcntl.h>//设置非阻塞

using namespace std;

int main() {
    const int EVENTS_SIZE = 20;
    //读socket的数组
    char buff[1024];

    //创建一个tcp socket
    int socketFd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

    //设置socket监听的地址和端口
    sockaddr_in sockAddr{};
    sockAddr.sin_port = htons(8088);
    sockAddr.sin_family = AF_INET;
    sockAddr.sin_addr.s_addr = htons(INADDR_ANY);

    //将socket和地址绑定
    if (bind(socketFd, (sockaddr *) &sockAddr, sizeof(sockAddr)) == -1) {
        cout << "bind error" << endl;
        return -1;
    }

    //开始监听socket,当调用listen之后,
    //进程就可以调用accept来接受一个外来的请求
    //第二个参数,请求队列的长度
    if (listen(socketFd, 10) == -1) {
        cout << "listen error" << endl;
        return -1;
    }

    //创建一个epoll,size已经不起作用了,一般填1就好了
    int eFd = epoll_create(1);

    //把socket包装成一个epoll_event对象
    //并添加到epoll中
    epoll_event epev{};
    epev.events = EPOLLIN;//可以响应的事件,这里只响应可读就可以了
    epev.data.fd = socketFd;//socket的文件描述符
    epoll_ctl(eFd, EPOLL_CTL_ADD, socketFd, &epev);//添加到epoll中
    
    //回调事件的数组,当epoll中有响应事件时,通过这个数组返回
    epoll_event events[EVENTS_SIZE];

    //整个epoll_wait 处理都要在一个死循环中处理
    while (true) {
        //这个函数会阻塞,直到超时或者有响应事件发生
        int eNum = epoll_wait(eFd, events, EVENTS_SIZE, -1);

        if (eNum == -1) {
            cout << "epoll_wait" << endl;
            return -1;
        }
        //遍历所有的事件
        for (int i = 0; i < eNum; i++) {
            //判断这次是不是socket可读(是不是有新的连接)
            if (events[i].data.fd == socketFd) {
                if (events[i].events & EPOLLIN) {
                    sockaddr_in cli_addr{};
                    socklen_t length = sizeof(cli_addr);
                    //接受来自socket连接
                    int fd = accept(socketFd, (sockaddr *) &cli_addr, &length);
                    if (fd > 0) {
                        //设置响应事件,设置可读和边缘(ET)模式
                        //很多人会把可写事件(EPOLLOUT)也注册了,后面会解释
                        epev.events = EPOLLIN | EPOLLET;
                        epev.data.fd = fd;
                        //设置连接为非阻塞模式
                        int flags = fcntl(fd, F_GETFL, 0);
                        if (flags < 0) {
                            cout << "set no block error, fd:" << fd << endl;
                            continue;
                        }
                        if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) < 0) {
                            cout << "set no block error, fd:" << fd << endl;
                            continue;
                        }
                        //将新的连接添加到epoll中
                        epoll_ctl(eFd, EPOLL_CTL_ADD, fd, &epev);
                        cout << "client on line fd:" << fd << endl;
                    }
                }
            } else {//不是socket的响应事件
                
                //判断是不是断开和连接出错
                //因为连接断开和出错时,也会响应`EPOLLIN`事件
                if (events[i].events & EPOLLERR || events[i].events & EPOLLHUP) {
                    //出错时,从epoll中删除对应的连接
                    //第一个是要操作的epoll的描述符
                    //因为是删除,所有event参数天null就可以了
                    epoll_ctl(eFd, EPOLL_CTL_DEL, events[i].data.fd, nullptr);
                    cout << "client out fd:" << events[i].data.fd << endl;
                    close(events[i].data.fd);
                } else if (events[i].events & EPOLLIN) {//如果是可读事件
                    
                    //如果在windows中,读socket中的数据要用recv()函数
                    int len = read(events[i].data.fd, buff, sizeof(buff));
                    //如果读取数据出错,关闭并从epoll中删除连接
                    if (len == -1) {
                        epoll_ctl(eFd, EPOLL_CTL_DEL, events[i].data.fd, nullptr);
                        cout << "client out fd:" << events[i].data.fd << endl;
                        close(events[i].data.fd);
                    } else {
                        //正常读取,打印读到的数据
                        cout << buff << endl;
                        
                        //向客户端发数据
                        char a[] = "123456";
                        //如果在windows中,向socket中写数据要用send()函数
                        write(events[i].data.fd, a, sizeof(a));
                    }
                }
            }
        }
    }
}

常见的问题和注意事项在注释中,就单解释一下新连接注册事件的问题吧,很多文章中都会把可写事件也注册进去,像这样

sockaddr_in cli_addr{};
socklen_t length = sizeof(cli_addr);
//接受来自socket连接
int fd = accept(socketFd, (sockaddr *) &cli_addr, &length);
if (fd > 0) {
    epev.events = EPOLLIN | EPOLLET | EPOLLOUT;
    epev.data.fd = fd;
    epoll_ctl(eFd, EPOLL_CTL_ADD, fd, &epev);
    cout << "client on line fd:" << fd << endl;
}

但是经过测试,不注册可写事件,直接往socket中写也是可以的

经过查资料得知:

  • EPOLLIN : 如果状态改变了(比如 从无到有),只要输入缓冲区可读就会触发
  • EPOLLOUT : 如果状态改变了(比如 从满到不满),只要输出缓冲区可写就会触
    如果把可写也注册上,会频繁回调,这里会有很多无用的回调,导致性能下降。
    有一种思路,当向socket写失败后(write函数返回值 == -1),注册上 EPOLLOUT 当响应了可写事件后,重新往socket中写数据,写成功后,再取消掉 EPOLLOUT。 这里就不给出示例了

客户端测试

这次关注的是服务端实现,客户端就不用C++写了,用Go写了一个client(没别的原因,只是因为Go写起了简单)

package main

import (
    "fmt"
    "net"
    "sync"
    "time"
)

const (
    MAX_CONN = 10
)

func main() {
    var wg sync.WaitGroup
    wg.Add(1)
    for i := 0; i < MAX_CONN; i++ {
        go Conn("192.168.199.164:8088", i)
        time.Sleep(time.Millisecond * 100)
    }
    wg.Wait()
}

func Conn(addr string, id int) {
    conn, err := net.Dial("tcp", addr)
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println("connect ", id)
    go func() {
        buf := make([]byte, 1024)
        for {
            n, err := conn.Read(buf)
            if err != nil {
                break
            }
            fmt.Println(id, "read: ", string(buf[:n]))
        }
    }()
    time.Sleep(time.Second * 1)
    for {
        _, err := conn.Write([]byte("hello"))
        if err != nil {
            break
        }
        time.Sleep(time.Second * 10)
    }
}

这只是一个测试用的,写的很粗糙,但是不影响使用

服务端打印信息


服务端

客户端打印信息


客户端

总结

  1. epoll是socket多路复用技术的一种,还有select和poll
  2. epoll 只能在linux使用(Windows下怎么用我没找到,如果说错了请指正)
  3. epoll 事件有 Level Triggered (LT) 和 Edge Triggered (ET) 两种模型,LT是默认模式,ET是高性能模式

另外,我使用面向对象的方式封装了一个epoll的tcpserver 代码有点多,就不贴在这了,已经上传
github
码云

欢迎给点个star ヾ(o◕∀◕)ノヾ

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 200,667评论 5 472
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,361评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 147,700评论 0 333
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,027评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,988评论 5 361
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,230评论 1 277
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,705评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,366评论 0 255
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,496评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,405评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,453评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,126评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,725评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,803评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,015评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,514评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,111评论 2 341

推荐阅读更多精彩内容