本文题目有点大,但其实我只想描述一些我个人一直比较关注的特性,并且不会太详细,跟往常一样,主要是帮忙理清思路的,不会分析源码。这主要是为了哪一天突然忘了的时候,一目十行扫一眼就能记忆当时的理解,不然写的太细节了,自己都看不懂了。
Lockless TCP listener
先从TCP的syncookie说起,如果都能使用syncookie机制该有多好,但是不能,因为它会丢失很多选项协商信息,这些信息对TCP的性能至关重要。TCP的syncookie主要是为了防止半连接的syn flood攻击,超级多的节点发送大量的syn包,然后就不管了,而被攻击的协议栈收到一个syn就会建立一个request,绑定在syn针对的Listener的request队列上。这会消耗很大的内存。
但是仔细想想,抛开选项协商不说,仅仅针对TCP的syn,synack而言,事实上TCP在3次握手过程,只需要查找一下Listener即可,只要它存在,就可以直接根据syn包构造synack包了,根本就不用Listener了,要记住2次握手包的信息,有两个办法,第一个办法就是syncookie机制给encode并echo回去,等第3次握手ack来了之后,TCP会decode这个ack的序列号信息,构造子socket,插入Listener的accept队列,还有一种办法就是在本地分配内存,记录这个连接客户端的信息,等第3次握手包ack到来之后,找到这个request,构造子socket,插入Listener的accept队列。
在4.4之前,一个request是属于一个Listener的,也就是说一个Listener有一个request队列,每构造一个request,都要操作这个Listner本身,但是4.4内核给出了突破性的方法,就是基于这个request构造一个新的socket!插入到全局的socket哈希表中,这个socket仅仅记录一个它的Listener的轻引用即可。等到第3个握手包ack到来后,查询socket哈希表,找到的将不再是Listnener本身,而是syn包到来时构造的那个新socket了,这样传统的下面的逻辑就可以将Listener解放出了:
传统的TCP协议栈接收
sk = lookup(skb);
lock_sk(sk);
if (sk is Listener); then
process_handshake(sk, skb);
else
process_data(skb);
endif
unlock_sk(sk);
可以看出,sk的lock期间,将是一个瓶颈,所有的握手逻辑将全部在lock期间处理。4.4内核改变了这一切,
下面是新的逻辑:
sk = lookup_form_global(skb);
if (sk is Listener); then
rv = process_syn(skb);
new_sk = build_synack_sk(skb, rv);
new_sk.listener = sk;
new_sk.state = SYNRECV;
insert_sk_into_global(sk);
send_synack(skb);
goto done;
else if (sk.state == SYNRECV); then
listener = sk.lister;
child_sk = build_child_sk(skb, sk);
remove_sk_from_global(sk);
add_sk_into_acceptq(listener, child_sk);
fi
lock_sk(sk);
process_data(skb);
unlock_sk(sk);
done:
这个逻辑中,只需要细粒度lock具体的队列就可以了,不需要lock整个socket了。对于syncookie逻辑更简单,根本连SYNRECV socket都不用构造,只要保证有Listener即可!
这是周四早上蹲厕所的时候猛然看到的4.4新特性,当时就震惊了,这正是我在2014年偶然想到的,但是后来由于没有环境就没有跟进,如今已经并在mainline了,不得不说这是一件好事。当时我的想法是依照一个syn包完全可以无视Listner而构造synack,需要协商的信息可以保存在别的地方而不必非要和Listner绑定,这样可以解放Listener的职责。但是我没有想到再构造一个socket,与所有socket平行插入到同一个socket哈希表中。
我觉得,4.4之前的逻辑是简单明了的,不管是握手包和数据包,处理逻辑完全一致,但是4.4将代码复杂化了,分离了那么多的if-else...但是这是不可避免的。事实上,syn构造的request本身就应该与Listener进行绑定,只是如果想到优化,代码会变得复杂,但是如果在代码本身下一番功夫,代码也会很好看,只是,我没有那个能力,我代码写的不好。
这个Lockless的思想跟nf_conntrack的思想类似,但是我觉得conntrack对于related conn逻辑也可以这么玩。
TCP listener的CPU亲和力与REUSEPORT
紧随着Lockless TCP Listener而来的accept队列的优化!众所周知,一个Listener只有一个accept队列,在多核环境下这个单一的队列绝对是个瓶颈,一个高性能服务器怎么可以忍受这样!
其实这个问题早就被REUSEPORT解决了。REUSEPORT允许多个独立的socket同时侦听同一个IP/Port对,这对于当今的多队列网卡,多CPU环境绝对是个福音。但是,虽然路宽了,车道多了,没有规则的话,性能反而下降,拥挤程度反而降级!
4.4内核为socket引入了一个SO_INCOMING_CPU选项,如果一个socket的该选项设置为n,意味着只有在n号cpu上处理协议栈逻辑的执行流才可以将数据包插入这个socket。体现在代码上,就是在compute_score上给与加分,也就是说,除了目标IP,目标端口,源IP,源端口之外,cpu也成了一个匹配项目。
正如patch说明说的,此特性与REUSEPORT,多队列网卡相结合,一定是一道美味佳肴!
新的基于流的多路径路由选路
以前的时候,有路由cache,一个路由cache项就是一个带有源信息的n元组信息,每一个数据包在匹配到FIB条目后都会建立一条cache项,后续的查找首先去查找cache,因此都是基于流的。然而在路由cache下课后,多路径选路变成了基于包的,这对于TCP这种协议而言肯定会造成乱序问题。为此4.4内核在多路径选路的时候,hash计算中引入了源信息,避免了这个问题。只要计算方法不变,永远一个流的数据hash到一个dst。
携带version number的socket路由缓存
这个不是4.4内核携带的特性,是我自己的一些想法。early_demux已经被引入了内核,旨在消除本机入流量的路由查找,毕竟路由查找后还要再socket查找,为何不直接socket查找呢?查找到的结果缓存路由信息。对于本机提供服务的设备而言,开启这个选项吧。
但是对于出流量,还是会有很大的开销浪费在路由查找上。虽然IP是无连接的,但是TCP socket或者一个connected UDP socket却是可以明确标示一个5元组的,如果把路由信息存储在socket中,是不是更好的。好吧!很多人会问,怎么解决同步问题,路由表改了怎么办,要notify socket吗?如果你被此引导而去设计一个“高效的同步协议”,你就输了!办法很简单,就是引入两个计数器-缓存计数器和全局计数器,socket的路由缓存如下:
sk_rt_cache {
atomic_t version;
dst_entry *dst;
};
全局计数器如下:
atomic_t gversion;
每当socket设置路由缓存的时候,读取全局gversion的值,设置进缓存version,每当路由发生任何改变的时候,全局gversion计数器递增。如果cache计数器的值与全局计数器值一致,就可用,否则不可用,当然,dst本身也要由引用计数保护。