This the multi-page printable view of this section. Click here to print.
TCP-IP
1 - 握手机制
一次握手表示向对方发送一个数据包,Client -> Server 或 Server -> Client。
建立连接:三次握手
目的是连接服务器指定端口、建立 TCP 连接,同步连接双方的序列号和确认号,交换 TCP 窗口的大小信息。
- Client -> Server:请求创建连接,SEQ=X
- Server -> Client:同意创建连接,ACK=X+1,SEQ=Y
- Client -> Server:得知同意创建,ACK=Y+1,SEQ=Z
关闭连接:四次挥手
双方均可主动发起挥手来关闭连接。
- Client -> Server:请求关闭
- Server -> Client:同意关闭
- Server -> Client:请求关闭
- Client -> Server:同意关闭
问题汇总
为什么要三次握手
为了防止已失效连接的请求报文段突然又传送到了服务端,因而产生错误。
Client 发出的第一个连接请求报文段并没有丢失,而是在某个网络结点长时间的滞留了,以致延误到连接释放以后的某个时间才到达 Server。
本来这是一个早已失效的报文段。但server收到此失效的连接请求报文段后,就误认为是client再次发出的一个新的连接请求。于是就向client发出确认报文段,同意建立连接。
假设不采用“三次握手”,那么只要server发出确认,新的连接就建立了。由于现在client并没有发出建立连接的请求,因此不会理睬server的确认,也不会向server发送数据。但server却以为新的运输连接已经建立,并一直等待client发来数据。这样,server的很多资源就白白浪费掉了。
如果采用三次握手,client不会向server的确认发出确认。server由于收不到确认,就知道client并没有要求建立连接。
为什么要四次挥手
TCP协议是一种面向连接的、可靠的、基于字节流的运输层通信协议。
TCP是全双工模式,这就意味着,当主机1发出FIN报文段时,只是表示主机1已经没有数据要发送了,主机1告诉主机2,它的数据已经全部发送完毕了;但是,这个时候主机1还是可以接受来自主机2的数据;当主机2返回ACK报文段时,表示它已经知道主机1没有数据发送了,但是主机2还是可以发送数据到主机1的;当主机2也发送了FIN报文段时,这个时候就表示主机2也没有数据要发送了,就会告诉主机1,我也没有数据要发送了,之后彼此就会愉快的中断这次TCP连接。
关闭时为什么要等待2MSL
MSL:报文段最大生存时间,它是任何报文段被丢弃前在网络内的最长时间。
- 保证TCP协议的全双工连接能够可靠关闭
- 保证这次连接的重复数据段从网络中消失
第一点:如果主机1直接CLOSED了,那么由于IP协议的不可靠性或者是其它网络原因,导致主机2没有收到主机1最后回复的ACK。那么主机2就会在超时之后继续发送FIN,此时由于主机1已经CLOSED了,就找不到与重发的FIN对应的连接。所以,主机1不是直接进入CLOSED,而是要保持TIME_WAIT,当再次收到FIN的时候,能够保证对方收到ACK,最后正确的关闭连接。
第二点:如果主机1直接CLOSED,然后又再向主机2发起一个新连接,我们不能保证这个新连接与刚关闭的连接的端口号是不同的。也就是说有可能新连接和老连接的端口号是相同的。一般来说不会发生什么问题,但是还是有特殊情况出现:假设新连接和已经关闭的老连接端口号是一样的,如果前一次连接的某些数据仍然滞留在网络中,这些延迟数据在建立新连接之后才到达主机2,由于新连接和老连接的端口号是一样的,TCP协议就认为那个延迟的数据是属于新连接的,这样就和真正的新连接的数据包发生混淆了。所以TCP连接还要在TIME_WAIT状态等待2倍MSL,这样可以保证本次连接的所有数据都从网络中消失。
2 - 协议栈精要
TCP/IP 精要
《TCP/IP详解学习笔记》系列文章的学习整理,点击标题查看原文。
基本概念
为什么会有TCP/IP协议
为了可以在多个单机的计算机之间进行通信,可以使用电线将他们连接在一起。但是简单的连接在一起还不够,好比语言不同的两个人见面后并不能正确的交流信息。因此需要定义一些共通的东西来进行交流,TCP/IP就是为此而生。
TCP/IP不是一个协议,而是一个协议簇的统称。里面包含了IP协议、IMCP协议、TCP协议,以及我们更加熟悉的HTTP、FTP、POP3协议等。计算机有了这些,就好像大家都统一使用英语来交流一样。
TCP/IP协议分层
协议分层经常会提到IOS-OSI七层协议经典架构,但是TCP/IP协议族的结构稍有不同。如图:
TCP/IP协议族按照层次由上到下,层层包装。最上面是应用层,里面包含HTTP、FTP等我们熟悉的协议。第二层是传输层,著名的TCP和UDP协议就在这层。第三层是网络层,包含IP协议,负责对数据加上IP信息和其他数据以确定传输的目标。第四层叫做数据链路层,为待传送的数据加入一个以太网协议头,并进行CRC编码,为最后的数据传输做准备。再往下则是硬件层次了,负责网络的传输,这个层次的定义包括网线的制式,网卡的定义等。
发送数据的主机从上自下将数据按照协议封装,而接收数据的主机则按照协议将得到的数据包解开,最后拿到需要的数据。
基本常识
互联网地址
网络上每一个节点都必须有一个独立的internet地址(即IP地址),现在常用的是IPV4地址,又被分为5类,常用的是B类地址。需要注意的是IP地址是网络号+主机号的组合,这非常重要。
域名系统
域名系统是一个分布的数据库,它提供将主机名(即网址)转换成IP地址。
RFC
RFC就是TCP/IP协议栈的标准文档,文档中可以看到一个很长的定义列表,现在一共有4000多个协议的定义,然而我们要学习使用的也就10多个。
端口号
这个号码是用在TCP和UDP上的一个逻辑号码,并不是一个硬件端口。平时所说的封掉某个端口,也只是在IP层次上把带有这个号码的IP包给过滤掉而已。
应用编程接口
现在常用的编程接口有socket和TLI。
数据链路层
数据链路层有三个目的:
- 为IP模块发送和接收IP数据报
- 为ARP模块发送ARP请求和接收ARP应答
- 为RARP发送RARP请求和接收RARP应答
ARP叫做地址解析协议,是用IP地址换MAC地址的一种协议,而RARP叫做逆地址解析协议。
数据链路层的协议还是很多的,有我们最常用的以太网(网卡)协议,也有不太常用的令牌环,还有FDDI,还有国内现在相当普及的PPP(adsl宽带),以及一个loopback协议。
在Linux终端中使用ifconfig -a
命令,这个命令通常会得到如下结果:
lo0: flags=8049<UP,LOOPBACK,RUNNING,MULTICAST> mtu 16384
options=3<RXCSUM,TXCSUM>
inet6 ::1 prefixlen 128
inet 127.0.0.1 netmask 0xff000000
inet6 fe80::1%lo0 prefixlen 64 scopeid 0x1
nd6 options=1<PERFORMNUD>
gif0: flags=8010<POINTOPOINT,MULTICAST> mtu 1280
stf0: flags=0<> mtu 1280
en0: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500
ether ac:bc:32:8e:41:57
inet6 fe80::aebc:32ff:fe8e:4157%en0 prefixlen 64 scopeid 0x4
inet 192.168.10.203 netmask 0xffffff00 broadcast 192.168.10.255
inet6 fd3d:5f4e:424b::aebc:32ff:fe8e:4157 prefixlen 64 autoconf
inet6 fd3d:5f4e:424b::806d:59f8:8422:b7ca prefixlen 64 autoconf temporary
nd6 options=1<PERFORMNUD>
media: autoselect
status: active
其中,eth0就是以太网接口,而lo则是lookback接口。这也说明这个主机在网络链路层上至少支持lookback协议和以太网协议。
以太网的定义是指:数字设备公司、英特尔公司和Xerox公司在1982年联合公布的一个标准,这个标准里面使用了一种称作CSMA/CD的接入方法。而IEEE802提供的标准集802.3(还有一部分定义在802.2中)也提供了一个CSMA/CD的标准。这两个标准稍有不同,TCP/IP对这种情况的处理方式如下:
- 以太网的IP数据报封装在RFC894中定义,而IEEE802网络的IP数据报封装在RFC1042中定义。
- 一台主机一定要能发送和接收RFC894定义的数据报。
- 一台主机可以接收RFC894和RFC1042的封装格式的混合数据报。
- 一台主机也许能够发送RFC1042数据报。如果主机能够同时发送两种类型的分组数据,那么发送的分组必须是可以设置的,而且默认的情况下必须是RFC894分组。
可见,RFC1042在TCP/IP里处于一个配角的地位。
PPP(点对点协议)是SLIP的替代品。他们都提供了一种低速接入的解决方案。而每一种数据链路层协议,都有一个MTU(最大传输单元)定义,在这个定义下面,如果IP数据报过大,则要进行分片(fragmentation),使得每片都小于MTU。注意PPP和MTU并不是一个物理定义,而是指一个逻辑定义(个人认为就是用程序控制)。可以用netstat打印MTU的结果,比如命令netstat -in
,可以看到各协议的MTU值。
环回接口(lookback),平时我们用127.0.0.1
测试本机服务器是否可以使用,走的就是这个环回接口。对于环回接口,有如下三点值得注意:
- 传给换回地址(127.0.0.1)的任何数据均作为IP输入
- 传给广播地址或多播地址的数据报复制一份传给环回接口,然后发送到以太网上。这是因为广播传送和多播传送的定义包含主机本身
- 任何传给主机IP地址的数据均送到环回接口
IP,ARP,RARP
这三个协议均属于网络层。ARP协议用于找到目标主机的Ethernet网卡MAC地址,IP要承载发送的消息。数据链路层可以从ARP得到数据的传送信息,而从IP得到要传输的数据的信息。
IP协议
IP协议用于将多个包交换网络连接起来,它在源地址可目标地址之间传送一种称为数据包的东西,并提供对数据包大小的重新组装功能,以适应不同网络对包大小的要求。
IP协议实现两个基本功能:寻址和分段。IP可以根据数据包包头中包括的目的地址将数据报传送到目的地址,在此过程中IP负责选择传送的道路,称为路由。如果有些网络内只能传输小数据报,IP可以将数据报重新组装并在包头域内注明。
IP协议是TCP/IP协议栈的核心,所有的TCP、UDP、IMCP、IGCP的数据都是以IP数据格式传输的。要注意的是,IP不是可靠的协议,就是说,IP协议没有提供一种数据未到达以后的处理机制,这被认为是上层协议–即TCP和UDP要做的事情。所以也就出现了TCP是一个可靠的协议,而UDP就没有那么可靠的区别。
IP协议头
其中8位的TTL
字段规定该数据报在穿过多少个路由之后才被丢弃(即它不保证数据被送达),某个IP数据包没穿过一个路由,该数据报的TTL值就会减少1,当该数据报的TTL值为0,它就会自动被丢弃。这个字段的值最大为255,也就是说一个协议包在路由里穿行255次就会被丢弃,根据系统的不同,这个值的大小也不一样,一般是32或64。Tracrouter这个工具就是用这个原理工作的,其-m
选项要求最大值是255,也就是说这个TTL在IP协议里面只有8bit。
先在的IP版本号是4,即成为IPV4,同时还有现在使用越来越广泛的IPV6。
IP路由选择
当一个IP数据包准备好之后,IP数据包(或路由器)是如何将数据包送到目的地的呢?它是如何选择一个合适的路径来“送货”?
最特殊的情况是主机和目的主机直连,这时主机根本不用寻找路由,直接将数据传送过去。至于是怎么直接传递的,会用到ARP协议。
稍微一般一点的情况是,主机通过若干个路由器和目的主机连接。那么路由器要用IP包的信息来为IP包找到一个合适的目标进行传递,比如合适的主机,或者合适的路由。路由或主机将会用如下的方式来处理一个IP数据包:
- 如果IP数据包的TTL值已经为0,则丢弃该IP数据包;
- 搜索路由表,有限搜索匹配主机,如果能找到和IP地址完全一致的目标主机,则将该包发向目标主机;
- 搜索路由表,如果匹配主机失败,则匹配同子网的路由器,这需要
子网掩码
(参考下面一节的子网寻址)的协助。如果找到路由器,则发送该数据包; - 搜索路由器,如果匹配相同子网路由器失败,则匹配同网号路哟器。如果找到,则发送该数据包;
- 搜索路由表,如果以上都失败,就搜索默认路由,如果默认路由存在,则发包;
- 如果都失败,丢弃该包。
这在一起说明,IP包是不可靠的,因为它不保证送达。
子网寻址
IP地址的定义是网络号+主机号。但是现在所有的主机都要求子网编址,也就是说,把主机号再细分成子网号+主机号。最终一个IP地址就成为:网络号码+子网号+主机号。例如一个B类地址:210.30.109.134。一般情况下,这个IP地址的红色部分就是网络号,蓝色部分就是子网号,绿色部分就是主机号。至于有多少位代表子网号这个问题,没有一个硬性的规定,取而代之的子网掩码,在校园网的设定里面有一个225.225.225.0的东西,就是子网掩码。
ARP协议
在数据链路层的以太网协议中,每一个数据包都有一个MAC地址头。每一块以太网卡都有一个MAC地址,这个地址是唯一的,那么IP包是如何知道这个MAC地址呢,这就是ARP的工作。
ARP(地址解析)协议是一种解析协议,本来主机是不知道这个IP对应的是哪个主机的哪个接口,当主机发送一个IP包的时候,首先会查一下自己的ARP告诉缓存(IP-MAC地址对应缓存),如果查询的IP-MAC不存在,那么主机就发送一个ARP协议广播包,这个广播包中包含待查询的IP地址,而直接收到这个广播包的所有主机都会查询自己的IP地址,如果其中一个主机发现自己符合条件,那么就准备好一个包含自己MAC地址的ARP包传送给发送ARP广播的主机,然后广播主机拿到ARP包后会更新自己的ARP缓存。发送广播的主机就会用新的ARP缓存数据准备好数据链路层的数据包发送工作。
一个典型的ARP缓存信息如下,在系统中使用arp -a
命令。
这个高速缓存的时限是20分钟。
ICMP协议
由于IP协议并不是一个可靠的协议,因此保证数据送达的工作就会由其他模块来完成,其中一个重要的模块就是IMCP(网络控制报文)协议。
当IP数据包发生错误,比如主机不可达、路由不可达等,ICMP就会将错误信息封包,然后传送回给主机。给主机一个处理错误的机会,这也就是为什么说建立在IP层以上的协议是能够做到安全的原因。ICMP数据包由8bit的错误类型+8bit的代码+16bit的校验和组成。而前16bit就组成了ICMP要传递的信息。
尽管在大多数情况下,错误的包传送应该给出ICMP报文,但是在特殊情况下,是不产生ICMP报文的:
- ICMP错误报文不会产生ICMP错误报文(出ICMP查询报文),防止ICMP的无线产生和传送
- 目的地址是广播地址或多播地址的IP数据包
- 作为链路层广播的数据包
- 不是IP分片的第一片
- 原地址不是单个主机的数据包
这里的一切规定,都是为了防止ICMP报文的无线传播而定义的。
ICMP协议大致分两类,一种是查询报文,一种是错误报文。其中查询报文的用途:
- ping查询
- 子网掩码查询
- 时间戳查询
ICMP的应用-ping
ping可以说是ICMP的最注明应用,可以通过ping一个网址来查看其是否可用。
原理是用类型码为0的ICMP发请求,收到请求的主机则用类型码为8的ICMP回应。ping程序来计算时间间隔,并计算有多少包被送达。用户就可以判断大致的网络情况。
ping还给我们一个看到目的主机路由的机会,这是因为,ICMP的ping请求数据包在没经过一个路由的时候,路由器会把自己的IP放到该数据包中。而目的主机则会把这个IP列表复制到回应ICMP数据包中发回给主机。但是这个信息比较有限,如果想要查看更详细的路由,可以使用tracerouter。
ICMP的应用-tracerouter
Tracerouter用来侦测主机到目的主机之间所经路由情况的重要工具,也是最便利的工具。
它的原理是,它收到目的主机的IP后,首先给目的主机发送一个TTL=1
的UDP数据包,而经过的第一个路由器收到这个包之后就自动把TTL减1,这时TTL为0,路由器就把这个包丢弃了,并同时产生一个主机不可达的ICMP数据包给主机。主机收到这个数据包以后再发一个TTL=2
的UDP数据包给目的主机,然后刺激第二个路由器给主机发送ICMP数据包。如此往复直到到达目的主机,这样,tracerouter就拿到了所有路由IP,从而避免了IP头只能记录有限路由IP的问题。
但是tracerouter是如何直到是否到达目的主机了呢。这就涉及到一个技巧问题,TCP和UDP协议有一个端口号定义,普通的网络程序只监控少数几个号码较小的端口,如80、23等。而tracerouter发送的端口号>30000,所以到达主机的时候,目的主机只能发送一个端口不可达的ICMP数据报给主机,主机接收到这个报告以后就知道主机到了。
IP选路、动态选路
UDP协议
UDP是传输层协议,和TCP处于同一个分层中,但是于TCP不同,UDP不提供超时重传,出错重传等功能,也就是说它是不可靠协议。
协议头
UDP端口号
由于很多软件要用到UDP协议,所以UDP协议必须通过某个标志用以区分不同的程序所需要的数据包,这就是端口号的功能。例如一个UDP程序在A系统中注册了3000端口,以后从外部传进来的端口号为3000的数据包就会交给该程序。
UDP检验和
这是一个可选的选项,并不是所有的系统都对UDP数据包加以检验和数据(相对TCP的必须来说),但是RFC中标准要求,发送端应该计算校验和。
UDP校验和覆盖UDP协议头和数据,这个IP的检验和是不同的,IP协议的检验和只是覆盖IP数据头,并不覆盖所有的数据。UDP和TCP都包含一个伪首部,这是为了计算校验和而设置的。伪首部甚至包含IP地址这样IP协议里面都有的数据,目的是让UDP两次检查数据是否正确到达目的地。如果发送端没有打开校验和选项,而接收端计算校验和有差错,那么UDP数据将会被悄悄的丢掉(不保证送达),而不会产生任何错误报文。
UDP长度
UDP可以很长,长达65535字节。但是一般网络在传输的时候,一次传输不了那么长的协议(MTU),就只好对数据分片,当然,这些是对UDP上层协议透明的,UDP不需要关心IP层如何对数据分片。
IP分片
IP是在从上层接到数据以后,根据IP地址来判断从哪个接口发送数据,并进行MTU查询,如果数据大小超过MTU就进行分片。数据的分片对上层和下层透明,而数据在到达目的地后会重新组装,IP层提供了足够的信息进行数据的再组装。
UDP服务器设计
UDP协议的特性将会影响我们的服务器程序设计,大致总结如下:
- 关于客户IP和地址:服务器必须有根据客户IP地址和端口号判断数据包是否合法的能力;
- 关于目的地址:服务器必须要有过滤广播地址的能力;
- 关于数据输入:通常服务器系统的每一个端口都会和一块输入缓冲区对应,进来的数据根据先来后到的原则等待服务器的处理,所以难免会出现缓冲区溢出的问题,这种情况可能会出现UDP被丢弃,而应用服务器并不知道这个问题;
- 服务器应该限制本地IP地址,就是说他应该可以把自己绑定到某一个网络接口的某一个端口上。
广播与多播、IGMP协议
单播、多播、组播
单播
单播是对特定的主机进行数据的传送。例如给某一个主机发送IP数据包。这时,数据链路层给出的数据头里面是非常具体的目的地址,对于以太网来说就是MAC地址。现在的具有路由功能的主机应该可以将单播数据定向转发,而目的主机的网络接口则可以过滤掉和自己MAC地址不一致的数据。
广播
广播是主机针对某一网络上的所有主机发送数据。这个网络可能是网络、子网,或所有子网。如果是网络,例如A类地址的广播就是netid.255.255.255
,如果是子网,则是netid.netid.subnetid.255
,如果是所有子网(B类IP),则是netid.netid.255.255
。广播所用的MAC地址是FF-FF-FF-FF-FF-FF
,网络内所有的主机都会收到这个广播数据,网卡只要把MAC地址为FF-FF-FF-FF-FF-FF
的数据交给内核就行了。一般来说,ARP或者路由协议RIP应该是广播的形式播发的。
多播
可以说广播的多播的特例,多播就是给一组特定的主机(多播组)发送数据。这样,数据的播发范围会小一些,多播的MAC地址是最高字节的低位为1,例如:01-00-00-00-00-00
,多播组的IP是D类IP,规定是224.0.0.0-239.255.255.255
。
IGMP协议
IGMP协议的作用在于,让其他所有需要知道自己处于哪个多播组的主机和路由器知道自己的状态。一般多播路由器根本不需要知道某一个多播组里有多少个主机,而只需要知道自己的子网内还有没有处于某个多播组的主机就行了。只要某一个多播组还有一台主机,多播路由器就会把数据传输过去,这样,接受方就会通过网卡过滤功能来得到自己想要的数据。为了知道多播组的信息,多播路由器需要定时的发送IGMP查询,各个多播组里面的主机需要根据查询来回复自己的状态。路由器来决定有几个多播组,自己要对某一个多播组发送什么样的数据。
TCP协议
TCP和UDP同样处于运输层,但是TCP和UDP最不同的地方是,TCP提供了一种可靠的的数据传输服务,TCP是面向连接的,也就是说,利用TCP通信的两台主机首先要精力一个拨打电话的过程,等到通信准备就绪才开始传输数据,最后结束通话。所以TCP要比UDP可靠的多,UDP是直接把数据发过去,而不管对方是不是在收信,就算是UDP无法送达,也不会产生ICMP差错报文。
TCP保证可靠性的工作原理:
- 应用数据被分割成TCP认为最适合发送的数据块。
- 当TCP发出一个段后,它启动一个定时器,等待目的端确认收到这个报文段,如果不能及时收到这个确认,将重发这个报文段。
- 当TCP收到发自TCP另一端的数据,它将发送一个确认。这个确认不是立即发送的,通常推迟几分之一秒。
- TCP将保持它首部和数据的校验和。这是一个端到端的检验和,目的是检测数据在传输过程中的任何变化。如果收到段的检验和有差错,TCP将丢弃这个报文段和不确认收到此报文段。
- 既然TCP报文段作为IP数据报来传输,而IP数据报的到达可能输失序,因此TCP报文段的到达也可能会失序。如果必要,TCP将对收到的数据进行重新排序,将收到数据以正确的顺序交给应用层。
- TCP还能提供流量控制。TCP连接的每一方都有固定大小的缓冲空间。TCP的接收端只允许另一端发送 接收端缓冲区所能接纳的数据。这将放置比较块的主机致使比较慢主机的缓冲区溢出。
由此可见,TCP中保持可靠性的方式就是超时重发。最可靠的方式就是只要不得到确认,就重新发送数据报,直到得到对方的确认为止。
TCP和UDP的首部一样。都有发送端口号和接收端口号。显然TCP的首部信息会更多,提供了发送和确认所需要的所有必要的信息。可以想象一个TCP数据的发送过程:
- 双方建立连接
- 发送方给接收方TCP数据报,然后等待对方的确认TCP数据报,有则发送下一个,没有则等待重发
- 接收方等待发送方的数据报,如果得到数据并检查无误,就发送ACK数据报,并等待下一个数据报
- 终止连接
DNS域名系统
DNS系统介绍
DNS的全称是“Domain Name Syetem”。它负责把FQDN翻译成一个IP,最初是一个巨大的host.txt文件,最终发展到现在的分布式数据库。
DNS是一个巨大的树,最上方是一个无名树根,下一层是“arpa,com,edu,gov,int,mil,us,cn”等。其中arpa是域名反解析树的顶端。
一个独立管理的DNS子树叫做zone,最常见的区域就是二级域名,比如说.com.cn,还可以把这个二级域名划分成更小的区域,比如sina.com.cn。
DNS系统是一个分布式数据库,当一个数据库发现并没有某查询所需要的数据时,它将把查询转发出去,而转发的目的地通常是根服务器,根服务器从上自下层层转发查询,直到找到目标为止。DNS的另一特点是使用高速缓存,DNS把查询过的数据缓存在某处,以便于下次查询时使用。
DNS协议
DNS协议定义了一个既可以查询也可以响应的报文格式,各个字段的解释如下:
- 最前面的16个bit唯一的标识了问题号码,用于查询端区别自己的查询
- 紧接着的16歌bit又可以做进一步的细分,标示了报文的性质和一些细节,比如说是查询报文还是响应报文,需要递归查询与否
- 查询问题后面有查询类型,包括“A,NS,CNAME,HINFO,MX”
- 响应报文可以回复多个IP,也就是说,域名可以和多个IP地址对应,并且有很多CNAME
反向查询
正向是指通过域名查询IP,反向是指通过IP查询域名。例如用host命令,host ip
就可以得到服务器的域名,host domainname
得到IP地址。
DNS服务器高速缓存
BIND9默认作为一个高速缓存服务器,其将所有的查询都交到服务器上去,然后得到的结果放在本地的缓存区,以加速查询。
用UDP还是TCP
DNS服务器同时支持UDP和TCP两种协议的查询方式,而且端口都是53,大多数都是UDP查询,需要TCP查询的一般有两种情况:
- 当查询过大以至于产生了数据截断(TC标志为1),这时,需要利用TCP的分片能力来进行数据传输
- 当master和slave服务器之间通信,辅服务器要拿到主服务器的zone信息的时候。
TCP数据包内容
TCP处于7层模型中的传输层,主要是用来建立可靠的连接。而建立连接的基础,就是其非常丰富的报文。首先,TCP3次握手用的报文就是绿色部分的TCP Flags
内容。通过发送ACK、SYN包实现。具体涉及的Tag详见:
- Source Port/Destination Post:即客户端和服务端端口号,端口号用于区分主机中不同的进程,通过结合源IP和目的IP,得出唯一的TCP连接;
- Sequence Number(seqNumber):一般由客户端发送,用来表示报文段中第一个数据字节在数据流中的序号,主要用来解决网络包乱序问题;
- Acknowledgment Number(ACK):就是用来存放客户端发来的seqNumber的下一个信号(seqNumber+1)。只有当TCP flags中的ACK为1时才有效。主要用来解决不丢包的问题。
- TCP flags:TCP中有6个首部,用来控制TCP连接的状态,取值为0或1。分别是:URG、ACK、PSH、RST、SYN、FIN:
- URG为1时,用来保证TCP连接不被中断。并且将该次TCP内容数据的紧急程度提升(即告诉计算机,首先处理该连接)
- ACK通常是服务端返回的。用来表示应答是否有效。
- PSH表示当数据包得到后,立马给应用程序使用(PUSH到最顶端)
- RST用来确保TCP连接的安全。该flag用来表示一个连接复位的请求。如果发生错误连接,则reset一次,重新连。同时可以用来拒绝非法数据包。
- SYN同步的意思,通常由客户端发出,用来建立连接。第一次握手时:SYN为1,ACK为0;第二次握手时:SYN为1,ACK为1。
- FIN用来表示是否结束该次TCP连接。通常当你的数据发送完后,会自动带上FIN然后断开连接。
TCP连接的建立与终止
TCP是一个面向连接的协议,所以在连接双方发送数据前,都需要建立一条连接。TCP连接的建立需要3次握手,终止需要4次握手。
建立连接
在建立连接时,客户端首先向服务器申请打开某一个端口(用SYN段等于1的TCP报文),然后服务器返回一个ACK报文通知客户端请求报文收到,客户端收到确认报文以后再次发送一个确认报文确认刚才服务器发出的确认报文,至此,连接建立完成,被称为3次握手。如果打算让双发都做好准备的话,一定要发送三次报文,而且只需要三次报文就可以了。
结束连接
TCP有一个特别的概念叫做half-close,TCP的连接是全双工(可以同时接收和发送)连接,因此在关闭连接的时候,必须关闭传个送两个方向上的连接。客户端给服务器一个FIN为1的TCP报文,然后服务器返回一个确认ACK报文,并且发送一个FIN报文,当客户机回复ACK报文后,连接就结束了。
最大报文长度
在建立连接时,通信的双方要互相确认对方的最大报文长度(MSS),以便通信,一般这个SYN长度是MTU长度减去固定IP首都和TCP首部长度。对于一个以太网,一般可以达到1460字节。当然如果对于非本地的IP,这个MSS可能只有536字节,而且,如果中间的传输网络的MSS更小的话,这个值会变得更小。
TCP的状态迁移图
包含两个部分,服务器状态和客户端状态,如果从某一个角度会更加清晰,这里面的服务器和客户端都不是绝对的,发送数据的就是客户端,接收数据的就是服务器。
另一种描述方式:
客户端路线
客户端状态可以用一下流程来表示:
CLOSER --> SYN_SENT -->ESTABLISHED --> FIN_WAIT_1 --> FIN_WAIT_2 --> TIME_WAIT -- CLOSED
该流程是在程序正常时应该有的流程,在建立连接时,当客户端收到SYN的ACK报文以后,客户端就打开了数据交互的连接。而结束连接则通常是客户端主动结束的,客户端结束应用程序以后,需要经历FIN_WAIT_1
、FIN_WAIT_2
等状态,这些状态的迁移就是前面提到的结束连接的4次握手。
服务器路线
服务器状态的流程:
CLOSED --> LISTEN --> SYN收到 --> ESTABLISHED --> CLOSE_WAIT --> LAST_ACK --> CLOSED
在建立连接的时候,服务器端就是在三次握手之后才进入数据交互状态,而关闭连接则是在关闭连接的第二次握手之后,而不是第四次握手之后。关闭以后还要等待客户端给出最后的ACK才能进入初始状态。
建立连接的三次握手流程
- 第一次握手:客户端向服务端发送一个SYN包,并且添加上seqNumber(假设为x),然后进入
SYN_SEND
状态,并且等待服务器的确认; - 第二次握手:服务器接收SYN包,并进行确认,如果该请求有效,则将TCP flags中的ACK标记为1,然后将AckNumber置为(seqNumber+1),并且再添加上自己的seqNumber(y),完成后,返回给客户端。服务器进入
SYN_RECV
状态(这里服务端是发送SYN+ACK包); - 第三次握手:客户端接收ACK+SYN报文后,获取到服务器发送的AckNumber(y),并且将新头部的AckNumber变为(y+1),然后发送给服务端,完成TCP的三次握手,建立连接。此时服务器和客户端都进入
ESTABLISHED
状态。
关闭连接的四次挥手流程
- 第一次挥手:A机感觉此时如果keepalive比较浪费资源,则它提出了分手的请求。设置SeqNumber和AckNumber之后,向B机发送FIN包,表示我这已经没有数据给你了,然后A机进入
FIN_WAIT_1
状态; - 第二次挥手:B机收到了A机的FIN包,已经知道了A机没有数据再发送了。此时B会给A发送一个ACK包,并且将AckNumber变为A传输来的SeqNumber+1。当A接收到之后,则变为
FIN_WAIT_2
状态。表示已经得到B机的许可,可以进行关闭操作。不过此时,B机还是可以向A机发送请求的。 - 第三次挥手:B机向A机发送FIN包,请求关闭,相当于告诉A机,我这里也没有你要的数据了。然后B进入
CLOSE_WAIT
状态(同时带上SeqNumber); - 第四次挥手:A接收到B的FIN包之后,然后同样,发送一个ACK包给B。B接收到之后就断开了。而A会等待2MSL的时间之后,如果没有回复,确保服务端确实是关闭了。然后A机也可以关闭连接。A、B都进入
CLOSE
状态。
2MSL
的意思是2 x MSL
。MSL的其实是 ”Maximum Segment Lifetime“,报文最大生存时间。RFC793中规定为2分钟,实际应用中常用的是30秒、1分钟等。同样上面的TIME_WAIT
状态其实也就是2MSL
状态,如果超过该时间,则会将报文丢弃,直接进入CLOSE
状态。
其他状态迁移
图中还有一些其他状态的迁移,针对服务端和客户端两方面总结如下:
LISTEN --> SYN_SENT
:指服务器有时候也需要打开连接SYN --> SYN收到
:服务器和客户端在SYN_SENT
状态下如果收到SYN数据报,则都需要发送SYN的ACK数据报并把自己的状态调整到SYN收到状态,准备进入ESTABLISHED
SYN_SENT --> CLOSED
:才发送超时的情况下,会返回到CLOSED
状态SYN收到 --> LISTEN
:如果收到RST包,会返回到LISTEN状态SYN收到 --> FIN_WAIT_1
:这个迁移是说,可以不用到ESTABLISHED
状态,可以直接跳转到FIN_WAIT_1
状态并等待关闭
2MSL等待状态
图中有一个TIME_WAIT
等待状态,又称为2MSL状态,说的是在TIME_WAIT_2
发送了最后一个ACK数据报以后,要进入TIME_WAIT
状态,这个状态是防止最后一次握手的数据报没有传送到对方那里准备的(注意这不是4次握手,这是第4次握手的保险状态),这个状态在很大程度上都保证了双方都可以正常结束,但是也伴随着问题。
由于插口的2MSL状态(插口是IP和端口对的意思,socket),使得应用程序在2MSL时间内无法再次使用同一个插口对,对于客户端程序还好,但是对于服务器程序,例如httpd,他总是要使用同一个端口来进行服务,而在2MSL时间内,启动httpd就会出现错误(插口被使用)。为了避免这个错误,服务器给出了一个平静时间的概念,在2MSL时间内,虽然可以重新启动服务器,但是这个服务器还是要平静的等待2MSL时间的过去才能进行下一次连接。
FIN_WAIT_2
状态
这是著名的半关闭状态,在关闭连接时,客户端和服务器两次握手之后的状态。这个状态下,应用程序还有接收数据的能力,但是已经无法发送数据,但是也有一种可能,客户端一直处于FIN_WAIT_2
状态,而服务器一直出去WAIT_CLOSE
状态,而直到应用层来决定关闭这个状态。
RST,同时打开和同时关闭
RST是另一种关闭连接的方式,应用程序可以判断RST包的真实性,即是否为异常终止。而同时开发和同时关闭时两种特殊的TCP状态,发生的概率很小。
TCP服务器设计
在前面的UDP服务器设计中,完全不需要所谓的并发机制,它只需要建立一个数据输入队列就可以。但是TCP不同,TCP服务器对于每一个连接都需要建立一个独立的进程(或者轻量级的线程),来宝成对话的独立性。所以TCP服务器是并发的。而TCP服务器还需要配备一个呼入连接请求队列,来为每一个连接请求建立对话进程,这也就是为什么各种TCP服务器都有一个最大连接数的限制。而根据源主机的IP和端口号,服务器可以很轻松的区别不同的会话,来进行数据的分发。
TCP交互数据流、成块数据流
目前建立在TCP协议上的网络协议很多,有telnet、ssh、ftp、http等。这些协议又可以根据数据吞吐量大致分为两类:
- 交互数据类型:例如telnet、ssh,这种协议通常只做小流量的数据交换,比如按下键盘,回显文字等;
- 数据成块类型:例如ftp。这种类型的协议要求TCP尽量的运载数据,把数据的吞吐量做到最大,并尽可能的提高效率。
TCP的交互流数据
对于交互性要求比较高的应用,TCP给出了两个策略来提高效率和减少网络负担:捎带ACK、Nagle算法(一次尽量多的发数据)。通常在网络速度很快的情况下,比如用lo接口进行telnet通信,当按下字母键并要求回显的时候,客户端和服务器将经历发送按键数据 --> 服务器发送按键数据的ACK --> 服务器端发送回显数据 --> 客户端发送回显数据的ACK
的过程,而其中的数据流将是40bit + 41bit + 41bit + 40bit = 162bit
,如果在广域网里面,这种小分组的TCP流量将会造成很大的网络负担。
捎带ACK的发送方式
这个策略是说,当主机收到远程主机的TCP数据报的时候,通常不马上发送ACK数据报,而是登上一个短暂的时间,如果这段时间内主机还有发送到远程主机的TCP数据报,那么就把这个ACK数据报捎带着发过去,把原本两个数据报整合成一个发送。一般这个时间是200ms。可以很明显的看到这个策略把TCP的数据报的利用率提高很多。
Nagle算法
Nagle算法是指,当A给B发送了一个TCP数据报并进入等待B的ACK数据报的状态时,TCP的输出缓冲区中只能有一个TCP数据报,并且,这个数据报不断的收集后来的数据,整合成一个大的数据报,等到B的ACK包一到,就把这些数据一股脑的发送出去。
在编写接口程序的时候,可以通过TCP_NODELAY
来关闭这个算法。同时使用这个算法需要据情况而定,比如基于TCP的X窗口协议,如果处理鼠标事件还是用这个算法的话延迟就会非常大了。
TCP的成块流数据
对于FTP这样对数据吞吐量有较高的要求,将总是希望每次尽量多的发送数据到对方主机,就算是有点延迟也无所谓。TCP也提供了一整套的策略来支持这样的需求。TCP协议中有16个bit表示窗口的大小,这是这些策略的核心。
传输数据是ACK的问题
在解释滑动窗口前,需要看看ACK的应答策略,一般来说,发送端发送一个TCP数据报,那么接收端就应该发送一个ACK数据报。但是事实上并不是这样,发送端将会连续发送数据尽量填满接收方的缓冲区,而接收方只要对这些数据发送一个ACK报文来回应就可以了,这就是ACK的累积特性,这个特性大大减少了发送端和接收端的负担。
滑动窗口
滑动窗口本质上是描述接收方的TCP数据报缓冲区大小的数据,发送方根据这个数据来计算自己最多能发送多长的数据。如果发送方收到接收方的窗口大小为0的TCP数据报,那么发送方将停止发送数据,等到接收方发送窗口大小不为0的数据报的到来。
关于滑动窗口协议,还有三个术语:
- 框框合拢:当窗口从左边向右边靠近的时候,这种现象发生在数据报被发送和确认的时候;
- 窗口张开:当窗口的右边沿向右边移动的时候,这种现象发生在接收端处理了数据以后;
- 窗口收缩:当窗口的右边沿向左边移动的时候,这种现象不常发生。
TCP就是利用这个窗口,慢慢的从数据的左边移动到右边,把处于窗口范围内的数据发送出去(但不是发送所有,只是处于窗口内的数据可以发送)。这就是窗口的意义。窗口的大小是可以通过socket来指定的,4096并不是最理想的窗口大小,而16384则可以使吞吐量大大的增加。
数据拥堵
上面的策略用于局域网内传输还可以,但是用在广域网中就可能出现问题,最大的问题就是当传输时出现了瓶颈(比如一定要经过一个slip低速链路)所产生的大量数据拥堵问题,为了解决这个问题,TCP发送方需要确认连接双方的线路的数据最大吞吐量是多少。
拥堵窗口的原理很简单,TCP发送方首先发送一个数据报,然后等待对方的回应,得到回应后就把这个窗口的大小加倍,然后连续发送两个数据报,等到对方回应以后,再把这个窗口加倍(先是2的指数倍,到了一定程度后变成线性增长,即慢启动),发送更多的数据报,直到出现超时错误。这样,发送端就了解了通信双方的线路承载能力,也就确定了拥堵窗口的大小,发送方就用这个拥堵窗口的大小发送数据。比如下载的时候一开始很慢,慢慢加速后变成匀速。
TCP的超时与重传
超时重传是TCP保证数据可靠性的另一个重要机制。其原理是在发送某一个数据以后就开启一个计时器,在一定的时间内如果没有得到发出数据的ACK报文,就重新发送数据,直到发送成功。
超时
超时时间的计算是超时的核心部分,TCP要求这个算法能大致估算出当前的网络状况,虽然这确实很困难。要求精确的原因有两个:1、定时长久会造成网络利用率不高;2、定时太短会造成多次重传,使得网络拥堵。所以书中(《TCP/IP详解:卷一》)给出了一套经验公式,和其他的保证计时器准确的措施。
递推公式概述
最早的TCP计算网络状况的公式:
R<-aR+(1-a)M
RTP=Rb
其中a是一个经验系数0.1,b通常为2,注意这是经验,没有推导过程,这个数值是可以被修改的。这个公式是说用旧的RTT(R)和新的RTT(M)综合到一起考虑新的RTT(R)的大小。但是,这种估计在网络变化很大的情况下完全不能做出灵敏的反应,于是就有下面的修正公式:
Err=M-A
A<-A+gErr
D<-D+h(|Err|-D)
RTO=A+4D
详细解释参考P228。这个递推公式甚至提到了方差这种统计概念,使得偏差更小。而且,必须要指出的是,这两组公司更新,都是在数据成功传输的情况下才进行,在发生数据重新传输的情况下,并不使用上面的公式进行网络国际,理由很简单,因为程序已经不再正常状态下了,估计出来的数据也是没有意义的。
RTO的初始化
RTO的初始化是由公式决定的,例如最初的公式,初始的值应该是1。而修正公式,初始RTO应该是A+4D。
RTO的更新
当输出传输正常的情况下,我们就会用上面的公式来更新各个数据,并重开定时器,来保证下一个数据被顺利传输。要注意的是:**重传的情况下,RTO不用上面的公式计算,而是采用一种叫”指数退避“的方式。**例如:当RTO为1S的情况下,发生了数据重传,我们就用RTO=2S的定时器来重新传输数据,下一次用4S。一直增加到64S为止。
估计器的初始化
在这里,SYN用的估计器初始化似乎和传输用的估计器不一样???
估计器的更新
Karn算法
应该称为一个策略,说的是更新RTO和估计器的时机选择问题。
计时器的使用
- 一个连接中,有且仅有一个测量定时器被使用。也就是说,如果TCP连续发出三组数据,只有一组数据会被测量;
- ACK数据报不会被测量,原因很简单,没有ACK的ACK回应可以供结束定时器测量。
重传
有了超时就有重传,但是会根据一定的策略重传,而不是将数据简单的发送。
重传时发送数据的大小
前面曾经提到过,数据在传输时不能只是用一种窗口协议,我们还需要有一个拥堵窗口来控制数据的流量,使得数据不会一下子都跑到网络中引起拥堵。也提到过,拥堵窗口最初使用指数增长的速度来增加自身的窗口,直到发生超时重传,在进行一次微调。但是没有提到,如何进行微调,拥塞避免算法和慢启动门限就是为此而生。
慢启动门限是说,当拥堵窗口超过这个门限的时候,就使用拥塞避免算法,而在门限以内就使用慢启动算法。所以这个标准才叫做门限,通常,拥塞窗口记做cwnd,慢启动门限记做ssthresh。
算法概要:
- 对一个给定的连接,初始化cwnd为1个报文段,ssthresh为65535字节;
- TCP输出历程的输出不能超过cwnd和接收方通告窗口的大小。拥塞避免是发送方使用的流量控制,而通告窗口则是接收方进行的流量控制。前者是发送发感受到网络拥堵的估计,而后者则与接收方在该连接上的可用缓存大小有关;
- 当拥堵发生时(超时或收到重复确认),sshthresh被设置为当前窗口大小的一半(cwnd和接收方通告窗口大小的最小值,但最少为2个报文段)。此外,如果是超时引起了阻塞,则cwnd被设置为一个报文段(这就是慢启动)。
- 当心的数据被对方确认时,就增加cwnd,但增加的方法依赖于我们是否正在进行慢启动或拥塞避免。如果cwnd小于或等于ssthresh,则正在进行慢启动,否则正在进行拥塞避免。慢启动一直持续到我们回到拥发生时所处位置的半时候才停止(因为我们记录了在步骤2中给我们制造麻烦的窗口大小的一半),然后转为执行拥塞避免。
快速重传和快速恢复算法
这是数据丢包的情况下给出的一种修补机制。一般来说,重传发生在超时之后,但是如果发送端收到超过3个以上的重复ACK的情况下,就应该意识到,数据丢了,需要重新传递。这个机制是不需要等到重传计时器溢出的,所以叫做快速重传,而重新传递以后,因为走的不是慢启动而是拥塞避免算法,所以又被称为快速恢复算法。流程如下:
- 当收到3个重复的ACK时,将ssthresh设置为当前拥堵窗口cwnd的一半,重传丢失的报文段。设置cwnd为ssthresh加上3倍的报文段大小;
- 每次收到另一个重复的ACK时,cwnd增加1个报文段大小并发送一个分组(如果新的cwnd允许发送);
- 当下一个确认新数据的ACK到达时,设置cwnd为ssthresh(在第一步中设置的值)。这个ACK应该是在重传后的一个往返时间内对步骤1中重传的确认。另外,这个ACK应该是对丢失的分组和收到的第一个重复的ACK之间的所有中间报文段的确认。这一步采用的是拥塞避免,因为当分组丢失时我们将当前的速率减半。
ICMP会引起重新传递吗
不会,TCP会坚持自己的定时器,但是TCP会保留下ICMP的错误并通知用户。
重新分组
TCP为了提高自己的效率。允许再重新传输的时候,只要传输包含重传数据报文的报文就可以,而不用只重传需要传输的报文。
TCP坚持定时器、TCP保活定时器
TCP一共提供了四个主要的定时器,前面已经说过最复杂的超时定时器,另外的三个是:
- 坚持定时器
- 保活定时器
- 2MSL定时器
坚持定时器
当TCP服务器收到了客户端的0滑动窗口报文时,就启动一个定时器计时,并在定时器溢出的时候想客户端查询窗口是否已经增大,如果得到非0的窗口就重新开始发送数据,如果得到0窗口就再开一个新的定时器准备下一次查询。通过观察可知,TCP的坚持定时器使用1、2、4、8、16、…64秒这样的普通指数退避序列来作为每一次的溢出时间。
糊涂窗口综合征
TCP的窗口协议,会引起一种叫做糊涂窗口综合征的问题,具体表现为,当客户端通告一个小的非0窗口时,服务器立即发送小数据给客户端并充满气缓冲区,一来二去就会让网络中充满小TCP数据报,从而影响网络利用率。对于发送方和接收端的这种糊涂行为,TCP给出了一些建议、规定:
- 接收方不通告小窗口。通常的算法是接收方不通告一个比当前窗口大的窗口(可以为0),除非窗口可以增加一个报文段大小(也就是将要接收的MSS),或者可以增加接收方缓存空间的一半,不论实际有多少;
- 发送方避免出现糊涂窗口综合症的措施是只有一下条件之一满足时才发送数据:
- 可以发送一个满长度的报文段
- 可以发送至少是接收方通告窗口大小一半的报文段
- 可以发送任何数据并且不希望接收ACK(也就是说,我们还没有未被确认的数据)或者该连接上不能使用Nagle算法
可以发现TCP的很多规定都是为了在一次发送中发送尽量多的数据,例如捎带ACK的策略,Nagle算法,重传时发送包含数据报文的策略,等等。
保活定时器
保活定时器更加简单,还记得FTP或者HTTP服务器都有Session Time机制吗?因为TCP是面向连接的,所以就会出现只连接不传数据的”半开放连接“,服务器当然要检测这种连接并且在某些情况下释放这些连接,这就是保活定时器的作用。其时限根据服务器的实现不同而不同。另外,当其中一端如果崩溃并重启的情况时,如果收到该端”前生“的保活探查,则要发送一个RST数据报文帮助另一端结束连接。
3 - 调优参数
Linux-TCP/IP 参数优化
相关参数释义
关于 Linux 下 TCP/IP 协议栈的参数调优,在/etc/sysctl.conf
修改,执行命令sysctl -p
可以永久生效,在/proc/sys/net/ipv4/
修改会在重启后失效。
/proc/sys/net/ipv4/
文件:
名称 | 默认值 | 建议值 | 描述 |
---|---|---|---|
tcp_syn_retries | 5 | 1 | 对于一个新建连接,内核要发送多少个 SYN 连接请求才决定放弃。不应该大于255,默认值是5,对应于180秒左右时间。。(对于大负载而物理通信良好的网络而言,这个值偏高,可修改为2.这个值仅仅是针对对外的连接,对进来的连接,是由tcp_retries1决定的) |
tcp_synack_retries | 5 | 1 | 对于远端的连接请求SYN,内核会发送SYN + ACK数据报,以确认收到上一个 SYN连接请求包。这是所谓的三次握手( threeway handshake)机制的第二个步骤。这里决定内核在放弃连接之前所送出的 SYN+ACK 数目。不应该大于255,默认值是5,对应于180秒左右时间。 |
tcp_keepalive_time | 7200 | 600 | TCP发送keepalive探测消息的间隔时间(秒),用于确认TCP连接是否有效。防止两边建立连接但不发送数据的攻击。 |
tcp_keepalive_probes | 9 | 3 | TCP发送keepalive探测消息的间隔时间(秒),用于确认TCP连接是否有效。 |
tcp_keepalive_intvl | 75 | 15 | 探测消息未获得响应时,重发该消息的间隔时间(秒)。默认值为75秒。 (对于普通应用来说,这个值有一些偏大,可以根据需要改小.特别是web类服务器需要改小该值,15是个比较合适的值) |
tcp_retries1 | 3 | 3 | 放弃回应一个TCP连接请求前﹐需要进行多少次重试。RFC 规定最低的数值是3 |
tcp_retries2 | 15 | 5 | 在丢弃激活(已建立通讯状况)的TCP连接之前﹐需要进行多少次重试。默认值为15,根据RTO的值来决定,相当于13-30分钟(RFC1122规定,必须大于100秒).(这个值根据目前的网络设置,可以适当地改小,我的网络内修改为了5) |
tcp_orphan_retries | 7 | 3 | 在近端丢弃TCP连接之前﹐要进行多少次重试。默认值是7个﹐相当于 50秒 - 16分钟﹐视 RTO 而定。如果您的系统是负载很大的web服务器﹐那么也许需要降低该值﹐这类 sockets 可能会耗费大量的资源。另外参的考tcp_max_orphans。(事实上做NAT的时候,降低该值也是好处显著的,我本人的网络环境中降低该值为3) |
tcp_fin_timeout | 60 | 2 | 对于本端断开的socket连接,TCP保持在FIN-WAIT-2状态的时间。对方可能会断开连接或一直不结束连接或不可预料的进程死亡。默认值为 60 秒。 |
tcp_max_tw_buckets | 180000 | 36000 | 系统在同时所处理的最大 timewait sockets 数目。如果超过此数的话﹐time-wait socket 会被立即砍除并且显示警告信息。之所以要设定这个限制﹐纯粹为了抵御那些简单的 DoS 攻击﹐不过﹐如果网络条件需要比默认值更多﹐则可以提高它(或许还要增加内存)。(事实上做NAT的时候最好可以适当地增加该值) |
tcp_tw_recycle | 0 | 1 | 打开快速 TIME-WAIT sockets 回收。除非得到技术专家的建议或要求﹐请不要随意修改这个值。(做NAT的时候,建议打开它) |
tcp_tw_reuse | 0 | 1 | 表示是否允许重新应用处于TIME-WAIT状态的socket用于新的TCP连接(这个对快速重启动某些服务,而启动后提示端口已经被使用的情形非常有帮助) |
tcp_max_orphans | 8192 | 32768 | 系统所能处理不属于任何进程的TCP sockets最大数量。假如超过这个数量﹐那么不属于任何进程的连接会被立即reset,并同时显示警告信息。之所以要设定这个限制﹐纯粹为了抵御那些简单的 DoS 攻击﹐千万不要依赖这个或是人为的降低这个限制。如果内存大更应该增加这个值。(这个值Redhat AS版本中设置为32768,但是很多防火墙修改的时候,建议该值修改为2000) |
tcp_abort_on_overflow | 0 | 0 | 当守护进程太忙而不能接受新的连接,就象对方发送reset消息,默认值是false。这意味着当溢出的原因是因为一个偶然的猝发,那么连接将恢复状态。只有在你确信守护进程真的不能完成连接请求时才打开该选项,该选项会影响客户的使用。(对待已经满载的sendmail,apache这类服务的时候,这个可以很快让客户端终止连接,可以给予服务程序处理已有连接的缓冲机会,所以很多防火墙上推荐打开它) |
tcp_syncookies | 0 | 1 | 只有在内核编译时选择了CONFIG_SYNCOOKIES时才会发生作用。当出现syn等候队列出现溢出时象对方发送syncookies。目的是为了防止syn flood攻击。 |
tcp_stdurg | 0 | 0 | 使用 TCP urg pointer 字段中的主机请求解释功能。大部份的主机都使用老旧的 BSD解释,因此如果您在 Linux 打开它﹐或会导致不能和它们正确沟通。 |
tcp_max_syn_backlog | 1024 | 16384 | 对于那些依然还未获得客户端确认的连接请求﹐需要保存在队列中最大数目。对于超过 128Mb 内存的系统﹐默认值是 1024 ﹐低于 128Mb 的则为 128。如果服务器经常出现过载﹐可以尝试增加这个数字。警告﹗假如您将此值设为大于 1024﹐最好修改include/net/tcp.h里面的TCP_SYNQ_HSIZE﹐以保持TCP_SYNQ_HSIZE16(SYN Flood攻击利用TCP协议散布握手的缺陷,伪造虚假源IP地址发送大量TCP-SYN半打开连接到目标系统,最终导致目标系统Socket队列资源耗尽而无法接受新的连接。为了应付这种攻击,现代Unix系统中普遍采用多连接队列处理的方式来缓冲(而不是解决)这种攻击,是用一个基本队列处理正常的完全连接应用(Connect()和Accept() ),是用另一个队列单独存放半打开连接。这种双队列处理方式和其他一些系统内核措施(例如Syn-Cookies/Caches)联合应用时,能够比较有效的缓解小规模的SYN Flood攻击(事实证明) |
tcp_window_scaling | 1 | 1 | 该文件表示设置tcp/ip会话的滑动窗口大小是否可变。参数值为布尔值,为1时表示可变,为0时表示不可变。tcp/ip通常使用的窗口最大可达到 65535 字节,对于高速网络,该值可能太小,这时候如果启用了该功能,可以使tcp/ip滑动窗口大小增大数个数量级,从而提高数据传输的能力(RFC 1323)。(对普通地百M网络而言,关闭会降低开销,所以如果不是高速网络,可以考虑设置为0) |
tcp_timestamps | 1 | 1 | Timestamps 用在其它一些东西中﹐可以防范那些伪造的 sequence 号码。一条1G的宽带线路或许会重遇到带 out-of-line数值的旧sequence 号码(假如它是由于上次产生的)。Timestamp 会让它知道这是个 ‘旧封包’。(该文件表示是否启用以一种比超时重发更精确的方法(RFC 1323)来启用对 RTT 的计算;为了实现更好的性能应该启用这个选项。) |
tcp_sack | 1 | 1 | 使用 Selective ACK﹐它可以用来查找特定的遗失的数据报— 因此有助于快速恢复状态。该文件表示是否启用有选择的应答(Selective Acknowledgment),这可以通过有选择地应答乱序接收到的报文来提高性能(这样可以让发送者只发送丢失的报文段)。(对于广域网通信来说这个选项应该启用,但是这会增加对 CPU 的占用。) |
tcp_fack | 1 | 1 | 打开FACK拥塞避免和快速重传功能。(注意,当tcp_sack设置为0的时候,这个值即使设置为1也无效)(这个是TCP连接靠谱的核心功能) |
tcp_dsack | 1 | 1 | 允许TCP发送"两个完全相同"的SACK。 |
tcp_ecn | 0 | 0 | TCP的直接拥塞通告功能。 |
tcp_reordering | 3 | 6 | TCP流中重排序的数据报最大数量。 (一般有看到推荐把这个数值略微调整大一些,比如5) |
tcp_retrans_collapse | 1 | 0 | 对于某些有bug的打印机提供针对其bug的兼容性。(一般不需要这个支持,可以关闭它) |
tcp_wmem:min | 4096 | 8192 | 发送缓存设置. 为TCP socket预留用于发送缓冲的内存最小值。每个tcp socket都可以在建议以后都可以使用它。默认值为4096(4K)。 |
tcp_wmem:default | 16384 | 131072 | 为TCP socket预留用于发送缓冲的内存数量,默认情况下该值会影响其它协议使用的net.core.wmem_default 值,一般要低于net.core.wmem_default的值。默认值为16384(16K)。 |
tcp_wmem:max | 131072 | 16777216 | 用于TCP socket发送缓冲的内存最大值。该值不会影响net.core.wmem_max,“静态"选择参数SOSNDBUF则不受该值影响。默认值为131072(128K)。(对于服务器而言,增加这个参数的值对于发送数据很有帮助,在我的网络环境中,修改为了51200 131072 204800,分别对应min、default、max。) |
tcprmem:min | 4096 | 32768 | 接收缓存设置。同tcp_wmem。 |
tcprmem:default | 87380 | 131072 | 接收缓存设置。同tcp_wmem。 |
tcprmem:max | 174760 | 16777216 | 接收缓存设置。同tcp_wmem。 |
tcp_mem:min | 根据内存计算 | 786432 | low:当TCP使用了低于该值的内存页面数时,TCP不会考虑释放内存。即低于此值没有内存压力。(理想情况下,这个值应与指定给 tcp_wmem 的第 2 个值相匹配 - 这第 2 个值表明,最大页面大小乘以最大并发请求数除以页大小 (131072 300 / 4096)。 ) |
tcp_mem:default | 根据内存计算 | 1048576 | pressure:当TCP使用了超过该值的内存页面数量时,TCP试图稳定其内存使用,进入pressure模式,当内存消耗低于low值时则退出pressure状态。(理想情况下这个值应该是 TCP 可以使用的总缓冲区大小的最大值 (204800 300 / 4096)。 ) |
tcp_mem:max | 根据内存计算 | 1572864 | high:允许所有tcp sockets用于排队缓冲数据报的页面量。(如果超过这个值,TCP 连接将被拒绝,这就是为什么不要令其过于保守 (512000 300 / 4096) 的原因了。 在这种情况下,提供的价值很大,它能处理很多连接,是所预期的 2.5 倍;或者使现有连接能够传输 2.5 倍的数据。 我的网络里为192000 300000 732000)。一般情况下这些值是在系统启动时根据系统内存数量计算得到的。 |
tcp_app_win | 31 | 31 | 保留max(window/2^tcp_app_win, mss)数量的窗口由于应用缓冲。当为0时表示不需要缓冲。 |
tcp_adv_win_scale | 2 | 2 | 计算缓冲开销bytes/2^tcp_adv_win_scale(如果tcp_adv_win_scale > 0)或者bytes-bytes/2^(-tcp_adv_win_scale)(如果tcp_adv_win_scale BOOLEAN>0) |
tcp_low_latency | 0 | 0 | 允许 TCP/IP 栈适应在高吞吐量情况下低延时的情况;这个选项一般情形是的禁用。(但在构建Beowulf 集群的时候,打开它很有帮助) |
tcp_bic | 0 | 0 | 为快速长距离网络启用 Binary Increase Congestion;这样可以更好地利用以 GB 速度进行操作的链接;对于 WAN 通信应该启用这个选项。 |
ip_forward | 0 | - | NAT必须开启IP转发支持,把该值写1 |
ip_local_port_range:min | 32768 | 1024 | 表示用于向外连接的端口范围,默认比较小,这个范围同样会间接用于NAT表规模。 |
ip_local_port_range:max | 61000 | 65000 | 同上 |
ip_conntrack_max | 65535 | 65535 | 系统支持的最大ipv4连接数,默认65536(事实上这也是理论最大值),同时这个值和你的内存大小有关,如果内存128M,这个值最大8192,1G以上内存这个值都是默认65536 |
/proc/sys/net/ipv4/netfilter/
文件,该文件需要打开防火墙再会存在。
名称 | 默认值 | 建议值 | 描述 |
---|---|---|---|
ip_conntrack_max | 65536 | 65536 | 系统支持的最大ipv4连接数,默认65536(事实上这也是理论最大值),同时这个值和你的内存大小有关,如果内存128M,这个值最大8192,1G以上内存这个值都是默认65536,这个值受/proc/sys/net/ipv4/ip_conntrack_max限制 |
ip_conntrack_tcp_timeout_established | 432000 | 180 | 已建立的tcp连接的超时时间,默认432000,也就是5天。影响:这个值过大将导致一些可能已经不用的连接常驻于内存中,占用大量链接资源,从而可能导致NAT ip_conntrack: table full的问题。建议:对于NAT负载相对本机的 NAT表大小很紧张的时候,可能需要考虑缩小这个值,以尽早清除连接,保证有可用的连接资源;如果不紧张,不必修改 |
ip_conntrack_tcp_timeout_time_wait | 120 | 120 | time_wait状态超时时间,超过该时间就清除该连接 |
ip_conntrack_tcp_timeout_close_wait | 60 | 60 | close_wait状态超时时间,超过该时间就清除该连接 |
ip_conntrack_tcp_timeout_fin_wait | 120 | 120 | fin_wait状态超时时间,超过该时间就清除该连接 |
/proc/sys/net/core/
文件:
名称 | 默认值 | 建议值 | 描述 |
---|---|---|---|
netdev_max_backlog | 1024 | 1024 | 每个网络接口接收数据包的速率比内核处理这些包的速率快时,允许送到队列的数据包的最大数目,对重负载服务器而言,该值需要调高一点。 |
somaxconn | 128 | 16384 | 用来限制监听(LISTEN)队列最大数据包的数量,超过这个数量就会导致链接超时或者触发重传机制。web应用中listen函数的backlog默认会给我们内核参数的net.core.somaxconn限制到128,而nginx定义的NGX_LISTEN_BACKLOG默认为511,所以有必要调整这个值。对繁忙的服务器,增加该值有助于网络性能 |
wmem_default | 129024 | 129024 | 默认的发送窗口大小(以字节为单位) |
rmem_default | 129024 | 129024 | 默认的接收窗口大小(以字节为单位) |
rmem_max | 129024 | 873200 | 最大的TCP数据接收缓冲 |
wmem_max | 129024 | 873200 | 最大的TCP数据发送缓冲 |
online | String | AA | AA |
online | String | AA | AA |
online | String | AA | AA |
online | String | AA | AA |
online | String | AA | AA |
生产环境参数优化
net.ipv4.tcp_syn_retries = 1
net.ipv4.tcp_synack_retries = 1
net.ipv4.tcp_keepalive_time = 600
net.ipv4.tcp_keepalive_probes = 3
net.ipv4.tcp_keepalive_intvl =15
net.ipv4.tcp_retries2 = 5
net.ipv4.tcp_fin_timeout = 2
net.ipv4.tcp_max_tw_buckets = 36000
net.ipv4.tcp_tw_recycle = 1
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_max_orphans = 32768
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_max_syn_backlog = 16384
net.ipv4.tcp_wmem = 8192 131072 16777216
net.ipv4.tcp_rmem = 32768 131072 16777216
net.ipv4.tcp_mem = 786432 1048576 1572864
net.ipv4.ip_local_port_range = 1024 65000
net.ipv4.ip_conntrack_max = 65536
net.ipv4.netfilter.ip_conntrack_max=65536
net.ipv4.netfilter.ip_conntrack_tcp_timeout_established=180
net.core.somaxconn = 16384
net.core.netdev_max_backlog = 16384
优化实例
sudops网站提供的优化例子,相关参数仅供参考,具体数值还需要根据机器性能,应用场景等实际情况来做更细微调整。
net.core.netdev_max_backlog = 400000
#该参数决定了,网络设备接收数据包的速率比内核处理这些包的速率快时,允许送到队列的数据包的最大数目。
net.core.optmem_max = 10000000
#该参数指定了每个套接字所允许的最大缓冲区的大小
net.core.rmem_default = 10000000
#指定了接收套接字缓冲区大小的缺省值(以字节为单位)。
net.core.rmem_max = 10000000
#指定了接收套接字缓冲区大小的最大值(以字节为单位)。
net.core.somaxconn = 100000
#Linux kernel参数,表示socket监听的backlog(监听队列)上限
net.core.wmem_default = 11059200
#定义默认的发送窗口大小;对于更大的 BDP 来说,这个大小也应该更大。
net.core.wmem_max = 11059200
#定义发送窗口的最大大小;对于更大的 BDP 来说,这个大小也应该更大。
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
#严谨模式 1 (推荐)
#松散模式 0
net.ipv4.tcp_congestion_control = bic
#默认推荐设置是 htcp
net.ipv4.tcp_window_scaling = 0
#关闭tcp_window_scaling
#启用 RFC 1323 定义的 window scaling;要支持超过 64KB 的窗口,必须启用该值。
net.ipv4.tcp_ecn = 0
#把TCP的直接拥塞通告(tcp_ecn)关掉
net.ipv4.tcp_sack = 1
#关闭tcp_sack
#启用有选择的应答(Selective Acknowledgment),
#这可以通过有选择地应答乱序接收到的报文来提高性能(这样可以让发送者只发送丢失的报文段);
#(对于广域网通信来说)这个选项应该启用,但是这会增加对 CPU 的占用。
net.ipv4.tcp_max_tw_buckets = 10000
#表示系统同时保持TIME_WAIT套接字的最大数量
net.ipv4.tcp_max_syn_backlog = 8192
#表示SYN队列长度,默认1024,改成8192,可以容纳更多等待连接的网络连接数。
net.ipv4.tcp_syncookies = 1
#表示开启SYN Cookies。当出现SYN等待队列溢出时,启用cookies来处理,可防范少量SYN攻击,默认为0,表示关闭;
net.ipv4.tcp_timestamps = 1
#开启TCP时间戳
#以一种比重发超时更精确的方法(请参阅 RFC 1323)来启用对 RTT 的计算;为了实现更好的性能应该启用这个选项。
net.ipv4.tcp_tw_reuse = 1
#表示开启重用。允许将TIME-WAIT sockets重新用于新的TCP连接,默认为0,表示关闭;
net.ipv4.tcp_tw_recycle = 1
#表示开启TCP连接中TIME-WAIT sockets的快速回收,默认为0,表示关闭。
net.ipv4.tcp_fin_timeout = 10
#表示如果套接字由本端要求关闭,这个参数决定了它保持在FIN-WAIT-2状态的时间。
net.ipv4.tcp_keepalive_time = 1800
#表示当keepalive起用的时候,TCP发送keepalive消息的频度。缺省是2小时,改为30分钟。
net.ipv4.tcp_keepalive_probes = 3
#如果对方不予应答,探测包的发送次数
net.ipv4.tcp_keepalive_intvl = 15
#keepalive探测包的发送间隔
net.ipv4.tcp_mem
#确定 TCP 栈应该如何反映内存使用;每个值的单位都是内存页(通常是 4KB)。
#第一个值是内存使用的下限。
#第二个值是内存压力模式开始对缓冲区使用应用压力的上限。
#第三个值是内存上限。在这个层次上可以将报文丢弃,从而减少对内存的使用。对于较大的 BDP 可以增大这些值(但是要记住,其单位是内存页,而不是字节)。
net.ipv4.tcp_rmem
#与 tcp_wmem 类似,不过它表示的是为自动调优所使用的接收缓冲区的值。
net.ipv4.tcp_wmem = 30000000 30000000 30000000
#为自动调优定义每个 socket 使用的内存。
#第一个值是为 socket 的发送缓冲区分配的最少字节数。
#第二个值是默认值(该值会被 wmem_default 覆盖),缓冲区在系统负载不重的情况下可以增长到这个值。
#第三个值是发送缓冲区空间的最大字节数(该值会被 wmem_max 覆盖)。
net.ipv4.ip_local_port_range = 1024 65000
#表示用于向外连接的端口范围。缺省情况下很小:32768到61000,改为1024到65000。
net.ipv4.netfilter.ip_conntrack_max=204800
#设置系统对最大跟踪的TCP连接数的限制
net.ipv4.tcp_slow_start_after_idle = 0
#关闭tcp的连接传输的慢启动,即先休止一段时间,再初始化拥塞窗口。
net.ipv4.route.gc_timeout = 100
#路由缓存刷新频率,当一个路由失败后多长时间跳到另一个路由,默认是300。
net.ipv4.tcp_syn_retries = 1
#在内核放弃建立连接之前发送SYN包的数量。
net.ipv4.icmp_echo_ignore_broadcasts = 1
# 避免放大攻击
net.ipv4.icmp_ignore_bogus_error_responses = 1
# 开启恶意icmp错误消息保护
net.inet.udp.checksum=1
#防止不正确的udp包的攻击
net.ipv4.conf.default.accept_source_route = 0
#是否接受含有源路由信息的ip包。参数值为布尔值,1表示接受,0表示不接受。
#在充当网关的linux主机上缺省值为1,在一般的linux主机上缺省值为0。
#从安全性角度出发,建议你关闭该功能。
4 - 理解网络栈
我们不敢想象离开了 TCP/IP 互联网服务会变成什么样子。所有我们在 NHN 上开发和使用的互联网服务都基于一个一致的基础,TCP/IP。理解数据是如何在网络上传输的,能够帮助你通过调优来提升性能、排除故障,或者是将其引入到新的技术中去。
本文将基于 Linux OS 和硬件层中的数据流及控制流来介绍网络栈的整体操作方案。
TCP/IP 的关键特性
怎样才能设计一个用来快速传输数据的网络协议,同时能够保证数据顺序又不丢失数据?
TCP/IP 就是以这样的初衷来设计的。以下是理解网络栈所必须的 TCP/IP 的关键特性。
TCP 与 IP 从技术上讲,由于 TCP 和 IP 拥有不同的层次结构,正确的方式是将它们分开来介绍。然而,这里我们会将其看做一个整体。
1、面向连接
首先,一个连接在两个端点之间(local and remote)被创建,然后数据才会被传输。这里的 “TCP 连接标识符” 是两个端点的地址的结合体,其结构如下:
<local IP address, local port number, remote IP address, remote port number>
2、双向字节流
双向的数据通信通过字节流(byte stream)来完成。
3、有序抵达
一个接收者以发送者发送的顺序来接收数据。为此,必须确保数据的顺序。为了标记顺序,使用了一个 32 位的整形数据类型。
4、通过 ACK 确保可靠性
当一个发送者在将数据发送给一个接收者之后没能收到一个 ACK(确认应答) 时,发送者 TCP 将会重新发送该数据给接收者。因此,发送方将会缓存那些没有得到接收者返回 ACK 的数据。
5、流控(Flow Control)
发送者竟会根据接收者的承受能力发送尽可能多的数据。接收者会将其能够接收的数据的最大字节数(unused buffer size, receive window)发送给发送者。发送者将会根据接收者的接收窗口所能支持的大小,尽可能发送更多的数据。
6、拥堵控制(Congestion Control)
拥堵窗口 以独立于接收窗口的方式来使用,通过限制网络上的数据流量来避免网络拥堵。与接收窗口一样,发送者通过一些算法来根据接收者拥堵窗口所能支持的最大字节数来发送尽可能多的数据,这些算法有 TCP Vegas、Westwood、BIC、CUBIC。与流控不同,拥堵控制仅由发送方实现。
数据传输
就像其名字中的提示一样,一个网络“栈”拥有很多层。下面的图示中展示了各层的类型:
可见有多个不同的层,并且归类为三种不同的空间:
- 用户空间
- 内核空间
- 设备空间
用户与内核空间的任务会由 CPU 来完成执行。用户与内核空间又被称为 “Host” 以与设备空间加以区分。这里的设备是网络适配器(Network Interface Card, NIC),它会发送或接收数据包。这比通常称呼的 “网卡(LAN Card)” 更为准确。
让我们看一下用户空间。首先,应用会创建用来发送的数据(User Data)并通过 write()
系统调用来发送数据。这里假定 socket(fd) 已经被创建过了。当执行了系统调用,则会切换到内核空间。
POSIX 系列的操作系统,包括 Linux 和 Unix,通过一个文件描述符将 socket 保留给应用。在 POSIX 系列的操作系统中,socket 只是文件的一种。文件层会执行一个简单的检查,并通过连接到文件构造体的 socket 构造体来调用 socket 函数。
内核 socket 拥有两个缓冲区:
- 一个是用于发送的 socket 发送缓冲区
- 一个是用于接收的 socket 接收缓冲区
当“系统写”被调用,处于用户空间的数据会被复制到内核内存并被添加到“socket 发送缓冲区”的末端,以按照数据添加的顺序进行发送。就像上图中浅灰色的方块引用着 socket 缓冲区中的数据。然后 TCP 被调用。
这里有一个关联到 socket 的 TCP 控制块(TCP Control Block, TCB) 结构,TCB 包含着用于处理 TCP 连接的必要信息。TCP 中的数据包括:连接状态(LISTEN
, ESTABLISHED
, TIME_WAIT
),接收窗口,拥堵窗口,序列号,重发计时器,等等。
如果 TCP 的当前状态运行数据传输,一个新的 TCP 段(segment, 即数据包) 会被创建。如果因为流控或类似的原因不能进行数据传输,系统调用就此终止并将模式(mode)返回给用户模式,即将控制权交给应用。
下面是两个 TCP 段,其结构如下图所示:
- TCP 头
- 负荷(payload)
负荷中包含了保存在未进行应答确认的 socket 发送缓冲区中的数据。负荷的最大长度即为接收窗口、拥堵窗口、最大分段值(MSS),这三者中的最大值。
然后,TCP 的校验和(checksum)被计算。在这个校验和计算中,包含了虚拟(pseudo)头信息,如 IP 地址、分段长度、协议号。根据 TCP 的状态,可以传输一个或更多的数据包。
事实上,由于现在的网络栈采用无负载(offload)的校验和,因此 TCP 校验和是由 NIC 计算的,而非内核。然而我们为了方便,这里假设是由内核完成的 TCP 校验和计算。
被创建的 TCP 分段会进入到 IP 层。IP 层将 IP 头添加到 TCP 分段,并执行 IP 路由。IP 路由 是为了搜索下一个跳板 IP 以最终能够抵达目的 IP。
在 IP 层计算并添加了 IP 头校验和之后,它会将数据发送到以太网(Ethernet)层。以太网层将会根据**地址解析协议(Address Resolution Protocol,ARP)**搜索下一个跳板 IP 的 MAC 地址。让后将以太网头添加到数据包。以太网数据包的添加则完成了主机(host)数据包的创建。
在 IP 路由执行之后,传输接口会得到 IP 路由的结果。该接口用于将数据包发送给下一个跳板 IP 或直接 IP。因此,网络适配器(NIC) 设备被调用。
这时,如果运行了一个数据包捕获程序,比如 tcpdump 或 Wireshark,内核将会把数据包数据复制到这些程序使用的内存中。这样,直接就能在设备上捕获接收到的数据包。通常来说,流量整形器(shaper)功能会被时限为运行在这一层上。
设备通过由 NIC 厂商定义的设备 NIC 通信协议来请求数据包的传输。
在接收到数据包传输请求之后,NIC 会将数据包从主内存复制到其自身的内存,然后发送给网线。这时,为了遵循以太网标准,会在数据包中添加 IFG(InterFrame Gap)、报头、CRC。IFG 和报头用于识别数据包的开始,CRC 则与 TCP 和 IP 的校验和一样用于保护数据。数据包的传输会基于以太网的速度和以太网的流控被启动。
当 NIC 发送一个数据包时,NIC 会使主机 CPU 中断。每次中断都拥有自己的中断号,然后 OS 通过该中断号查找合适的设备来处理该中断。在设备启动时会注册一个函数(中断处理器)来处理该中断。OS 调用该中断处理器,然后中断处理器将被传输的数据包返回给 OS。
到目前为止,我们已经讨论了当应用执行一次写入时贯穿内核与设备的整个数据传输过程。然而,再没有一个来自应用的直接写请求的情况下,内核可以直接通过调用 TCP 来传输一个数据包。比如,当接收了一个 ACK 且接收窗口被扩充,内核创建一个包含 socket 缓冲区中剩余数据的 TCP 分段,并将其发送给接收者。
数据接收
现在让我们看一下数据是如何被接收的。数据接收是网络栈用来处理传入的数据包的步骤。下图展示了网络栈如何处理一个接收到的数据包:
首先,NIC 将数据包写人到它的内存。它通过执行 CRC 检查来验证其有效性,然后将其发送给主机(host)的内存缓冲区。该缓冲区是一块已经由驱动程序向内核请求过的专门用于接收数据包的内存。一旦该内存被分配,驱动程序则会将其地址和大小发送给 NIC。如果是 NIC 已经接收到一个数据包但又没有分配可用的主机内存缓冲区,则 NIC 会将该数据包丢弃。
将数据包发送给主机内存缓冲区之后,NIC 会向主机 OS 发送一个中断(interrupt)。
然后,驱动器会检查它是否能够继续处理新的数据包。截止目前,驱动器与 NIC 使用的的通信协议由厂商定义。
当驱动器需要向上层发送数据包时,该数据包的结构必须被包装为一个 OS 使用的数据包结构,以便 OS 能够理解该数据包。比如,Linux 中的 sk_buff,BSD 系列内核中的 mbuf,微软 Windows 中的 NET_BUFFER_LIST,这些均是对应到各个 OS 的数据包结构。然后,驱动程序将被包装过的数据包发送给上层。
以太网层会检查该数据包的有效性,然后根据以太网头部中的以太网类型(ethertype)的值多路分解(de-multiplexes)上层协议(网络协议)。 比如 IPv4 的以太网类型协议是 0x0800。然后移除掉数据包的以太网头部并将其发送被 IP 层。
IP 层同样会检查数据包的有效性,或者说是检查 IP 头部的校验和。它将逻辑性的检测是否需要执行 IP 路由并使本机系统来处理该数据包,还是将其发送给其他的系统。如果该数据包必须由本机系统来处理,IP 层会通过查阅 IP 头部的原始值来多路分解上层协议(传输协议)。TCP 的原始值是 6。然后移除掉数据包的 IP 头部并将其发送给 TCP 层。
向下层一样,TCP 层会通过检查 TCP 校验和来验证数据包的有效性。如前面提到的,由于当前的网络栈使用的是无负载(offload)校验和,因此 TCP 的校验和由 NIC 完成计算,而非内核。
然后开始搜索数据包关联的 TCP 控制块(TCB)。这时,<source IP, source port, target IP, target port>
会作为数据包的标示符。在搜索连接之后,它会执行协议来处理数据包。如果接收到的是新的数据包,会将其数据添加到 socket 接收缓冲区。根据 TCP 的状态,它可以发送一个新的 TCP 数据包,比如一个 ACK 数据包。现在,TCP/IP 对数据包的接收已经处理完成。
Socket 接收缓冲区的大小是 TCP 的接收窗口大小。确定的一点是,当接收窗口很大的时候 TCP 的吞吐会随之增长。在过去,socket 的缓冲区的大小会由应用或 OS 的配置来调整。最新的网络栈会拥有自动调整 socket 接收缓冲区大小的功能,比如调整接收窗口。
当应用调用了系统读(system read)调用,空间会被切换到内核空间,socket 缓冲区中的数据则会被复制到用户空间的内存。然后被复制过的数据会被从 socket 缓冲区移除。然后 TCP 被调用。因为 socket 缓冲区中出现了新的空间,TCP 则会增长接收窗口的大小。然后根据协议的状态发送一个数据包。如果没有需要传输的数据包,系统调用则会被终止。
网络栈开发指南
目前已经介绍的网络栈层次的功能都是非常基础的功能。上世纪 90 年代初的网络栈功能并没有比上面所介绍的功能多。然而,最新的网络栈则拥有更多的功能,因为网络栈的实现也变得更加高级,所以也更加复杂。
最新的网络栈按用途分类如下。
数据包处理步骤控制(Manipulation)
这是一个类似网络过滤器(NetFilter, NAT, 防火墙)或流量控制的功能。通过在基本的处理流程中插入用户可控的代码,基于用户的配置,该功能能够完成不同的工作。
协议性能
其目的在于提升 TCP 协议在给定网络环境下的吞吐量、延迟和稳定性。通过一些拥堵控制算法和额外的 TCP 功能来实现,比如经典的 SACK。协议的提升在这里不会做过多讨论,因为已经超出了本文的范围。
数据包处理效率
数据包处理效率的目的在于,通过减少系统处理数据包时的 CPU 周期、内存使用、内存访问,来提高每秒能够处理的数据包的最大数量。已经有多种尝试来减少系统中的延迟。这些尝试包括栈并行处理、头部预测、zero-copy、single-copy、无负载校验和、TSO、LRO、RSS 等等。
网络栈中的控制流程
现在让我们更加详细的看一下 Linux 网络栈的内部流程。网络栈更像是一个子系统,一个网络栈根本上来说是以时间驱动的方式运行,并对发生的事件做出相应。因此,并没有单独的线程来执行网络栈。上面在对网络栈层次的讨论中展示了其简化版的流程,下图中阐述了更加准确的控制流程。
Flow(1),一个应用调用了系统调用来执行(使用) TCP。比如,调用了系统读或系统写,然后执行 TCP。然而,这里并没有数据包传输。
Flow(2) 与 Flow(1)类似,但它在执行 TCP 之后需要对数据包进行传输。它会创建一个数据包并将其向下发送给驱动设备。驱动设备之前会有一个队列。数据包首先会进入到队列,然后队列的实现结构决定了将数据包发送给驱动设备的时机。这便是 Linux 的排队机制(qdisc)。Linux 的流量控制功能便是对 qdisc 的控制。默认的 qdisc 是一个简单的先进先出(FIFO)队列。通过使用一个另外的 qdisc,操作者可以实现多种效果,比如人造丢包、包延迟、传输速度控制等等。在 Flow(1) 和 Flow(2) 中,应用的处理线程同样会用来执行驱动设备。
Flow(3) 展示了 TCP 所使用的计时器(timer)过期的场景。比如,当 TIME_WAIT 计时器过期时,TCP 会被调用以删除连接。
Flow(4) 与 Flow(3) 类似,即 TCP 使用的计时器过期,且 TCP 执行结果的数据包需要被传输。比如,当重复计时器(retransmit timer)过期时,为得到 ACK 的数据包将会被重新传输。
Flow(3) 和 Flow(4) 展示了执行计时器软中断请求(softiq)的步骤,它处理了计时器中断。
当 NIC 驱动设备接收到一个中断,它会释放已传输的数据包。大多数情况下,驱动设备的执行会在这里终止。Flow(5) 展示的是数据包在传输队列中的累积。驱动设备会请求软中断(softiq),软中断处理器会执行传输队列来将累积的数据包发送给驱动设备。
当 NIC 驱动设备收到了一个中断并发现了一个新接收到的数据包,它会请求软中断。软中断会调用驱动是被来处理数据包并将其发送给上层。在 Linux 中,向上面展示的对接收到的数据包的处理被称为 New API(NAPI)。这个过程类似于轮询,因为驱动设备并未直接将数据包发送给上层,但是上层会直接得到该数据报。这里实际的代码会被称为 NAPI poll 或 poll。
Flow(6) 展示了 TCP 的执行完成,而 Flow(7) 展示了仍需要处理额外的数据包。Flow(5,6,7) 都是由处理了 NIC 中断的软中断请求执行。
如何处理中断、接收数据包
中断的处理是相当复杂的;然而,你需要了解与数据包接收处理相关的性能问题。下图展示了中断的处理步骤:
假如 CPU 0 正在处理一个应用程序(用户程序)。这时,NIC 收到一个数据包并为 CPU 0 生成一个中断。然后 CPU 执行了内核的中断处理器(irq)。这个处理器会引用该中断的序号,然后调用驱动设备的中断处理器。驱动设备首先会释放掉已经传输过的数据包,然后调用napi_schedule()
来处理接收到数据包。该函数会请求 softirq(软中断)。
在驱动设备的中断处理器的执行终止后,控制权会传递给内核处理器。内核处理器会为 softirq 执行中断处理器。
在中断上下文被处理之后,softirq(软中断)上线文会被执行。中断上下文与软中断上下文会被同一个线程处理,但是,两个上下文使用不同的栈。同时,中断上下文会阻塞硬件中断,但软中断上下文则允许硬件中断。
处理接收到的数据包的软终端处理器为 net_rx_action()
函数。该函数会对驱动设备调用 poll()
函数。然后 poll()
函数会调用 netif_receive_skb()
函数将接收到的数据包一个接一个的发送给上层。在处理完软中断之后,应用会中停止点重启执行,以便能够请求一个系统调用。
然而,接收到中断的 CPU 会从头到尾的处理接收到的数据。在 Linux、BSD、微软中的处理步骤基本如此。
如果你检查服务器的 CPU 利用率,有时你会发现服务器的众多 CPU 中仅有一个 CPU 在艰难的处理软中断。这种现象的发生则是因为目前我们已经解释过的对接收到的数据包的处理方式。为了解决这个问题,出现了多队列 NIC、RSS、RPS。
数据结构
下面是一些关键的数据结构。
sk_buff 结构
首先是表述数据包的 sk_buff 或 skb。下图展示了 sk_buff 的一部分结构。由于函数已经发生了进化因此变得更加复杂。然而其基本功能则十分简单,任何人都能理解。
包数据与元数据
该结构直接包含了包数据或者通过一个指针来引用包数据。上图中,一些(由以太网至缓冲区)数据包通过数据指针引用,一些额外的数据(frags)则引用了实际的数据页(page)。
一些必要的信息比如头部、荷载长度则被保存在元数据区。比如上图中,mac_header、network_header、transport_header 拥有相同的指针数据,并分别指向以太网头、IP 头、TCP 头的起始位置。这种方式使得 TCP 协议的处理变得简单。
如何添加或删除头部
头部在经过各个网络栈的层时会被添加或删除。指针的使用则为了更加高效的处理。比如,想要移除以太网头部,仅需要递增对应的头部指针。
如何合并或拆分数据包
链接列表用于高效的处理类似从 socket 缓冲区、数据包链中添加或删除数据包的荷载数据这样的任务。next 指针、prev 指针则用于此目的。
快速分配与释放
因为数据包一旦创建就需要分配该结构,所以使用了快速分配器(allocator)。比如,如果数据在 10GB 带宽的以太网中传输,每秒则会有多余 1 百万的数据包被创建和删除。
TCP 控制块(TCB)
然后,是一个用于表示 TCP 连接的结构,前面我们笼统的称之为 TCB。Linux 使用 tcp_sock 来表示该结构。在下图中,你可以看到文件、socket、tcp_sock 之间的关系。
当触发了一个系统调用时,它会在文件描述符中搜索该应用使用的触发了该调用的文件。对于 Unix 系列的 OS 来说,文件以及用于通用文件系统存储的驱动设备均被抽象为文件。因此,该结构仅包含了最少的信息。对于一个 socket,一个单独的 socket 结构保存了 socket 相关的信息,然后文件以指针的形式引用该 socket。然后 socket 又以同样的方式引用了 tcp_sock。tcp_sock 又被划分为 into_sock、inet_sock 等等,以支持不同类型的特定 TCP 协议。可以被看做是一种多态。
所有被 TCP 协议使用的状态信息均被保存在 tcp_sock 中。比如,序列号、接收窗口、阻塞控制、重发计时器。
Socket 发送缓存区、socket接收缓冲区实际上是 sk_buff 列表,其中包含的是 tcp_sock。同时引用了 dst_entry、IP 路由结构,以避免过度频繁的路由。dst_entry 支持对 ARP 结构的简单搜索,比如目的地的 MAC 地址。dst_entry 是路由表的一部分。而路由表的结构则太过复杂了,本文不再讨论。用于数据包传输的 NIC 则通过 dst_entry 进行搜索。而 NIC 则被表示为 net_device 结构。
因此通过搜索文件,可以使用指针非常容易的找到用于处理 TCP 连接所必须的所有结构(从文件到驱动设备)。结构的大小则是 TCP 连接所使用的内存大小,仅占用很少的几个 KB(包含包数据)。随着功能的增加,内存占用也会逐渐增加。
最后,让我们看一下 TCP 连接的查找表。这是一个用于搜索数据包所归属的 TCP 连接的哈希表。哈希值使用数据包的 <source IP, target IP, source port, target port>
作为输入,基于 Jenkins 哈希算法来计算。据说哈希算法的选择是基于对防御哈希表攻击的考虑。
跟随代码:如何传输数据
我们将通过跟随实际的 Linux 内核源码来检查网络栈中所执行的关键任务。这里我们将遵循两个常用的路径。
首先,这个路径用于在应用调用一个系统写调用时传输数据。
SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf, ...)
{
struct file *file;
[...]
file = fget_light(fd, &fput_needed);
[...] ===>
ret = filp->f_op->aio_write(&kiocb, &iov, 1, kiocb.ki_pos);
struct file_operations {
[...]
ssize_t (*aio_read) (struct kiocb *, const struct iovec *, ...)
ssize_t (*aio_write) (struct kiocb *, const struct iovec *, ...)
[...]
};
static const struct file_operations socket_file_ops = {
[...]
.aio_read = sock_aio_read,
.aio_write = sock_aio_write,
[...]
};
当系统调用系统写时,内核会执行文件层的 write()
函数。首先,文件描述符 fd 的实际文件结构会被取到。然后 aio_wirte 被调用。这是一个函数指针。在文件结构中,你会看到 file_operattions 结构体。该结构通常被称为函数表,包含了 aio_read 和 aio_write 这样的函数指针。socket 的实际函数表是 socket_file_ops。socket 使用的 aio_write 函数实际是 sock_aio_write。函数表的目的与 Java 中的接口类似。它被普遍的用于内核对代码的抽象或重构。
static ssize_t sock_aio_write(struct kiocb *iocb, const struct iovec *iov, ..)
{
[...]
struct socket *sock = file->private_data;
[...] ===>
return sock->ops->sendmsg(iocb, sock, msg, size);
struct socket {
[...]
struct file *file;
struct sock *sk;
const struct proto_ops *ops;
};
const struct proto_ops inet_stream_ops = {
.family = PF_INET,
[...]
.connect = inet_stream_connect,
.accept = inet_accept,
.listen = inet_listen, .sendmsg = tcp_sendmsg,
.recvmsg = inet_recvmsg,
[...]
};
struct proto_ops {
[...]
int (*connect) (struct socket *sock, ...)
int (*accept) (struct socket *sock, ...)
int (*listen) (struct socket *sock, int len);
int (*sendmsg) (struct kiocb *iocb, struct socket *sock, ...)
int (*recvmsg) (struct kiocb *iocb, struct socket *sock, ...)
[...]
};
socket_aio_write()
函数重文件得到 socket 结构体并调用 sendmsg。它同样是一个函数指针。socket 结构体包含了 proto_ops 函数表。IPV4 TCP 对 proto_ops 的实现是 inet_stream_ops,对 sendmsg 函数的实现是 tcp_sendmsg。
int tcp_sendmsg(struct kiocb *iocb, struct socket *sock,
struct msghdr *msg, size_t size)
{
struct sock *sk = sock->sk;
struct iovec *iov;
struct tcp_sock *tp = tcp_sk(sk);
struct sk_buff *skb;
[...]
mss_now = tcp_send_mss(sk, &size_goal, flags);
/* Ok commence sending. */
iovlen = msg->msg_iovlen;
iov = msg->msg_iov;
copied = 0;
[...]
while (--iovlen >= 0) {
int seglen = iov->iov_len;
unsigned char __user *from = iov->iov_base;
iov++;
while (seglen > 0) {
int copy = 0;
int max = size_goal;
[...]
skb = sk_stream_alloc_skb(sk,
select_size(sk, sg),
sk->sk_allocation);
if (!skb)
goto wait_for_memory;
/*
* Check whether we can use HW checksum.
*/
if (sk->sk_route_caps & NETIF_F_ALL_CSUM)
skb->ip_summed = CHECKSUM_PARTIAL;
[...]
skb_entail(sk, skb);
[...]
/* Where to copy to? */
if (skb_tailroom(skb) > 0) {
/* We have some space in skb head. Superb! */
if (copy > skb_tailroom(skb))
copy = skb_tailroom(skb);
if ((err = skb_add_data(skb, from, copy)) != 0)
goto do_fault;
[...]
if (copied)
tcp_push(sk, flags, mss_now, tp->nonagle);
[...]
}
tcp_sendmsg 从 socket 得到 tcp_sock(i.e. TCB),并将应用请求用来传输的数据复制到 socket 发送缓冲区。当把数据复制到 sk_buff 时,一个 sk_buff 又会包含多少字节呢?一个 sk_buff 复制并包含 MSS(tcp_send_mss) 个字节以帮助实际创建数据包的代码。Maximum Segment Size(MSS) 实际表示了一个 TCP 包能够包含的最大荷载大小。通过使用 TSO 和 GSO,sk_buff 能够保存比 MSS 更多的数据。这些将会在后续详细讨论,而非在本文。
sk_stream_alloc_skb 函数创建了一个新的 sk_buff, skb_entail 将新创建的 sk_buff 添加到 send_socket_buffer 的头部。skb_add_data 函数会将实际的应用数据复制到 sk_buff 的数据缓冲区。通过几次这样的重复(创建 skb_buff 并添加到 socket 发送缓冲区),所有数据都会被复制。因此,以 MSS 为大小的 sk_buffs 会以列表的形式保存在 socket 发送缓冲区中。最终,tcp_push 被调用以使得这些数据能够以一个数据包的形式被传输,然后数据包被发送。
static inline void tcp_push(struct sock *sk, int flags, int mss_now, ...)
[...] ===>
static int tcp_write_xmit(struct sock *sk, unsigned int mss_now, ...)
int nonagle,
{
struct tcp_sock *tp = tcp_sk(sk);
struct sk_buff *skb;
[...]
while ((skb = tcp_send_head(sk))) {
[...]
cwnd_quota = tcp_cwnd_test(tp, skb);
if (!cwnd_quota)
break;
if (unlikely(!tcp_snd_wnd_test(tp, skb, mss_now)))
break;
[...]
if (unlikely(tcp_transmit_skb(sk, skb, 1, gfp)))
break;
/* Advance the send_head. This one is sent out.
* This call will increment packets_out.
*/
tcp_event_new_data_sent(sk, skb);
[...]
tcp_push 函数会基于 TCP 允许的范围尽可能多的传输 socket 发送缓冲区中的 sk_buffs。首先,tcp_send_head 被调用以得到 socket 发送缓冲区中的第一个 sk_buff,然后执行 tcp_cwnd_test 和 tcp_snd_wnd_test 来检查正在接收的 TCP 的阻塞窗口和接收窗口是否允许新数据包的传输。然后,tcp_transmit_skb 函数被调用以创建一个数据包。
static int tcp_transmit_skb(struct sock *sk, struct sk_buff *skb,
int clone_it, gfp_t gfp_mask)
{
const struct inet_connection_sock *icsk = inet_csk(sk);
struct inet_sock *inet;
struct tcp_sock *tp;
[...]
if (likely(clone_it)) {
if (unlikely(skb_cloned(skb)))
skb = pskb_copy(skb, gfp_mask);
else
skb = skb_clone(skb, gfp_mask);
if (unlikely(!skb))
return -ENOBUFS;
}
[...]
skb_push(skb, tcp_header_size);
skb_reset_transport_header(skb);
skb_set_owner_w(skb, sk);
/* Build TCP header and checksum it. */
th = tcp_hdr(skb);
th->source = inet->inet_sport;
th->dest = inet->inet_dport;
th->seq = htonl(tcb->seq);
th->ack_seq = htonl(tp->rcv_nxt);
[...]
icsk->icsk_af_ops->send_check(sk, skb);
[...]
err = icsk->icsk_af_ops->queue_xmit(skb);
if (likely(err <= 0))
return err;
tcp_enter_cwr(sk, 1);
return net_xmit_eval(err);
}
tcp_transmit_skb 创建了对应 sk_buff 的副本(pskb_copy)。这次,并未复制应用的完整数据,而仅仅是元数据。然后调用 skb_push 来保护头部数据空间并记录头部数据值。send_check 计算了 TCP 的校验和。基于无负载(offload)校验和,荷载数据并未被计算。最终,queue_xmit 被调用以将数据包发送给 IP 层。IPV4 的 queue_xmit 由 ip_queue_xmit 函数实现。
int ip_queue_xmit(struct sk_buff *skb)
[...]
rt = (struct rtable *)__sk_dst_check(sk, 0);
[...]
/* OK, we know where to send it, allocate and build IP header. */
skb_push(skb, sizeof(struct iphdr) + (opt ? opt->optlen : 0));
skb_reset_network_header(skb);
iph = ip_hdr(skb);
*((__be16 *)iph) = htons((4 << 12) | (5 << 8) | (inet->tos & 0xff));
if (ip_dont_fragment(sk, &rt->dst) && !skb->local_df)
iph->frag_off = htons(IP_DF);
else
iph->frag_off = 0;
iph->ttl = ip_select_ttl(inet, &rt->dst);
iph->protocol = sk->sk_protocol;
iph->saddr = rt->rt_src;
iph->daddr = rt->rt_dst;
[...]
res = ip_local_out(skb);
[...] ===>
int __ip_local_out(struct sk_buff *skb)
[...]
ip_send_check(iph);
return nf_hook(NFPROTO_IPV4, NF_INET_LOCAL_OUT, skb, NULL,
skb_dst(skb)->dev, dst_output);
[...] ===>
int ip_output(struct sk_buff *skb)
{
struct net_device *dev = skb_dst(skb)->dev;
[...]
skb->dev = dev;
skb->protocol = htons(ETH_P_IP);
return NF_HOOK_COND(NFPROTO_IPV4, NF_INET_POST_ROUTING, skb, NULL, dev,
ip_finish_output,
[...] ===>
static int ip_finish_output(struct sk_buff *skb)
[...]
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_queue_xmit 函数会执行一些 IP 层要求的函数。__sk_dst_check 检查被缓存的路由是否有效。如果没有缓存的路由或者缓存的路由无效,它会执行 IP 路由。然后调用 skb_push 来保护 IP 头部空间并记录 IP 头部字段值。之后,随着函数的调用,ip_send_check 会计算 IP 头部的校验和并调用网络过滤器(netfilter)函数。如果 ip_finish_output 函数需要 IP 分片则会创建 IP 片段。当使用 TCP 时并不需要 IP 分片。因此,ip_finish_output2 会被调用,它添加了以太网(Ethernet)头部。最终,一个数据包完成。
int dev_queue_xmit(struct sk_buff *skb)
[...] ===>
static inline int __dev_xmit_skb(struct sk_buff *skb, struct Qdisc *q, ...)
[...]
if (...) {
....
} else
if ((q->flags & TCQ_F_CAN_BYPASS) && !qdisc_qlen(q) &&
qdisc_run_begin(q)) {
[...]
if (sch_direct_xmit(skb, q, dev, txq, root_lock)) {
[...] ===>
int sch_direct_xmit(struct sk_buff *skb, struct Qdisc *q, ...)
[...]
HARD_TX_LOCK(dev, txq, smp_processor_id());
if (!netif_tx_queue_frozen_or_stopped(txq))
ret = dev_hard_start_xmit(skb, dev, txq);
HARD_TX_UNLOCK(dev, txq);
[...]
}
int dev_hard_start_xmit(struct sk_buff *skb, struct net_device *dev, ...)
[...]
if (!list_empty(&ptype_all))
dev_queue_xmit_nit(skb, dev);
[...]
rc = ops->ndo_start_xmit(skb, dev);
[...]
}
已完成的数据包由 dev_queue_xmit 函数来传输。首先,数据包通过 qdisc(队列规则) 来传递。如果使用的是默认的 qdisc 且队列为空,sch_direct_xmit 函数被调用以将数据包直接下发给驱动设备,从而跳过队列。dev_hard_start_xmit 函数会调用实际的驱动设备。在调用驱动设备之前,首先对驱动设备的 TX 加锁。这么做是为了避免多个线程对驱动设备的同时访问。由于内核锁住了驱动设备的 TX,驱动设备的传输代码则无需再进行加锁。这些与并行编程紧密相关,我们下次再进行讨论。
ndo_start_xmit 函数调用了驱动设备代码。就在之前,你会看到 ptype_all 和 dev_queue_xmit_nit。ptype_all 是一个包含了一些模块的列表,比如数据包捕获。如果捕获程序正在运行,数据包则会被 ptype_all 复制到另外的程序。因此,tcpdump 所展示的数据包正式要传输给驱动设备的数据包。如果使用了无负载校验和或 TSO,NIC 会篡改数据包。因此 tcpdump 得到的数据包会与传输到网线上的数据包有所不同。在完成数据包的传输之后,驱动设备中断处理器会返回 sk_buff。
跟随代码:如何接收数据
大体的执行路线为接收一个数据包,然后将数据添加到 socket 接收缓冲区。在执行完驱动设备处理器之后,紧接着会首先执行 napi 拉取处理。
static void net_rx_action(struct softirq_action *h)
{
struct softnet_data *sd = &__get_cpu_var(softnet_data);
unsigned long time_limit = jiffies + 2;
int budget = netdev_budget;
void *have;
local_irq_disable();
while (!list_empty(&sd->poll_list)) {
struct napi_struct *n;
[...]
n = list_first_entry(&sd->poll_list, struct napi_struct,
poll_list);
if (test_bit(NAPI_STATE_SCHED, &n->state)) {
work = n->poll(n, weight);
trace_napi_poll(n);
}
[...]
}
int netif_receive_skb(struct sk_buff *skb)
[...] ===>
static int __netif_receive_skb(struct sk_buff *skb)
{
struct packet_type *ptype, *pt_prev;
[...]
__be16 type;
[...]
list_for_each_entry_rcu(ptype, &ptype_all, list) {
if (!ptype->dev || ptype->dev == skb->dev) {
if (pt_prev)
ret = deliver_skb(skb, pt_prev, orig_dev);
pt_prev = ptype;
}
}
[...]
type = skb->protocol;
list_for_each_entry_rcu(ptype,
&ptype_base[ntohs(type) & PTYPE_HASH_MASK], list) {
if (ptype->type == type &&
(ptype->dev == null_or_dev || ptype->dev == skb->dev ||
ptype->dev == orig_dev)) {
if (pt_prev)
ret = deliver_skb(skb, pt_prev, orig_dev);
pt_prev = ptype;
}
}
if (pt_prev) {
ret = pt_prev->func(skb, skb->dev, pt_prev, orig_dev);
static struct packet_type ip_packet_type __read_mostly = {
.type = cpu_to_be16(ETH_P_IP),
.func = ip_rcv,
[...]
};
前面已经提到过,net_rx_action 是接收数据包的软中断处理器。首先,请求过 napi 拉取的驱动设备从 poll_list 中被找到,然后驱动设备的拉取处理器被调用。驱动设备使用 sk_buff 包装接收到的数据包,然后调用 netif_receive_skb。
如果有一个请求所有数据包的模块,netif_receive_skb 会将所有的数据包发送给该模块。像数据包传输中一样,数据包会被传输给注册到 ptype_all 列表的模块。数据包在这里被捕获。
然后,会基于数据包的类型将其传输给上层。以太网(Ethernet)数据包的头部中拥用 2-byte 的以太网类型字段,其值表示了数据包的类型。驱动设备会将该值记录到 sk_buff(sk->protocol) 中。每种协议都拥有各自的 packet_type 结构体,并将该结构体的指针注册到名为 ptype_base 的哈希表。IPV4 使用 ip_packet_type,其 Type 字段的值是 IPV4 以太网类型(ETH_P_IP)。因此,IPV4 数据包会调用 ip_rcv 函数。
int ip_rcv(struct sk_buff *skb, struct net_device *dev, ...)
{
struct iphdr *iph;
u32 len;
[...]
iph = ip_hdr(skb);
[...]
if (iph->ihl < 5 || iph->version != 4)
goto inhdr_error;
if (!pskb_may_pull(skb, iph->ihl*4))
goto inhdr_error;
iph = ip_hdr(skb);
if (unlikely(ip_fast_csum((u8 *)iph, iph->ihl)))
goto inhdr_error;
len = ntohs(iph->tot_len);
if (skb->len < len) {
IP_INC_STATS_BH(dev_net(dev), IPSTATS_MIB_INTRUNCATEDPKTS);
goto drop;
} else if (len < (iph->ihl*4))
goto inhdr_error;
[...]
return NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING, skb, dev, NULL,
ip_rcv_finish);
[...] ===>
int ip_local_deliver(struct sk_buff *skb)
[...]
if (ip_hdr(skb)->frag_off & htons(IP_MF | IP_OFFSET)) {
if (ip_defrag(skb, IP_DEFRAG_LOCAL_DELIVER))
return 0;
}
return NF_HOOK(NFPROTO_IPV4, NF_INET_LOCAL_IN, skb, skb->dev, NULL,
ip_local_deliver_finish);
[...] ===>
static int ip_local_deliver_finish(struct sk_buff *skb)
[...]
__skb_pull(skb, ip_hdrlen(skb));
[...]
int protocol = ip_hdr(skb)->protocol;
int hash, raw;
const struct net_protocol *ipprot;
[...]
hash = protocol & (MAX_INET_PROTOS - 1);
ipprot = rcu_dereference(inet_protos[hash]);
if (ipprot != NULL) {
[...]
ret = ipprot->handler(skb);
[...] ===>
static const struct net_protocol tcp_protocol = {
.handler = tcp_v4_rcv,
[...]
};
ip_rcv 函数会执行 IP 层所必须的一些任务。它会检验数据包,比如长度和头部校验和。在流经网络过滤器码时,它会执行 ip_local_deliver 函数。如有必要,它会装配 IP 分段。然后,通过网络过滤器码调用 ip_local_deliver_finish。ip_local_deliver_finish 函数会通过 __skb_pull 移除掉 IP 头部,然后搜索协议值与 IP 头部的协议值一样的上层协议。类似于 ptype_base,每个传输协议都会将其各自的 net_protocol 结构体注册到 inet_protos。IPV4 TCP 使用 tcp_protocol 并调用注册为一个处理器的 tcp_v4_rcv。
当数据包来到 TCP 层,数据包处理流程会基于 TCP 状态和数据包类型变化。这里,我们将会看到这样的数据包处理步骤:预期的下一个数据包已经以 ESTABLISHED 的 TCP 连接状态被接收到。当没有丢失的或乱序抵达的数据包时,接收数据的服务端将会频繁执行这样的路线。
int tcp_v4_rcv(struct sk_buff *skb)
{
const struct iphdr *iph;
struct tcphdr *th;
struct sock *sk;
[...]
th = tcp_hdr(skb);
if (th->doff < sizeof(struct tcphdr) / 4)
goto bad_packet;
if (!pskb_may_pull(skb, th->doff * 4))
goto discard_it;
[...]
th = tcp_hdr(skb);
iph = ip_hdr(skb);
TCP_SKB_CB(skb)->seq = ntohl(th->seq);
TCP_SKB_CB(skb)->end_seq = (TCP_SKB_CB(skb)->seq + th->syn + th->fin +
skb->len - th->doff * 4);
TCP_SKB_CB(skb)->ack_seq = ntohl(th->ack_seq);
TCP_SKB_CB(skb)->when = 0;
TCP_SKB_CB(skb)->flags = iph->tos;
TCP_SKB_CB(skb)->sacked = 0;
sk = __inet_lookup_skb(&tcp_hashinfo, skb, th->source, th->dest);
[...]
ret = tcp_v4_do_rcv(sk, skb);
首先,tcp_v4_rcv 函数首先会验证接收到的数据包。如果头部大小大于数据偏移,即 th->doff < sizeof(struct tcphdr) / 4
,则出现头部错误。然后,__inet_lookup_skb 会从 TCP 连接哈希表中查找数据包对应的 TCP 连接。基于找到的 sock 结构体,所有必要的结构体和 sock 都会被找到,比如 tcp_sock。
int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb)
[...]
if (sk->sk_state == TCP_ESTABLISHED) { /* Fast path */
sock_rps_save_rxhash(sk, skb->rxhash);
if (tcp_rcv_established(sk, skb, tcp_hdr(skb), skb->len)) {
[...] ===>
int tcp_rcv_established(struct sock *sk, struct sk_buff *skb,
[...]
/*
* Header prediction.
*/
if ((tcp_flag_word(th) & TCP_HP_BITS) == tp->pred_flags &&
TCP_SKB_CB(skb)->seq == tp->rcv_nxt &&
!after(TCP_SKB_CB(skb)->ack_seq, tp->snd_nxt))) {
[...]
if ((int)skb->truesize > sk->sk_forward_alloc)
goto step5;
NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_TCPHPHITS);
/* Bulk data transfer: receiver */
__skb_pull(skb, tcp_header_len);
__skb_queue_tail(&sk->sk_receive_queue, skb);
skb_set_owner_r(skb, sk);
tp->rcv_nxt = TCP_SKB_CB(skb)->end_seq;
[...]
if (!copied_early || tp->rcv_nxt != tp->rcv_wup)
__tcp_ack_snd_check(sk, 0);
[...]
step5:
if (th->ack && tcp_ack(sk, skb, FLAG_SLOWPATH) < 0)
goto discard;
tcp_rcv_rtt_measure_ts(sk, skb);
/* Process urgent data. */
tcp_urg(sk, skb, th);
/* step 7: process the segment text */
tcp_data_queue(sk, skb);
tcp_data_snd_check(sk);
tcp_ack_snd_check(sk);
return 0;
[...]
}
实际的协议会从 tcp_v4_do_rcv 函数执行。如果 TCP 处于 ESTABLISHED 状态,则调用 tcp_rcv_esablished 函数。ESTABLISHED 状态会被单独的处理和优化,因为这是最常见的状态。tcp_rcv_esablished 函数首先会执行头部预测代码,在通常场景中,头部预测也会被快速地执行以进行检测。这里的通常场景指的是没有需要传输的数据,而已接收到的数据包其实是需要下次被接收的数据包,比如,序列号是正在接收的 TCP 所预期的序列号。这时,通过将数据添加到 socket 缓冲区并发送 ACK 类完成该步骤。
接着你会看到实际大小(truesize)与 sk_forward_alloc 的比较语句,用来检查 socket 接收缓冲区中是否有自由空间来添加新的数据包数据。如果有,则“命中”头部预测(预测成功)。然后调用 __skb_pull 来移除 TCP 头部。之后,调用 __skb_queue_tail 将数据包添加到 socket 接收缓冲区。最终,__tcp_ack_snd_check 会被调用以发送 ACK,如果需要的话。通过以上方式,数据包的处理完成。
如果没有足够的自由空间,会执行一个较慢的路线。tcp_data_queue 会重新申请缓冲空间并将数据包添加到 socket 缓冲区。这时,如果可能的话,socket 接收缓冲区大大小会被自动增长。与快速路线不同的是,如果可能的话,tcp_data_snd_check 会被调用以传输一个新的数据包。最终,__tcp_ack_snd_check 会被调用以发送 ACK,如果需要的话。
这两种执行路线的代码规模并不大,这是基于对常见场景的优化实现的。换句话说,这意味着不常见的场景的处理会显著变慢。乱系抵达就是非常见场景的一种。
驱动设备与 NIC 之间如何通信
驱动设备与 NIC 之间的通信是网络栈的底层部分,多数人并不关心这一块。然而,NIC 正在执行越来越多的任务以解决性能问题。理解基本的操作模式将有助于你理解额外的技术。
驱动设备与 NIC 之间进行的是 异步 通信。首先,驱动设备会请求一个数据包传输调用,然后 CPU 并不等待响应,而是执行另一个任务。然后,NIC 发送数据包并提醒 CPU,驱动设备返回接收到的数据包结果。数据包接收与传输一样,也是异步的。首先,驱动设备数据包接收调用,CPU 则会去指定别的任务。然后,NIC 接收数据包并提醒 CPU,驱动设备会处理接收到的数据包并返回结果。
因此,需要一个地方来保存请求和响应。大多数情况下,NIC 会使用 环(ring) 结构体,ring 类似于普通的队列结构,带有固定的条目数量,一个条目保存一个请求或响应数据。这些条目会按顺序依次被使用。固定的数量以及按顺序的复用,因此称此结构为 ring。
下图展示了数据包的传输步骤,你可以看到 ring 是如何被使用的。
驱动设备从上层接收数据包并穿件 NIC 能够理解的发送描述符。发送描述不默认会包含数据包大小及其内存地址。因为 NIC 需要物理地址来访问内存,驱动设备需要将虚拟地址转换为物理地址。然后,驱动设备将发送描述符添加到 TX ring(1)。TX ring 是一个发送描述符 ring。
接着,驱动设备会通知 NIC 新的请求(2)。驱动设备直接将注册数据写入到指定的 NIC 内存地址。这样,程序化(programmed) IO(PIO) 则成为 CPU 直接将数据发送给驱动设备的传输方法。
被通知的 NIC 从主机内存中获得 TX ring 的发送描述符(3)。由于驱动设备直接访问内存而无需 CPU 的干预,这种访问被称为 Direct Memory Acess(DMA)。
在得到发送描述符之后,NIC 会检测数据包地址、大小,然后从主机内存中得到实际额的数据包(4)。基于无负载校验和(offload checksum),NIC 会在从内存得到数据包时计算校验和。因此,很少会出现开销。
NIC 发送数据(5) 并将发送的数据包的需要写入到内存(6)。然后,它会发送一个中断(7)。驱动设备读取所发送的数据包的序号,然后返回到目前为止已发送的数据包。
下图展示了接收数据包的步骤。
首先,驱动设备会分配主机内存缓冲以接收数据包,然后创建一个接收描述符。接收描述符默认会包含缓存大小及内存地址。像发送描述符一样,它会在接收描述符中保存 DMA 需要使用的物理地址。然后,将接收描述符添加到 TX ring(1)。描述符作为一个接收请求,RX ring 作为一个接收请求 ring。
通过 PIO,驱动设备会通知 NIC 有一个新的描述符(2)。NIC 从 RX ring 中得到新的描述符,并将描述符中的缓冲带下、位置保存到 NIC 内存(3)。
在数据被接收到之后(4),NIC 会将接收到的数据包发送给主机内存缓冲区(5)。如果存在无负载校验和函数,NIC 会在此刻计算校验和。所接收数据包的实际大小、校验和结果以及其他一些信息会被保存到另一个单独的 ring(6),即“接收返回 ring”。接收返回 ring 中包含了接收请求处理的结果,比如响应。然后 NIC 发送一个中断(7)。驱动设备从接收返回 ring 中获得数据包信息并处理接收到的数据包。如有必要,它会分配新的内存并重复步骤 (1)、(2)。
为了调优网络栈,很多人指出对 ring 和中断的设置进行调整。当 TX ring 很大时,大量发送请求可以被一次处理完成。当 RX ring 很大时,大量数据包的接收可以被一次处理完成。对于带有较大爆发的数据包传输、接收来说,一个大的 ring 将会有助于负载。在大多数情况下,NIC 会使用一个计时器来减少中断的数量,因为 CPU 可能需要遭受大量处理中断的开销。为了避免主机系统中大量中断的泛滥,中断可以被收集并在发送或接收数据包时定期发送(中断合并)。
栈缓冲区与流控
流控会在网络栈的多个阶段中执行,下图中展示了用于发送数据的缓冲区。
首先,一个应用创建数据并将其添加到 socket 发送缓冲区。如果缓冲区中没有自由空间,系统调用则会失败,或者应用线程会发生阻塞。因此,必须使用 socket 缓冲区大小限制来控制数据流入内核的速度。
TCP 创建并通过传输数据队列(qdisc) 将其发送到驱动设备。这是一个典型的 FIFO 队列类型,队列的最大长度是 txqueuelen 的值,该值可以通过 ifconfig 命令来检查。通常它可以承载数千个数据包。
TX ring 介于驱动设备和 NIC 之间。如前面提到的,它被认为是一个传输请求队列。如果队列中没有可用的自由空间,没有传输请求会被创建,数据包也会被累积到传输队列中。如果基类了太多数据包,多余的数据包则会被丢弃。
NIC 将需要发送的数据包保存到内部缓冲。来自该缓冲的数据包速度受物理速度的影响,比如 1 Gb/s 的 NIC 无法提供 10 GB/s 的性能。同时基于以太网的流控,如果 NIC 接收缓冲中没有可用的自由空间,数据包传输将会被停止。
下图展示了接收数据所流经的缓冲。数据包首先被保存到 NIC 的接收缓冲。基于流控的视角,驱动设备与 NIC 之间的 RX ring 被认为是一个数据包 缓冲。驱动设备从 RX ring 获取传入的数据包并将其发送给上层。由于被服务系统使用的 NIC 驱动设备默认使用 NAPI,驱动设备与上层之间没有缓冲。因此,可以认为是上层直接从 RX ring 直接获得数据包。荷载数据被保存的 socket 接收缓冲区。然后应用从 socket 接收缓冲区获取这些数据。
不支持 NAPI 的驱动设备会将数据包保存的后备(backlog)队列。然后,NAPI 来获取这些数据包。因此,后备队列可以被看做是上层和驱动设备间的缓冲。
如果内核对数据包的处理速度慢于 NIC 中数据包流的速度, RX ring 会变满。然后 NIC 的缓冲变满。当使用了以太网流控时,NIC 会发送一个请求给传输 NIC 以停止传输,或者丢去数据包。
在 socket 接收缓冲区中不会出现因为空间不足而丢弃数据包,因为 TCP 支持端到端的流控。对于大多数工作而言吞吐量是重中之重,提升 ring 和 socket 缓冲区大小将大有帮助。增加大小能够在快速传输或接收大量数据包时减少因为空间不足引起的错误。
总结
一开始,我计划仅向大家介绍有助于开发网络程序、执行性能测试、故障排除相关的内容。尽管我有初步计划,但本文件中的描述数量并不小。我希望本文能帮助您开发网络应用程序并监视他们的性能。TCP/IP协议本身很复杂,有很多例外。不过,您不必了解 OS 中 TCP/IP 相关的每一行代码以了解性能并分析这些现象。仅了解它的上下文就会对你很有帮助。
随着系统性能和 OS 网络栈实现的不断进化,最新的服务能够为任意程序提供 10-20 Gb/s 的 TCP 吞吐。这些时间以来,已经有太多性能相关的技术类型,比如 TSO, LRO, RSS, GSO, GRO, UFO, XPS, IOAT, DDIO, TOE,就像字母汤(alphabet soup),让我们变得困惑。
在下一篇文章中,我将从性能的角度解释网络堆栈,并讨论该技术的问题和影响。
Reference
5 - RFC-1180
1、介绍
本教程仅包含 TCP/IP 各个关键点的一个视图,因此它是 TCP/IP 技术的“骨架”。这里省略了其发展与资助历史、商业用例,以及与 ISO OSI 作为对比的前景。事实上,也省略了大量的技术信息。剩下的是在 TCP/IP 环境中工作的专业人员所必须理解的最小信息。这些专业人员包括系统管理员、系统程序员以及网络管理员。
本教程使用 Unix TCP/IP 环境作为示例,但是各个要点适用于所有 TCP/IP 实现。
注意,本备忘录的目的在于解释,而非定义。如果对协议的正确规范有任何疑问,请参考定义 RFC 的实际标准。
下一节是对 TCP/IP 的概览,接着是对各个组件的详细描述。
2、TCP/IP 概览
“TCP/IP”,这个通用的术语通常表示任何、所有与 TCP 和 IP 相关的特定协议。它可以包含其他协议、应用,甚至是网络媒介。协议相关的例子有:UDP、ARP、ICMP。应用相关的例子有:TELNET、FTP、rcp。一个更加准确的术语应该是“互联网(internet)技术”。一个使用互联网技术的网络则被称为“互联网”。
2.1、基本结构
想要理解该技术,你首先得理解下面的逻辑结构:
这是一个互联网上的计算机内部的分层协议的逻辑结构。每个使用拥有这样一个逻辑结构的互联网技术的计算机都能够进行通信。该逻辑结构用于判断处于互联网上的计算机的行为。这些方框表示数据通过计算机时的处理过程,连接方框的线段则表示数据的路径。处于底部的水平线表示以太网电缆;“o” 表示收发器(transceiver),“*” 表示 IP 地址;“@” 表示以太网地址。对该逻辑结构的理解是对互联网技术本质的理解;它将贯穿于整个教程。
2.2、术语
流经互联网的数据单元的名称,取决于它在协议栈中的位置。总的来说:如果它处于以太网(Ethernet),则被称为 “以太网帧(frame)”;如果它处于以太网驱动设备和 IP 模块之间,则被称为 “IP 数据包(packet)”;如果它处于 IP 模块和 UDP 模块之间,则被称为 “UDP 数据报文(datagram)”;如果它处于 IP 模块和 TCP 模块之间,则被称为 “TCP 段(segment)”,或者更通常的称为“传输消息”;如果它处于网络应用之中,则被称为 ”应用消息“。
这些定义是不完善的。实际的定义会在不同出版物之间发生变化。更加明确的定义可以在 “小节 1.3.3 RFC 1122” 中找到。
驱动设备(driver)是一种在网络接口硬件之间通信的软件。模块(module)是一种与驱动设备、网络应用、或者另一个模块进行通信的软件。
这些术语,驱动设备、模块、以太网帧、IP 数据包、UDP 数据报、TCP 消息、应用消息,会适当的使用于整个教程中。
2.3、数据流
让我们顺着数据在图一中所展示的、通过协议栈向下流动的方向。对于一个使用 TCP(Transmission Control Protocol) 的应用,数据会在应用和 TCP 模块之间传递。对于使用 UDP(User Datagram Protocl) 的应用,数据会在应用和 UDP 模块之间传递。FTP(File Transfer Protocol) 是一个使用 TCP 的典型应用。在本例中它的协议栈是 FTP/TCP/IP/ENET。SNMP(Simple Network Management Protocol) 是一个使用 UDP 的应用。它在本例中的协议栈是 SNMP/UDP/IP/ENET。
TCP 模块、UDP 模块以及以太网驱动设备,都是多对一的多路转接器(multiplexer)。作为多路转接器,它们会将多种输入转换为一种输出。它们同时又是一对多的反-多路转接器(de-multiplexer)。作为反-多路转接器,它们可以根据协议头部中的类型字段,将一种输入转换为多种输出。
如果一个以太网帧从网络向上来到以太网驱动设备,数据包可以被传递到上游的 ARP(Address Resolution Protocol) 模块或 IP(Internet Protocol) 模块。以太网帧中的类型字段的值会用于判断是将以太网帧传递给 ARP 还是 IP 模块。
如果一个 IP 数据包向上来到 IP 模块,数据单元会被传递到上游的 TCP 或 UDP,这取决于 IP 头部中协议字段的值。
如果一个 UDP 数据报向上来到 UDP 模块,应用消息会基于 UDP 头部中端口字段的值传递给上游的网络应用。如果 TCP 消息向上来到 TCP 模块,应用消息会基于 TCP 头部中端口字段的值传递给上游的网络应用。
向下游的多路转接会比较易于执行,因为从每个起始点开始仅有一个下游路径;每个协议模块会在数据单元中添加各自的头部信息,因此数据包能够在目的计算机中进行反多路转接。
通过 TCP 或 UDP 从应用中传出的数据会聚集到 IP 模块上,让后通过低层的网络接口驱动设备向下发送。
尽管互联网技术支持多种不同的网络媒体,但是本教程中的所有示例都会使用以太网(Ethernet),因为它是 IP 协议下使用的最普遍的物理网络。图一中的计算机拥有单个以太网连接。6 字节的以太网地址对于一个以太网中的每个接口来说是唯一的,并位于以太网驱动设备的低层接口。
该计算机(图一)同样拥有一个 4 字节的 IP 地址,该地址位于 IP 模块的低层接口。每个 IP 地址对于互联网来说必须是唯一的。
一个运行中的计算机总是知道它自己的 IP 地址和以太网地址。
2.4、两个网络接口
如果一个计算机被连接到两个单独的以太网,像下图(3)中一样:
请注意该计算机拥有两个以太网地址和 IP 地址。
从图中的结构可以发现,该计算机拥有两个物理网络接口,IP 模块同时作为多对多的多路转接器和多对多的反多路转接器。
它会在任意一个方向上执行多路转接以适应传入和传出的数据。一个伴随多于一个网络接口的 IP 模块比我们一开始的例子要复杂的多,它可以将数据转发到另一个网络。数据可以到达任意网络接口,也可以从任意其他的网络接口被发出。
将一个 IP 数据包发送到另外一个网络的过程被称为 IP 数据包的”转发“。一个专门用来转发 IP 数据包的计算机被称为 ”IP-路由器(router)“。
从图中你可以发现,被转发的 IP 数据包绝不会接触到 IP 路由器中的 TCP 或 UDP 模块。有些 IP 路由器的实现中甚至并不拥有 TCP 或 UDP 模块。
2.5、IP 创建了一个单一逻辑网络
IP 模块是互联网技术成功的核心。在消息向下流经协议栈时,每个模块都会添加各自的头部信息到消息中。当消息由协议栈向上传输到应用时,每个模块或驱动设备都会从消息中删除对应的头部信息。IP 头部中包含了 IP 地址,它从多个物理网络构建了一个单个逻辑网络。这种物理网络的互联也即名称的来源:Internet(因特网、互联网)。限制了 IP 数据包分组范围的一组相互关联的物理网络则称为”互联网“。
2.6、物理网络独立性
IP 对网络应用隐藏了底层的网络硬件。如果你发明了一个新的物理网络,你可以通过实现一个能够连接到互联网底层 IP 的驱动设备来将其投入使用。因此,网络应用能够保持不被硬件技术的改变所影响,从而保持完整。
2.7、互操作性
如果互联网之上的两个计算机能够通信,它们被称为可以”互操作“;如果一个互联网技术的实现很好,则被称为拥有”互操作性“。通用计算机用户受益于互联网的安装,因为计算机在市场上的互操作性。通常来说,如果你购买了一台计算机,它将会进行互操作。如果一台计算机不拥有互操作性,或者不能添加互操作性,它将在市场中占有一个狭小而特殊的地位。
2.8、概览之后
基于设置的背景,我们将回答以下问题:
- 当发出一个 IP 数据包时,目标以太网地址是如何确定的?
- 当发出一个 IP 数据包时,IP 模块如何知道使用多个低层网络接口中的哪一个?
- 一台计算机中的客户端如何连接到另一台计算机中的服务端?
- 为什么同时存在 TCP 和 UDP,而不是仅有一个或另一个?
- 有哪些可用的网络应用程序?
这些将会在补习完以外网之后进行解释。
3、以太网
本节是对以太网(Ethernet)技术的简要概览。
一个以太网帧包含了目的地址、来源地址、类型字段以及数据。
一个以太网地址占用 6 个字节。每个驱动设备都有各自的以太网地址,并使用目的地址来监听以太网帧。所有的驱动设备同时还会监听一个十六进制的通配地址 ”FF-FF-FF-FF-FF-FF-FF“,称为”广播“地址。
以太网使用了 CSMA/CD(Carrier Sense and Multiple Access with Collision Detection, 基带冲突检测的载波监听多路访问技术),CSMA/CD 意味着所有驱动设备的通信都基于一个单独的媒介,而同一时间仅能有一个驱动设备执行发送,但所有驱动设备能够同时进行接收。如果两个驱动设备尝试在一个瞬间同时发送,则会被检测到传输碰撞,然后两个驱动设备均会等待一个随机很短的周期来再次尝试发送。
3.1、人类类比
以太网技术的一个很好的类比是一组人在一个小的、全黑的房间中进行讨论。在这个类比中,物理网络媒介是空气中的声波,来替代同轴电缆中的电信号。
每个人都能听到其他人说话的声音(载波侦听)。房间中的所有人都有相同的能力进行谈话(多路访问),但是他们都没有长篇大论,因为他们都很有礼貌。如果一个人很无礼,他则会被要求离开房间(比如抛出网络)。
当别人正在说话时,则没人再会说话。如果两个人在同一瞬间开始说话,则两个人都会知道这种状况的发生,因为他们听到了一些他们没有说过的事情(冲突检测)。当两个人意识到这个状态,都会开始等待一段时间,然后其中一个会再次开始谈话。其他人则会等待正在说话的人结束,然后开始自己的发言。
每个人都拥有唯一的名字(唯一的以太网地址)以避免混淆。每个人每次开始谈话时,都会以他要进行谈话的人的名字以及自己的名字(以太网目的地址与源地址)作为谈话消息的开场白。比如,“Hello Jane, this is Jack, ..blah blah blah…"。如果发送者想要对所有人说话则会说 ”everyone“(广播地址),比如,”Hello Everyone, this is Jack, ..blah blah blah…“。
4、ARP
当发出一个 IP 数据包时,目的以太网地址又是如何被识别的呢?
ARP(Address Resolution Protocol) 用于将 IP 地址翻译为以太网地址。这种翻译仅应用于传出的数据包,因为这也就是 IP 头部和以太网头部被创建的时机。
4.1、用于地址翻译的 ARP 表
翻译通过一个表查找过程来执行。该表被称为 ARP 表,被保存在内存中,而每个计算机包含一行。一列用于保存 IP 地址,一列用于保存以太网地址。当把 IP 地址翻译为以太网地址时,会在表中搜索一个匹配的 IP 地址。下面是一个简化的 ARP 表:
人们约定,在书写 4 字节的 IP 地址时,每个字节按照 10 进制的形式,并且各个字节间使用句号分割。同时约定,在书写 6 进制的以太网地址时,每个字节按照 16 进制的形式,并且每个字节间使用端横杠或冒号分割。
ARP 表是必不可少的,因为 IP 地址及以太网地址在被选择时是各自独立的;你无法使用一个算法来将 IP 地址翻译为以太网地址。IP 地址由网络管理员基于计算机在互联网上的位置进行选择。当计算机被移动到互联网的另一个部分,IP 地址也必须随着改变。而以太网地址则由制造厂商基于该厂商所注册的以太网地址空间进行选择。当以太网硬件接口板改变时,以太网地址随之改变。
4.2、典型翻译场景
在常规的网络应用操作中,比如 TELNET,发送一个应用消息给 TCP,然后 TCP 将对应的 TCP 消息发送给 IP 模块。应用、TCP 模块、IP 模块会知道目的 IP 地址。这时 IP 数据包模块已经被构造并准备好发送给以太网驱动设备,但是首先得识别目的以太网地址。
而 ARP 表则就是为了查找目的以太网地址。
4.3、ARP 请求-响应对
但是 ARP 表首先又是如何被填充的呢?答案是”基于需要“的基础,由 ARP 自动完成填充。
当 ARP 表不能用于转换一个地址时会发生两件事:
- 一个带有以太网广播地址的 ARP 请求被发送到网络上的所有计算机;
- 需要被传出的 IP 数据包被排队。
所有计算机的以太网接口都会接收到这个广播的以太网帧。每个以太网驱动设备会检查以太网帧中的类型字段并将 ARP 数据包发送给 ARP 模块。ARP 请求数据包中会说”如果你的 IP 地址与这个目标 IP 地址匹配,请告诉我你的以太网地址“。一个 ARP 请求数据包的具体格式会像下面这样:
每个 ARP 模块会检查对 IP 地址进行检查,以确定目标 IP 地址与自身的 IP 地址是否匹配,然后直接向源以太网地址发送一个响应。ARP 响应中会说”是的,那个目标 IP 地址就是我,让我告诉你我的以太网地址“。一个 ARP 响应数据包中会包含与请求中对调的发送者、目标字段内容。看起来会是这样:
该响应会被原始的发送者计算机收到。以太网驱动设备会查看以太网帧中的类型字段然后将 ARP 数据包发送给 ARP 模块。ARP 模块检查 ARP 数据包并将发送者的 IP 地址添加到自己的 ARP 表中。
被更新后的 ARP 表看起来会是这样:
4.4、场景延续
新的翻译现在已经被自动安装到表中,从它被需要到完成仅需要数毫秒。如果你还记得上面的第二步,将要被传出的 IP 数据包目前正在被排队。接着,IP 地址到以太网地址的翻译会通过查找 ARP 表来执行,然后以太网帧被发送到以太网。因此,基于新的 3、4、5 步,发送者计算机的情况应该是这样的:
- 一个带有以太网广播地址的 ARP 请求被发送到网络上的所有计算机;
- 需要被传出的 IP 数据包被排队;
- 用于 ARP 表的、带有 IP 到以太网地址翻译的 ARP 响应返回;
- 对于被排队的 IP 数据包,ARP 表会被用于将 IP 地址转换到以太网地址;
- 以太网帧被发送到以太网。
总的来说,当基于 ARP 表的翻译无法完成,一个数据包会被排队。翻译数据会基于 ARP 请求、响应快速被填充,然后 IP 数据包被传送出去。
每个计算机的每个以太网接口都有一个单独的 ARP 表。如果目标计算机不存在,则不会有 ARP 响应、ARP 表中也不会存在对应的条目。IP 模块会删除掉需要发送到该地址的 IP 数据包。上层协议也无法识别一个断开的以太网与属于目标 IP 的地址的计算机的缺失之前的区别。
有些 IP 和 ARP 的实现不会在等到 ARP 响应的时候将 IP 数据包排队。取而代之的是 IP 数据包会被删除,对于丢失的 IP 数据包的恢复则会留给 TCP 模块或 UDP 网络应用来完成。恢复通过超时和重发来完成。被重新发送的消息则能够被成功发送出去,因为该消息的第一个副本已经使得 ARP 表被填充。
5、因特网协议
IP 模块是因特网技术的核心,而 IP 的精髓是其路由表。IP 使用这个内存中的表来制定有关 IP 数据包路由的所有决定。IP 路由表的内容有网络管理员定义。
立即路由表示是如何被使用的既是对因特网原理的理解。这些理解对成功的 IP 网络管理及维护是必须的。
想要更好的理解路由表,我们首先要拥有一个对路由概览,然后是学习 IP 网络地址,然后再查看各个细节。
5.1、直接路由
下图是一个包含三台计算机的小型因特网:A、B、C。每个计算机都拥有相同的、最开始的图一中所示的 TCP/IP 协议栈。每个计算机的以太网接口都拥有各自的以太网地址。每台计算机都拥有各自的、由网络管理员设置到 IP 接口的 IP 地址,同时网络管理员还在以太网上设置了 IP 网络编号。
当 A 向 B 发送一个 IP 数据包时,IP 头部中包含了 A 的 IP 地址来作为 IP 源地址,以太网头部中包含了 A 的以太网地址作为源以太网地址。同时,IP 头部中包含了 B 的 IP 地址作为目的 IP 地址,以太网头部中包含了 B 的以太网地址作为目的以太网地址。
在这个简单场景中,IP 是算一项开销,因为 IP 为由以太网提供的服务增加了很少的能力,相反,IP 却增加了消耗,需要额外的 CPU 处理、网络带宽用于生成、传输、解析 IP 头部。
当 B 的 IP 模块收到了来自 A 的 IP 数据包,它会对比目的 IP 地址与自身的 IP 地址,如果匹配,则会将数据包传递给上层协议。
这种 A 和 B 之间的通信称为直接路由。
5.2、间接路由
下图展示了一种更加贴近现实的因特网视图。它通过一个称为计算机 D 的 IP 路由器将 3 个以太网和 3 个 IP 网络组合,进而连接在一起。每个 IP 网络都拥有 4 台计算机;每台计算机都拥有各自的 IP 地址和以太网地址。
除了计算机 D,其他每个计算机都拥有图一中所示的相同的 TCP/IP 协议栈。计算机 D 是 IP 路由器;它被连接到 3 个网络,因此拥有 3 个 IP 地址及 3 个以太网地址。计算机 D 拥有类似图 3 中的 TCP/IP 洗衣栈,除此之外,他拥有 3 个 ARP 模块及 3 个以太网驱动设备,而非图 3 中的两个。但是注意计算机 D 仅有一个 IP 模块。
网络管理员已经为每个以太网设置了一个唯一的序号,称为 IP 网络序号。IP 网络序号并未在图中展示,其中仅展示了网络名。
当 A 发送一个 IP 数据包给 B 时,处理过程与上面的单个网络相同。位于同一个 IP 网络中的任意计算机之间的通信都与前面介绍过的直接路由的例子匹配。
当 D 与 A 通信时是直接通信。当 D 与 E 通信时是直接通信。当 D 与 H 通信时是直接通信。这是因为这些计算机对都处于同于一个 IP 网络。
然而,当计算机 A 与一个处于 IP 路由器源端的计算机通信时,通信则不再是直接的。这种通信被称为”间接“通信。
这种对 IP 数据包的路由由 IP 模块完成,并会透明的出现于 TCP、UDP 及网络应用中。
如果 A 向 E 发送一个 IP 数据包,源 IP 地址、以太网地址则为 A 的相应地址。目的 IP 地址则为 E 的相应地址,但是 A 的 IP 模块会将数据包发送给 D 以进行转发,这是目的以太网地址则为 D 的相应地址。
D 的 IP 模块接收到 IP 数据包并对目标 IP 地址进行判断,发现并非自己的 IP 地址,然后直接将 IP 数据包发送给 E。
总的来说,对于直接通信,源 IP 地址、源以太网地址都是发送者的对应地址,目标 IP 地址、以太网地址都是接收者的对应地址。对于间接通信,IP 地址与以太网地址不会像这样结对出现。
该示例因特网也是相当简单的一个。真实的网络通常因为各种因素变得复杂,最终拥有多个 IP 路由器以及多种类型的物理网络。
5.3、IP 模块路由规则
上面的概览已经展示了发生了什么,而不是如何发生的。现在让我们审查一下 IP 模块使用的一些规则、算法。
- 对于一个传出的 IP 数据包,从上层输入 IP,IP 必须决定是以直接还是间接的方式发送数据包,同时 IP 必须选择一个低层网络接口。这些选择的确定都基于对路由表的查询。
- 对于一个传入的 IP 数据包,从低层接口输入 IP,IP 必须决定是将 IP 数据包转发还是传递到上层。如果 IP 数据包被转发,则又会被当做是一个传出数据包。
- 当传入的 IP 数据包抵达时,它永远不会再通过相同的网络接口被转发回去。
这些决定会在 IP 数据包被低层网络接口处理及 ARP 表被商议之前确定。
5.4、IP 地址
网络管理员会根据计算机所附属的 IP 网络将 IP 地址设置到计算机。4 字节 IP 地址的一部分是网络 IP 序号,其他部分是计算机 IP 序号(或主机号)。对于表一种的计算机,其 IP 地址为 223.1.2.1,其网络序号为 223.1.2,主机序号为数字 1。
本教程中所有示例的 IP 地址都是 C 类地址,这意味着前三段为网络序号,最后一段为主机号码。网络地址的最高位是 110,C 类 IP 地址中网络序号的标识长度为 24 位,主机序号的长度为 8 位。因此可以有 2,097,152 个 C 类网络地址,每个网络中可以有 254 个主机。
IP 地址空间由 NIC(Network Information Center) 负责管理。所有连接到唯一一个万维网的因特网必须使用有 NIC 设定的网络序号。如果你在组建自己的因特网且不打算连接到因特网上,你也仍然需要从 NIC 获得网络序号。如果你选择使用自己的序号,一旦你的网络连接到其他网络则会存在出现混乱的风险。
5.5、命名
人们通过名称来引用到计算机,而非序号。一个名为 alpha 的计算机的 IP 地址或许为 223.1.2.1。对于小的网络,这种名称到地址的翻译数据通常会保存在各个计算机的 hosts 文件中。而对于大的网络,这种翻译数据文件被保存在一个服务器,并在需要的时候通过跨越网络来访问。文件中的几行可能看起来会是这样:
223.1.2.1 alpha
223.1.2.2 beta
223.1.2.3 gamma
223.1.2.4 delta
223.1.3.2 epsilon
223.1.4.2 iota
IP 地址作为第一列,计算机名作为第二列。
大多数情况下,你可以在所有计算机上安装完全相同的 hosts 文件。你会注意到 ”delta“ 在文件中仅有一条,尽管他可能拥有 3 个 IP 地址。Delta 可以通过其中任意一个 IP 地址抵达,使用哪一个是无关紧要的。当 delta 接收到一个 IP 数据包时它会查看目标地址,它会认出任意一个它自己的 IP 地址。
IP 网络同样也提供了名称。如果你拥有 3 个 IP 网络,你的用于注释这些名称的 ”networks“ 文件看起来会是这样:
223.1.2 development
223.1.3 accounting
223.1.4 factory
IP 网络序号是第一列,名称位于第二列。
从本例中你可以看到,alpha 是位于开发网络的计算机编号 1,beta 是位于开发网络的计算机编号 2,等等。同样你也可以说成是:alpha 是 develop.1,beta 是 develop.2,等等。
上面的 hosts 文件对用户来说是足够满足使用的,但是网络管理员可能将 delta 那一行替换为:
223.1.2.4 devnetrouter delta
223.1.3.1 facnetrouter
223.1.4.1 accnetrouter
hosts 文件中新增加的三行为 delta 的每个 IP 地址提供了更有意义的名称。事实上,第一个 IP 地址拥有两个名字:delta 与 devnetrouter 代表了相同的意义。在实践中,delta 会作为计算机的通用名称,而其他 3 个名称则仅用于对 IP 路由表的管理工作。
这些文件会被网络管理命令或网络应用使用以便提供有意义的名称。这些对因特网上的操作来说并非必要的,但是会使相关工作变得更加简单。
5.6、IP 路由表
IP 又是如何知道在发出一个 IP 数据包时使用哪个底层网络接口呢?IP 模块会从目的 IP 地址中解析出 IP 网络编号,然后作为一个键来搜索路由表。
路由表会为每个路径包含一行。路由表中主要的列包括:IP 网络编号、直接/间接标识、路由器 IP 地址、接口编号。该表会被每个传出的 IP 数据包通过 IP 引用。
在大多数计算机中,路由表可以通过 route
命令来修改。路由表的内容有网络管理员来定义,因为网络管理员为每个计算机设置了对应的 IP 地址。
5.7、直接路由细节
5.8、直接路由场景
5.9、间接路由细节
5.10、间接路由场景
5.11、路由总结
5.12、管理路由
6、用户数据报协议-UDF
6.1、端口
6.1、校验和
7、传输控制协议-TCP
8、网络应用
8.1、TELNET
8.2、FTP
8.3、rsh
8.4、NFS
8.5、SNMP
8.6、X-Window
9、其他信息
10、相关引用
6 - 可靠性疑问
TCP 是可靠的传输协议,不会丢包、乱序,其在理论上是非常可靠的,但在实际应用中需要区分场景。
- 发送方能不能知道已发送的数据对方是不是都收到了?或者收到多少?不能。
- 如果怀疑对方没收到,有没有办法可以确认对方没有收到?不能。
- 需要发送 123,对方会不会却收到 1223?会的。
第一个问题
众所周知 TCP 拥有 ACK,ACK 就是用来确认对方接收到了多少字节。但是 ACK 是 OS 的操作,OS 收到之后并不会通知用户程序。发送的流程如下:
- 应用程序把待发送的数据交给操作系统
- 操作系统把数据接收到自己的 buffer 里,接收完成后通知应用程序发送完成
- 操作系统进行实际的发送操作
- 操作系统收到对方的 ACK
如果在执行第二步之后,网络出现了暂时性故障,TCP 断开了连接,会发生什么?如果是网络游戏则很简单,可以将用户踢下线,让其重新登录。但如果是比较严肃的场景,当然希望能够支持 TCP 重连,但是重连后如何知道哪些数据已发送、哪些数据已丢失。
以Windows I/O completion ports举个例子。一般的网络库实现是这样的:在调用WSASend之前,malloc一个WSABuffer,把待发送数据填进去。等到收到操作系统的发送成功的通知后,把buffer释放掉(或者转给下一个Send用)。在这样的设计下,就意味着一旦遇上网络故障,丢失的数据就再也找不回来了。你可以reconnect,但是你没办法resend,因为buffer已经被释放掉了。所以这种管理buffer的方式是一个很失败的设计,释放buffer应当是在收到response之后。
方案:不要依赖于操作系统的发送成功通知,也不要依赖于TCP的ACK,如果你希望保证对方能收到,那就在应用层设计一个答复消息。再或者说,one-way RPC都是不可靠的,无论传输层是TCP还是UDP,都有可能会丢。
第二个问题
这是设计应用层协议的人很需要考虑的,简单来说,”成功一定意味着成功,而失败则未必意味着失败“。比如正在通过网银转账,这是出现“网络超时,转账操作可能失败”,这时并不能确定是否转账成功。即“失败”的定义可以包含多个层次。
方案:采用positioned write。即在客户端发给服务器的请求里加上文件偏移量(offset)。缺点是:若你想要多个客户端同时追加写入同一个文件,那几乎是不可能的。
第三个问题
方案:在应用层给每个message标记一个id,让接收者去重即可。
如何正确关闭连接
简单来说,谁是收到最后一条消息的人,谁来主动关闭 TCP 连接。另一方在 recv 返回 0 字节之后 close,千万不要主动 close。
在协议设计上,分两种情况:
- 协议是一问一答,类似于 HTTP,且发问的总是同一方。一方只问,另一方只答;
- 有显示 EOF 的消息通知对方 shutdown。
如果不满足以上两点的任何一点,那么就没有任何一方能够判断它收到的消息是不是最后一条。
7 - 握手详解
概览
TCP 基本认识
- TCP 头部格式
- 为什么需要 TCP 协议?TCP 工作在那一层?
- 什么是 TCP?
- 什么是 TCP 连接?
- 如何唯一确定一个 TCP 连接?
- TCP 最大连接数?
- UDP 与 TCP 的区别?各自应用场景?
- 为什么 UDP 头部没有“首部长度”字段,而 TCP 头部有“首部长度”字段?
- 为什么 UDP 头部有“包长度”字段,而 TCP 头部没有“包长度”字段?
TCP 建立连接
- TCP 三次握手过程和状态变迁
- 如何在 Linux 系统中查看 TCP 状态?
- 为什么是三次握手?不是两次或四次?
- 为什么客户端和服务端的初始需要 ISN 是不同的?
- 初始序号 ISN 是如何随机生成的?
- 既然 IP 层会分片,为什么 TCP 层还需要 MSS?
- 什么是 SYN 攻击?如何避免 SYN 攻击?
TCP 断开连接
- TCP 四次挥手过程和状态变迁
- 为什么挥手需要四次?
- 为什么 TIME_WAIT 等待的时间是 2MSL?
- 为什么需要 TIME_WAIT 状态?
- TIME_WAIT 过程有什么危害?
- 如何优化 TIME_WAIT?
- 如何已经建立了连接,但是客户端突然出现故障了怎么办?
Socket 编程
- 针对 TCP 应该如何 Socket 编程?
- Listen 时候参数 backlog 的意义?
- accept 发送在三次握手的哪一步?
- 客户端调用 close 了,连接断开的流程是什么?
1. TCP 基本认识
1.1 TCP 头部格式
- 序列号:在建立连接时由计算机生成的随机数作为其初始值,通过 SYN 包传给接收端主机,每发送一次数据,就「累加」一次该「数据字节数」的大小。用来解决网络包乱序问题。
- 确认应答号:指下一次「期望」收到的数据的序列号,发送端收到这个确认应答以后可以认为在这个序号以前的数据都已经被正常接收。用来解决不丢包的问题。
- 控制位:
- ACK:该位为
1
时,「确认应答」的字段变为有效,TCP 规定除了最初建立连接时的SYN
包之外该位必须设置为1
。 - RST:该位为
1
时,表示 TCP 连接中出现异常必须强制断开连接。 - SYC:该位为
1
时,表示希望建立连,并在其「序列号」的字段进行序列号初始值的设定。 - FIN:该位为
1
时,表示今后不会再有数据发送,希望断开连接。当通信结束希望断开连接时,通信双方的主机之间就可以相互交换FIN
位置为 1 的 TCP 段。
- ACK:该位为
1.2 为什么需要 TCP 协议?TCP 工作在那一层?
IP
层是「不可靠」的,它不保证网络包的交付、不保证网络包的按序交付、也不保证网络包中的数据的完整性。
如果需要保障网络数据包的可靠性,那么就需要由上层(传输层)的 TCP
协议来负责。
因为 TCP 是一个工作在传输层的可靠数据传输的服务,它能确保接收端接收的网络包是无损坏、无间隔、非冗余和按序的。
1.3 什么是 TCP?
TCP 是面向连接的、可靠的、基于字节流的传输层通信协议。
- 面向连接:一定是「一对一」才能连接,不能像 UDP 协议 可以一个主机同时向多个主机发送消息,也就是一对多是无法做到的;
- 可靠的:无论的网络链路中出现了怎样的链路变化,TCP 都可以保证一个报文一定能够到达接收端;
- 字节流:消息是「没有边界」的,所以无论我们消息有多大都可以进行传输。并且消息是「有序的」,当「前一个」消息没有收到的时候,即使它先收到了后面的字节已经收到,那么也不能扔给应用层去处理,同时对「重复」的报文会自动丢弃。
1.4 什么是 TCP 连接?
我们来看看 RFC 793 是如何定义「连接」的:
Connections:
The reliability and flow control mechanisms described above require that TCPs initialize and maintain certain status information for each data stream.
The combination of this information, including sockets, sequence numbers, and window sizes, is called a connection.
简单来说就是,用于保证可靠性和流量控制维护的某些状态信息,这些信息的组合,包括 Socket、序列号和窗口大小称为连接。
建立一个 TCP 连接是需要客户端与服务器端达成上述三个信息的共识:
- Socket:由 IP 地址和端口号组成
- 序列号:用来解决乱序问题等
- 窗口大小:用来做流量控制
1.5 如何唯一确定一个 TCP 连接?
TCP 四元组可以唯一的确定一个连接,四元组包括如下:
- 源地址
- 源端口
- 目的地址
- 目的端口
源地址和目的地址的字段(32位)是在 IP 头部中,作用是通过 IP 协议发送报文给对方主机。
源端口和目的端口的字段(16位)是在 TCP 头部中,作用是告诉 TCP 协议应该把报文发给哪个进程。
1.6 TCP 的最大连接数是多少?
服务器通常固定在某个本地端口上监听,等待客户端的连接请求。因此,客户端 IP 和 端口是可变的,其理论值计算公式如下:
对 IPv4,客户端的 IP 数最多为 2
的 32
次方,客户端的端口数最多为 2
的 16
次方,也就是服务端单机最大 TCP 连接数,约为 2
的 48
次方。
当然,服务端最大并发 TCP 连接数远不能达到理论上限。
- 首先主要是文件描述符限制,Socket 都是文件,所以首先要通过
ulimit
配置文件描述符的数目; - 另一个是内存限制,每个 TCP 连接都要占用一定内存,操作系统是有限的。
1.7 UDP 与 TCP 的区别?各自应用场景?
UDP 不提供复杂的控制机制,利用 IP 提供面向「无连接」的通信服务。
UDP 协议真的非常简,头部只有 8
个字节( 64 位),UDP 的头部格式如下:
- 目标和源端口:主要是告诉 UDP 协议应该把报文发给哪个进程。
- 包长度:该字段保存了 UDP 首部的长度跟数据的长度之和。
- 校验和:校验和是为了提供可靠的 UDP 首部和数据而设计。
TCP 和 UDP 区别:
- 连接
- TCP 是面向连接的传输层协议,传输数据前先要建立连接。
- UDP 是不需要连接,即刻传输数据。
- 服务对象
- TCP 是一对一的两点服务,即一条连接只有两个端点。
- UDP 支持一对一、一对多、多对多的交互通信
- 可靠性
- TCP 是可靠交付数据的,数据可以无差错、不丢失、不重复、按需到达。
- UDP 是尽最大努力交付,不保证可靠交付数据。
- 拥塞控制、流量控制
- TCP 有拥塞控制和流量控制机制,保证数据传输的安全性。
- UDP 则没有,即使网络非常拥堵了,也不会影响 UDP 的发送速率。
- 首部开销
- TCP 首部长度较长,会有一定的开销,首部在没有使用「选项」字段时是
20
个字节,如果使用了「选项」字段则会变长的。 - UDP 首部只有 8 个字节,并且是固定不变的,开销较小。
- TCP 首部长度较长,会有一定的开销,首部在没有使用「选项」字段时是
TCP 和 UDP 应用场景:
由于 TCP 是面向连接,能保证数据的可靠性交付,因此经常用于:
FTP
文件传输HTTP
/HTTPS
由于 UDP 面向无连接,它可以随时发送数据,再加上UDP本身的处理既简单又高效,因此经常用于:
包总量较少的通信,如
DNS
、SNMP
等视频、音频等多媒体通信
广播通信
1.8 头部字段:首部长度
为什么 UDP 头部没有「首部长度」字段,而 TCP 头部有「首部长度」字段呢?
原因是 TCP 有可变长的「选项」字段,而 UDP 头部长度则是不会变化的,无需多一个字段去记录 UDP 的首部长度。
1.9 头部字段:包长度
为什么 UDP 头部有「包长度」字段,而 TCP 头部则没有「包长度」字段呢?
先说说 TCP 是如何计算负载数据长度:
其中 IP 总长度 和 IP 首部长度,在 IP 首部格式是已知的。TCP 首部长度,则是在 TCP 首部格式已知的,所以就可以求得 TCP 数据的长度。
大家这时就奇怪了问:“ UDP 也是基于 IP 层的呀,那 UDP 的数据长度也可以通过这个公式计算呀?为何还要有「包长度」呢?”
这么一问,确实感觉 UDP 「包长度」是冗余的。
因为为了网络设备硬件设计和处理方便,首部长度需要是 4
字节的整数倍。
如果去掉 UDP 「包长度」字段,那 UDP 首部长度就不是 4
字节的整数倍了,所以小林觉得这可能是为了补全 UDP 首部长度是 4
字节的整数倍,才补充了「包长度」字段。
2. TCP 建立连接
2.1 TCP 三次握手过程和状态变迁
TCP 是面向连接的协议,所以使用 TCP 前必须先建立连接,而建立连接是通过三次握手而进行的。
- 一开始,客户端和服务端都处于
CLOSED
状态。先是服务端主动监听某个端口,处于LISTEN
状态
- 客户端会随机初始化序号(
client_isn
),将此序号置于 TCP 首部的「序号」字段中,同时把SYN
标志位置为1
,表示SYN
报文。接着把第一个 SYN 报文发送给服务端,表示向服务端发起连接,该报文不包含应用层数据,之后客户端处于SYN-SENT
状态。
- 服务端收到客户端的
SYN
报文后,首先服务端也随机初始化自己的序号(server_isn
),将此序号填入 TCP 首部的「序号」字段中,其次把 TCP 首部的「确认应答号」字段填入client_isn + 1
, 接着把SYN
和ACK
标志位置为1
。最后把该报文发给客户端,该报文也不包含应用层数据,之后服务端处于SYN-RCVD
状态。
客户端收到服务端报文后,还要向服务端回应最后一个应答报文,首先该应答报文 TCP 首部
ACK
标志位置为1
,其次「确认应答号」字段填入server_isn + 1
,最后把报文发送给服务端,这次报文可以携带客户到服务器的数据,之后客户端处于ESTABLISHED
状态。服务器收到客户端的应答报文后,也进入
ESTABLISHED
状态。
从上面的过程可以发现第三次握手是可以携带数据的,前两次握手是不可以携带数据的,这也是面试常问的题。
一旦完成三次握手,双方都处于 ESTABLISHED
状态,此致连接就已建立完成,客户端和服务端就可以相互发送数据了。
2.2 如何在 Linux 系统中查看 TCP 状态?
TCP 的连接状态查看,在 Linux 可以通过 netstat -napt
命令查看。
2.3 为什么是三次握手?不是两次、四次?
相信大家比较常回答的是:“因为三次握手才能保证双方具有接收和发送的能力。”
这回答是没问题,但这回答是片面的,并没有说出主要的原因。
在前面我们知道了什么是 TCP 连接:
- 用于保证可靠性和流量控制维护的某些状态信息,这些信息的组合,包括Socket、序列号和窗口大小称为连接。
所以,重要的是为什么三次握手才可以初始化Socket、序列号和窗口大小并建立 TCP 连接。
接下来以三个方面分析三次握手的原因:
- 三次握手才可以阻止历史重复连接的初始化(主要原因)
- 三次握手才可以同步双方的初始序列号
- 三次握手才可以避免资源浪费
原因一:避免历史连接
我们来看看 RFC 793 指出的 TCP 连接使用三次握手的首要原因:
The principle reason for the three-way handshake is to prevent old duplicate connection initiations from causing confusion.
简单来说,三次握手的首要原因是为了防止旧的重复连接初始化造成混乱。
网络环境是错综复杂的,往往并不是如我们期望的一样,先发送的数据包,就先到达目标主机,反而它很骚,可能会由于网络拥堵等乱七八糟的原因,会使得旧的数据包,先到达目标主机,那么这种情况下 TCP 三次握手是如何避免的呢?
客户端连续发送多次 SYN 建立连接的报文,在网络拥堵等情况下:
- 一个「旧 SYN 报文」比「最新的 SYN 」 报文早到达了服务端;
- 那么此时服务端就会回一个
SYN + ACK
报文给客户端; - 客户端收到后可以根据自身的上下文,判断这是一个历史连接(序列号过期或超时),那么客户端就会发送
RST
报文给服务端,表示中止这一次连接。
如果是两次握手连接,就不能判断当前连接是否是历史连接,三次握手则可以在客户端(发送方)准备发送第三次报文时,客户端因有足够的上下文来判断当前连接是否是历史连接:
- 如果是历史连接(序列号过期或超时),则第三次握手发送的报文是
RST
报文,以此中止历史连接; - 如果不是历史连接,则第三次发送的报文是
ACK
报文,通信双方就会成功建立连接;
所以, TCP 使用三次握手建立连接的最主要原因是防止历史连接初始化了连接。
原因二:同步双方初始序列号
TCP 协议的通信双方, 都必须各自维护一个「序列号」, 序列号是可靠传输的一个关键因素,它的作用:
- 接收方可以去除重复的数据;
- 接收方可以根据数据包的序列号按序接收;
- 可以标识发送出去的数据包中, 哪些是已经被对方收到的;
可见,序列号在 TCP 连接中占据着非常重要的作用,所以当客户端发送携带「初始序列号」的 SYN
报文的时候,需要服务端回一个 ACK
应答报文,表示客户端的 SYN 报文已被服务端成功接收,那当服务端发送「初始序列号」给客户端的时候,依然也要得到客户端的应答回应,这样一来一回,才能确保双方的初始序列号能被可靠的同步。
四次握手其实也能够可靠的同步双方的初始化序号,但由于第二步和第三步可以优化成一步,所以就成了「三次握手」。
而两次握手只保证了一方的初始序列号能被对方成功接收,没办法保证双方的初始序列号都能被确认接收。
原因三:避免资源浪费
如果只有「两次握手」,当客户端的 SYN
请求连接在网络中阻塞,客户端没有接收到 ACK
报文,就会重新发送 SYN
,由于没有第三次握手,服务器不清楚客户端是否收到了自己发送的建立连接的 ACK
确认信号,所以每收到一个 SYN
就只能先主动建立一个连接,这会造成什么情况呢?
如果客户端的 SYN
阻塞了,重复发送多次 SYN
报文,那么服务器在收到请求后就会建立多个冗余的无效链接,造成不必要的资源浪费。
即两次握手会造成消息滞留情况下,服务器重复接受无用的连接请求 SYN
报文,而造成重复分配资源。
总结
TCP 建立连接时,通过三次握手能防止历史连接的建立,能减少双方不必要的资源开销,能帮助双方同步初始化序列号。序列号能够保证数据包不重复、不丢弃和按序传输。
不使用「两次握手」和「四次握手」的原因:
- 「两次握手」:无法防止历史连接的建立,会造成双方资源的浪费,也无法可靠的同步双方序列号;
- 「四次握手」:三次握手就已经理论上最少可靠连接建立,所以不需要使用更多的通信次数。
2.4 为什么客户端和服务端的初始序列号 ISN 是不相同的?
因为网络中的报文会延迟、会复制重发、也有可能丢失,这样会造成的不同连接之间产生互相影响,所以为了避免互相影响,客户端和服务端的初始序列号是随机且不同的。
2.5 初始序列号 ISN 是如何随机产生的?
起始 ISN
是基于时钟的,每 4 毫秒 + 1,转一圈要 4.55 个小时。
RFC1948 中提出了一个较好的初始化序列号 ISN 随机生成算法。
ISN = M + F (localhost, localport, remotehost, remoteport)
M
是一个计时器,这个计时器每隔 4 毫秒加 1。F
是一个 Hash 算法,根据源 IP、目的 IP、源端口、目的端口生成一个随机数值。要保证 Hash 算法不能被外部轻易推算得出,用 MD5 算法是一个比较好的选择。
2.6 既然 IP 层会分片,为什么 TCP 层还需要 MSS 呢?
我们先来认识下 MTU 和 MSS
MTU
:一个网络包的最大长度,以太网中一般为1500
字节;MSS
:除去 IP 和 TCP 头部之后,一个网络包所能容纳的 TCP 数据的最大长度;
如果TCP 的整个报文(头部 + 数据)交给 IP 层进行分片,会有什么异常呢?
当 IP 层有一个超过 MTU
大小的数据(TCP 头部 + TCP 数据)要发送,那么 IP 层就要进行分片,把数据分片成若干片,保证每一个分片都小于 MTU。把一份 IP 数据报进行分片以后,由目标主机的 IP 层来进行重新组装后,在交给上一层 TCP 传输层。
这看起来井然有序,但这存在隐患的,那么当如果一个 IP 分片丢失,整个 IP 报文的所有分片都得重传。
因为 IP 层本身没有超时重传机制,它由传输层的 TCP 来负责超时和重传。
当接收方发现 TCP 报文(头部 + 数据)的某一片丢失后,则不会响应 ACK 给对方,那么发送方的 TCP 在超时后,就会重发「整个 TCP 报文(头部 + 数据)」。
因此,可以得知由 IP 层进行分片传输,是非常没有效率的。
所以,为了达到最佳的传输效能 TCP 协议在建立连接的时候通常要协商双方的 MSS 值,当 TCP 层发现数据超过 MSS 时,则就先会进行分片,当然由它形成的 IP 包的长度也就不会大于 MTU ,自然也就不用 IP 分片了。
经过 TCP 层分片后,如果一个 TCP 分片丢失后,进行重发时也是以 MSS 为单位,而不用重传所有的分片,大大增加了重传的效率。
2.7 什么是 SYN 攻击?如何避免 SYN 攻击?
SYN 攻击
我们都知道 TCP 连接建立是需要三次握手,假设攻击者短时间伪造不同 IP 地址的 SYN
报文,服务端每接收到一个 SYN
报文,就进入SYN_RCVD
状态,但服务端发送出去的 ACK + SYN
报文,无法得到未知 IP 主机的 ACK
应答,久而久之就会占满服务端的 SYN 接收队列(未连接队列),使得服务器不能为正常用户服务。
避免 SYN 攻击方式一
其中一种解决方式是通过修改 Linux 内核参数,控制队列大小和当队列满时应做什么处理。
当网卡接收数据包的速度大于内核处理的速度时,会有一个队列保存这些数据包。控制该队列的最大值如下参数:
net.core.netdev_max_backlog
SYN_RCVD 状态连接的最大个数:
net.ipv4.tcp_max_syn_backlog
超出处理能时,对新的 SYN 直接回 RST,丢弃连接:
net.ipv4.tcp_abort_on_overflow
避免 SYN 攻击方式二
我们先来看下Linux 内核的 SYN
(未完成连接建立)队列与 Accpet
(已完成连接建立)队列是如何工作的?
正常流程:
- 当服务端接收到客户端的 SYN 报文时,会将其加入到内核的「 SYN 队列」;
- 接着发送 SYN + ACK 给客户端,等待客户端回应 ACK 报文;
- 服务端接收到 ACK 报文后,从「 SYN 队列」移除放入到「 Accept 队列」;
- 应用通过调用
accpet()
socket 接口,从「 Accept 队列」取出的连接。
应用程序过慢:
- 如果应用程序过慢时,就会导致「 Accept 队列」被占满。
受到 SYN 攻击:
- 如果不断受到 SYN 攻击,就会导致「 SYN 队列」被占满。
tcp_syncookies
的方式可以应对 SYN 攻击的方法:net.ipv4.tcp_syncookies = 1
- 当 「 SYN 队列」满之后,后续服务器收到 SYN 包,不进入「 SYN 队列」;
- 计算出一个
cookie
值,再以 SYN + ACK 中的「序列号」返回客户端, - 服务端接收到客户端的应答报文时,服务器会检查这个 ACK 包的合法性。如果合法,直接放入到「 Accept 队列」。
- 最后应用通过调用
accpet()
socket 接口,从「 Accept 队列」取出的连接。
3. TCP 断开连接
3.1 TCP 四次挥手过程和状态变迁
天下没有不散的宴席,对于 TCP 连接也是这样, TCP 断开连接是通过四次挥手方式。
双方都可以主动断开连接,断开连接后主机中的「资源」将被释放。
- 客户端打算关闭连接,此时会发送一个 TCP 首部
FIN
标志位被置为1
的报文,也即FIN
报文,之后客户端进入FIN_WAIT_1
状态。 - 服务端收到该报文后,就向客户端发送
ACK
应答报文,接着服务端进入CLOSED_WAIT
状态。 - 客户端收到服务端的
ACK
应答报文后,之后进入FIN_WAIT_2
状态。 - 等待服务端处理完数据后,也向客户端发送
FIN
报文,之后服务端进入LAST_ACK
状态。 - 客户端收到服务端的
FIN
报文后,回一个ACK
应答报文,之后进入TIME_WAIT
状态 - 服务器收到了
ACK
应答报文后,就进入了CLOSE
状态,至此服务端已经完成连接的关闭。 - 客户端在经过
2MSL
一段时间后,自动进入CLOSE
状态,至此客户端也完成连接的关闭。
你可以看到,每个方向都需要一个 FIN 和一个 ACK,因此通常被称为四次挥手。
这里一点需要注意是:主动关闭连接的,才有 TIME_WAIT 状态。
3.2 为什么挥手需要四次?
再来回顾下四次挥手双方发 FIN
包的过程,就能理解为什么需要四次了。
- 关闭连接时,客户端向服务端发送
FIN
时,仅仅表示客户端不再发送数据了但是还能接收数据。 - 服务器收到客户端的
FIN
报文时,先回一个ACK
应答报文,而服务端可能还有数据需要处理和发送,等服务端不再发送数据时,才发送FIN
报文给客户端来表示同意现在关闭连接。
从上面过程可知,服务端通常需要等待完成数据的发送和处理,所以服务端的 ACK
和 FIN
一般都会分开发送,从而比三次握手导致多了一次。
3.3 为什么 TIME_WAIT 等待的时间是 2MSL?
MSL
是 Maximum Segment Lifetime,报文最大生存时间,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。因为 TCP 报文基于是 IP 协议的,而 IP 头中有一个 TTL
字段,是 IP 数据报可以经过的最大路由数,每经过一个处理他的路由器此值就减 1,当此值为 0 则数据报将被丢弃,同时发送 ICMP 报文通知源主机。
MSL 与 TTL 的区别:MSL 的单位是时间,而 TTL 是经过路由跳数。所以 MSL 应该要大于等于 TTL 消耗为 0 的时间,以确保报文已被自然消亡。
TIME_WAIT 等待 2 倍的 MSL,比较合理的解释是:网络中可能存在来自发送方的数据包,当这些发送方的数据包被接收方处理后又会向对方发送响应,所以一来一回需要等待 2 倍的时间。
比如,如果被动关闭方没有收到断开连接的最后的 ACK 报文,就会触发超时重发 Fin 报文,另一方接收到 FIN 后,会重发 ACK 给被动关闭方, 一来一去正好 2 个 MSL。
2MSL
的时间是从客户端接收到 FIN 后发送 ACK 开始计时的。如果在 TIME-WAIT 时间内,因为客户端的 ACK 没有传输到服务端,客户端又接收到了服务端重发的 FIN 报文,那么 2MSL 时间将重新计时。
在 Linux 系统里 2MSL
默认是 60
秒,那么一个 MSL
也就是 30
秒。Linux 系统停留在 TIME_WAIT 的时间为固定的 60 秒。
其定义在 Linux 内核代码里的名称为 TCP_TIMEWAIT_LEN:
#define TCP_TIMEWAIT_LEN (60*HZ) /* how long to wait to destroy TIME-WAIT
state, about 60 seconds */
如果要修改 TIME_WAIT 的时间长度,只能修改 Linux 内核代码里 TCP_TIMEWAIT_LEN 的值,并重新编译 Linux 内核。
3.4 为什么需要 TIME_WAIT 状态?
主动发起关闭连接的一方,才会有 TIME-WAIT
状态。
需要 TIME-WAIT 状态,主要是两个原因:
- 防止具有相同「四元组」的「旧」数据包被收到;
- 保证「被动关闭连接」的一方能被正确的关闭,即保证最后的 ACK 能让被动关闭方接收,从而帮助其正常关闭;
原因一:防止旧连接的数据包
假设 TIME-WAIT 没有等待时间或时间过短,被延迟的数据包抵达后会发生什么呢?
- 如上图黄色框框服务端在关闭连接之前发送的
SEQ = 301
报文,被网络延迟了。 - 这时有相同端口的 TCP 连接被复用后,被延迟的
SEQ = 301
抵达了客户端,那么客户端是有可能正常接收这个过期的报文,这就会产生数据错乱等严重的问题。
所以,TCP 就设计出了这么一个机制,经过 2MSL
这个时间,足以让两个方向上的数据包都被丢弃,使得原来连接的数据包在网络中都自然消失,再出现的数据包一定都是新建立连接所产生的。
原因二:保证连接正确关闭
在 RFC 793 指出 TIME-WAIT 另一个重要的作用是:
TIME-WAIT - represents waiting for enough time to pass to be sure the remote TCP received the acknowledgment of its connection termination request.
也就是说,TIME-WAIT 作用是等待足够的时间以确保最后的 ACK 能让被动关闭方接收,从而帮助其正常关闭。
假设 TIME-WAIT 没有等待时间或时间过短,断开连接会造成什么问题呢?
- 如上图红色框框客户端四次挥手的最后一个
ACK
报文如果在网络中被丢失了,此时如果客户端TIME-WAIT
过短或没有,则就直接进入了CLOSE
状态了,那么服务端则会一直处在LASE-ACK
状态。 - 当客户端发起建立连接的
SYN
请求报文后,服务端会发送RST
报文给客户端,连接建立的过程就会被终止。
如果 TIME-WAIT 等待足够长的情况就会遇到两种情况:
- 服务端正常收到四次挥手的最后一个
ACK
报文,则服务端正常关闭连接。 - 服务端没有收到四次挥手的最后一个
ACK
报文时,则会重发FIN
关闭连接报文并等待新的ACK
报文。
所以客户端在 TIME-WAIT
状态等待 2MSL
时间后,就可以保证双方的连接都可以正常的关闭。
3.5 TIME_WAIT 过多有什么危害?
如果服务器有处于 TIME-WAIT 状态的 TCP,则说明是由服务器方主动发起的断开请求。
过多的 TIME-WAIT 状态主要的危害有两种:
- 第一是内存资源占用;
- 第二是对端口资源的占用,一个 TCP 连接至少消耗一个本地端口;
第二个危害是会造成严重的后果的,要知道,端口资源也是有限的,一般可以开启的端口为 32768~61000
,也可以通过如下参数设置指定 net.ipv4.ip_local_port_range
。
如果服务端 TIME_WAIT 状态过多,占满了所有端口资源,则会导致无法创建新连接。
3.6 如何优化 TIME_WAIT?
这里给出优化 TIME-WAIT 的几个方式,都是有利有弊:
- 打开
net.ipv4.tcp_tw_reuse
和net.ipv4.tcp_timestamps
选项; net.ipv4.tcp_max_tw_buckets
- 程序中使用 SO_LINGER ,应用强制使用 RST 关闭。
方式一:net.ipv4.tcp_tw_reuse 和 tcp_timestamps
如下的 Linux 内核参数开启后,则可以复用处于 TIME_WAIT 的 socket 为新的连接所用。
net.ipv4.tcp_tw_reuse = 1
使用这个选项,还有一个前提,需要打开对 TCP 时间戳的支持,即
net.ipv4.tcp_timestamps=1(默认即为 1)
这个时间戳的字段是在 TCP 头部的「选项」里,用于记录 TCP 发送方的当前时间戳和从对端接收到的最新时间戳。
由于引入了时间戳,我们在前面提到的 2MSL
问题就不复存在了,因为重复的数据包会因为时间戳过期被自然丢弃。
温馨提醒:net.ipv4.tcp_tw_reuse
要慎用,因为使用了它就必然要打开时间戳的支持 net.ipv4.tcp_timestamps
,当客户端与服务端主机时间不同步时,客户端的发送的消息会被直接拒绝掉。
方式二:net.ipv4.tcp_max_tw_buckets
这个值默认为 18000,当系统中处于 TIME_WAIT 的连接一旦超过这个值时,系统就会将所有的 TIME_WAIT 连接状态重置。
这个方法过于暴力,而且治标不治本,带来的问题远比解决的问题多,不推荐使用。
方式三:程序中使用 SO_LINGER
我们可以通过设置 socket 选项,来设置调用 close 关闭连接行为。
struct linger so_linger;
so_linger.l_onoff = 1;
so_linger.l_linger = 0;
setsockopt(s, SOL_SOCKET, SO_LINGER, &so_linger,sizeof(so_linger));
如果l_onoff
为非 0, 且l_linger
值为 0,那么调用close
后,会立该发送一个RST
标志给对端,该 TCP 连接将跳过四次挥手,也就跳过了TIME_WAIT
状态,直接关闭。
但这为跨越TIME_WAIT
状态提供了一个可能,不过是一个非常危险的行为,不值得提倡。
3.7 如果已经建立了连接,但是客户端突然出现故障了怎么办?
TCP 有一个机制是保活机制。这个机制的原理是这样的:
定义一个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP 保活机制会开始作用,每隔一个时间间隔,发送一个探测报文,该探测报文包含的数据非常少,如果连续几个探测报文都没有得到响应,则认为当前的 TCP 连接已经死亡,系统内核将错误信息通知给上层应用程序。
在 Linux 内核可以有对应的参数可以设置保活时间、保活探测的次数、保活探测的时间间隔,以下都为默认值:
net.ipv4.tcp_keepalive_time=7200
net.ipv4.tcp_keepalive_intvl=75
net.ipv4.tcp_keepalive_probes=9
- tcp_keepalive_time=7200:表示保活时间是 7200 秒(2小时),也就 2 小时内如果没有任何连接相关的活动,则会启动保活机制
- tcp_keepalive_intvl=75:表示每次检测间隔 75 秒;
- tcp_keepalive_probes=9:表示检测 9 次无响应,认为对方是不可达的,从而中断本次的连接。
也就是说在 Linux 系统中,最少需要经过 2 小时 11 分 15 秒才可以发现一个「死亡」连接。
这个时间是有点长的,我们也可以根据实际的需求,对以上的保活相关的参数进行设置。
如果开启了 TCP 保活,需要考虑以下几种情况:
第一种,对端程序是正常工作的。当 TCP 保活的探测报文发送给对端, 对端会正常响应,这样 TCP 保活时间会被重置,等待下一个 TCP 保活时间的到来。
第二种,对端程序崩溃并重启。当 TCP 保活的探测报文发送给对端后,对端是可以响应的,但由于没有该连接的有效信息,会产生一个 RST 报文,这样很快就会发现 TCP 连接已经被重置。
第三种,是对端程序崩溃,或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,石沉大海,没有响应,连续几次,达到保活探测次数后,TCP 会报告该 TCP 连接已经死亡。
4. Socket 编程
4.1 针对 TCP 应该如何 Socket 编程?
- 服务端和客户端初始化
socket
,得到文件描述符; - 服务端调用
bind
,将绑定在 IP 地址和端口; - 服务端调用
listen
,进行监听; - 服务端调用
accept
,等待客户端连接; - 客户端调用
connect
,向服务器端的地址和端口发起连接请求; - 服务端
accept
返回用于传输的socket
的文件描述符; - 客户端调用
write
写入数据;服务端调用read
读取数据; - 客户端断开连接时,会调用
close
,那么服务端read
读取数据的时候,就会读取到了EOF
,待处理完数据后,服务端调用close
,表示连接关闭。
这里需要注意的是,服务端调用 accept
时,连接成功了会返回一个已完成连接的 socket,后续用来传输数据。
所以,监听的 socket 和真正用来传送数据的 socket,是「两个」 socket,一个叫作监听 socket,一个叫作已完成连接 socket。
成功连接建立之后,双方开始通过 read 和 write 函数来读写数据,就像往一个文件流里面写东西一样。
4.2 listen 时候参数 backlog 的意义?
Linux内核中会维护两个队列:
- 未完成连接队列(SYN 队列):接收到一个 SYN 建立连接请求,处于 SYN_RCVD 状态;
- 已完成连接队列(Accpet 队列):已完成 TCP 三次握手过程,处于 ESTABLISHED 状态;
int listen (int socketfd, int backlog)
- 参数一 socketfd 为 socketfd 文件描述符
- 参数二 backlog,这参数在历史有一定的变化
在早期 Linux 内核 backlog 是 SYN 队列大小,也就是未完成的队列大小。
在 Linux 内核 2.2 之后,backlog 变成 accept 队列,也就是已完成连接建立的队列长度,所以现在通常认为 backlog 是 accept 队列。
4.3 accept 发送在三次握手的哪一步?
我们先看看客户端连接服务端时,发送了什么?
- 客户端的协议栈向服务器端发送了 SYN 包,并告诉服务器端当前发送序列号 client_isn,客户端进入 SYNC_SENT 状态;
- 服务器端的协议栈收到这个包之后,和客户端进行 ACK 应答,应答的值为 client_isn+1,表示对 SYN 包 client_isn 的确认,同时服务器也发送一个 SYN 包,告诉客户端当前我的发送序列号为 server_isn,服务器端进入 SYNC_RCVD 状态;
- 客户端协议栈收到 ACK 之后,使得应用程序从
connect
调用返回,表示客户端到服务器端的单向连接建立成功,客户端的状态为 ESTABLISHED,同时客户端协议栈也会对服务器端的 SYN 包进行应答,应答数据为 server_isn+1; - 应答包到达服务器端后,服务器端协议栈使得
accept
阻塞调用返回,这个时候服务器端到客户端的单向连接也建立成功,服务器端也进入 ESTABLISHED 状态。
从上面的描述过程,我们可以得知客户端 connect 成功返回是在第二次握手,服务端 accept 成功返回是在三次握手成功之后。
4.4 客户端调用 close 了,连接是断开的流程是什么?
我们看看客户端主动调用了 close
,会发生什么?
- 客户端调用
close
,表明客户端没有数据需要发送了,则此时会向服务端发送 FIN 报文,进入 FIN_WAIT_1 状态; - 服务端接收到了 FIN 报文,TCP 协议栈会为 FIN 包插入一个文件结束符
EOF
到接收缓冲区中,应用程序可以通过read
调用来感知这个 FIN 包。这个EOF
会被放在已排队等候的其他已接收的数据之后,这就意味着服务端需要处理这种异常情况,因为 EOF 表示在该连接上再无额外数据到达。此时,服务端进入 CLOSE_WAIT 状态; - 接着,当处理完数据后,自然就会读到
EOF
,于是也调用close
关闭它的套接字,这会使得会发出一个 FIN 包,之后处于 LAST_ACK 状态; - 客户端接收到服务端的 FIN 包,并发送 ACK 确认包给服务端,此时客户端将进入 TIME_WAIT 状态;
- 服务端收到 ACK 确认包后,就进入了最后的 CLOSE 状态;
- 客户端进过
2MSL
时间之后,也进入 CLOSED 状态;