V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
huahsiung
V2EX  ›  程序员

Linux 非阻塞 epoll 编程中,如何解决大量 ESTABLISHED 连接后占着茅坑不拉屎的行为?

  •  
  •   huahsiung · 271 天前 · 2716 次点击
    这是一个创建于 271 天前的主题,其中的信息可能已经有所发展或是发生改变。

    Linux socket 中,无论是
    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    listen(server_fd)

    还是接过来的
    client_fd=accept(server_fd)

    全部加进 epoll 事件监听中。 epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &ev)

    但是接到的 client_fd ,必须要收到对方发送数据才能激活事件。 如果对方一直不 send()任何数据。那么建立了 ESTABLISHED 连接后就占着茅坑不拉屎。epoll 也不会通知


    网上找到两个方法:

    1.

    epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
    改为
    epoll_wait(epoll_fd, events, MAX_EVENTS, 10);
    

    没用,这是 epoll 事件超时,而不是连接超时。
    epoll_wait 返回的是活跃事件,如果不发送任何数据,epoll_wait 不会返回这个事件的 fd

    2.

    struct timeval timeout;
    timeout.tv_sec = 10;
    timeout.tv_usec = 0;
    setsockopt(client_fd, SOL_SOCKET, SO_RCVTIMEO, (char *)&timeout, sizeof(timeout);
    

    没用,这是阻塞超时,recv()用的。非阻塞会立即返回。


    这种连接浪费了茅坑资源,不知道有什么解决方法。设置 accept 后 10s不拉屎就断开。
    阻塞情况下很好解决,但是非阻塞暂时没想到好办法。

    28 条回复    2024-03-20 08:49:55 +08:00
    lcdtyph
        1
    lcdtyph  
       271 天前 via iPhone   ❤️ 1
    每个 socket 绑定一个 timer ,一起 epoll 就好了
    PythonYXY
        2
    PythonYXY  
       271 天前
    我看 chatgpt 给出的答案是利用 setsockopt 函数的 SO_RCVTIMEO 和 SO_SNDTIMEO 选项,不过我没试过
    choury
        3
    choury  
       271 天前   ❤️ 1
    搜下 TCP_DEFER_ACCEPT
    PythonYXY
        4
    PythonYXY  
       271 天前   ❤️ 1
    没看到原来 OP 已经试过 setsockopt 了。。。我的想法是定时发送心跳信息,将长时间不响应的 fd 给手动剔除
    henix
        5
    henix  
       271 天前   ❤️ 1
    这个应该要应用自己维护的吧。记录每个 fd 上一次操作的时间戳,你的第一种方法,epoll_wait 会在中途返回,返回时检查当前时间戳跟记录的 fd 上一次操作时间戳之差,如果超时了就执行某个动作,比如关闭连接。

    这里采用的数据结构是堆( heap )或者时间轮( timing wheel )

    可参考 [Linux 多线程服务端编程]( https://book.douban.com/subject/20471211/) 的“7.10 用 timing wheel 踢掉空闲连接”一章
    BBCCBB
        6
    BBCCBB  
       271 天前   ❤️ 1
    要实现应用层的心跳.
    roykingz
        7
    roykingz  
       270 天前   ❤️ 1
    你说的这个特性,Linux 的 TCP_DEFER_ACCEPT 标志可以支持,Nginx 源码中大量使用,Freebsd 中也有类似的特性,叫做 SO_ACCEPTFILTER
    roykingz
        8
    roykingz  
       270 天前
    不过,这个特性是延迟通知进程,要解决 ESTABLISHED 上一直不发数据的情况,应该还是得靠自己维护超时时间来检查,时间轮用的比较多
    Sephirothictree
        9
    Sephirothictree  
       270 天前   ❤️ 1
    开个线程做 select 设置阻塞超时来监控非阻塞的 client_fd ,到时间或者可读,就超时踢人或者干活 recv (成功转回阻塞逻辑了 2333 ,不过感觉还是 1 楼方案比较省事
    Nazz
        10
    Nazz  
       270 天前 via Android   ❤️ 1
    网络库都有 SetDeadline 吧
    huahsiung
        11
    huahsiung  
    OP
       270 天前
    @lcdtyph
    @choury
    @henix
    @BBCCBB
    @roykingz

    ------

    感谢各位回答

    >TCP_DEFER_ACCEPT

    我看了看 TCP_DEFER_ACCEPT 的 man,里面说(Takes an integer value (seconds), this can bound the maximum number of attempts TCP will make to complete the connection 。

    就是说当重传次数超过限制之后,并且客户端依然还在回复 ack 时,到达最大超时,客户端再次回复的 syn-ack ,那么这个 defer 的连接依然会变成 ESTABLISHED 队列。必须要应用层关闭。

    -----

    >每个 socket 绑定一个 timer

    这个方法刚才试了试,发现接到(event[i].events & EPOLLIN)后,无法区分是 timer_fd 还是 socket_fd ,就不能直接 accept(),因为可能接到 accept(timer_fd),就会错误,在程序看来都是 fd 。

    -----

    >记录每个 fd 上一次操作的时间戳,定时检查当前时间戳跟记录的 fd 上一次操作时间戳之差

    这个我最开始就是这样的,开了一个 pthread 专门处理超时,刚开始测试一切正常。但是后来发现,TCP 连接数超过 100K 时,这个 pthread 会卡死,导致整个程序退出。然后去掉了这个超时处理的 pthread ,就一切正常。

    在 800K TCP 连接左右只占了 962M 内存。每个 fd 维护一个时间轮消耗巨大,程序为每个 tcp 分配的内存只有 1k 左右,全靠 epoll 的通知和内存 pointer 撑住的。遍历上万的 fd 的话时间轮这样内存会膨胀 2~3 倍,CPU 上下文切换时间也会激增。


    ----

    **最后的解决方法是暂时不解决,毕竟几十万左右的 TCP 连接才 4k~6k 的僵尸连接,好像也不是影响很大。**


    不知道怎么把 fd 省内存的加入超时队列,我是直接把 fd CRC32 放入类似 hash 表的,但是连接过多 hash 会撞的。
    huahsiung
        12
    huahsiung  
    OP
       270 天前
    @Sephirothictree

    select 好像不行啊,连接太多,不够用。

    -----

    @Nazz

    SetDeadline 是 go 语言的,C/C++的库好像没用

    go 语言用 go route 起几十万个连接,内存会高达 10G+的。
    lcdtyph
        13
    lcdtyph  
       270 天前
    @huahsiung #11
    啊这,你要用 event[i].data.ptr 来给这个 fd 一个私有数据结构,这样可以帮助你区分这个 fd 是什么,或者维护一些 fd 相关的上下文
    huahsiung
        14
    huahsiung  
    OP
       270 天前
    @lcdtyph 为了省内存,我把 event[i].data.ptr 的指针当作 long long int(x64 位)用的



    后来发现这个东西不会触发,不知道哪里问题。



    x64 系统正常运行(除了不会触发超时)

    x86 系统直接“段错误”
    huahsiung
        15
    huahsiung  
    OP
       270 天前
    @huahsiung 得到答案了,好像是编译器问题,编译器认为指针不可能为负数,帮我把“负指针”优化了。
    Sephirothictree
        16
    Sephirothictree  
       270 天前
    @huahsiung 试试 poll 不限制 fd 数量,跟 select 差不多,不过这么多连接,就不知道 poll 效率上能不能行了
    lcdtyph
        17
    lcdtyph  
       270 天前 via iPhone
    @huahsiung
    都 x86 了还要省内存吗

    而且你最好用 intptr_t int_ptr = (intptr_t)data.ptr;
    x86 和 amd64 的指针长度不一样
    huahsiung
        18
    huahsiung  
    OP
       270 天前
    @lcdtyph 准备 x86 架构直接放弃了吧,就只能在 64 位上面运行。

    准备上数据的时候把 把 pointer 的最高位(符号位) 与 0x00FFFFFF 让 pointer 变成正的。下数据使用的时候再把左移一位把“符号位”顶掉,还原负的

    -----

    感觉这种就像在玩飞刀一样刺激,稍不注意就“刀起头落”。唉~正常编程内存会翻 5-10 倍的,试试奇淫技巧了
    lesismal
        19
    lesismal  
       270 天前   ❤️ 1
    定时器的实现主要有两个点:
    1. 管理定时器的数据结构
    如果你用 c++ ,priority_queue 维护每个 fd 的超时时间:
    https://en.cppreference.com/w/cpp/container/priority_queue
    如果用 c ,找个或者自己实现个小堆也可以
    除非你对精确度要求非常低、时间轮间隔很小这种,否则真没必要:一是不精确,越想要精确则间隔越小越可能空跑,二是小堆做优先级队列基本是行业认可的最佳时间

    2. 定时器的触发器,简单点可以用 epoll_wait ,虽然秒级精确度但对于 read deadline 足够了,如果想更精确或者框架提供通用的精确定时器,可以用 timer_fd

    1 、2 结合起来,如果更新、设置超时时间都是在 epoll event loop 里,就是把 priority_queue 堆顶最小超时时间作为 epoll_wait 下一轮的 timeout 参数或者 timer_fd 的超时时间,如果跨线程设置还要考虑唤醒 epoll_wait 或者更新 timer_fd 相关

    这只是简单实现方案,涉及到完整框架的你还要考虑并发调用、锁、一致性等各种细节


    > 这个方法刚才试了试,发现接到(event[i].events & EPOLLIN)后,无法区分是 timer_fd 还是 socket_fd ,就不能直接 accept(),因为可能接到 accept(timer_fd),就会错误,在程序看来都是 fd 。

    #11 这就是说胡话了,你自己创建的 listener fd 、自己创建的 timer_fd ,你 switch case listener case timer default socket 一下就知道是哪个了,再不济,你存储 fd 对应的结构的地方,结构体加个字段标记 type 也就知道了
    lesismal
        20
    lesismal  
       270 天前
    @lesismal #19

    =》除非你对精确度要求非常低、时间轮间隔很小这种,否则真没必要用时间轮:一是不精确,越想要精确则间隔越小越可能空跑,二是小堆做优先级队列定时器这种性能已经足够强、基本是行业认可的最佳实践

    虽然秒级 =》虽然毫秒级
    nuk
        21
    nuk  
       270 天前
    倒是没必要用 timer ,可以用 3 个 epoll ,两个 epoll 间隔轮换来加入新的 fd ,然后轮换的时候清空另外一个 epoll 里所有的 fd ,然后 poll 有数据的放到第三个 epoll 里干活。
    huahsiung
        22
    huahsiung  
    OP
       270 天前
    @lesismal

    我要接的不是 bind 和 listening 的一个 listen fd 连接,而是 accept(listen_fd)出来的几十万个 client_fd 连接。我也无法区分。


    另外:试了在每个 socket_fd 同时绑定一个 timer_fd ,文件描述符会膨胀 2 两倍。普通使用没有感觉,但是高并发测试下性能急剧下降。


    ------------------结帖-----------------

    ## 之前的奇淫技巧在 TCP 并发数超过 30 万+的时候指针会莫名其妙的跑飞,导致程序卡死无法退出。只能去掉这个。

    之中发现百度的服务器也没有进行超时处理,运行:

    `nc www.baidu.com 443`

    发现一直不发送数据,连接会一直保持。



    ## 百度也没处理,我也不处理了,就这样吧。

    另外,nginx 也可以加入
    ```ini
    client_body_timeout 5s;
    client_header_timeout 5s;
    ```
    来进行连接超时。

    使用 ab 测试,发现性能会略微下降

    # nginx 未加入超时

    ```txt
    Document Path: /
    Document Length: 146 bytes

    Concurrency Level: 2000
    Time taken for tests: 0.950 seconds
    Complete requests: 20000
    Failed requests: 14144
    (Connect: 0, Receive: 0, Length: 7072, Exceptions: 7072)
    Non-2xx responses: 12928
    Total transferred: 3736192 bytes
    HTML transferred: 1887488 bytes
    Requests per second: 21052.99 [#/sec] (mean)
    Time per request: 94.998 [ms] (mean)
    Time per request: 0.047 [ms] (mean, across all concurrent requests)
    Transfer rate: 3840.72 [Kbytes/sec] received

    Connection Times (ms)
    min mean[+/-sd] median max
    Connect: 0 40 7.8 40 60
    Processing: 16 50 12.4 51 76
    Waiting: 0 28 21.4 37 57
    Total: 58 90 8.7 90 104

    Percentage of the requests served within a certain time (ms)
    50% 90
    66% 93
    75% 97
    80% 98
    90% 101
    95% 103
    98% 103
    99% 103
    100% 104 (longest request)

    ```

    # nginx 加入 timeout 超时
    ```ini
    client_body_timeout 5s;
    client_header_timeout 5s;
    ```


    ```txt
    Document Path: /
    Document Length: 146 bytes

    Concurrency Level: 2000
    Time taken for tests: 0.971 seconds
    Complete requests: 20000
    Failed requests: 14464
    (Connect: 0, Receive: 0, Length: 7232, Exceptions: 7232)
    Non-2xx responses: 12768
    Total transferred: 3689952 bytes
    HTML transferred: 1864128 bytes
    Requests per second: 20604.20 [#/sec] (mean)
    Time per request: 97.068 [ms] (mean)
    Time per request: 0.049 [ms] (mean, across all concurrent requests)
    Transfer rate: 3712.33 [Kbytes/sec] received

    Connection Times (ms)
    min mean[+/-sd] median max
    Connect: 0 41 8.7 42 71
    Processing: 20 51 14.7 51 94
    Waiting: 0 29 22.9 38 74
    Total: 50 93 11.9 92 120

    Percentage of the requests served within a certain time (ms)
    50% 92
    66% 99
    75% 101
    80% 102
    90% 105
    95% 109
    98% 119
    99% 120
    100% 120 (longest request)

    ```

    ## 进行多次高并发测试,发现性能都低于。暂时没有探究原因

    # 就这样了,不处理了,结帖。谢谢大家的回答
    lesismal
        23
    lesismal  
       269 天前   ❤️ 1
    合着我说的你根本就没好好看,或者看不懂:

    > 我要接的不是 bind 和 listening 的一个 listen fd 连接,而是 accept(listen_fd)出来的几十万个 client_fd 连接。我也无法区分。

    switch(fd) {
    case listenerfd:
    handle_accept()
    case timerfd:
    handler_timer()
    default: // 除去 listener 和 timerfd 就是已经 accept 了的 socket
    handle_socket()
    }

    再不济,你在 event 里那个 void*存储这个 fd 对应的结构体指针、或者只存一个 fd type 也是可以的

    > 另外:试了在每个 socket_fd 同时绑定一个 timer_fd ,文件描述符会膨胀 2 两倍。普通使用没有感觉,但是高并发测试下性能急剧下降。

    不需要每个 fd 一个 timer_fd ,一个 eventloop 只需要一个 timer_fd ,具体的你看我上一楼的回复吧


    我认认真真给 OP 写了一大段,OP 连看都不好好看就来随便回复,如果这次还不看,那请 OP 不要回复我了
    tuiL2
        24
    tuiL2  
       269 天前
    这是应用层的问题,不是 epoll 和 socket 的问题
    realJamespond
        25
    realJamespond  
       269 天前
    另外维护一个队列,每隔几秒更新一下超时的 fd
    huahsiung
        26
    huahsiung  
    OP
       269 天前
    @lesismal 还是感谢你提供思路

    原来你的思路和我的不一样,设置一个堆 Heap ,每 5s 超时,取出堆顶最后的 fd ,进行 closed 吧。这种设计只有一个 timer_fd 。
    而我的是每一个 accept 后,就创建一个 timer_fd 。然后被挤爆了。

    C++倒简单,使用
    #include <queue>就行。

    但是我是 C 语言需要自己实现 堆 Heap ,确实比较麻烦,特别是维护几十万的数据。后来去抄 apache 的堆 Heap 实现。
    https://github.com/vy/libpqueue/blob/master/src/pqueue.c

    堆的删除只能在对顶进行,fd 接收数据后必须删除这个堆中的数据,但是没法删除堆中。
    想到的解决办法是设置 fd 标注位,fd 发现接收到数据后设置禁止 closed 的标志。


    期间把把多线程架构改为了多进程,去抄了 nginx 的 master/worker 方法,发现性能确实会提升。就是通信变复杂了

    其间发现,使用状态防火墙是最简单的,还不用改代码。状态防火墙会自动掐断空连接。
    lesismal
        27
    lesismal  
       269 天前
    > 原来你的思路和我的不一样,设置一个堆 Heap ,每 5s 超时,取出堆顶最后的 fd ,进行 closed 吧。这种设计只有一个 timer_fd 。

    用堆就不是固定 5s 超时了,而是根据堆顶的超时时间设置超时时间。
    也不是只取出堆顶,因为代码可能导致延迟误差,所以是需要循环查看堆顶是否超时、超时就 close ,没超时则更新当前堆顶的超时时间为触发器的超时时间


    > 而我的是每一个 accept 后,就创建一个 timer_fd 。然后被挤爆了。

    这种是最浪费的方式之一,没必要拿来对比,应该用来改进


    > 堆的删除只能在对顶进行,fd 接收数据后必须删除这个堆中的数据,但是没法删除堆中。

    堆可以删除任意元素,up 、down fix 位置就可以

    红黑树也可以用来做这个,但这个场景堆比红黑树要好
    ben666
        28
    ben666  
       253 天前
    一般连接里面要放多个定时器,读超时、写超时、idle 超时等,可以自己实现一个定时器,每个连接有一个定时器,可以是时间轮定时器,也可以是 rbtree 定时器。
    大体如下:
    struct connection {
    int fd;
    struct timer read_timer;
    struct timer write_timer;
    struct timer idle_timer;
    };

    可以参考:
    - nginx 的连接 强烈推荐 https://github.com/nginx/nginx/blob/master/src/core/ngx_connection.h
    - dpvs 的连接 https://github.com/iqiyi/dpvs/blob/master/include/ipvs/conn.h
    - dperf 的连接是用单链表队列做超时的 https://github.com/baidu/dperf/blob/main/src/socket.h
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   3223 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 25ms · UTC 12:50 · PVG 20:50 · LAX 04:50 · JFK 07:50
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.