前言
1978 年在 TCP 协议迭代了 3 个版本后,才被 Jon Postel(IANA 创始人)提出违反了网络分层原则,网络层和传输层耦合在一起很难扩展。于是在 TCP 的第 4 个迭代版本中把协议一分为二,包括网络层 IP 协议和传输层 TCP 协议(这也是今天的 IP 协议被称为 IPv4 的原因,IP层专注于解决跨网络传输消息,TCP 解决任意长度消息的可靠传输)。TCP/IP协议族按照层次由上到下,层层包装。发送协议的主机从上自下将数据按照协议封装,而接收数据的主机则按照协议从得到的数据包解开,最后拿到需要的数据。这种结构非常有栈的味道,所以某些文章也把tcp/ip协议族称为tcp/ip协议栈。
TCP/IP不是一个协议,而是一个协议族的统称,里面包括了IP协议、IMCP协议、TCP协议以及我们更加熟悉的http、ftp、pop3协议等等。
程序员如何把控自己的职业把知识结构化。从一个技术关键点开始不断地关联和细化下去,比如:关于TCP协议,首先第一个要记住状态图,怎么建立连接,怎么断连接,状态怎么变迁。TCP没有连接,是靠状态维护连接的(http是无状态的,所以每一次通信 header 都要带上所有信息)。其次,要了解TCP怎么保证可靠性,就是丢包以后怎么重传,重传有哪些技术点。然后,重传会让你联想到拥塞控制,拥塞控制到滑动窗口……。这基本就是TCP的所有东西了,找到关键点,然后顺着这个脉络一点点往下想,通过知识图关联就可以进行顺藤摸瓜。
TCP/IP 的七个设计理念
- internet communication must continue despite loss of networks or gateways. 要能容错。
- the network must support multiple types of communications service. 支持不同类型的通讯设备。
- the internet architecture must accommodate a variety of networks. 支持连接不同种类的网络比如wifi、光纤等
- the internet architecture must permit distributed management of its resources.
- the internet architecture must be cost effective.
- the internet architecture must permit host attachment with a low level of effort.
- the resources used in the internet architecture must be accountable.
从抓包看tcp
使用wireshark 抓包工具,从上到下是
- 抓到的数据包
- 中间是 每个数据包的多层数据,从最外到最内是Frame ==> Ethernet II(可能是apple 的一个特殊协议) ==> Internet Protocol Version 4 ipv4 协议 ==> Transmission Control Protocol tcp 传输控制层协议
- 最底部是 数据包的 二进制数据,点击 中间每一个层 后每一层内的字段,底部会高亮对应的 数据。
一个协议由:字段 + 基于字段之上的策略 组成
比如图中的“window size”,是不是看起来很耳熟。
tcp协议字段组成
就像一致性协议一样,可以尝试从容错角度看待 tcp的各项机制。
一说到TCP和UDP,就是
- TCP 是面向连接的(一对一的;四元组),UDP 是面向无连接的。
- TCP 提供可靠交付,无差错、不丢失、不重复、并且按序到达;UDP 不提供可靠交付,不保证不丢失,不保证按顺序到达。
- TCP 是面向字节流的,发送时发的是一个流,缺点是没头没尾,不维护应用报文的边界(要求上层协议比如HTTP/GRPC 自己维护报文的边界);优点是不强制应用必须离散的创建数据块,不限制数据块大小。UDP是面向数据报的,一个一个的发送。
- TCP 是可以提供流量控制和拥塞控制的,解决速度不匹配的问题,既防止对端被压垮,也防止网络被压垮。http2 将frame 分为控制frame和数据frame,tcp 没有区分,所以tcp 协议控制字段和通信字段混在一起。
这些特性=算法+数据结构,算法由端上的代码体现,数据结构由协议格式体现。
滑动窗口
客户端维护了一个 待发送的数据包的缓冲区,对于缓冲区的数据包,“发送一个数据包,等待该数据包的ack,再发送下一个” 的方式太低效了,所以客户端一次发送多个数据包(批量发送)
- client 不等第一个数据包收到ack就发送第二个,那么收到ack包时,如何确定这个ack包是对第一个还是第二个包的确认呢?序列号确认号
- 这个“多个”是多少呢? 就是滑动窗口的长度。
发送窗口
接收窗口
在此之后,基于跟接收方的沟通,可以调整滑动窗口的大小。滑动窗口在支持批量发送之外(类似于redis pipeline),又承载了 流控机制/拥塞控制的实现。
序列号和确认号
序列号和确认号针对的是字节而不是报文。
TCP会话的每一端都包含一个32位(bit)的序列号,该序列号被用来跟踪该端发送的数据量。每一个包中都包含确认号,在接收端则通过确认号用来通知发送端数据成功接收。从序列号和确认号的角度看,三次握手是这样的:
- 客户端向服务器发送一个同步数据包请求建立连接,该数据包中,初始序列号(ISN)是客户端随机产生的一个值。
- 服务器收到这个同步请求数据包后,会对客户端进行一个同步确认ACK(确认号是客户端的初始序列号+1 )。这个数据包中,序列号是服务器随机产生的一个值。
- 客户端收到这个同步确认数据包后,再对服务器进行一个确认。该数据包中,序列号是上一个同步请求数据包中的确认号值,确认号是服务器的初始序列号+1。
假设初始序列号是0(不管是客户端请求,还是服务端响应),那么序列号为当前端成功发送的数据位数,确认号为当前端成功接收的数据位数。握手过程中,尽管没有传输有效数据,确认号还是被加1,这是因为接收的包中包含SYN或FIN标志位(占1bit)。
由此,我们就可以知道为什么一些linux命令可以统计流量,为什么说tcp是可靠地?序列号、确认号、checksum即可以保证交互双方正确传输了n字节的数据。序列号来保证所有传输的数据可以按照正常的顺序进行重组,从而保障数据传输的完整。
初始序列号(ISN)随时间而变化的,而且不同的操作系统也会有不同的实现方式,所以每个连接的初始序列号是不同的。TCP连接两端会在建立连接时,交互一些信息,如窗口大小、MSS等,以便为接着的数据传输做准备。
tcp的传输过程是可靠的,那为什么许多较大的下载最终还要校验文件完整性?
- TCP 的可靠传输就是保证在传送丢失或者是包校验和出错的时候重传,但 crc 校验只能大概判断一下,并不能保证数据 100% 正确。
- 传输层协议只保证传输过程的校验。假如发送方进程在部分数据还没有发送的时候,进程崩溃了,或者断点续传的时候断点计算漏了。这时候数据还没有进入到传输层,整体上也就无法保证了。
- 传输过程中我们的包要经过很多复杂的环境,在 HTTP 时代,中间的某个环节的运营商出于利益驱使完全是有能力修改传输的数据的(运营商劫持),当然现在 HTTPS 的广泛应用使得这种情况已经好多了。
- tcp 接收方传输层的 ack 确认其实只是确认的接收方的内核正确地收到了。这时候用户进程有没有收到其实不一定。假如用户进程还没来得及接收,进程崩溃了。或者读取内核中的数据时候发生了极低概率的内存翻转等错误,或者是说接收正确,但是写硬盘的时候出错了。 以上这些这些错误都是所谓可靠的 tcp 所无法照顾到的场景。
tcp连接建立与关闭
tcp为了数据通信的可靠性,增加了很多操作(比如数据通信前后,要建立和释放连接),不像udp直接把包发出去就可以。
TCP的“假”连接/状态机
从本质上来讲,所谓的建立连接,其实是为了在客户端和服务端维护连接,而建立一定的数据结构来维护双方交互的状态,并用这样的数据结构来保证面向连接的特性。TCP 无法左右中间的任何通路,也没有什么虚拟的连接,中间的通路根本意识不到两端使用了 TCP 还是 UDP。
流量控制和拥塞控制其实就是根据收到的对端的网络包,调整两端数据结构的状态。TCP 协议的设计理论上认为,这样调整了数据结构的状态,就能进行流量控制和拥塞控制了,其实在通路上是不是真到了,谁也管不着。
所谓的可靠,也是两端的数据结构做的事情。不丢失其实是数据结构在“点名”,顺序到达其实是数据结构在“排序”,面向数据流其实是数据结构将零散的包,按照顺序捏成一个流发给应用层。总而言之,“连接”两个字让人误以为功夫在通路,其实功夫在两端。
TCP使用了三种基础机制来实现面向连接的服务:
- 消息顺序编号:使用序列号进行标记,以便TCP接收服务在向目的应用传递数据之前修正错序的报文排序;
- 客户端重发
- 服务端顺序ACK。服务端虽然接收数据包是并发的(数据包到达的顺序性无法保证),但数据包的ack是按照编号从小到大逐一确认的。比如服务端已收到了数据包123,又收到了567,服务端会回复ack=3,等到客户端重发4567后收到了4,才回复ack=7。这样只需一个变量,便表达了哪些数据包收到哪些未收到。顺序确认在一致性协议Raft中也有应用。
- 对于建立连接来说,都是由客户端发起,所以client 是主动方,server 是被动方。 对于关闭连接来说, client 和 server 都可以发起(通常由客户端发起)
- 起初,client 和server 都处于closed状态,连接建立好后,双方都处于established状态,开始传输数据。最后连接关闭, 双方再次回到closed状态。closed/established/time_wait 因为持续时间较久,通过netstat 命令比较容易看到。
为什么一定要进行三次握手呢?
tcp握手的目标:同步初始sequence序列号;交换tcp 通讯参数(比如MSS、窗口比例因子、指定校验和算法等)
超时重发机制:在TCP传送一个数据包时,它会把这个数据包放入重发队列中,同时启动计时器,如果收到了关于这个包的确认信息,便将此数据包从队列中删除,如果在计时器超时的时候仍然没有收到确认信息,则需要重新发送该数据包。Linux下,默认重试次数为5次,重试的间隔时间从1s开始每次都翻售,5次的重试时间间隔为1s, 2s, 4s, 8s, 16s。客户端和服务端有多种类型的报文段,对于特定报文段,超时重发机制会带来意想不到的后果。
前两次的握手很显然是必须的,主要是最后一次,即客户端收到服务端发来的确认后为什么还要向服务端再发送一次确认呢?这主要是为了防止已失效的请求报文段突然又传送到了服务端而产生连接的误判。
考虑如下的情况:客户端发送了一个连接请求报文段/SYN到服务端,但是在某些网络节点上长时间滞留了,而后客户端又超时重发了一个连接请求报文段该服务端,而后正常建立连接,数据传输完毕,并释放了连接。如果这时候第一次发送的请求报文段延迟了一段时间后,又到了服务端,很显然,这本是一个早已失效的报文段
处理失效SYN | 三次握手 | 两次握手 |
---|---|---|
失效SYN到达服务端 | 服务端返回ACK | 服务端返回ACK,连接建立 |
服务端ACK到达客户端 | 客户端不理会 | 客户端不理会 |
服务端 | 超过时间未收到ACK认为连接未建立 | 等待客户端发送数据,直到超出保活计数器的设定值 而将客户端判定为出了问题,才会关闭这个连接 |
《软件架构设计》:无论两次、三次、四次,永远都不知道最后发出去的数据包对方是否收到了,问题无解。那为什么是三次呢?因为三次握手恰好可以保证client 和server 对自己的发送、接收能力做了一次确认
- client 发送seq=x,收到了回复的seq=y,ack=x+1 则客户端知道自己的发送、接收没问题
- 服务端发送 seq=y,收到了第三次的ack = y+1,可以确认自己的发送、接收也没问题
四次分手
关闭连接的操作其实是告诉通信的另一方自己没有需要发送的数据,但是它仍然保持了接收对方数据的能力。 所以拉手3次即可(服务端SYN伴随ACK 一起发了),分手需要4次(服务端ACK 与FIN分开发送)。
为什么 TCP 协议有 TIME_WAIT 状态TIME_WAIT 状态是 TCP 与不确定的网络延迟斗争的结果,而不确定性是 TCP 协议在保证可靠这条路的最大阻碍。
MSL及 配合措施
- 进入 TIME_WAIT 的客户端/主动关闭方需要等待 2 MSL 才可以真正关闭连接。
- 任何一个IP数据包在网络上逗留的最长时间是MSL,默认120s(Linux 60s),超过这个时间,中间的路由节点会将数据包丢弃
关闭时为什么要TIME_WAIT 2MSL?换个表述,主动方为什么要等待2MSL 不能直接进入 CLOSED 状态?TIME_WAIT 仅在主动断开连接的一方出现(另一方是CLOSE_WAIT),被动断开连接的一方发完FIN后会直接进入 CLOSED 状态。PS:连接是由状态体现的,是两个人的事儿,特定的数据包和计时器都可以更改通信两端的状态。
- 防止延迟的数据包被其他使用相同
<源地址,源端口,目的地址,目的端口>
的 TCP 连接收到。连接关闭之后再重开一个新的连接,新老连接的四元组是一样的。老连接关闭后,仍可能有数据包在网络上“闲逛”,但是序列号是老的,这个过期的消息却可能被服务端正常接收,这就会带来比较严重的问题。 - 等待足够长的时间以确定被动关闭连接的一方收到 FIN 对应的 ACK 消息。如果客户端等待的时间不够长,当服务端还没有收到 ACK 消息时,客户端就重新与服务端建立 TCP 连接就会造成以下问题 — 服务端因为没有收到 ACK 消息,所以仍然认为当前连接是合法的,客户端重新发送 SYN 消息请求握手时会收到服务端的 RST 消息,连接建立的过程就会被终止。在默认情况下,如果客户端等待足够长的时间就会遇到以下两种情况:
- 服务端正常收到了 ACK 消息并关闭当前 TCP 连接;
- 服务端没有收到 ACK 消息,重新发送 FIN 关闭连接并等待新的 ACK 消息;只要客户端等待 2 MSL 的时间,客户端和服务端之间的连接就会正常关闭,新创建的 TCP 连接收到影响的概率也微乎其微,保证了数据传输的可靠性。
等待2*MSL造成一个问题:在 Linux 上,客户端的可以使用端口号 32,768 ~ 61,000,总共 28,232 个端口号与远程服务器建立连接,但是如果主机在MSL时间内创建的 TCP 连接数超过 28,232,那么再创建新的 TCP 连接就会发生错误。这也是为什么client要建连接池的原因之一。
PS: 灰度发布服务时,停掉老服务之前,一般要等老服务处理完 已经接收到的用户请求再销毁,那么如何判断服务已经将用户请求处理完毕呢?
- 服务提供零流量检查接口
- 约定所有用户请求的最长处理时间,收到停止服务命令后,等待最长处理时间后再销毁。
backlog
tcp 连接机制 的缺陷 常见Dos攻击原理及防护(死亡之Ping、Smurf、Teardown、LandAttack、SYN Flood) 故意让服务端 维持一堆半连接,直到超过 backlog
再聊 TCP backlogbacklog 参数跟 listen 函数有关,listen 函数的定义如下:int listen(int sockfd, int backlog);
To understand the backlog argument, we must realize that for a given listening socket, the kernel maintains two queues :
- An incomplete connection queue, which contains an entry for each SYN that has arrived from a client for which the server is awaiting completion of the TCP three-way handshake. These sockets are in the SYN_RCVD state .
- A completed connection queue, which contains an entry for each client with whom the TCP three-way handshake has completed. These sockets are in the ESTABLISHED state. 「全连接队列」包含了服务端所有完成了三次握手,但是还未被应用调用 accept 取走的连接队列。此时的 socket 处于 ESTABLISHED 状态。每次应用调用 accept() 函数会移除队列头的连接。如果队列为空,accept() 通常会阻塞。全连接队列也被称为 Accept 队列。
你可以把这个过程想象生产者、消费者模型。内核是一个负责三次握手的生产者,握手完的连接会放入一个队列。我们的应用程序是一个消费者,取走队列中的连接进行下一步的处理。这种生产者消费者的模式,在生产过快、消费过慢的情况下就会出现队列积压。
如果某服务挂了,那么内核会帮忙收尾,根据情况或走 RST 或走 FIN,访问者就知道链接关了。但如果主机挂了,或者中间网络设备挂了,客户端没有超时配置,就只能 tcp keepalive 来判断死链接,按照默认内核配置语言两个多小时。
为什么服务端程序都需要先 listen 一下? listen 最主要的工作就是申请和初始化接收队列,包括全连接队列和半连接队列。其中全连接队列是一个链表,而半连接队列由于需要快速的查找,所以使用的是一个哈希表。全/半两个队列是三次握手中很重要的两个数据结构,有了它们服务器才能正常响应来自客户端的三次握手。所以服务器端都需要 listen 一下才行。
为什么可靠
协议就是一系列约定,屏蔽底层各种错乱,得到一个完整有序的数据包序列:
如何看待谷歌 Google 打算用 QUIC 协议替代 TCP/UDP?
TCP 发展与上下游
TCP 与操作系统的关系
tcp/ip 只是一系列的协议,tcp真正的实现靠的是操作系统,进而抽象到语言层 有一个socket api作为入口,进行字节流交互。除linux 实现外,lwIP 是由瑞典计算机科学研究院(SICS)的 Adam Dunkels 开发的小型开源 TCP/IP 协议栈,它是一个用 C 语言实现的软件组件。
//golang
(c *conn) Read(b []byte) (int, error)
// c
int recv(int sockfd, void *buf, int len, int flags);
// java
InputStream in = socket.getInputStream();
int InputStream.read(byte b[], int off, int len);
编程接口
从基于 IP 协议的网络视角来看,数据并不是源源不断的流(stream),而是一个个大小有明确限制的 IP 数据包。
package net
type IPAddr struct {
IP IP
Zone string // IPv6 scoped addressing zone
}
func DialIP(network string, laddr, raddr *IPAddr) (*IPConn, error)
func ListenIP(network string, laddr *IPAddr) (*IPConn, error)
func (c *IPConn) Read(b []byte) (int, error)
func (c *IPConn) ReadFrom(b []byte) (int, Addr, error)
func (c *IPConn) ReadFromIP(b []byte) (int, *IPAddr, error)
func (c *IPConn) Write(b []byte) (int, error)
func (c *IPConn) WriteTo(b []byte, addr Addr) (int, error)
func (c *IPConn) WriteToIP(b []byte, addr *IPAddr) (int, error)
func (c *IPConn) Close() error
为什么需要有多套传输层的协议(TCP 和 UDP)呢?还是因为应用需求是多样的。底层的 IP 协议不保证数据是否到达目标,也不保证数据到达的次序。出于编程便捷性的考虑,TCP 协议就产生了。但是 TCP 协议对传输协议的可靠性保证,对某些应用场景来说并不是一个好特性。最典型的就是音视频的传输。在网络比较差的情况下,我们往往希望丢掉一些帧,但是由于 TCP 重传机制的存在,可能会反而加剧了网络拥塞的情况。这种情况下,UDP 协议就比较理想,它在 IP 协议基础上的额外开销非常小,基本上可以认为除了引入端口(port)外并没有额外做什么,非常适合音视频的传输需求。
package net
type TCPAddr struct {
IP IP
Port int
Zone string // IPv6 scoped addressing zone
}
func DialTCP(network string, laddr, raddr *TCPAddr) (*TCPConn, error)
func ListenTCP(network string, laddr *TCPAddr) (*TCPListener, error)
func (c *TCPConn) Read(b []byte) (int, error)
func (c *TCPConn) Write(b []byte) (int, error)
func (c *TCPConn) Close() error
func (l *TCPListener) Accept() (Conn, error)
func (l *TCPListener) AcceptTCP() (*TCPConn, error)
func (l *TCPListener) Close() error
引用
- TCP中并不是所有的RST都有效
- Linux内核究竟有多少TCP端口可用。其中 ip_local_port_range 范围内的可以被系统随机分配,其他需要指定绑定使用,同一个端口只要TCP连接四元组不完全相同可以无限复用。