性能文章>关于Linux网络的网络性能优化建议>

关于Linux网络的网络性能优化建议原创

536359

导语

我们在Linux性能方面有哪些优化手段可用呢?我在本章中给出一些开发或者运维中的性能优化建议。注意,我用的字眼是建议,而不是原则之类的。每一种性能优化方法都有它适用或者不适用的应用场景。你应当根据你当前的项目现状灵活来选择用或者不用。

 

正文

1、网络请求优化

建议1:尽量减少不必要的网络 IO

我要给出的第一个建议就是不必要用网络 IO 的尽量不用。


是的,网络在现代的互联网世界里承载了很重要的角色。用户通过网络请求线上服务、服务器通过网络读取数据库中数据,通过网络构建能力无比强大分布式系统。网络很好,能降低模块的开发难度,也能用它搭建出更强大的系统。但是这不是你滥用它的理由!

 

我曾经见过有的同学在自己开发的接口里要请求几个第三方的服务。这些服务提供了一个 C 或者 Java 语言的SDK,说是 SDK 其实就是简单的一次 UPD 或者 TCP 请求的封装而已。这个同学呢,不熟悉 C 和 Java 语言的代码,为了省事就直接在本机上把这些 SDK 部署上来,然后自己再通过本机网络 IO 调用这些 SDK。我们接手这个项目以后,分析了一下这几个 SDK 的实现,其实调用和协议解析都很简单。我们在自己的服务进程里实现了一遍,干掉了这些本机网络 IO 。效果是该项目 CPU 整体核数削减了 20 % +。另外除了性能以外,项目的部署难度,可维护性也都得到了极大的提升。

 

原因我们在本书第 5 章的内容里说过,即使是本机网络 IO 开销仍然是很大的。先说发送一个网络包,首先得从用户态切换到内核态,花费一次系统调用的开销。进入到内核以后,又得经过冗长的协议栈,这会花费不少的 CPU周期,最后进入环回设备的“驱动程序”。接收端呢,软中断花费不少的 CPU 周期又得经过接收协议栈的处理,最后唤醒或者通知用户进程来处理。当服务端处理完以后,还得把结果再发过来。又得来这么一遍,最后你的进程才能收到结果。你说麻烦不麻烦。另外还有个问题就是多个进程协作来完成一项工作就必然会引入更多的进程上下文切
换开销,这些开销从开发视角来看,做的其实都是无用功。

 

上面我们还分析的只是本机网络 IO,如果是跨机器的还得会有双方网卡的 DMA 拷贝过程,以及两端之间的网络RTT 耗时延迟。所以,网络虽好,但也不能随意滥用!

 

建议2:尽量合并网络请求

在可能的情况下,尽可能地把多次的网络请求合并到一次,这样既节约了双端的 CPU 开销,也能降低多次 RTT 导致的耗时。

我们举个实践中的例子可能更好理解。假如有一个 redis,里面存了每一个 App 的信息(应用名、包名、版本、截图等等)。你现在需要根据用户安装应用列表来查询数据库中有哪些应用比用户的版本更新,如果有则提醒用户更新。

那么最好不要写出如下的代码:

<?php
for(安装列表 as 包名){
redis­>get(包名)
...
}


上面这段代码功能上实现上没问题,问题在于性能。据我们统计现代用户平均安装 App 的数量在 60 个左右。那这段代码在运行的时候,每当用户来请求一次,你的服务器就需要和 redis 进行 60 次网络请求。总耗时最少是 60个 RTT 起。更好的方法是应该使用 redis 中提供的批量获取命令,如 hmget、pipeline等,经过一次网络 IO 就获取到所有想要的数据,如图 1.1。

7973E99A-3D4E-4666-914C-E7F2B0010DCC.png

图1.1 网络请求合并

建议3:调用者与被调用机器尽可能部署的近一些

在前面的章节中我们看到在握手一切正常的情况下, TCP 握手的时间基本取决于两台机器之间的 RTT 耗时。虽然我们没办法彻底去掉这个耗时,但是我们却有办法把 RTT 降低,那就是把客户端和服务器放的足够地近一些。尽量把每个机房内部的数据请求都在本地机房解决,减少跨地网络传输。

举例,假如你的服务是部署在北京机房的,你调用的 mysql、redis最好都位于北京机房内部。尽量不要跨过千里万里跑到广东机房去请求数据,即使你有专线,耗时也会大大增加!在机房内部的服务器之间的 RTT 延迟大概只有零点几毫秒,同地区的不同机房之间大约是 1 ms 多一些。但如果从北京跨到广东的话,延迟将是 30 - 40 ms 左右,几十倍的上涨!

建议4:内网调用不要用外网域名

假如说你所在负责的服务需要调用兄弟部门的一个搜索接口,假设接口是:"http://www.sogou.com/wq?key=开发内功修炼"。

那既然是兄弟部门,那很可能这个接口和你的服务是部署在一个机房的。即使没有部署在一个机房,一般也是有专线可达的。所以不要直接请求 www.sogou.com, 而是应该使用该服务在公司对应的内网域名。在我们公司内部,每一个外网服务都会配置一个对应的内网域名,我相信你们公司也有。

为什么要这么做,原因有以下几点

1)外网接口慢。本来内网可能过个交换机就能达到兄弟部门的机器,非得上外网兜一圈再回来,时间上肯定会慢。
2)带宽成本高。在互联网服务里,除了机器以外,另外一块很大的成本就是 IDC 机房的出入口带宽成本。两台机器在内网不管如何通信都不涉及到带宽的计算。但是一旦你去外网兜了一圈回来,行了,一进一出全部要缴带宽费,你说亏不亏!!
3)NAT 单点瓶颈。一般的服务器都没有外网 IP,所以要想请求外网的资源,必须要经过 NAT 服务器。但是一个公司的机房里几千台服务器中,承担 NAT 角色的可能就那么几台。它很容易成为瓶颈。我们的业务就遇到过好几次 NAT 故障导致外网请求失败的情形。NAT 机器挂了,你的服务可能也就挂了,故障率大大增加。

2、接收过程优化

建议1:调整网卡 RingBuffer 大小

当网线中的数据帧到达网卡后,第一站就是 RingBu៛?er。网卡在 RingBuffer 中寻找可用的内存位置,找到后 DMA引擎会把数据 DMA 到 RingBuffer 内存里。因此我们第一个要监控和调优的就是网卡的 RingBuffer,我们使用ethtool 来来查看一下 Ringbuffer 的大小。

# ethtool ­g eth0
Ring parameters for eth0:
Pre­set maximums:
RX: 4096
RX Mini: 0
RX Jumbo: 0
TX: 4096
Current hardware settings:
RX: 512
RX Mini: 0
RX Jumbo: 0
TX: 512

 

这里看到我手头的网卡设置 RingBuffer 最大允许设置到 4096 ,目前的实际设置是 512。

这里有一个小细节,ethtool 查看到的是实际是 Rx bd 的大小。Rx bd 位于网卡中,相当于一个指针。
RingBu៛?er 在内存中,Rx bd 指向 RingBuffer。Rx bd 和 RingBuffer 中的元素是一一对应的关系。在网卡启
动的时候,内核会为网卡的 Rx bd 在内存中分配RingBu៛?er,并设置好对应关系。

在 Linux 的整个网络栈中,RingBuffer 起到一个任务的收发中转站的角色。对于接收过程来讲,网卡负责往RingBuffer 中写入收到的数据帧,ksoftirqd 内核线程负责从中取走处理。只要 ksoftirqd 线程工作的足够快,RingBuffer 这个中转站就不会出现问题。但是我们设想一下,假如某一时刻,瞬间来了特别多的包,而 ksoftirqd处理不过来了,会发生什么?这时 RingBuffer 可能瞬间就被填满了,后面再来的包网卡直接就会丢弃,不做任何处理!

 

30D47C90-A907-4F66-BDD1-DD60D6569DDB.png

图2.1 RingBuffer 溢出

那我们怎么样能看一下,我们的服务器上是否有因为这个原因导致的丢包呢?前面我们介绍的四个工具都可以查看
这个丢包统计,拿 ethtool 来举例:

# ethtool ­S eth0
......
rx_fifo_errors: 0
tx_fifo_errors: 0

rx_fifo_errors 如果不为 0 的话(在 ifconfig 中体现为 overruns 指标增长),就表示有包因为 RingBuffer 装不下而被丢弃了。那么怎么解决这个问题呢?很自然首先我们想到的是,加大 RingBuffer 这个“中转仓库”的大小,如图2.2 通过 ethtool 就可以修改。

# ethtool ­G eth1 rx 4096 tx 4096

1DF6A85A-163C-43C9-88CC-EECD3692BD1A.png

图2.2 RingBuffer 扩容

这样网卡会被分配更大一点的”中转站“,可以解决偶发的瞬时的丢包。不过这种方法有个小副作用,那就是排队的包过多会增加处理网络包的延时。所以应该让内核处理网络包的速度更快一些更好,而不是让网络包傻傻地在RingBuffer 中排队。我们后面会再介绍到 RSS ,它可以让更多的核来参与网络包接收。

建议2:多队列网卡 RSS 调优

硬中断的情况可以通过内核提供的伪文件 /proc/interrupts 来进行查看。拿飞哥手头的一台虚机来举例:

8BF25617-208A-433D-8101-F070B488C383.png

上述结果是我手头的一台虚机的输出结果。上面包含了非常丰富的信息。网卡的输入队列 virtio1-input.0 的中断号是 27,总的中断次数是 1109986815,并且 27 号中断都是由 CPU3 来处理的。

那么为什么这个输入队列的中断都在 CPU3 上呢?这是因为内核的一个中断亲和性配置,在我机器的伪文件系统中可以查看到。

# cat /proc/irq/27/smp_affinity
8

smp_affinity 里是CPU的亲和性的绑定,8 是二进制的 1000, 第4位为 1。代表的就是当前的第 27 号中断的都由第 4 个 CPU 核心 - CPU3 来处理。

现在的主流网卡基本上都是支持多队列的。通过 ethtool 工具可以查看网卡的队列情况。

# ethtool ­l eth0
Channel parameters for eth0:
Pre­set maximums:
RX: 0
TX: 0
Other: 1
Combined: 63
Current hardware settings:
RX: 0
TX: 0
Other: 1
Combined: 8

上述结果表示当前网卡支持的最大队列数是 63 ,当前开启的队列数是 8 。这样当有数据到达的时候,可以将接收进来的包分散到多个队列里。另外每一个队列都有自己的中断号。比如我手头另外一台多队列的机器上看到结果(为了方便展示我删除了部分不相关内容):

32BC338D-E5CA-48EB-9E90-45F811402EA3.png

这台机器上 virtio 这块虚拟网卡上有四个输入队列,其硬中断号分别是 27、29、31 和 33。有独立的中断号就可以独立向某个 CPU 核心发起硬中断请求,让对应 CPU 来 poll 包。中断和 CPU 的对应关系还是通过 cat/proc/irq/{中断号}/smp_affinity 来查看。通过将不同队列的 CPU 亲和性打散到多个 CPU 核上,就可以让多核同时并行处理接收到的包了。这个特性叫做 RSS(Receive Side Scaling,接收端扩展),如图 2.3。这是加快 Linux内核处理网络包的速度非常有用的一个优化手段。

360E743B-242E-418F-B61C-94A6170EC699.png

图2.3 多队列网卡

在网卡支持多队列的服务器上,想提高内核收包的能力,直接简单加大队列数就可以了,这比加大 RingBuffer 更为有用。因为加大 RingBuffer 只是给个更大的空间让网络帧能继续排队,而加大队列数则能让包更早地被内核处理。ethtool 修改队列数量方法如下:

#ethtool ­L eth0 combined 32

不过在一般情况下,由一个叫队列中断号和 CPU 之间的亲和性并不需要手工维护,有一个 irqbalance的服务来自动管理。通过 ps 命令可以查看到这个进程。

# ps ­ef | grep irqb
root 29805 1 0 18:57 ? 00:00:00 /usr/sbin/irqbalance ­­foreground

Irqbalance 会根据系统中断负载的情况,自动维护和迁移各个中断的 CPU 亲和性,以保持各个 CPU 之间的中断开销均衡。如果有必要,irqbalance 也会自动把中断从一个 CPU 迁移到另一个 CPU 上。如果确实想自己维护亲和性,那得先关掉 irqbalance,然后再修改中断号对应的 smp_affinity。

# service irqbalance stop
# echo 2 > /proc/irq/30/smp_affinity

建议3:硬中断合并

在第 1 章中我们看到,当网络包接收到 RingBuffer 后,接下来通过硬中断通知 CPU。那么你觉得从整体效率上来讲,是有包到达就发起中断好呢,还是攒一些数据包再通知 CPU 更好。

先允许我来引用一个实际工作中的例子,假如你是一位开发同学,和你对口的产品经理一天有10 个小需求需要让你帮忙来处理。她对你有两种中断方式:

第一种:产品经理想到一个需求,就过来找你,和你描述需求细节,然后让你帮你来改。
第二种:产品经理想到需求后,不来打扰你,等攒够 5 个来找你一次,你集中处理。

我们现在不考虑及时性,只考虑你的工作整体效率,你觉得那种方案下你的工作效率会高呢?或者换句话说,你更喜欢哪一种工作状态呢?只要你真的有过工作经验,一定都会觉得第二种方案更好。对人脑来讲,频繁的中断会打乱你的计划,你脑子里刚才刚想到一半技术方案可能也就废了。当产品经理走了以后,你再想捡起来刚被中断之的工作的时候,很可能得花点时间回忆一会儿才能继续工作。

对于CPU来讲也是一样,CPU要做一件新的事情之前,要加载该进程的地址空间,load进程代码,读取进程数据,各级别 cache 要慢慢热身。因此如果能适当降低中断的频率,多攒几个包一起发出中断,对提升 CPU 的整体工作效率是有帮助的。所以,网卡允许我们对硬中断进行合并。

现在我们来看一下网卡的硬中断合并配置。

# ethtool ­c eth0
Coalesce parameters for eth0:
Adaptive RX: off TX: off
......
rx­usecs: 1
rx­frames: 0
rx­usecs­irq: 0
rx­frames­irq: 0
......

我们来说一下上述结果的大致含义

Adaptive RX::自适应中断合并,网卡驱动自己判断啥时候该合并啥时候不合并
rx-usecs:当过这么长时间过后,一个 RX interrupt 就会被产生
rx-frames:当累计接收到这么多个帧后,一个 RX interrupt 就会被产生

如果你想好了修改其中的某一个参数了的话,直接使用 ethtool -C 就可以,例如:

# ethtool ­C eth0 adaptive­rx on

不过需要注意的是,减少中断数量虽然能使得 Linux 整体网络包吞吐更高,不过一些包的延迟也会增大,所以用的时候得适当注意。

建议4:软中断 budget 调整

再举个日常工作相关的例子,不知道你有没有听说过番茄工作法这种高效工作方法。它的大致意思就是你在工作的时候,要有一整段的不被打扰的时间,集中精力处理某一项工作。这一整段时间时长被建议是 25 分钟。对于我们的Linux的处理软中断的 ksoftirqd 来说,它也和番茄工作法思路类似。一旦它被硬中断触发开始了工作,它会集中精力处理一波儿网络包(绝不只是1个),然后再去做别的事情。

我们说的处理一波儿是多少呢,策略略复杂。我们只说其中一个比较容易理解的,那就是net.core.netdev_budget 内核参数。

# sysctl ­a | grep
net.core.netdev_budget = 300

这个的意思说的是,ksoftirqd 一次最多处理300个包,处理够了就会把 CPU 主动让出来,以便 Linux 上其它的任务可以得到处理。那么假如说,我们现在就是想提高内核处理网络包的效率。那就可以让 ksoftirqd 进程多干一会儿网络包的接收,再让出 CPU。至于怎么提高,直接修改这个参数的值就好了。

#sysctl ­w net.core.netdev_budget=600

如果要保证重启仍然生效,需要将这个配置写到/etc/sysctl.conf

建议5:接收处理合并

硬中断合并是指的攒一堆数据包后再通知一次 CPU,不过数据包仍然是分开的。Lro(Large Receive Offload) /Gro(Generic Receive Offload) 还能把数据包合并起来后再往上层传递。

如果应用中是大文件的传输,大部分包都是一段数据,不用 LRO / GRO 的话,会每次都将一个小包传送到协议栈(IP接收函数、TCP接收)函数中进行处理。开启了的话,内核或者网卡会进行包的合并,之后将一个大包传给协议处理函数,如图 2.4。这样 CPU 的效率也就提高了。

C0008841-952C-4196-9D92-0EA95F0F68EC.png

图2.4 接收处理合并

Lro 和 Gro 的区别是合并包的位置不同。Lro 是在网卡上就把合并的事情给做了,因此要求网卡硬件必须支持才行。而 Gso 是在内核源码中用软件的方式实现的,更加通用,不依赖硬件。

那么如何查看你的系统内是否打开了 LRO / GRO 呢?

# ethtool ­k eth0
generic­receive­offload: on
large­receive­offload: on
...

如果你的网卡驱动没有打开 GRO 的话,可以通过如下方式打开。

# ethtool ­K eth0 gro on
# ethtool ­K eth0 lro on

3、发送过程优化

建议1:控制数据包大小

在第四章中我们看到,在发送协议栈执行的过程中到了 IP 层如果要发送的数据大于 MTU 的话,会被分片。这个分片会有哪些影响呢?首先就是在分片的过程中我们看到多了一次的内存拷贝。其次就是分片越多,在网络传输的过程中出现丢包的风险也越大。当丢包重传出现的时候,重传定时器的工作时间单位是秒,也就是说最快 1 秒以后才能开始重传。所以,如果在你的应用程序里可能的话,可以尝试将数据大小控制在一个 MTU 内部来极致地提高性能。我所知道的是在早期的 QQ 后台服务中应用过这个技巧,不知道现在还有没有在用。

建议2:减少内存拷贝

假如你要发送一个文件给另外一台机器上,那么比较基础的做法是先调用 read 把文件读出来,再调用 send 把数据把数据发出去。这样数据需要频繁地在内核态内存和用户态内存之间拷贝,如图 3.1。

D7409E2D-D7F0-4B0F-8692-3855B9670557.png

图3.1 read + write 发送文件

目前减少内存拷贝主要有两种方法,分别是使用 mmap 和 sendfile 两个系统调用。使用 mmap 系统调用的话,映射进来的这段地址空间的内存在用户态和内核态都是可以使用的。如果你发送数据是发的是 mmap 映射进来的数据,则内核直接就可以从地址空间中读取,如图 3.2,这样就节约了一次从内核态到用户态的拷贝过程。

87307780-E2F8-48B3-8D71-9BBC3CD42727.png

图3.2 mmap + write 发送文件

不过在 mmap 发送文件的方式里,系统调用的开销并没有减少,还是发生两次内核态和用户态的上下文切换。如果你只是想把一个文件发送出去,而不关心它的内容,则可以调用另外一个做的更极致的系统调用 - sendfile。在这个系统调用里,彻底把读文件和发送文件给合并起来了,系统调用的开销又省了一次。再配合绝大多数网卡都支持的"分散-收集"(Scatter-gather)DMA 功能。可以直接从 PageCache 缓存区中 DMA 拷贝到网卡中,如图3.3。这样绝大部分的 CPU 拷贝操作就都省去了。

14913FDB-847B-4F7C-8B7B-B3844BD6A12A.png

图3.3 sendfile 发送文件

建议3:发送处理合并

在建议 1 中我们说到过发送过程在 IP 层如果要发送的数据大于 MTU 的话,会被分片。但其实是有一个例外情况,那就是开启了 TSO(TCP Segmentation Offload)/ GSO(Generic Segmentation Offload)。我们来回顾和跟进
一下发送过程中的相关源码:

//file: net/ipv4/ip_output.c
static int ip_finish_output(struct sk_buff *skb)
{
......
//大于 mtu 的话就要进行分片了
if (skb­>len > ip_skb_dst_mtu(skb) && !skb_is_gso(skb))
return ip_fragment(skb, ip_finish_output2);
else
return ip_finish_output2(skb);
}

ip_finish_output 是协议层中的函数。skb_is_gso 判断是否使用 gso,如果使用了的话,就可以把分片过程推迟到更下面的设备层去做。

//file: net/core/dev.c
int dev_hard_start_xmit(struct sk_buff *skb, struct net_device *dev,
struct netdev_queue *txq)
{
......
if (netif_needs_gso(skb, features)) {
if (unlikely(dev_gso_segment(skb, features)))
goto out_kfree_skb;
if (skb­>next)
goto gso;
}
}

dev_hard_start_xmit 位于设备层,和物理网卡离得更近了。netif_needs_gso 来判断是否需要进行 GSO 切分。在这个函数里会判断网卡硬件是不是支持 TSO,如果支持则不进行 GSO 切分,将大包直接传给网卡驱动,切分工作推迟到网卡硬件中去做。如果硬件不支持,则调用 dev_gso_segment 开始切分。

推迟分片的好处是可以省去大量包的协议头的计算工作量,减轻 CPU 的负担。

BB3A83B9-DF1C-4256-A9DB-C86993E9F5CC.png

图3.4 发送处理合并

使用 ethtool 工具可以查看当前 tso 和 gso 的开启状况。

# ethtool ­k eth0
tcp­segmentation­offload: on
tx­tcp­segmentation: on
tx­tcp­ecn­segmentation: off [fixed]
tx­tcp6­segmentation: on
udp­fragmentation­offload: off [fixed]
generic­segmentation­offload: off

如果没有开启,可以使用 ethtool 打开。

# ethtool ­K eth0 tso on
# ethtool ­K eth0 gso on

建议4:多队列网卡 XPS 调优

在第四章的发送过程中 4.4.5 小节,我们看到在 __netdev_pick_tx 函数中,要选择一个发送队列出来。如果存在XPS (Transmit Packet Steering)配置,就以 XPS 配置为准。过程是根据当前 CPU 的 id 号去到 XPS 中查看是要用哪个发送队列,来看下源码。

//file: net/core/flow_dissector.c
static inline int get_xps_queue(struct net_device *dev, struct sk_buff *skb)
{
//获取 xps 配置
dev_maps = rcu_dereference(dev­>xps_maps);
if (dev_maps) {
map = rcu_dereference(map = rcu_dereference(
//raw_smp_processor_id() 是获取当前 cpu id
dev_maps­>cpu_map[raw_smp_processor_id()]);
if (map) {
if (map­>len == 1)
queue_index = map­>queues[0];
...
}

源码中 raw_smp_processor_id 是在获取当前执行的 CPU id。用该 CPU 号查看对应的 CPU 核是否有配置。XPS配置在 /sys/class/net//queues/tx-/xps_cpus 这个伪文件里。例如对于我手头的一台服务器来说,配置是这样的。

# cat /sys/class/net/eth0/queues/tx­0/xps_cpus
00000001
# cat /sys/class/net/eth0/queues/tx­1/xps_cpus
00000002
# cat /sys/class/net/eth0/queues/tx­2/xps_cpus
00000004
# cat /sys/class/net/eth0/queues/tx­3/xps_cpus
00000008
......

上述结果中 xps_cpus 是一个 CPU 掩码,表示当前队列对应的 CPU 号。从上面输出看对于 eth0 网卡 下的 tx-0 队列来说,是和 CPU0 绑定的。00000001 表示 CPU0,00000002 表示 CPU1,...,以此类推。假如当前 CPU 核是CPU0,那么找到的队列就是 eth0 网卡 下的 tx-0。

0AA69A77-7125-45EE-A03C-5CA1F51C14D3.png

图3.5 多队列网卡发送

那么通过 XPS 指定了当前 CPU 要使用的发送队列有什么好处呢。好处大致是有两个:

第一,因为更少的 CPU 争用同一个队列,所以设备队列锁上的冲突大大减少。如果进一步配置成每个 CPU都有自己独立的队列用,则会完全消除队列锁的开销。
第二,CPU 和发送队列一对一绑定以后能提高传输结构的局部性,从而进一步提升效率。

关于 RSS、RPS、RFS、aRFS、XPS 等网络包收发过程中的优化手段可用参考源码中
Documentation/networking/scaling.txt 这个文档。里面有关于这些技术的详细官方说明。

建议5:使用 eBPF 绕开协议栈的本机 IO

如果你的业务中涉及到大量的本机网络 IO 可以考虑这个优化方案。
在第 5 章中我们看到,本机网络 IO 和跨机 IO 比较起来,确实是节约了驱动上的一些开销。发送数据不需要进RingBuffer 的驱动队列,直接把 skb 传给接收协议栈(经过软中断)。但是在内核其它组件上,可是一点都没少,系统调用、协议栈(传输层、网络层等)、设备子系统整个走 了一个遍。连“驱动”程序都走了(虽然对于回环设备来说这个驱动只是一个纯软件的虚拟出来的东东)。

如果想用本机网络 IO,但是又不想频繁地在协议栈中绕来绕去。那么你可以试试 eBPF。使用 eBPF 的 sockmap和 sk redirect 可以绕过 TCP/IP 协议栈,而被直接发送给接收端的 socket,业界已经有公司在这么做了。

4、内核与进程协作优化

建议1:尽量少用 recvfrom 等进程阻塞的方式

在 3.3 节我们看到,在使用了 recvfrom 阻塞方式来接收 socket 上数据的时候。每次一个进程专⻔为了等一个socket 上的数据就得被从 CPU 上拿下来。然后再换上另一个 进程。等到数据 ready 了,睡眠的进程又会被唤醒。总共两次进程上下文切换开销。如果我们服务器上需要有大量的用户请求需要处理,那就需要有很多的进程存在,而且不停地切换来切换去。这样的缺点有如下这么几个:

因为每个进程只能同时等待一条连接,所以需要大量的进程。
进程之间互相切换的时候需要消耗很多 CPU 周期,一次切换大约是 3 - 5 us 左右。
频繁的切换导致 L1、L2、L3 等高速缓存的效果大打折扣

大家可能以为这种网络 IO 模型很少见了。但其实在很多传统的客户端 SDK 中,比如 mysql、redis 和 kafka 仍然是沿用了这种方式。

建议2:使用成熟的网络库

使用 epoll 可以高效地管理海量的 socket。在服务器端。我们有各种成熟的网络库进行使用。这些网络库都对epoll 使用了不同程度的封装。

首先第一个要给大家参考的是 Redis。老版本的 Redis 里单进程高效地使用 epoll 就能支持每秒数万 QPS 的高性能。如果你的服务是单进程的,可以参考 Redis 在网络 IO 这块的源码。

如果是多线程的,线程之间的分工有很多种模式。那么哪个线程负责等待读 IO 事件,那个线程负责处理用户请求,哪个线程又负责给用户写返回。根据分工的不同,又衍生出单 Reactor、多 Reactor、以及 Proactor 等多种模式。大家也不必头疼,只要理解了这些原理之后选择一个性能不错的网络库就可以了。比如 PHP 中的 Swoole、Golang 的 net 包、Java 中的 netty 、C++ 中的 Sogou Workflow 都封装的非常的不错。

建议3:使用 Kernel-ByPass 新技术

如果你的服务对网络要求确实特别特特别的高,而且各种优化措施也都用过了,那么现在还有终极优化大招 --Kernel-ByPass 技术。在本书我们看到了内核在接收网络包的时候要经过很⻓的收发路径。在这期间牵涉到很多内核组件之间的协同、协议栈的处理、以及内核态和用户态的拷贝和切换。Kernel-ByPass 这类的技术方案就是绕开内核协议栈,自己在用户态来实现网络包的收发。这样不但避开了繁杂的内核协议栈处理,也减少了频繁了内核态用户态之间的拷贝和切换,性能将发挥到极致!

目前我所知道的方案有 SOLARFLARE 的软硬件方案、DPDK 等等。如果大家感兴趣,可以多去了解一下!

5、握手挥手过程优化

建议1:配置充足的端口范围

客户端在调用 connect 系统调用发起连接的时候,需要先选择一个可用的端口。内核在选用端口的时候,是采用从可用端口范围中某一个随机位置开始遍历的方式。如果端口不充足的话,内核可能需要循环撞很多次才能选上一个可用的。这也会导致花费更多的 CPU 周期在内部的哈希表查找以及可能的自旋锁等待上。因此不要等到端口用尽报错了才开始加大端口范围,而且应该一开始的时候就保持一个比较充足的值。

# vi /etc/sysctl.conf
net.ipv4.ip_local_port_range = 5000 65000
# sysctl ­p //使配置生效
如果端口加大了仍然不够用,那么可以考虑开启端口 reuse 和 recycle。这样端口在连接断开的时候就不需要等待2MSL 的时间了,可以快速回收。开启这个参数之前需要保证 tcp_timestamps 是开启的。

# vi /etc/sysctl.conf
net.ipv4.tcp_timestamps = 1
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tw_recycle = 1
# sysctl ­p

建议2:客户端最好不要使用 bind

如果不是业务有要求,建议客户端不要使用 bind。因为我们在 6.3 节看到过,connect 系统调用在选择端口的时候,即使一个端口已经被用过了,只要和已经有的连接四元组不完全一致,那这个端口仍然可以被用于建立新连接。但是 bind 函数会破坏 connect 的这段端口选择逻辑,直接绑定一个端口,而且一个端口只能被绑定一次。如果使用了 bind,则一个端口只能用于发起一条连接上。总体上来看,你的机器的最大并发连接数就真的受限于65535 了。

建议3:小心连接队列溢出

服务器端使用了两个连接队列来响应来自客户端的握手请求。这两个队列的长度是在服务器 listen 的时候就确定好了的。如果发生溢出,很可能会丢包。所以如果你的业务使用的是短连接且流量比较大,那么一定得学会观察这两个队列是否存在溢出的情况。因为一旦出现因为连接队列导致的握手问题,那么 TCP 连接耗时都是秒级以上了。

对于半连接队列, 有个简单的办法。那就是只要保证 tcp_syncookies 这个内核参数是 1 就能保证不会有因为半连接队列满而发生的丢包。对于全连接队列来说,可以通过 netstat -s 来观察。netstat -s 可查看到当前系统全连接队列满导致的丢包统计。但该数字记录的是总丢包数,所以你需要再借助 watch 命令动态监控。

# watch 'netstat -­s | grep overflowed'
160 times the listen queue of a socket overflowed //全连接队列满导致的丢包


如果输出的数字在你监控的过程中变了,那说明当前服务器有因为全连接队列满而产生的丢包。你就需要加大你的全连接队列的⻓度了。全连接队列是应用程序调用 listen时传入的 backlog 以及内核参数 net.core.somaxconn 二者之中较小的那个。如果需要加大,可能两个参数都需要改。如果你手头并没有服务器的权限,只是发现自己的客户端机连接某个 server 出现耗时长,想定位一下是否是因为握手队列的问题。那也有间接的办法,可以 tcpdump 抓包查看是否有 SYN 的 TCP Retransmission。如果有偶发的 TCP Retransmission, 那就说明对应的服务端连接队列可能有问题了。

建议4:减少握手重试

在 6.5 节我们看到如果握手发生异常,客户端或者服务端就会启动超时重传机制。这个超时重试的时间间隔是翻倍地增长的,1 秒、3 秒、7 秒、15 秒、31 秒、63 秒 ......。对于我们提供给用户直接访问的接口来说,重试第一次耗时 1 秒多已经是严重影响用户体验了。如果重试到第三次以后,很有可能某一个环节已经报错返回 504 了。所以在这种应用场景下,维护这么多的超时次数其实没有任何意义。倒不如把他们设置的小一些,尽早放弃。其中客户端的 syn 重传次数由 tcp_syn_retries 控制,服务器半连接队列中的超时次数是由 tcp_synack_retries 来控制。把它们两个调成你想要的值。

建议5:打开 TFO( TCP Fast Open)

我们第 6 章的时候没有介绍一个细节,那就是 fastopen 功能。在客户端和服务器端都支持该功能的前提下,客户端的第三次握手 ack 包就可以携带要发送给服务器的数据。这样就会节约一个 RTT 的时间开销。如果支持,可以尝试启用。

# vi /etc/sysctl.conf
net.ipv4.tcp_fastopen = 3 //服务器和客户端两种角色都启用
# sysctl -­p

建议6:保持充足的文件描述符上限

在 Linux 下一切皆是文件,包括我们网络连接中的 socket。如果你的服务进程需要支持海量的并发连接。那么调整和加大文件描述符上限是很关键的。否则你的线上服务将会收到 “Too many open files”这个错误。
相关的限制机制请参考 8.2 节,这里我们给出一套推荐的修改方法。例如你的服务需要在单进程支持 100 W 条并发,那么建议:

# vi /etc/sysctl.conf
fs.file­max=1100000 //系统级别设置成 110 W,多留点 buffer。
fs.nr_open=1100000 //进程级别也设置成 110 W,因为要保证比 hard nofile 大
# sysctl ­-p
# vi /etc/security/limits.conf
//用户进程级别都设置成 100W
* soft nofile 1000000
* hard nofile 1000000

建议7:如果请求频繁,请弃用短连接改用长连接

如果你的服务器频繁请求某个 server,比如 redis 缓存。和建议 1 比起来,一个更好一点的方法是使用长连接。这样的好处有

1)节约了握手开销。短连接中每次请求都需要服务和缓存之间进行握手,这样每次都得让用户多等一个握手的时间开销。
2)规避了队列满的问题。前面我们看到当全连接或者半连接队列溢出的时候,服务器直接丢包。而客户端呢并不知情,所以傻傻地等 3 秒才会重试。要知道 tcp 本身并不是专门为互联网服务设计的。这个 3 秒的超时对于互联网用户的体验影响是致命的。
3)端口数不容易出问题。端连接中,在释放连接的时候,客户端使用的端口需要进入 TIME_WAIT 状态,等待 2MSL的时间才能释放。所以如果连接频繁,端口数量很容易不够用。而长连接就固定使用那么几十上百个端口就够用了。

建议8:TIME_WAIT 的优化

很多线上服务如果使用了短连接的情况下,就会出现大量的 TIME_WAIT。

首先,我想说的是没有必要见到两三万个 TIME_WAIT 就恐慌的不行。从内存的⻆度来考虑,一条 TIME_WAIT 状态的连接仅仅是 0.5 KB 的内存而已。从端口占用的角度来说,确实是消耗掉了一个端口。但假如你下次再连接的是不同的 Server 的话,该端口仍然可以使用。只有在所有 TIME_WAIT 都聚集在和一个 Server 的连接上的时候才会有问题。

那怎么解决呢? 其实办法有很多。第一个办法是按上面建议 1 中的开启端口 reuse 和 recycle。第二个办法是限制TIME_WAIT 状态的连接的最大数量。

# vi /etc/sysctl.conf
net.ipv4.tcp_max_tw_buckets = 32768
# sysctl ­-p


如果再彻底一些,也可以干脆采用建议 7 ,直接用⻓连接代替频繁的短连接。连接频率大大降低以后,自然也就没有 TIME_WAIT 的问题了。

点赞收藏
Linux阅码场
请先登录,查看5条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步
9
5