欢迎大家来到IT世界,在知识的湖畔探索吧!
本文以从硬件到软件,从内核到应用,从底层到上层,从系统到算法,从核心到边缘的顺序,试图萃取人类网络技术各抽象层次的知识。
硬件:商用主机
主机是网络设备的最重要节点,内核主要实现了7层协议中的L2、L3、L4三层,L5之上则更是以主机为主的应用层。
现代x86商用硬件(commodity hardware)相比小型机、大型机、各种高端服务器,是廉价又好用的最主流存储、计算设备,是目前世界上绝大多数服务的提供者,是孕生这一代网络技术的温床。所谓网络编程、后端开发就是以商用主机为核心。
商用主机主要通过增加节点的办法scale out。相比之下,高端服务器(Oracle、IBM、HP)可能数目就那么几天,每台价格却是普通x86服务器的十倍甚至百倍,是典型的scale up系统。
商用主机通常有16核,几十GB内存,1~2个千兆或万兆网卡,十几块普通服务器级别的传统硬盘。开发时就应以此为假想环境,实际上与个人主机相差不远(甚至我的SSD还要更快一些)。
此外,这些机器一般都用某种Linux,所以Mac的kqueue,Win的iocp就算比epoll更优秀也更优雅,还是毫无意义,编程时总是以Linux为默认环境。
硬件:封包转发设备
1. 中继器(repeater)只有2个端口,将A复制到B,与任何协议无关。
2. 集线器(hub)就是多端口中继器。
3. 网桥(bridge)了解链路层协议,因此可以按帧复制数据,而非按位复制。因此每个端口上,网桥都有帧的缓冲区,至少缓存一个帧。网桥和交换机(switch)是一回事,只不过后者一般用来指物理设备,前者是指逻辑上的概念。普通的Linux PC加一块NIC就可以变身网桥。网桥常用于把物理主机组建成LAN,或把多个LAN合并成一个。网桥虽然没有IP协议,却也不会愚蠢地把入口数据复制到所有节点上,自带一种学习算法,了解各个主机的信息后,根据Ethernet帧头里的src/dst就可以针对性地传输数据了。不过网桥的特性决定了它的拓扑里不能有环路,否则容易引发灾难性的无限循环帧传输,无保护的话,很快所有主机都要崩。因此桥接里还用到了各种生成树算法。
4. 路由器(router)了解网络层协议,可以根据路由表转发入口封包。它也常被成为网关(gateway)。
硬件:NAT,如何让内网服务器对外服务?
IP的世界本身公平的,节点与节点之间可平等对话。因此家用路由器、一直开着的家用电脑甚至手机都完全可以搭载服务器,为全世界提供服务。
但NAT和防火墙的存在对此造成了妨碍,给人一种家用设备和服务器有本质区别的错觉。
通过iptables命令可以设置nat table和filter table。
用户 WAN Interface: vlan10 LAN Interface: br0
服务器 LAN Web server IP: 192.168.1.1 LAN Web server Port: 8080
服务器网关 Router WAN IP: 192.168.10.129 Router LAN IP: 192.168.1.254
1. Port-Forwarding rules
iptables -I FORWARD 1 -i vlan10 -p tcp -d 192.168.1.1 –dport 8080 -j ACCEPT
这里-i表示输入interface,为空则任意;-d和-dport指定需要端口转发目标,即本地服务器的ip和端口。
iptables -A PREROUTING -t nat -i vlan10 -p tcp –dport 8080 -j DNAT –to 192.168.1.1
这里-A表示添加规则,-t用于指定nat表(不指定则默认filter表)。nat表显然也要设置一下,将访问路由器端口8080的。
2. NAT loopback rules
iptables -t nat -A PREROUTING -i br0 -s 192.168.1.0/24 -d 192.168.10.129/32 -p tcp -m tcp –dport 8080 -j DNAT –to-destination 192.168.1.1
iptables -t nat -A POSTROUTING -o br0 -s 192.168.1.0/24 -d 192.168.1.1/32 -p tcp -m tcp –dport 8080 -j SNAT –to-source 192.168.1.254
在内网192.168.1.1:8080上搭建tcp服务器,通过路由器192.168.1.254(外网ip 192.168.10.129)向外网提供服务。这需要进行端口转发和NAT映射。
此外,为了解决nat loopback问题(hairpinning问题,即同一内网的机器通过外网ip访问),还需要添加loopback规则。规则加了,但并不保证有效,有些路由器支持,有些路由器不支持。为何身处内网还要用外网ip呢?因为很多时候用的是域名,DNS解析后得到的就是外网ip。
在这之后,内网服务器再获得域名、证书,就能对外提供服务了。
硬件:NAT穿透(UDP hole punching)
穿透技术,punch-through/hole pounching,是用来绕过防火墙或NAT路由器的技术,主要用于UDP,TCP穿透更难,处于试验阶段,因为握手阶段NAT会直接对陌生地址的syn返回rst导致无法进行下去。
有4种NAT,其中3种可穿透,1种不可:
1. (Address)-restricted-cone NAT,向某个外网ip:port发送试探消息后,这个外网地址就会自动取消被屏蔽的状态。这一点可以被利用,建立P2P程序的udp通信渠道。
2. Full-cone NAT可以无需发送试探消息,默认就不屏蔽外网ip。
3. Port-restricted cone NAT与第一种很像,只不过更严格,发试探消息到目标addr:port后,外网addr:port会取消被屏蔽状态,非这个port的同一个ip仍被屏蔽。
4. Symmetric NAT无法建立稳定的内网外网ip:port映射,每次发往不同目的地,就换了另一个唯一的外网(ip:port)对,只有接收过自己消息的外网主机能回复。位于这种NAT内的机器显然只能和公网主机互联,无法通过中介暴露自身的公网ip:port(除非让对端暴力硬猜),也就无法实现p2p的NAT穿透。
UDP hole punching技术过程简单讲就是:
1. A连公网中介S
2. B也连S
3. S直到A和B的公网ip:port后转发给A和B
4. A和B再回想发试探报文。
5. 根据NAT规则,只要发了报文就会将目标加入NAT表,不再丢弃。所以不管哪方先发送或哪方先抵达,总会有一方能收到对方的报文,再回复就连上了。
6. 由于NAT设备维护session时间有限,需要P2P双方定期发送心跳,保证session不被清除。
内核:L2链路层实现
网络设备不对应/dev/下的文件,是不适用于一切皆文件抽象的特例。内核对网络设备驱动有独立的接口、通用数据结构,甚至有两个专门的软中断。
中断处理的下半段以soft irq形式运行,与传统的下半段函数相比,它能在多核上多线程运行。
一共存在6种soft irq:HI(高优先级)、TIMER(时钟)、NET_TX(传输流量,传输一词用于表示离开)、NET_RX(接收流量)、SCSI、TASKLET(微任务,可延迟执行)。其中两个专门用于网络流量处理。
每个CPU都有自己独立的链路层网络帧流量出入队列,以及流量控制与拥塞避免变量——并不复杂,一个bit标识CPU是否过载,一个平均队列长度可表示拥塞程度,一个拥塞等级在平均队列长度触及某个值后发生变动。
1. “输入队列”本质是一个缓冲区,保存所有进来的帧,这些帧尚未被驱动程序处理。
2. “完成队列”是多个缓冲区的队列,其中的缓冲区都已完成处理,可以施放。
3. “发送设备队列”是多个设备队列,这些设备有数据要传输。
4. “接收设备链表”是多个设备的双向链表,这些设备有数据要接收。
输入和完成队列对绝大多数设备都是必需的,除了回环设备(loopback),因为回环设备发送接收都可以立刻执行完(本地兜圈)。
设备会因为各种理由发出网络中断,如新帧已接收,帧已传输成功,内核会配合中断通知信息进行不同的处理。这些中断先从设备传播到驱动,再由驱动触发内核的软中断,处理之后再通知驱动从硬件中断中返回。
假如一个链路层帧抵达,触发NET_RX软中断,它会经历下列步骤:
1. 拷贝到入队列缓冲区(但是DMA设备不需要,如今基本都是DMA)。
2. 初始化缓冲区参数,以便后续IP层网络层使用。
3. 更新相关设备的私用参数。
纯粹基于中断的帧接收设计,可能导致高负载下中断处理过于频繁,因此内核后续改进(NAPI)时混合了中断与旧式的轮询:若新帧收到,且内核尚未处理完前几个帧,则驱动程序不产生中断,暂时关闭设备的中断功能,等队列空了再开启。这段时间内核就无需处理中断,而是轮询队列直到队列里积压的处理完。
帧传输(输出)的主要问题是输出目标的内存可能不够,若不够则暂时关闭出口队列,任何该设备的输出尝试都会被拒绝,避免这个设备内存溢出。此外,很多设备都是有锁的,所以输出时还要取锁。各个设备还有看门狗定时器,一段时间关闭后要重启,不同网卡有不同的重启时限。内核流量控制实现了这种看门狗定时器重启输出设备的机制,不过设备驱动里也能实现自己的看门狗定时器作为补充。
内核:L2 ARP协议
ARP是地址解析协议,将IP地址映射到48位MAC地址。其实现方式是分布式的IP-MAC映射高速缓存。
如果查询ARP失败,则不得不先广播询问目的主机硬件地址的ARP报文,等收到应答之后才与之能进行IP层通信。
内核:L3网络层IP协议实现
IPv4的主要工作是封包的分段(fragmentation)和重组(defragmentation)。因为各种中间层有不同大小的缓冲区,所以分段切割成足够小的包才能原子化地发出去,直到目的端主机再重组起来(NAT和防火墙是例外,NAT必须要查看完整的IP包,所以它也要重组)。
分段相对简单,大部分复杂度在重组中:如何正确重传,如何重新组合封包?
分段
新内核对IP分段做了优化,允许L4了解L3的细节,与之密切配合,不再给L3单个整的缓冲区,而是给一组大小刚好匹配PMTU的缓冲区,这样可以让L3分段时提高性能。
从这里可以看出分层架构设计的一大矛盾——层与层之间的信息隐藏导致性能降低!但若是层与层之间密切配合,彼此了如指掌,那岂不是就成了一层?
重组
重组时报文分段是没有唯一id去映射到报文的,而是通过(ip报文id,L4protocol,saddr,daddr)这4元组进行判断的。用hash表存放待重组的分段,以这个四元组做key,相同key的hash到一个bucket上的链表里,后面再安排顺序形成完整报文。
为何IP报文id不足以为唯一id呢?因为它只有16位,ip报文数量奇多,很快就溢出卷绕回来了。而且不同机器不知道彼此用了那个id,完全可以采用相同的。
【注:这里提及了卷绕问题(wraparound),这是一个无法解决只能减轻的问题。Linux下的策略是不用全局ip id计数器,而是给每个dst ip一个独立计数器。全局自增半秒钟就卷绕一次,独立自增则可能很长时间卷绕一次,那么之前相同id的报文已经处理完了,所以卷绕概率大大降低。】
为何再加上saddr,daddr,也不足为信呢?因为NAT设备会替换IP报文头里的saddr和daddr。saddr换成路由器网关的地址,当然就一样了。
【注:这里提及了NAT的最致命隐患,而且是无法解决的根本问题。若两个不同IP报文的ID相同,又被NAT转换成相同src IP,且抵达dst前进行了IP报文分段,则这两个报文的分段会被混在一起!若长度不对则会被L4认定内容损毁而丢弃,L4会重传,这还算好的。要是长度刚好符合报文头,就会被L4当做正确报文送过去,谁知道应用层会出什么问题(因此应用层最好有自己的校验和重传)。】
内核:L4传输层的内核空间与用户空间
传输层只有tcp、udp、icmp静态编入内核。其中tcp、udp完全运行在内核态。icmp在发送时用的是raw socket接口,所以是用户态生成ip报文才入内核,但接受并回复的部分是内核态,所以是半内核半用户。用户自己写的传输层协议基本都是通过raw socket接口,是纯用户态的。
内核:L4 TCP有限状态机
Berkerly实现(4.4BSD,差不多相当于FreeBSD2.0)中,TCP代码包括28个函数,4500行C代码。UDP则是9个函数,800行。ip、tcp、udp都写在netinet目录下,实现的非常精简。
tcp的状态机模型(其实就是以4.4BSD为准,现代实现复杂得多)如下:
FIN WAIT 1,FIN WAIT 2,CLOSING,TIME WAIT这4个状态同属“主动关闭”。
主动关闭方会立刻进入FIN WAIT 1状态,已久把fin发给对端,期待对端的ack或fin+ack。
FIN WAIT 2需要对端发FIN,要是一直不发则等到60s后自动关闭,这个值可以用TCP_LINGER2选项进行设置。
4次挥手结束进入TIME WAIT,再继续等2MSL,确保网络中残存的报文消失。
LISTEN到SYN SENT的状态转移不被Berkeley API支持,但在TCP协议中是合法的。
LISTEN到SYNC RECVD只需一个SYN,进入SYNC RECVD后会发回SYN+ACK,期待ACK。
SYN RECVD到LISTEN,只需一个RST,期待中的ACK没等到,丢弃本次连接建立。
SYNC RECVD到ESTABLISHED只需一个ACK,三次握手完成。
内核:L4 TCP计时器
1. REXMT,重传计时器:用于强制重传。每次timeout都发送一个unack的报文,并backoff计时器,新连接重传初始6秒,backoff到24秒、48秒。收到ACK后重传计时器重置。如果是旧连接重传,则timeout时间根据rtt统计数据估值来定,第一次重传就发生在rtt + 4 * rttvar之后。
2. PERSIST,持续计时器:若过去发的报文都已经ack,且window size已经变得太小不足以发送任何报文,则开启这个计时器,timeout时若window非零,则进入transmit状态。否则定期(间隔小于RTT)给peer发送消息强制对端更新window信息,当对端window更新消息收到后持续计时器重置。【见L4 TCP流量控制与窗口管理的“零窗口”一节】
3. KEEP,保活计时器:定期发送心跳(不过间隔比应用层心跳长得多,一般2小时),若对端未能即使响应则丢弃这个连接。除此之外,KEEP还额外用作连接建立定时器:客户端应用调用connect或服务端应用从listen变迁到sync_recvd状态时,连接建立定时器开始计时,一般75秒,若连接未进入established状态则丢弃。
4. 2MSL,2MSL计时器:FIN_WAIT_2阶段等2MSL从而确保关闭,避免过期报文残存于网络。
内核:L4 TCP控制块
1. tcpiphdr结构体的链表:用于存放的报文容器,侵入式双链表。
2. 上一章节提到的4个计时器和状态。
3. 传输时用的tcpiphdr模板报文,可方便迅速生成待发送报文。
4. Flags:各种选项与配置,比如nodelay,acknow,ack but delay,don’t use tcp option, have window scaling。
5. 发送序号变量(滑动窗口):都用序号数字来标记一个流中的窗口和位置。
a) snd una:尚未ack的序号
b) snd nxt:下一个发送的序号
c) snd up:紧急报文专用序号
d) snd iss:初始发送序号
e) snd wnd:send window,发送窗口大小
f) snd max:最高的已发送报文的序号,只在重传时用到
g) snd cwnd:拥塞窗口,用于拥塞控制
h) snd ssthresh:拥塞控制,慢启动阈值
6. 接收序号变量(滑动窗口):
a) rcv wnd:recv window,接收窗口大小
b) rcv nxt:下一个接收的序号
c) rcv irs:初始接收序号
d) rcv up:紧急报文专用序号
7. RFC1323的Window Scaling扩展:窗口缩放机制,只出现在syn报文段里。此前wnd size限制为64K(因为header用16位整数来存窗口大小),ack前最多发64K,有效带宽被压制到64K/RTT,在巨大的LAN上(大带宽高延迟网络,所需窗口数值大,用户应设置窗口缩放),RTT大,则有效带宽低,且容易导致seq数值wrap-around(高速网络上高频报文sequence增长会导致短时间内wrap-around,时间短于MSL,上一轮相同sequence的报文说不定还在网络内)。RFC1323引入windows scaling机制,允许wnd size突破64K。
8. 重传、ack需要的辅助变量,比如连续收到的重复ack数目,当前重传值。
9. RFC1323的时间戳扩展:最近update时间,最近发出的ack的序号。
10. 各种时间,rtt,rttvar(variance),srtt(平滑后的rtt),初始时间点,这些时间变量针对的报文序号,能允许的最小rtt。
内核:L4 TCP连接管理
常规情形
一个TCP连接有3个阶段:建立、已建立、关闭。
连接建立需要3个报文,分别是syn,syn+ack和ack。3次握手既让通信双方都知道正在建立连接,也交换了初始序列号。TCP协议里,syn段其实是能存放实际数据的,但Berkeley API不允许,所以很少为人所用。
连接已建立则开始正常的数据传输、重传。每个连接有自己的滑动窗口。每次ack中接收者都会返回自己的window大小,称之为advertised window,以便发送者据此进行调整。此外还有一些非常复杂的拥塞控制算法控制congestion window,发送者也必须考虑它。Min(advertised window, congestion window)才是effective window。
连接关闭需要4个报文,分别是fin+ack(fin段顺便包含ack用于确认最近一次接受的报文),ack(确认收到fin+ack),fin+ack,ack。客户端服务端都可以主动关闭,但最好还是客户端主动关闭,免得服务端大量进入time_wait。
罕见情形
除了上述正常情形,还存在半关闭和同时打开这两个罕见情形。
同时打开没有主动被动之分,需要4个报文:syn,syn,syn+ack,syn+ack,其中两个syn近乎同时交错发出,两个syn+ack也交错回复。这种机制对上层应用其实感知不到,主要还是tcp实现时要多考虑一种情形。同时关闭与之类似,也是4个报文,只不过报文段是交错的。
半关闭是应用程序可以利用的一个机制,但很多网络库也没有用,因为毕竟引入了复杂度。要求应用程序(通常是被动关闭的服务端)用shutdown而非close关闭连接,这时不会立刻回复fin,而是继续保持服务端到客户端的单向通道,可以再继续发一些数据,直到服务端最终close。
四元组与端口号
TCP连接是内核对象,由四元组srcIP,srcPort,dstIP,dstPort的demultiplex来确定唯一性。端口号仅仅是四元组的组成,没其他含义。
1. TCP服务器绑定的本地地址一般是*:localport,外部地址是*.*。
2. 特殊情况下会绑定本地地址到localip:localport,外部地址是*.*(需要指定一个特定localip,一旦绑定了它,所有访问者必须明确将目的ip写成这个ip,否则收不到,哪怕目的是127.0.0.1的本机请求也收不到),这很不常见,唯一能想到的例子是DNS服务器。
3. TCP协议理论上可以限定本地地址localip:localport,外部地址foreignip.foreignport,但实际上Berkeley接口不允许这么做。
内核:L4 TCP重传
计时器重传
计时器重传就是标准的重传,基于重传计时器,RTO之后触发。
重传分为新连接建立重传和已建立连接发送报文重传两种,前者计时器固定6秒,backoff到24、48秒。后者的第一次重传则发生在rtt + 4 * rttvar之后。
1. 后面重传12次都不行则丢弃连接。
2. 重传达到4次,就施放缓存在的路由,重新向IP询问,更换路由。
3. 重传会把rtt置为0,因为对即将重传的报文再做计时是无意义的。这是karn算法对时间统计的处理,后面rfc 1323引入的时间戳取代了karn算法。
Karn算法
Karn算法是TCP协议的必要组成部分。
它的第一部分是:“接收到重传报文时不进行RTT估值”。这一规则解决了重传二义性问题。
它的第二部分是:“多次重传会越来越慢”。这一规则有利于在网络暂无丢包时降低重传率,从而减轻网络负载。其实现方式是引入退避系数。TCP在计算RTO(重传时间)时会参照退避系数,backoff factor。每次重传超时都会导致退避系数增加,从而降低重传频率。
时间测量
现代的Linux、Windows都默认开启时间戳,毕竟既精确又防序列号绕回,基本上算是必要选项了。
有了时间戳后,接收方可以根据ack里的时间戳,当前时间减去时间戳就得到RTT了。
Linux下的时间戳还更加精确,时钟粒度达到1ms,经典RTT估计算法提出时时钟粒度为500ms,这么改进之后也不得不对RTT估计算法做了更新。
快速重传
快速重传并不依赖RTO,而是基于接收端的反馈,即重复ACK,因此能更敏捷地发现丢包。
重复ACK的重复即字面意义上的重复,只不过隐含着出现空洞的暗示,既有可能是丢包、包失序,也有可能是包重复。
TCP在接收到失序报文后会生成SACK块,内含空洞信息,重复ACK包含在SACK块中。
重复ACK表明接收端明确发现某个报文丢了,需要重传。但发送端仍会犹豫,因为说不定我发的报文还在路上,是接收端太着急了,因而会积累一定重复ACK,达到某个阈值再触发重传。这种触发机制就是快速重传。
【注:若TCP协议不支持SACK,则重复ACK一次只能返回一条,因此重传也只能传一条等一条的有效ACK,一条一条地重传。不过SACK支持很广,不支持SACK的情形应该已经成为历史。】
伪重传的检测与处理
过早判定超时、包失序、ACK丢失等可导致伪重传。处理伪重传主要依靠检测算法+响应算法。
1. D-SACK是一种检测算法,可在第一个SACK块中告知接收端收到的重复报文序列号,从而检测出伪重传。
2. Eifel检测算法用TCP的TSOPT来检测伪重传:超时重传后会等待下一个ACK,若ACK为原始传输的确认,则说明发生了伪重传。Eifel能比D-SACK更早探查到伪重传行为,但有可能遇到报文抵达而ACK丢失的情况,因此可以和D-SACK结合起来使用。
3. 前移RTO恢复(Forward-RTO)是伪重传的标准检测算法,不需要任何TCP选项支持,对不支持TSOPT的接收端也有效:该算法中,会在重传发生后对TCP行为进行修改,使之收到ACK后不立刻响应,而是发送新的数据(非重传),之后再收到一个ACK,看看两个ACK里有没有重复ACK(这是个专有名词,快速重传中用来表示空洞),有则表明正常(因为接收端存在空洞,所以才会恢复重复ACK),没有伪重传;无则表明发生了伪重传。
发送端知道伪重传检测出来后,会采用某种响应算法,用于撤销或减轻这次伪超时带来的负面影响,通常响应算法和拥塞控制混在一起。
Eifel响应算法是一套标准的伪重传响应算法,将下一个报文改成最新的未发送的报文,修正各种rtt统计变量。
包失序
IP层不保证有序,所以包失序是常见的,不能把它误认为是包丢失。若只是相邻包失序,可简单地检测出来。若相隔较远的包失序,则会被认定为丢包,导致伪重传(由快速重传触发)。严重失序并不常见,因此不是什么大问题,不需要和丢包严格区分开来处理。
如果真的很容易失序,提高dupthresh(即触发快速重传的阈值)即可区分丢包和失序。默认的阈值一般是3。
包重复
IP层基本上不会重复传输单个包,但偶尔还是会发生,例如链路层重传生成两个副本导致L4混淆。
重复包意味着重复ACK。但重复ACK中主要用于表示丢包,无SACK机制会触发伪快速重传。如果启用DSACK就可以指出这不是丢包而是重复,则直接忽略掉。(普通SACK也可以指出没有丢包,所以也能忽略掉,只不过没有DSACK那样明确认定出现包重复)
重新组包
TCP重传不必传完整报文,可以把缺的数据拼凑起来,整合成一个重组报文段。这减小了重传体积,还顺便解决了重传二义性问题,让重传数据和原始数据有了区分,从而方便伪重传检测。
内核:L4 TCP流量控制与窗口管理
90%流量都是非交互式的批量数据传输,应该用Nagle算法;10%交互式流量(比如游戏、远程登录)一般体量较小而时延敏感,故需禁用Nagle。TCP在流量管理上的努力主要是为了降低负载,而非降低时延。
延迟ACK算法
实践中TCP并不立刻回复ACK,而是延迟一会发送,不仅可以多个ACK聚起来,还可以和数据报文一起捎带着发送出去。大大减少ACK报文数目,降低网络负载。一般最多延迟200ms。
Nagle算法
Nagle算法要求只要连接中存在在传数据,小的报文段就不准发送,直到所有在传数据都收到ACK为止。这迫使TCP遵循停等规程,大幅减少小报文段数量。
延迟ACK和Nagle相结合会导致永远要等延迟ACK计时器timeout才能传输,形成两端互等,浪费空闲带宽,而且带来不必要的延迟。这对批量数据传输场景可以容忍,但对游戏等交互式场景来说则不可,所需应用程序经常要关闭Nagle。
滑动窗口
TCP连接有一个接收窗口,一个发送窗口。都有左右边界。
接收窗口分3部分:已接收并ack,接收后会保存,不能接收。
发送窗口分4部分:已发送并ack,已发送但未收到ack,即将发送,窗口移动前不能发送。
发送窗口除了左右边界外,中间的offered window还被next指针划分为两块。
每个TCP报文都包含窗口通告信息,告知发送方窗口增大,则发送窗口前移。
接受方窗口前移则建立在左界已收到的基础之上的。
通常窗口都是变大,TCP避免窗口收缩。RFC1122不支持窗口收缩,即右界左移。
零窗口
接收端advertised窗口为0,则可阻止发送端继续发送。当又有空间后,可发送一个空ACK作为windows update。若这个ack丢失,则双方会陷入僵局。因此发送端会用持续计时器间歇性地查询接收端,进行window probe,看看窗口是否增长。
SWS(糊涂窗口综合症)
接收端窗口太小或发送方每次发的数据太少都可能引发SWS,导致报文体积太小。
Nagle算法可以避免发送方发得太少。
接收端的advertised window在窗口增长得足够大之前不允许出现小窗口,要么是当前窗口(通常为0),要么是已经足够大的大窗口,中间突变。
窗口自动调优
Windows Vista和Linux支持接收窗口的自动调优,使得TCP既能达到最大吞吐,又不必提前分配固定大小的巨大缓存。
Linux2.6之后同时支持发送窗口自动调优和接收窗口自动调优,还可以设置参数,设置最大缓存上限值。
内核:L4 TCP拥塞控制
【前注:下列算法越靠前越基础。后续算法并不完全否决前面的算法,而是对不一致处进行替换,没有的进行补充。】
慢启动阶段
新TCP连接建立后,或丢包重传(后面改了)时,需要执行慢启动。
初始窗口一般为1~4SMSS(SMSS为发送方最大报文段,一般是路径MTU和接收方MSS的最小值)。
每次ACK都把cwnd倍增。显然这时若存在延迟ACK会导致慢启动太慢。好在与延迟ACK对应,linux还有一个quick ACK模式,这种quick ack模式下就会对每个数据包都回复一个ACK。
拥塞避免阶段
当cwnd达到slow start threshold决定的转折点时,TCP进入拥塞避免阶段,不再指数增长。
每次增长值近似于成功传输的报文段大小,基本上是线性增长。
Reno版快速恢复
丢包重传慢启动浪费带宽,因此BSD 4.3 Reno版里改成cwnd减半,且增长速率变为恒定的1SMSS。大大提高重传的恢复速度。这很快成为新的TCP标准。
上述都是绝对标准的TCP,下文则是对标准的改进,不保证所有操作系统都实现。
NewReno修复局部ACK问题
原版Reno快速恢复阶段cwnd在第一个重传报文ack后就停止了,这可能导致过早停止,而重传未竟全功。NewReno对此进行了改进,记录上一个数据传输窗口的最高序列号,一直等这个序列号的报文ack了,才停止快速恢复。
NewReno是目前比较常见的一种实现(甚至可以就把它称作传统TCP),相当于Reno的改良版,没有SACK机制的复杂度。
SACK选择确认机制
SACK块记录空洞,从而让发送端知道具体哪几个数据段需要重传,还可以再次重组。
SACK将拥塞管理和选择重传机分离,传统TCP恢复阶段用cwnd滑动控制的方法对SACK不适用了,因此SACK TCP是区别于传统TCP的独立流派。
上述都是古老年代的TCP标准,下文则是较新但被Linux采用的修正。
转发确认(FACK)与速率减半(RHBP)
传统TCP快速重传时会导致cwnd减半,也就意味着还需要等待一段时间,让这一半cwnd内的数据ack了,才能发新数据。为了避免等待,提出了FACK,不过它并不成熟,后来改良为RHBP带界定参数的速率减半算法。Linux默认开启RHBP。(tcp_fack=1)
在一个RTT内,每接收2个重复的ACK就允许发送方发送一个数据包,这样恢复阶段结束之前就能够发送新数据出去,而不必等到重发完全结束才把新数据发送全挤到后半个RTT里去,数据发送更加均衡。RHBP采用一个等式确保新发数据量足够小,和重传的加在一起不会超出cwnd。
速率减半算法能调节快速重传阶段的新数据发送,使之分布更加均衡,避免过度集中。
限制传输
限制传输是TCP的新的推荐策略。此前需要3次ACK才触发快速重传,但如果网络连3次ACK都因为丢包而达不到,就无法触发快速重传了。限制传输策略使3次触发变为2次触发。
拥塞窗口校验(CWV)
若cwnd较大,但发送暂停了一段时间,那么cwnd很可能已经过时,不反映当下网络拥塞情况。于是引入CWV:若距离上次发送超过一个RTO,则更新ssthresh值,将其减小,每经历一个空闲的RTT时间就把cwnd值减半,但不小于1SMSS。
如果很长时间之后再继续发送,相当于进入慢启动,这也是合乎情理的。
Linux默认启用CWV。
上述都是低速环境下的TCP标准,下文则是高速TCP标准(兼容低速)。
HSTCP
HSTCP对TCP做了补充:当cwnd大于一个常数LOW_WINDOW(一般为38个MSS)时,TCP的行为应当从标准TCP转变为高速TCP。
1. 响应函数调整:包括对拥塞事件的响应(主要是丢包),对窗口更新的响应。
2. 慢启动限制:避免大窗口阶段仍然倍增(这样一次增加的太多了)。
3. 拥塞避免的调整:避免缓慢线性增长,更快地使窗口增加至饱和。
BIC
高速TCP与普通TCP并存于普通低速网络时,必须保证二者的RTT公平。HSTCP不会管二者的竞争,而BIC就是为了解决公平性问题。
BIC用二分搜索增大、加法增大两种方式增大发送窗口。
二分搜索的下限是当前最小窗口,上限是最近一次出现丢包时的窗口(那时未陷入快速恢复,所以窗口很大)。取中点作为试验窗口,若仍会丢包,则将它作为新的二分搜索上限。
二分搜索的上下限若差距太大,则不能贸然取中点,而是要先用加法增大徐徐图之。
Linux 2.6里实现了BIC-TCP,可以用tcp_bic, tcp_bic_beta, tcp_bic_low_window, tcp_bic_fast_convergence这4个参数进行控制。
CUBIC
CUBIC是对BIC的改良,是Linux 2.6以来的默认TCP算法(Reno是旧时代默认,现在也支持,但要用户主动改配置,它是绝大多数非Linux操作系统的默认)!
改进了BIC在一些情况下增长过快的问题。
简化了窗口增长机制。不再用一个阈值来决定何时二分搜索增长,何时加法增长。而是用一个三次方程控制窗口增长。窗口增长函数是时间t的三次函数,曲线先陡然升起,再平缓,再陡然升起。
上述都是基于丢包的拥塞控制算法,下面简单列举另一个流派,基于延迟的拥塞控制。
Vegas
并不基于Reno版修改。
测量每个RTT内传输的数据量,除以最小延迟时间,推测吞吐量,吞吐量小则扩大cwnd,反之减小cwnd。增加减小都说线性的。
Linux也支持,非默认,需要作为分离的内核模块载入,因为不常用。
FAST
原理和Vegas相同,但额外依据当前性能和预期值的不同调整窗口。若测量延迟远小于阈值,则允许窗口快速增长。
TCPW
基于Reno版修改,类似Vegas。
Linux支持,需要加载模块。
CTCP
Windows Vista起使用的,将Reno和Vegas相结合,还包含了一些HSTCP的特点。至此,Windows也有了比较先进的拥塞控制。但它并不是Windows默认的,要用的话需要手动开启。
BBR
Google的新算法,与TCP状态解耦,完全独立实现自己的状态机,基于即时带宽。
应用:Socket API
Socket IO接口树
Socket API中send、recv是库函数,调用底层系统调用sendto、recvfrom。与之并列的sendmsg、recvmsg是二者的多缓冲区scatter/gather IO版本,且附带Socket IO的全部可选功能。它们底层是sendit和recvit系统调用,二者与read/write,readv/writev的底层soo_read/soo_write共用最最底层的sosend和soreceive。因此sendto也可以用来发tcp消息,哪怕没人会这么做,这也是可行的。
sendmsg/recvmsg接口:
1. 参数里设置MSG_DONTWAIT和把socket设为O_NONBLOCK是等价的。
2. 参数里设置MSG_MORE和启用TCP_CORK是等价的。
3. 参数里设置MSG_NOSIGNAL和禁用SIGPIPE(向已rst的连接再发数据则触发sigpipe)是等价的。
sendmmsg接口:
Linux特有的接口,允许一次发多个msg。sendmsg只能一次发一个。一个msg其实本身就包含多个iovec,不过iovec数目太大时效率低(比如多于8个不能直接用栈内内存)。一次接收多个msg在网络库实现时又能略微提升性能。
recvmmsg接口:
Linux特有接口,一次接收多个msg。它还有个特有选项:MSG_WAITFORONE,作用是一开始阻塞,等到第一条数据抵达后变成非阻塞。
此外它的接口里有个参数可指定timeout、vlen,timeout为null则一直阻塞。它会阻塞直到vlen条数据收到或timeout时间到。
sendfile接口
除此之外还有个新的DMA接口sendfile,用于发送文件到网络socket,减少将CPU复制次数从4次降低到1次。它不属于任何标准,但很多操作系统都有。
传统read/write方法是disk à kbuf à ubuf à socket kbuf à tcp stack,共计4次CPU复制。而sendfile则将disk à kbuf变成了DMA,socket kbuf à tcp stack也变成了DMA,中间也省去了拷贝到内核空间的无谓步骤,只剩下kbuf à socket kbuf 这一次CPU复制!(用户态0次拷贝,内核态1次拷贝)
需要注意,不同OS,不同内核版本的sendfile实现不同,因此需封装后使用(参考ngx),且一次调用最多发送2GB。Linux 2.6之后sendfile的outfd支持普通文件(但不支持O_APPEND)。
这个接口在ngx实现httpv2时使用,在kafka里也用了。如果使用后返回EINVAL或ENOSYS,则仍应用传统的read/write实现。
Urgent和Push可用吗?
TCP报文头里的Push是内核自动决定的,应用层开发者无权控制。Urgent实现(16位字段,表示从第一个字节起多少字节是urgent的)需要对端开启OOBINLINE选项,而且实现的很差,基本上不可用也没人用。显然用另外渠道进行紧急消息通知会更合理,比如另一条连接。
其他一些细节
1. UDP不存在发送缓冲区,只是复制应用数据并逐层加header,这是数据报协议的好处之一。因此不会像tcp那样因为缓冲区满而阻塞,不过还是会因为其他原因阻塞。
2. TCP的阻塞read/recv默认等到有一些数据到来,若要强制等到一定数目的数据,则可用readn或者设置MSG_WAITALL。UDP的阻塞read/recv则是一直等到完整报文来,而且就算你在函数里设置了2个报文的大小,缓冲区内也的确有2个报文,一次还是只读1个报文。
应用:TCP选项(setsockopt+IPPROTO_TCP)
1. TCP_MAX_SEG:tcp协议允许接收的最大报文段长度(只包括数据,不含首部),MSS选项里可设置,默认536字节(因为这样刚好和报文头拼起来凑成576字节IP报文,任何主机都保证能处理576字节的IP报文)。这个值显然可以调大很多,1460是个常用值(适合ipv6)。
2. 选择确认(SACK):接收方数据队列里收到报文无序,因此会产生空洞,应用程序在读时最多不能读到第一个空洞所在处。为了解决空洞迟迟不来的问题,TCP允许接收方向发送方发布空洞相关信息,使发送方有针对性地重传空洞报文。这种信息就用SACK块(记录一对序列号)表示,每个报文可以容纳3个SACK块。
3. TCP-AO:其实TCP有原生支持加密的选项,但要求创建并分发公钥,所以没流行起来。
4. TCP-USER_TIMEOUT:用户超时机制,作为一个新选项,基本没人用,它是指用户建议的“发送后等待ack”的时间上限。设置的太小可能导致连接过早断开。
5. 时间戳:时间戳选项允许每个报文里多2个4字节时间戳,接收方在ack里反映这些数值。启用后每个报文多了10字节,但能更精确地计算RTT。此外,时间戳还非常有效地解决此前提到的序列号wrap-around绕回问题。
6. TCP_CORK:若设置了,则不发送局部帧。它和TCP_NODELAY一样会关闭nagle算法。二者共存时CORK优先级更高。它比TCP_NODELAY更激进地缓存数据而不发。这基本上只在sendfile之前向数据前添加报文头的时候有用。也可以在你确认有大量数据要发时设置。【Linux】
7. TCP_CONGESTION:用于设置拥塞控制算法。
8. TCP_DEFFER_ACCEPT:若设置了,则listen进程一直睡眠直到真正数据抵达才被唤醒。【Linux】
9. TCP_WINDOW_CLAMP:将advertised window数值固定设为某个值,这样就只看congestion window了。【Linux】
10. TCP_SYNCNT:设置connect时syn的重传次数上限,不可超过255次。【Linux】
11. TCP_LINGER2:设置孤悬FIN WAIT2阶段的连接的生命周期。【Linux】
12. TCP_QUICKACK:设置之后ack不再延迟到下次数据一起发送,而是立刻发送。比较适合对ack时延要求特别高的场景。【Linux】
应用:Socket选项(setsockopt)
SO_REUSEPORT 端口复用
Linux kernel 3.9开始支持SO_REUSEPORT。只要开启SO_REUSEPORT,tcp和udp就允许多个socket绑定同一个port。(通常bind已占用端口会报错,但只要第一个程序设置了)
传统做法1:单线程单socket监听、accept,然后把得到的fd传给工作线程。显然存在瓶颈。
传统做法2:先建好单socket,多线程accept并处理fd。这存在“惊群”效应,且并不公平。
SO_REUSEPORT公平地将accept结果分配给所有线程或进程,能更好地利用多核CPU并发。
SO_REUSEADDR允许服务器快速重启
TCP的time_wait状态持续一两分钟,这段时间要想迅速重启就要设置SO_REUSEADDR,允许bind处于time_wait的地址。这么做会降低tcp的可靠性,存在接收到过去报文的可能性。但绝大多数网络服务器都选择快速重启。
SO_LINGER 不再4次挥手,而是RST秒断
如果SO_LINGER设为0,timewait机制将被取消,close不再发送fin,4次挥手正常退出,而是发出rst。
虽然说,timewait是好东西(确保对端已经关闭),但它占用的内存和established状态比少不了多少,还限定了同一个srcIP, dstIP所能使用的端口号(这个端口号可以给其他的IP对用,但相同IP对不能再用),不过它并不占用文件描述符(因为已经close了)。
客户端频繁连接同一个服务器ip,又频繁销毁连接,就会导致大量临时端口处于timewait,以至于无法connect。这时timewait总数等于local ip ports range大小。
服务端最好不要主动关闭,陷入time_wait,内存开销太大,如果是http服务器最好设置keepalive,不要用httpv1的服务端主动关闭。
应用: TCP相关的黑客攻击手段
SYN洪泛攻击
SYN Flood是一种非常危险的DoS攻击。一个或多个客户端恶意产生大量syn报文,通常采用伪造的源ip地址,从而迫使服务端不断分配新的连接。
唯一的应对策略是SYN Cookie,修改三次握手过程,不分配内存存储连接信息,而是将连接信息存入SYN报文段。需要将这个功能编译进内核,不过显然开销很大,不太常用——而且,它会导致超时重传失效,导致很多无法使用任意大小的报文段,弊端很大,计算开销还引入另一种攻击手段,即ACK攻击,大量ACK导致服务端疲于Hash计算,也能造成洪泛。
MTU攻击
攻击者伪造一个ICMP PCB消息,其中包含一个超小的MTU,迫使整个主机都使用非常小的数据包填充数据,大大降低性能。
TCP劫持(序列号攻击)
通过某些手段(连接建立时引发不正确的状态传输,或已建立时产生额外数据),使正常通信的连接失去同步,发送不正确的序列号。
ICMP欺骗攻击
攻击者精心定制TCP报文段,破坏或改变现有连接的行为。不正确的四元组
此外ICMP协议既能修改MTU,也能指出某端口或主机已失效从而丢弃连接,这都可以被利用来进行ICMP欺骗攻击。
保活攻击
保活心跳报文里不含数据,因此很容易伪造,可造成本来不必要维护的连接始终存在,从而消耗服务器资源。
低速率DoS攻击
攻击者向网关或主机发送大量数据,使受害系统持续处于超时重传的状态。攻击者可预知受害系统何时发起重传,因此可以在那个时刻再生成大量数据,从而让受害系统始终感受到拥塞(实际上只是间歇性针对),从而根据Karn算法不断退避,重传速率越来越慢,导致无法正常利用网络带宽。
解决方法是使用随机RTO,使攻击者无法预知何时重传。
RTT误导
攻击者故意减慢ack恢复速率,使受害系统误以为RTT很大,从而在丢包后不会立刻重传,降低对带宽的利用。
反之也可行,故意伪造ack提前恢复,使受害系统误以为RTT很小,从而一发生丢包就立刻重传,导致过分发送,产生大量无效传输。
ACK分裂攻击
假设一个ACK能确认100字节,攻击者就可以把它拆分成10个ACK,每个确认10字节,增加了很多ACK报文,不影响功能,却导致某个客户端的cwnd增长比其他客户端快(因为每次ack收到都会触发cwnd窗口增长),获得不正当优势。
重复ACK欺骗攻击
攻击者伪造重复ack,使处于快速恢复阶段的发送端误以为时间加速了,从而加快cwnd增长。这可以通过时间戳选项解决。
乐观响应攻击
针对尚未抵达的报文伪造ack。解决办法:用一个随机数让发送报文段长度不断变化,让攻击者猜测的ack确认长度出错,发送端发现不匹配的ack会丢弃。
应用:时延、吞吐、并发
低时延、高并发往往矛盾。
比如一个client向server获取web page时开启3个tcp连接并行下载web page上的9个图片,这固然降低了时延,却让server的并发能力下降。
低时延、高吞吐之间并没有确定的联系。
上述做法有可能在广域网环境导致重传报文变多,服务器总负载增加,从而略微降低吞吐。但也有可能在服务器负载刚好不重时迅速终止自身任务,从而充分利用带宽和处理能力,提高了总体吞吐。
【注:这种客户端并行下载的并发数不宜太大,收益会迅速衰减,拥塞时重发报文太多严重影响负载。同时建立多个连接可以用非阻塞connect,也可以开多线程connect,前者的性能要比后者略高,且内存开销低。】
应用:非阻塞网络发送数据
为何要非阻塞写?
因为阻塞写不适合写大批量的数据,很快把socket缓冲区写满,会导致线程阻塞,影响整个reactor正常工作。
注意必须只在需要写的时候再关注Writable事件
写事件在level-triggered模式下要注意只在需要写的时候注册Writable事件,且写完后及时关闭事件观测,否则可写状态会一直触发事件。这在编码上比较麻烦。
比较理想的模式是对readble事件level-triggered,对writable事件edge-triggered。但epoll不支持这种方式。
所以nng那种用level-triggered + ONESHOT是另一种非主流但不错的选择(而且规避了多线程拿到多个事件的问题)。
发送数据速率高于接收方接受速率怎么办?
单纯依靠Epoll事件管理写事件是不够的,应用层也应该有自己的流量控制,防止缓冲区爆炸。
可实现应用层的高低水位。高水位(即发送缓冲区太满,可能是因为对方接收的慢,也可能网络有问题)则触发高水位回调。允许用户在回调里暂停写数据。
再提供一个低水位回调或写完成回调,用户在这两个回调之一里继续写数据。
怎么暂停,怎么继续?可以简单粗暴地断开连接,过一会儿重连。也可以应用层用某个状态变量控制,一直保持连接,却在高水位阶段不允许往里面添加数据。
应用:非阻塞connect
1. 与accept不同,非阻塞connect返回eagain并不是还没连上的意思,而是真的错误,表示临时端口用完了,短期内估计恢复不了,需要关闭socket等一段时间再重试。
2. socket是一次性的,一旦connect出错就要关闭,不能再次connect。
3. 要注意自连接问题:若目的地址在本机,srcIP:srcPort有可能刚好等于dstIP:dstPort。(因为srcPort是随机选的,小概率和服务端port重叠)。因此服务器程序最好不用临时端口区域的端口号,实在要用,则客户端程序需显式绑定一个不一样的本地端口。
应用:非阻塞accept
epoll捕获listenfd的连接epollin事件之后,accept一般会立刻成功。
那么为什么还要用非阻塞呢?
这是为了防止RST攻击——客户端设置了SO_LINGER后,connect完立刻close就会发RST,这时若服务端刚拿到listenfd事件还没有accept,再accept时就会发现并没有新的连接,会陷入迷茫的阻塞之中。非阻塞的accept就可以避免这个问题了:
可以accept一次,也可以backoff再来几次,期间忽略源自EAGAIN和与它等价的Berkeley实现的EWouldBlock,源自POSIX的ECONNABORTED,ECONNRESET,EINTR。
应用:epoll vs poll
Select并不高效:较大的bitmap操作是低效的。
Poll并不总比epoll差。并发连接数很少,且活跃比例高,则用poll,反之用epoll。
应用:性能优化时的常识
1. 千兆以太网是后端服务器编程的主要网络载体,将它用到饱和也就不到120MB/s的吞吐量。瓶颈一般不在网络而在数据库入库这种耗时操作上。
2. 内存中复制开销非常低,一般是4GB/s,所以大多数场景根本不需要优化内存,各种内存分配器对网络应用的优化微乎其微。我认为真的只有操作系统内核才值得用内存池。
更值得关注的优化是cpu affinity,比如ngx的多进程架构里给每个进程绑定cpu,避免进程切换的开销,这可以有效降低cpu处理延迟。
3. 此外还有一个比较玄学的cache friendliness,只需在无锁数据结构或者特别高并发的全局变量上注意避免false sharing。
免责声明:本站所有文章内容,图片,视频等均是来源于用户投稿和互联网及文摘转载整编而成,不代表本站观点,不承担相关法律责任。其著作权各归其原作者或其出版社所有。如发现本站有涉嫌抄袭侵权/违法违规的内容,侵犯到您的权益,请在线联系站长,一经查实,本站将立刻删除。 本文来自网络,若有侵权,请联系删除,如若转载,请注明出处:https://itzsg.com/49334.html