协议栈源端口选择性能衰减问题

0x00 问题描述

这是前阵子发现的一个问题,当时我正在写一个简单的http benchmark工具,具体简单到什么程度呢?就是创建一堆socket,去connect目标服务器。

接下来,问题就出现了,我发现1w并发链接和2w并发连接的耗时完全无线性关系,而是呈现一种近指数上升的趋势。例如,1w并发链接建连1w次,耗时不到1s,如果改为2w并发链接,建连2w次,耗时突然变成了10+s。

工具是基于自己开发的框架实现的,这个框架里有一个用户态调度器,拥有一定的复杂度,因此我一度以为是该框架存在bug,而没有将焦点聚焦于Kernel,多次使用lttng踩点无果后,开始怀疑Kernel,索性分别在3.10、4.9进行测试,结果发现3.10内核并无相关问题,而4.9存在该问题。

0x01 问题分析

目前排查性能问题的主流手段就是tracing + perf,鉴于问题可能是在kernel,在框架里继续做tracing无太大意义,因此直接用火焰图进行分析,如下:

inet_connect_fg.jpg

很明显,tcp_v4_connect竟然占用了90%以上的开销,而其中__inet_hash_connect函数主要用于找到一个合适的源端口,相关代码如下,火焰图中的__inet_check_established就是作为回调传入__inet_hash_connect中的。

int __inet_hash_connect(struct inet_timewait_death_row *death_row, struct sock *sk, u32 port_offset,
                        int (*check_established)(struct inet_timewait_death_row *, struct sock *,
                                                 __u16, struct inet_timewait_sock **))
{
    ...
    int port = inet_sk(sk)->inet_num;
    ...
    if (port) { // connect with specific src port
        ...
        // 显然,不是这里造成的
        ret = check_established(death_row, sk, port, NULL);
        ...
        return ret;
    }
    inet_get_local_port_range(net, &low, &high);
    high++; /* [32768, 60999] -> [32768, 61000[ */
    remaining = high - low;
    if (likely(remaining > 1))
        remaining &= ~1U;

    offset = (hint + port_offset) % remaining;
    /* In first pass we try ports of @low parity.
     * inet_csk_get_port() does the opposite choice.
     */
    offset &= ~1U;
other_parity_scan:
    port = low + offset;
    for (i = 0; i < remaining; i += 2, port += 2) {
        if (unlikely(port >= high))
            port -= remaining;
        if (inet_is_local_reserved_port(net, port))
            continue;
        head = &hinfo->bhash[inet_bhashfn(net, port, hinfo->bhash_size)];
        spin_lock_bh(&head->lock);

        /* Does not bother with rcv_saddr checks, because
         * the established check is already unique enough.
         */
        inet_bind_bucket_for_each(tb, &head->chain) {
            if (net_eq(ib_net(tb), net) && tb->port == port) {
                if (tb->fastreuse >= 0 || tb->fastreuseport >= 0)
                    goto next_port;
                WARN_ON(hlist_empty(&tb->owners));
                // 看这里,看这里
                if (!check_established(death_row, sk, port, &tw))
                    goto ok;
                goto next_port;
            }
        }
        tb = inet_bind_bucket_create(hinfo->bind_bucket_cachep, net, head, port);
        ...
        goto ok;
next_port:
        ...
    }

    offset++;
    if ((offset & 1) && remaining > 1)
        goto other_parity_scan;

    return -EADDRNOTAVAIL;
ok:
    ...
    return 0;
}

这个函数的实现方式比较简单,它会获取port range中设置的端口范围,将端口资源按照偶数、奇数分为两组,依次尝试去获取,当然每次获取的起点是随机的。很明显,当我们完全占用偶数组的端口后,所有后续的connect调用,所需的源端口应该位于奇数组中,然而该函数依旧会尝试完整遍历偶数组资源,这也就是__inet_check_established耗时占比这么高的原因。

0x02 问题溯源

印象中3.10的相关实现并非如此,那么是从哪个版本开始加入的该特性呢?一番搜索后,得到了如下commit msg。

k42_commit_msg.jpg

简而言之,就是自kernel 4.2开始,端口资源的分配策略改了,目前奇数端口留给bind,偶数端口留给connect为了均衡资源的占用,但是显然,这种策略不适合本文所述的特殊场景,并且对于bind而言,也存在性能衰减的问题。

0x03 问题验证

为验证这个问题,我分别进行了测试,测试机的port range配置如下,共计40000个源端口,每组资源20000个可用端口。

net.ipv4.ip_local_port_range = 20000    60000

首先是连续connect场景,横坐标表示connect的次数,纵坐标表示耗时,单位ms。

connect_ts_consume.jpg

再来看下连续bind场景,横坐标表示bind的次数,纵坐标表示耗时,单位ms。

bind_ts_consume.jpg

0xFF 总结

当然,实际场景中,这个问题的影响并没有那么大:

  1. client端很少会向相同服务建连1w+次
  2. server端也很少见暴露1w+端口提供服务

最容易复现该问题的场景就是端到端压测场景,比如本文所说的http benchmark,解决方案也很简单,就是自己在connect前做手动bind,在端口资源有限的情况下,这么做还是很有必要的。另外,也可以增加port range的范围,能够缓解该问题。

那么,这个策略的实现方式是否有得当呢?我的理解,这么做显然是有问题的,出发点是为了更好地均衡端口资源的使用,但结果却是浪费了这么多的cpu资源,不应该。

说到底,根本原因还是tcp协议过于老旧,16bit的端口在目前看来捉襟见肘。

说两句: