linux2.6 epoll惊群

  昨天修改客户一个插件模式时。出现了listen_fd + fork+epoll_wait 模式的惊群线程。随后惊群会唤醒所有的进程,但是由于accept的时候会对listen fd上锁,所以基本上第一个accept的会处理完一直到主动退出或者新链接处理完。

  当时准备设置一把锁,让各个进程来抢占想nginx一样来处理,测试结果效果不理想。因为无法确定一个进程最大处理数,毕竟一个CPS里面可能有多个TPS。

  然后使用lighttpd方式来处理,每个进程线程被唤醒时,最多只accept 100个。测试效果也不是非常耗。

  最后使用最大连接数就不在监听了,如果当前进程tps/cps 属于前三,则不再监听,目前看在48核cpu上效果比较好。

在此来说一说fork+epoll_wait惊群的原因:

  父进程创建了 listen fd 后,fork 多个 worker 子进程来共同处理同一个 listen fd 上的请求。这个时候,A、B、C...等多个子进程分别创建自己独立的 epoll fd,然后将同一个 listen fd 加入到 epoll 中,监听其可读事件。这种情况下,epoll 有以下这些特性:

  1. 由于相对同一个listen fd而言, 多个进程之间的epoll是平等的,于是,listen fd上的一个请求上来,会唤醒所有睡眠在listen fd睡眠队列上的epoll,epoll又唤醒对应的进程task,从而唤醒所有的进程(这里不管listen fd是以LT还是ET模式加入到epoll)。
  2. 多个进程间的epoll是独立的,对epoll fd的相关epoll_ctl操作相互独立不影响。

注意看第一条特性。多个进程之间的epoll是平等,所以listen fd会唤醒等待队列上的所有进程。看下唤醒所有队列的代码

 

static void sock_def_wakeup(struct sock *sk)
{
    struct socket_wq *wq;

    rcu_read_lock();
    wq = rcu_dereference(sk->sk_wq);
    if (wq_has_sleeper(wq)) {
        wake_up_interruptible_all(&wq->wait);//唤醒所有的进程
    }
    rcu_read_unlock();
}

如果此时将代码逻辑改为只唤醒一个进程呢?

static void sock_def_wakeup(struct sock *sk)
{
    struct socket_wq *wq;

    rcu_read_lock();
    wq = rcu_dereference(sk->sk_wq);
    if (wq_has_sleeper(wq)) {
        wake_up_interruptible_only_one(&wq->wait);// 只换醒一个进程
    }
    rcu_read_unlock();
}

目前看这样也可以。

EPOLLEXCLUSIVE 排他唤醒 Epoll

  linux4.5 以后的内核版本中,增加了 EPOLLEXCLUSIVE, 该选项只能通过 EPOLL_CTL_ADD 对需要监控的 fd(例如 listen fd)设置 EPOLLEXCLUSIVE 标记。这样 epoll entry 是通过排他方式挂载到 listen fd 等待队列的尾部的,睡眠在 listen fd 的等待队列上的 epoll entry 会加上 WQ_FLAG_EXCLUSIVE 标记。根据前面介绍的内核 wake up 机制,listen fd 上的事件上来,在遍历并唤醒等待队列上的 entry 的时候,遇到并唤醒第一个带 WQ_FLAG_EXCLUSIVE 标记的 entry 后,就结束遍历唤醒过程。于是,多进程独立 epoll 的"惊群"问题得到解决。

/*
 * This is the callback that is used to add our wait queue to the
 * target file wakeup lists.
 */
static void ep_ptable_queue_proc(struct file *file, wait_queue_head_t *whead,
                 poll_table *pt)
{
    struct epitem *epi = ep_item_from_epqueue(pt);
    struct eppoll_entry *pwq;

    if (epi->nwait >= 0 && (pwq = kmem_cache_alloc(pwq_cache, GFP_KERNEL))) {
        init_waitqueue_func_entry(&pwq->wait, ep_poll_callback);
        pwq->whead = whead;
        pwq->base = epi;
        if (epi->event.events & EPOLLEXCLUSIVE)
            add_wait_queue_exclusive(whead, &pwq->wait);// 加入 exclusive 队列 后面wake的时候只唤醒等待队列上的一个进程
        else
            add_wait_queue(whead, &pwq->wait);
        list_add_tail(&pwq->llink, &epi->pwqlist);
        epi->nwait++;
    } else {
        /* We have to signal that an error occurred */
        epi->nwait = -1;
    }
}

可知唤醒的时候只会唤醒一个进程

问题原因

  一般一个 TCP 服务只有一个 listen socket、一个 accept 队列,而一个 TCP 服务一般有多个服务进程(一个核一个)来处理请求。于是并发请求到达 listen socket 处,那么多个服务进程势必存在竞争,竞争一存在,那么就需要用排队来解决竞态问题,于是似乎锁就无法避免了。在这里,有两类竞争主体,一类是内核协议栈(不可睡眠类)、一类是用户进程(可睡眠类),这两类主体对 listen socket 发生三种类型的竞争。

[1] 协议栈内部之间的竞争
[2] 用户进程内部之间的竞争
[3] 协议栈和用户之间的竞争

  内核协议栈是不可睡眠的,为此 linux 中采用两层锁定的 lock 结构,一把 listen_socket.lock 自旋锁,一把 listen_socket.own 排他标记锁。其中,listen_socket.lock 用于协议栈内部之间的竞争、协议栈和用户之间的竞争,而 listen_socket.own 用于用户进程内部之间的竞争,listen_socket.lock 作为 listen_socket.own 的排他保护(要获取 listen_socket.own 首先要获取到 listen_socket.lock)。

  对于处理 TCP 请求而言,一个 SYN 包 syn_skb 到来,这个时候内核 Lock(RCU 锁)住全局的 listeners Table,查找 syn_skb 对应的 listen_socket,没找到则返回错误。否则,就需要进入三次握手处理,首先内核协议栈需要自旋获得 listen_socket.lock 锁,初始化一些数据结构,回复 syn_ack,然后释放 listen_socket.lock 锁。

  接着,client 端的 ack 包到来,协议栈这个时候,需要自旋获得 listen_socket.lock 锁,构造 client 端的 socket 等数据结构,如果 accept 队列没有被用户进程占用,那么就将连接排入 accept 队列等待用户进程来 accept,否则就排入 backlog 队列(职责转移,连接排入 accept 队列的事情交给占有 accept 队列的用户进程)。可见,处理一个请求,协议栈需要竞争两次 listen_socket 的自旋锁。由于内核协议栈不能睡眠,于是它只能自旋不断地去尝试获取 listen_socket.lock 自旋锁,直到获取到自旋锁成功为止,中间不能停下来。自旋锁这种暴力、打架的抢锁方式,在一个高并发请求到来的服务器上,就有可能出现上面这种 80%多的 CPU 时间被内核占用,应用程序只能够分配到较少的 CPU 时钟周期的资源的情况。

问题的解决

解决这个问题无非两个方向:(1) 多队列化,减少竞争者 (2) listen_socket 无锁化。

多队列化 - SO_REUSEPORT

  通过上面的介绍,在 Linux kernel 3.9 以上,可以通过 SO_REUSEPORT 来创建多个 bind 相同 IP、PORT 的 listen_socket。我们可以每一个 CPU 核创建一个 listen_socket 来监听处理请求,这样就是每个 CPU 一个处理进程、一个 listen_socket、一个 accept 队列,多个进程同时并发处理请求,进程之间不再相互竞争 listen_socket。SO_REUSEPORT 可以做到多个 listen_socket 间的负载均衡,然而其负载均衡效果是取决于 hash 算法,可能会出现短时间内的负载极端不均衡。

  SO_REUSEPORT 是在将一对多的问题变成多对多的问题,将 Listen Socket 无序暴力争抢 CPU 的现状变成更为有序的争抢。多队列化的优化必须要面对和解决的四个问题是:队列比 CPU 多,队列与 CPU 相等,队列比 CPU 少,根本就没有队列,于是,他们要解决队列发生变化的情况。

如果仅仅把 TCP 的 Listener 看作一个被协议栈处理的 Socket,它和 Client Socket 一起都在相互拼命抢 CPU 资源,那么就可能出现上面的,短时间大量并发请求过来的时候,大量的 CPU 时间被消耗在自旋锁的争抢上了。我们可以换个角度,如果把 TCP Listener 看作一个基础设施服务呢?Listener 为新来的连接请求提供连接服务,并产生 Client Socket 给用户进程,它可以通过一个或多个两类 Accept 队列提供一个服务窗口给用户进程来 accept Client Socket 来处理。仅仅在 Client Socket 需要排入 Accept 队列的是,细粒度锁住队列即可,多个有多个 Accept 队列(每 CPU 一个,那么连锁队列的操作都可以省了)。这样 Listener 就与用户进程无关了,用户进程的产生、退出、CPU 间跳跃、绑定,解除绑定等等都不会影响 TCP Listener 基础设施服务,受影响的是仅仅他们自己该从那个 Accept 队列获取 Client Socket 来处理。

 

listen socket 无锁化- 旁门左道之 SYN Cookie

  SYN Cookie 原理由 D.J. Bernstain 和 Eric Schenk 提出,专门用来防范 SYN Flood 攻击的一种手段。它的原理是,在 TCP 服务器接收到 SYN 包并返回 SYN ACK 包时,不分配一个专门的数据结构(避免浪费服务器资源),而是根据这个 SYN 包计算出一个 cookie 值。这个 cookie 作为 SYN ACK 包的初始序列号。当客户端返回一个 ACK 包时,根据包头信息计算 cookie,与返回的确认序列号(初始序列号 + 1)进行对比,如果相同,则是一个正常连接,然后,分配资源,创建 Client Socket 排入 Accept 队列等等用户进程取出处理。于是,整个 TCP 连接处理过程实现了无状态的三次握手。SYN Cookie 机制实现了一定程度上的 listen socket 无锁化,但是它有以下几个缺点:

  • (1)丢失 TCP 选项信息 在建立连接的过程中,不在服务器端保存任何信息,它会丢失很多选项协商信息,这些信息对 TCP 的性能至关重要,比如超时重传等。但是,如果使用时间戳选项,则会把 TCP 选项信息保存在 SYN ACK 段中 tsval 的低 6 位。
  • (2)cookie 不能随地开启 Linux 采用动态资源分配机制,当分配了一定的资源后再采用 cookie 技术。同时为了避免另一种拒绝服务攻击方式,攻击者发送大量的 ACK 报文,服务器忙于计算验证 SYN Cookie。服务器对收到的 ACK 进行 Cookie 合法性验证前,需要确定最近确实发生了半连接队列溢出,不然攻击者只要随便发送一些 ACK,服务器便要忙于计算了

listen socket 无锁化- Linux 4.4 内核给出的 Lockless TCP listener

  SYN cookie 给出了 Lockless TCP listener 的一些思路,但是我们不想是无状态的三次握手,又不想请求的处理和 Listener 强相关,避免每次进行握手处理都需要 lock 住 listen socket,带来性能瓶颈。4.4 内核前的握手处理是以 listen socket 为主体,listen socket 管理着所有属于它的请求,于是进行三次握手的每个数据包的处理都需要操作这个 listener 本身,而一般情况下,一个 TCP 服务器只有一个 listener,于是在多核环境下,就需要加锁 listen socket 来安全处理握手过程了。我们可以换个角度,握手的处理不再以 listen socket 为主体,而是以连接本身为主体,需要记住的是该连接所属的 listen socket 即可。4.4 内核握手处理流程如下:

[1] TCP 数据包 skb 到达本机,内核协议栈从全局 socket 表中查找 skb 的目的 socket(sk),如果是 SYN 包,当然查找到的是 listen_socket 了,于是,协议栈根据 skb 构造出一个新的 socket(tmp_sk),并将 tmp_sk 的 listener 标记为 listen_socket,并将 tmp_sk 的状态设置为 SYNRECV,同时将构造好的 tmp_sk 排入全局 socket 表中,并回复 syn_ack 给 client。

[2] 如果到达本机的 skb 是 syn_ack 的 ack 数据包,那么查找到的将是 tmp_sk,并且 tmp_sk 的 state 是 SYNRECV,于是内核知道该数据包 skb 是 syn_ack 的 ack 包了,于是在 new_sk 中拿出连接所属的 listen_socket,并且根据 tmp_sk 和到来的 skb 构造出 client_socket,然后将 tmp_sk 从全局 socket 表中删除(它的使命结束了),最后根据所属的 listen_socket 将 client_socket 排如 listen_socket 的 accept 队列中,整个握手过程结束。

4.4 内核一改之前的以 listener 为主体,listener 管理所有 request 的方式,在 SYN 包到来的时候,进行控制反转,以 Request 为主体,构造出一个临时的 tmp_sk 并标记好其所属的 listener,然后平行插入到所有 socket 公共的 socket 哈希表中,从而解放掉 listener,实现 Lockless TCP listener。

 

posted @ 2022-11-18 11:59  codestacklinuxer  阅读(66)  评论(0)    收藏  举报