Photo credit: my mother, 2014

Hi,欢迎来到 卡瓦邦噶!我是 laixintao,现在生活在新加坡。我的工作是 SRE,喜欢在终端完成大部分工作。我从 2013 年开始写这个博客,写的内容很广泛,运维的方法论,编程的思考,工作的感悟,除了技术内容之外,还会分享一些读书感想,旅行游记,电影和音乐等。欢迎留下你的评论。

声明:本博客内容仅代表本人观点,和我的雇主无关。本博客承诺不含有 AI 生成的内容,所有内容未加说明均为博主原创,一经发布自动进入公有领域,本人放弃所有权利。转载无需本人同意。但是依然建议在转载的时候留下本博客的链接,因为这里的很多内容在发布之后会还会不断地继续更新和追加内容。

去悉尼

上个月看到飞澳大利亚机票很便宜,于是就想去澳洲旅行一下。 算得上是一场说走就走的旅行了。(虽然最后算了一下,机票在这次的旅行中反而是最小的花费,酒店住宿才是贵的地方,哈哈。)

我们这次先后去了悉尼和墨尔本,这篇博客分享一些旅行中的七七八八的事情和照片。先写悉尼篇,后面有空再来写墨尔本篇。

签证申请

出发之前的签证申请,直接让我审视了一遍自己的人生,需要的材料非常多,稀奇的材料包括:

  1. 当前持有的签证;
  2. 家庭中有没有人不申请签证,不申请的话也要填资料并说明原因;
  3. 户口本(??);
  4. 过去5年去过哪些国家(??)

一共 21 页呀。整个流程 Kuact 写的这个版本 依然有效,可以参考。费用也比较高,一共 220 新币一个人,不过出签了才意识到这是一个一年多次入境的签证,那还不错,下一次可以去西澳旅行一下。如果只去一次的话,这个签证就显得有些太贵了。总体的流程如下:

  1. 注册 immi 账号,签证类型选择 Visitor (subclass 600)
  2. 登陆之后开始为每一个人创建一份申请,填表
  3. 提交申请,付费
  4. 会收到邮件,然后去 VFSGLOBAL 预约生物信息采集。VFSGLOBAL 是一个签证外包的机构,在新加坡负责很多国家的签证
  5. 注册一个 VFSGLOBAL 账号,登陆之后为每一个人预约生物信息采集(是每一个人,如果只给自己预约了,那么家人是没有预约的,到了现场才发现的话只能交昂贵的现场预约费用了)
  6. 现场生物采集,只收新币现金
  7. 采集完成之后我没有做 immi 上 submit 的操作,然后就出签了

签证下来的速度倒是异常的快,提交申请 3 天就出签了。

入境

机场对水果的控制格外严格,过关之前自觉地丢了很多东西。通关意外地非常顺利,直接刷护照过了,连句话都没人跟我说,下飞机到过关几乎花了10min左右。(最近发现中国新建的机场也支持自助刷护照入关了,非常便捷)。

悉尼衣食住行感受

到了悉尼,一出机场就感受到了一阵凉爽的风——好舒服呀。好像也好久没有经历过这样的风了,不知道在新加坡连续度过了多少个夏天。

出发之前还问刚回来的一个新加坡朋友,那边冷吗?朋友说冷,我打趣问道冷是 for Singaporan or for everyone?

到了发现果然不是很冷,白天T恤晚上加一件卫衣或者外套够了。最好是能防风的,温度不是特别低,但是风很大。

说吃的。没想到来了澳大利亚的几顿饭——汉堡,汉堡,还是……汉堡。一连吃了四顿汉堡,机场的麦当劳,餐厅的汉堡,酒吧的汉堡。有时候不想吃汉堡,点了一个叫做 PBB 的东西,上来一看,还是汉堡。

澳大利亚对澳大利亚的产品非常自豪,超市买的零食,蔬菜,显眼的地方都印着“澳大利亚生产”,有一些不是,也碰瓷似的写着“90%的原料来自澳大利亚”。麦当劳和 Burger King 的汉堡盒子上也特殊地写澳大利亚牛肉。哦对了,冷知识:澳大利亚的 Burger King 不叫 Burger King,叫 Hungry Jack’s。因为 Burger King 进驻澳洲市场的时候,已经有一家公司叫 Burger King 了。

住。相当的贵。定的酒店都在 1000 人民币一晚以上。到这里才意识到这趟旅行最贵的是酒店,机票反而是小 Case 了。但是听说房价相比于其他一线城市也不算贵,况且居民一般也不会选择住在市区,郊区就更便宜了。我们出 City(不是装逼,那里管市区就叫 City)看到过很多独栋的 House,看起来很漂亮,居住应该也很舒适。

行。习惯了在新加坡方便的公交系统,去到了广袤的澳大利亚,发现这里的公交系统乘坐起来很不方便。首先是线路少,乘坐巴士经常要走上个七八百米;其次是车次少,有的地方要等上半小时甚至一小时;最后是价格昂贵,我以为新加坡相比中国的公交已经很贵了,没想到澳大利亚更贵。

公交系统的数据上,整个新南威尔士州(悉尼所在的州),2023年10月有巴士 837 辆,而新加坡在 2020年就有大约 5800 辆巴士

看来,在澳大利亚生活,至少得有辆车。

说起坐车,遇到一件文化冲击的事。一天时间比较晚了,我们坐公交车回酒店。车上有一些看起来刚参加完 party 的青少年,他们比我们早下车,下车的时候,每一个人都说 “Thank you”。我之前从没见有人跟公交车司机说谢谢的,司机上班就是开车,开车载乘客不是天经地义吗?回到酒店之后躺在床上看了一下网友的说法,说在澳洲孩子就被教育说谢谢,虽然大部分的工作是领薪水提供服务的,但是这些工作人员依然帮助了你——超市收银员帮你把东西收了起来,司机安全地把你送到了目的地,服务员帮你点餐,等等。这些服务值得让我们表达谢意。

有趣的是,像新加坡这种人口极度密集的社会,反而没有这么多客套话,人们以效率为优先目标,只问必要的问题,然后马上服务下一个顾客。反而是在地广人稀的地方,人与人之间的关系更加密切一些。另一个表现是,在悉尼进到餐厅或者检票的时候,人们也会客套很多,都会问一下你今天怎么样?有一次买咖啡,服务员问我今天要去哪里,我说要去动物园,他的同事们就开始跟我说起他们喜欢的动物。

在悉尼我们参观了澳大利亚博物馆,教堂,监狱,皇家植物园,在城市里徒步(现在好想叫做 City Walk 了?),去了悉尼歌剧院,去邦迪海滩徒步,去了蓝山徒步,参观了悉尼大学,新南威尔士美术馆,等等。最有意思的是,还歪打正着去了议会,由于已经下班了,工作人员只给我们做了简单的介绍。晚上躺在酒店里还看了发生在议院里面的电视辩论。后来去墨尔本的时候,我们专门预约了议会导览参观,非常赞。

悉尼的旅行结束之后,我们坐一家本地的航空公司 JetStar 的飞机去往墨尔本。又遇见一件文化冲击的事情。JetStar 算是一家廉航,廉航的酒水是收费的(非廉航的酒精类饮料一般也是收费的)。我之前的飞行经历中,在飞机上买酒水的是少数人,大部分都不会在飞机上购买饮料,尤其是这种两个小时的短途飞行。可在悉尼飞墨尔本这架飞机上,推着餐车的空姐异常忙碌,我前面的人每一个人,小桌上都摆上了酒,要么是啤酒,要么是红酒,要不是看到他们刷卡,我都以为澳大利亚的飞机喝酒免费。如此真真切切体会到本地人的潇洒。

接下来是一些旅行片段。

先放地标建筑
在歌剧院附近,意外地碰到了邮轮起航。就是我们之前去坐过的那家
从酒店望出去的风景,虽然算是在 City 区域了,但是楼房普遍都不高。
在博物馆里看到奇奇怪怪的动物。袋鼠的蛋蛋是不是很可爱?袋鼠蛋蛋在澳大利亚是可以买到的纪念品。(好残忍)
免费参观一个悉尼的监狱。居然是这么先进的一个 ipod 和森海塞尔的耳机。导游程序做的非常赞。随着你走到一个地方,它会自动给你介绍周围的东西,模拟之前监狱的情景等等。
参观监狱。
监狱内部的样子
皇家植物园
参观新南威尔士州的议院
CBD 风景线
邦迪海滩徒步,太阳暴晒,风景很美。
去蓝山徒步,蓝山很美,视野辽阔,跟新加坡很不一样。
蓝山的景点,三姐妹峰
在一个港湾的桥上随手拍的一个小亭子

同事刚从悉尼回来,推荐我们这个蛋糕,于是按照 Google 去买。到了之后发现店面位置很小,排队的人也不多,尴尬的是,买了之后没地方吃。于是在旁边的一家咖啡店问店员,能否买两杯咖啡,在店里用我们外带的食物,店员爽朗的说完全没问题。并且称赞我们买的蛋糕很好看。

喝咖啡的时候,店员小姐站在店门口的街上,用比较前卫的方式在买咖啡,她拿着一个杯子,对路过人的大声说,不来杯咖啡吗?

漂亮的小蛋糕
在美术馆里面参与的一种很新颖的艺术——搓泥巴。

就写到这里吧,有空了再来写墨尔本篇,请期待!

 

iowait 的含义

iowait 是 CPU 的一种状态,表示此时 CPU 正处于 idle 状态,并且至少有一个进程正在等待 IO。

CPU 一共有四种状态。在任一时刻,CPU 的状态都是四种中的一种。这四种状态是:user, sys, idle iowait. 一般的程序,比如 sar top,会用百分比表示 CPU 分别处于这四种状态的时间,这四种状态相加的结果是 100%。

其实准确来说,CPU 只有两种状态:busy 和 idle。Busy 又分成了两种,user 和 system,可以表示 CPU 目前正在执行用户空间的代码,还是正在执行 kernel 空间的代码,方便开发者定位问题。Idle 又分成了两种:idle 和 iowait。笼统来说,idle 就是目前系统中没有 Runnable 的进程了(参考之前的博文:Linux 进程的生命周期),iowait 就是目前系统中没有 Runnable 进程(所以说 iowait 是 idle 的一种),并且,有进程卡在 IO 上。

(注意,本文说的“进程”是从资源调度的角度讲的,本文的语境中,进程包括线程)

统计的方式

Kernel 会定期更新 counter(AIX,CPU state counter 每个 clock interrrupt 更新,每 10ms 一个 interrupt)。

对于每次更新,检查 CPU 是否是 busy,如果是 busy,检查 CPU 在执行用户态还是内核态的代码,增加相应的计数器。如果不是 busy,那就是 idle,检查此 CPU 之前有没有发出 IO 操作,如果有,那么就增加 iowait,如果没有,那么就增加 idle 的计数器。(所以可以认为这个数值是抽样统计的方法?)

像 sar,top 这种展示工具,就去读 counter,计算时间段内的比例,展示结果。

举例说明含义

综上,假设 CPU state 出现了比较高的 iowait,那么就意味着:这个 CPU (在大部分时间)找不到可以执行的进程了,至少有一个进程在等待 IO。

iowait 只包括文件系统的 IO,不包括:sockets, pipes, ttys, select(), poll(), sleep(), pause()。

举例1: 一个机器有 4 个 CPU,1个CPU iowait 比较高,3 个 CPU user+sys 很高,iowait 很低。

意思是当前有 3 个 Runnable 的进程,有至少1个进程阻塞在 IO,其他的进程要么阻塞在 IO,要么阻塞在其他状态,但是不是 Runnable 状态,Runnable 状态的进程有且只有 3 个。因为如果再多来一个 Runnable,那么它就会被 iowait 高的那个 CPU 去执行,此 CPU 的 iowait 也不会很高了,user+sys 会变高。

举例2: 这个例子是参考资料 What exactly is “iowait”? 中的,非常典型。

假设有 6 个 CPU,而且只有 6 个进程:

  • 2个进程不涉及磁盘 IO 操作,只有 CPU 计算;
  • 4个进程要花 70% 的时间做 IO 操作,30% 的时间在 CPU 上计算,这 30% 中,5% 是在 kernel mode,25% 是在 user mode。

那么 CPU 利用率将会如下,最后一行是综合所有的 CPU 的数据:

现在,还是6个进程完全不变,但是 CPU 从6个减少到了4个,数据将会是如下:

可以看到,2个 100% 利用率的 CPU 保持不变,但是总的 CPU 的利用率是 80% 了。解释如下。

4个进程,每一个进程只要花 30% 的时间来计算,其他的时间是 iowait,iowait 是 idle 中的一种,iowait 的时候,如果能有别的事情可以做,CPU 就会去做别的事情。所以 2 个 CPU 每一个会花 60% 的时间做计算工作(一个进程是 25% + 5%,做完之后做下一个进程的 25% + 5%)。做完 60% 就无事可做了,剩下 40% 的 iowait(就是 idle)。

总结

iowait 是一种 idle,如果比例过高,它可以告诉我们几件事情:

  • 系统正在做的工作,大部分时间都是在等待 io 了,io 系统的性能不够高(也有可能是硬盘坏了);
  • CPU 没有更多的工作可以做了,我们可以给系统分配更多的计算工作;
  • 它不是 CPU 的一种“阻塞”状态,它不能说明:CPU 现在在等待 IO,无法运行其他进程。而是:没有可以运行的进程能给 CPU 做;

iowiat 是 CPU 角度的一个状态,它不是进程角度的状态。iowait 很低,不能代表进程没有卡在 IO 上。假设有一个进程需要花 70% 的时间做 io操作,把它放到一个空闲的,单 CPU 的系统中,显示的 iowait 是 70%,但是我在这个系统中增加一个不依赖 io 的计算任务,iowait 就变成 0 了。但是我们之前的那个进程,依然需要花 70% 的时间等待 io。所以,它不代表进程的状态。

参考内容:

  1. What exactly is “iowait”?
 

探测 TCP 乱序问题

TCP 协议是基于 IP 协议的。IP 协议不保证顺序,只能说尽力保证包的顺序。如果发生乱序,TCP 的性能就会下降很多。最近就遇到一个 TCP 下载速度很慢的问题,抓包分析发现有很多乱序的包。

网络发生了乱序,那就把锅甩给网络组的同事,但那不是我的风格。虽然我没有二层和三层设备的权限,无法排查哪里出了问题。但是我感觉我可以只从 TCP 的两端,探测出来乱序发生在哪一个节点上。

traceroute 的原理

traceroute 是一个很好的网络工具。之前在 mtr 的教程中也介绍过。

traceroute 的原理很精妙。它利用了 IP 协议本身的特性:每一个 IP 包都有一个 TTL 字段,表示这个包还能在网络中被转发多少次,每次路由器转发一个 IP 包就将其 -1,如果一个路由器发现 IP 包 -1 之后是 0 了,就直接丢弃,并且给 IP 包的 Source IP 发送一个 ICMP 包(包含此 hop 自己的 IP),说这个包气数已尽,无法送达目的地。

traceroute 原理

traceroute 想要知道去往另一个 IP,中间都会经过哪些 IP 节点。它发送一个 TTL=1 的 ping 包出去,包会挂在第一个 hop 上,第一个 hop 发回去 ICMP,traceroute 就知道了第一个 hop 的 IP 地址;它再发送一个 TTL=2 的 ping 包,就知道了第二个 hop 的 IP 地址…… 直到收到正确的 ping 响应,就算到头了。

mtr 类似于一个 traceroute + ping 的工具,还能告诉我们中间每一个 hop 的延迟。原理其实和 traceroute 是一样的,以下用 mtr 举例。

路由 path 的一致性问题

回到乱序的问题,最常见的问题是,中间某一跳到下一跳有多个节点可以选择。这时候,正确的情况下,对于 (src ip, src port, dst ip, dst port) 这样的四元组,应该固定走一条线路。就像 ECMP 的 hash 一样

ICMP Ping 没有端口的概念,但是对于 (src ip, dst ip),路线应该是一致的。即,我们在对某一个 IP mtr 的结果中,每一跳都应该只有一个唯一 IP。

mtr with ICMP, 每一跳都只有一个 IP

mtr 有 tcp traceroute 的功能,我用 tcpdump 看了下,原理和 traceroute 相同,只不过 IP 包的内容不是 ICMP 了,而是 TCP。路由器丢了 IP 包,不管里面是 TCP 还是 ICMP,都会发回来 ICMP 消息。于是 tcp traceroute,我们就可以用 tcp 包探测出来中间的转发节点。mtr 使用的 tcp 包是 syn 包,remote port 可以指定,但是 local port 每一个包都是随机选择的。这就导致,每一次发起探测,都是一个不同的四元组:(src ip, src port, dst ip, dst port) ,因为 src port 每次都在变,所以路径每次都是不一样的。这样的话相当于可以将路径中所有的路由设备 IP 都探测出来。

mtr with TCP, 每一跳可能有多个 IP

如图可以看出,一跳可能有多个 IP。

TCP multipath 探测

但是正确网络中的 TCP,如果 local port 也不变,那么相同的四元组,应该走同一条路,来尽量保持到达顺序和发送顺序一致。

利用这个原理,假设我们使用的 local port 一直保持不变,那么 TCP traceroute 的结果应该是,每一跳都只有一个 IP

mtr 没有提供固定 local ip 来 trace 的功能,我用 scapy 写了一个。

代码非常简单,就是发送 ttl 不同的包,然后检查收到的 ICMP 包。限制最多 20 次,如果 20 次还没到达终点就放弃。如果 reply 里面有 TCP 包,说明收到了正常的回复(即使是 RST 也算)。

另外,因为我们每次都用同一个 local port,所以不能用多线程,每次只能有一个探测存在。

实际测试发现,收到的 ICMP 响应并不是很稳定,有的时候 hop 4 丢了,有的时候 hop 5 丢了。可以使用一个 bash 循环多测试几次。

跑一段时间之后,我们可以查看这个日志文件,过滤掉错误的包 (??),然后排序之后,去重。理论上,每一跳都应该只有一个 IP 才对,如果有某一个 hop 出现了两个 IP,那说明就是出现了 multiple,会知道乱序。

如上输出,在 hop 5 出现了两个 IP,说明是 hop 4 到 hop 5 的时候出现了 multipath。

在得到了这个结果之后,去和网络组的同事做了确认。结果发现这个不是导致问题的根因。不过觉得挺有意思的,稍作记录一下。

小技巧:traceroute 只能测一个方向,有些情况下去的方向和回来的方向可能不是同一个路线。所以无论是 traceroute 还是 tcp traceroute,不妨从两端都试试。说不行会发现新的线索。

 

如何阅读火焰图

这篇文章是火焰图阅读的简明教程。

火焰图是我们用来分析性能的可视化工具。很多 profile 工具输出的信息都非常多,是一个巨大的文本,在这个文本中,找到性能瓶颈,会比较困难。但是如果画出来一张图,可以一下就看到问题所在。

火焰图是 Brendan Gregg 发明的。使用官方的工具 FlameGraph,可以将文本渲染成 svg。如下。

官方的 FlameGraph 渲染出来的 svg 截图

现在也会有其他的工具能渲染出来类似的图了,比如 golang 的 pprof 现在内置了一个新版的火焰图预览工具,在线的 speedscope 也可以渲染。我最喜欢的是 Flameshow,一个终端工具,可以直接在终端用字符渲染出来火焰图,设计的非常精妙。(其实就是我自己写的)。由于是我自己写的,那么下文我就以 Flameshow 来做展示的例子了。

Flameshow

阅读方法

火焰图作为一个可视化的工具,着重表达的信息是:父子之间的关系,每一个块的占比。

火焰图有从下向上的和从上向下的,本质是相同的,只是方块之间的关系方向不同。从上向下:下面的方块是上面的子块;从下向上:上面的方块是下面的子块。

主要信息有(以从上到下为例子):

  • 每一个方块,都是一个函数,方块的宽度,就表示函数消耗的时间占比。(如果是内存火焰图,那就表示的这个函数申请的内存占比。)所以我们看火焰图,主要去找最宽的一个方块。
  • 上下堆叠在一起的是表示函数调用。Y 轴表示调用的深度。

火焰图一般是支持交互式的,svg 和 flameshow 都支持点击其中一个 function,来放大。如下例子:

点击放大其中一个 function

标记的是,最开始调用的函数是 collector.NodeCollector.Collect.func1,然后这个函数的所有时间都在调用 collector.execute,以此类推。到下下面的 os.(*File).readdir,其中有一大部分是在调用函数 os.Lstat,然后其余的时间花在了 os.direntReclen

很多人对火焰图容易有一些误解,这里着重说明一下:

  • Y 轴的深度一般不是问题。我们用火焰图主要是排查性能问题,是要找消耗时间长的地方。调用深度很深,但是没花多久时间,一般不要紧;
  • 颜色(几乎)没有意义。不是说颜色越深时间越久。颜色只是为了区分出来不同的块而已。一般会将相同名字的函数都使用同一个颜色,这样,即使它们分散在不同的 stack 中,也能清晰看出总时间比较高。从 FlameGraph 的源代码也可以看出,颜色是根据 function 名字随机生成的。但是有一种优化:比如对于 Java 的 JVM 来说,可以用不同的红色表示 Java 代码消耗的时间,可以用黄色表示 Kernel 消耗的时间,用蓝色表示 JIT 时间。但是不同的红色,红色深浅,还是没有什么意义的。
  • 方块之间的顺序没有意义。因为火焰图的生成方式(后文介绍),和渲染方式(一般会将同名字的方块 merge 在一起,方便阅读),导致火焰图方块之间的顺序是没有意义的。不代表函数调用的顺序

火焰图的本质是旭日图(Sunburst Graph)

你有没有发现,主要表示占比,又能表示占比之间的关系,是不是跟某一种图很像?

使用 tokei-pie 渲染出来的旭日图

是的,其实火焰图的本质就是拉平了的旭日图。上图是我用 tokei-pie 渲染出来的代码仓库中不同文件夹、文件的行数占比。打开一个新的项目的时候可以轻松找到核心代码。

火焰图的生成和格式

火焰图的生成主要依赖 profile 工具,目前很多工具都支持了,比如 py-spy, golang 的 pprof.

生成的原理大致是去扫描程序的内存,主要是内存的 stack 部分,对 stack 做一个快照。如果扫描了 10 次,其中 function1 出现了 3 次,function2 出现了 6 次。那么它们的宽度占比就是 1:2. 很多 profile 工具就是如此工作的,不是 100% 精确的,但足以让我们分析性能问题了。

生成的格式一般是 stackcollapse 格式,这是官方的一种定义。比如如下的文本:

每一行就代表一个 stack,数字代表整个 stack 的占比。我们要把所有的 stack 相同层级相同名字的 merge 起来,最后就变成下面这样:

简单的 stackcollapse

另一种常见的格式是 pprof 的格式。虽然是 golang 最先开始用的,但是设计的(我个人认为)比较好,也是开源的,protobuf 定义,所以很多工具也支持输出这种格式了。

Continuous Profiling

持续 Profiling 也是我比较感兴趣的一个领域,很多 APM 工具都已经支持了。比如 DatadogGrafana。简单来说,就是不断地对线上部分实例进行 Profile,然后对结果不是简单的展示,而是收集起来。将它们的 stack 都合并起来,做成一个由多个实例的 stack 组成的 Flame Graph,就可以找到集群层面的性能热点了。

另外一个用处是,在发布新版本的时候,可以在灰度的时候,检查新版本的 Flame Graph 和之前的,看有没有引入新的性能热点。

相关链接

  1. https://www.brendangregg.com/flamegraphs.html
  2. https://www.brendangregg.com/FlameGraphs/cpuflamegraphs.html
  3. https://queue.acm.org/detail.cfm?id=2927301
  4. https://youtu.be/6uKZXIwd6M0
  5. https://youtu.be/6uKZXIwd6M0
  6. https://www.webperf.tips/tip/understanding-flamegraphs/
  7. https://github.com/jlfwong/speedscope/wiki
  8. https://www.speedscope.app/
 

再多来点 TCP 吧:Delay ACK 和 Nagle 算法

教科书介绍的 TCP 内容通常比较基础:包括三次握手,四次挥手,数据发送通过收到 ACK 来保证可靠传输等等。当时我以为已经学会了 TCP,但是后来在工作中,随着接触 TCP 越来越多,我发现很多内容和书上的不一样——现实世界的 TCP 要复杂一些。

我们从一个简单的 HTTP 请求开始。发送一个简单的 HTTP 请求,tcpdump 抓包如下:

第一个和书上不一样的地方是,TCP 结束连接不是要 4 次挥手吗?为什么这里只出现了 3 次?

回顾 TCP 的包结构,FIN 和 ACK 其实是不同的 flags,也就是说,理论上我可以在同一个 Segment 中,即可以设置 FIN 也可以同时设置 ACK。

TCP segment,图来源

所以如果在结束连接的时候,客户端发送 FIN,这时候服务端一看:“正好我也没有东西要发送了。”于是,除了要 ACK 自己收到的 FIN 之外,也要发送一个 FIN 回去。那不如我一石二鸟,直接用一个包好了。

TCP FIN 教科书的图,和实际的图

这个例子在 TCP可以使用两次握手建立连接吗? 中详细介绍过。

既然 FIN 可以附带去 ACK 自己收到的 FIN,那么数据是否也可以附带 ACK?也是可以的。

Delay ACK

TCP 是全双工的,意味着两端都可以同时向对方发送数据,而两端又需要分别去 ACK 自己收到的数据。

TCP 的一端在收到数据之后,反正马上也要发送数据回去,与其发送两个包:一个 ACK 和一个数据包,不如不立即发送 ACK 回去,而是等待一段时间——我反正一会要发送数据给你,等到那时候,我再带上 ACK 就好啦。这就是 Delay ACK

数据 + ACK

Delay ACK 可以显著降低网络中纯 ACK 包的数量,大概 1/3. 纯 ACK 包(即 payload length 是 0 ),有 20 bytes IP header 和 20 bytes TCP header。

Delay ACK 的假设是:如果我收到一个包,那么应用层会需要对这个包做出回应,所以我等到应用的回应之后再发出去 ACK。这个假设是有问题的。而且现实是,Delay ACK 所造成的问题比它要解决的问题要多。(下文详解)

Nagle’s Algorithm

现在再考虑这样一个问题:像 ncssh 这样的交互式程序,你按下一个字符,就发出去一个字符给 Server 端。每通过 TCP 发送一个字符都需要额外包装 20 bytes IP header 和 20 bytes TCP header,发送 1 bytes 带来额外的 40 bytes 的流量,不是很不划算吗?

除了像这种程序,还有一种情况是应用代码写的不好。TCP 实际上是由 Kernel 封装然后通过网卡发送出去的,用户程序通过调用 write syscall 将要发送的内容送给 Kernel。有些程序的代码写的不好,每次调用 write 都只写一个字符(发送端糊涂窗口综合症)。如果 Kernel 每收到一个字符就发送出去,那么有用数据和 overhead 占比就是 1/41.

为了解决这个问题,Nagle 设计了一个巧妙的算法 (Nagle’s Algorithm),其本质就是:发送端不要立即发送数据,攒多了再发。但是也不能一直攒,否则就会造成程序的延迟上升。

算法的伪代码如下:

简单来说,就是如果要发送的内容足够一个 MSS 了,就立即发送。否则,每次收到对方的 ACK 才发送下一次数据。

Delay ACK 和 Nagle 算法

这两个方法看似都能解决一些问题。但是如果一起用就很糟糕了。

假设客户端打开了 Nagle’s Algorithm,服务端打开了 Delay ACK。这时候客户端要发送一个 HTTP 请求给服务端,这个 HTTP 请求大于 1 MSS,要用 2 个 IP 包发送。于是情况就变成了:

  • Client: 这是第一个包
  • Server:… (不会发送 ACK,直到 Server 想发送数据给 Client,但是这里因为 Server 没有收到整个 HTTP 请求内容,所以 Server 不会发送数据给 Client)
  • Client: … (因为 Nagle 算法,Client 在等待对方的 ACK,然后再发送第二个包的数据)
  • Server: 好吧,我等够了,这是 ACK
  • Client: 这是第二个包
Nagle’s Algorithm 和 Delay ACK 在一起使用的时候的问题

这里有一个类似死锁的情况发生。会导致某些情况下,HTTP 请求有不合理的延迟

再多说一点有关的历史,我曾经多次在 hackernews 上看到 Nagle 的评论(Nagle 亲自解释 Nagle 算法!12)。大约 1980s,Nagle 和 Berkeley 为了解决几乎相同的问题,发明了二者。Berkeley 的问题是,很多用户通过终端共享主机,网络会被 ssh 或者 telnet 这样的字符拥塞。于是用 Delay ACK,确实可以解决 Berkeley 的问题。但是 Nagle 觉得,他们根本不懂问题的根源。如果他当时还在网络领域的话,就不会让这种情况发生。可惜,他当时改行去了一家创业公司,叫 Autodesk

解决方法是关闭 Delay ACK 或者 Nagle’s Algorithm。

配置方法

关闭 Nagle’s Algorithm 的方法:可以给 socket 设置 TCP_NODELAY. 这样程序在 write 的时候就可以 bypass Nagle’s Algorithm,直接发送。

关闭 Delay ACK 的方法:可以给 socket 设置 TCP_QUICKACK,这样自己(作为 server 端)在收到 TCP segment 的时候会立即 ACK。实际上,在现在的 Linux 系统默认就是关闭的。

前面这篇文章提到:

如果在收到对方的第二次包SYN+ACK之后很快要发送数据,那么第三次包ACK可以带着数据一起发回去。这在Windows和Linux中都是比较流行的一种实现。但是数据的大小在不同实现中有区别。

如果我们关闭 TCP_QUICKACK ,就可以看到几乎每一次 TCP 握手,第三个 ACK 都是携带了数据的。