网路传输层UDP/TCP

发布于:2025-03-29 ⋅ 阅读:(27) ⋅ 点赞:(0)

一、端口号

1.端口号

1.1 五元组

端口号(port)标识了一个主机上进行通信的不同的应用程序.

如图所示, 在一个机器上运行着许多进程, 每个进程使用的应用层协议都不一样, 比如FTP, SSH, SMTP, HTTP等.

当主机接收到一个报文中, 网络层一定封装了一个目的ip标识我这台主机, 传输层一定封装了一个目的端口号标识应该把数据传给应用层的哪个进程.

 在TCP/IP协议中, 用 <"源IP", "源端口号", "目的IP", "目的端口号", "协议号"> 这样一个五元组来标识一个通信(可以通过netstat -n查看); 我们清楚 ip+port 标识了互联网中唯一的一个进程, 因此前两组信息标识了互联网中通信的两个进程.

所以作为一款服务器, 其实并不担心未来不知道要把响应报文发给谁, 四元组就可以做到:

 此外我们还需要一个协议号,   协议号(Protocol Number) 表示使用的传输层协议, 例如6 代表 TCP, 17 代表 UDP, 1 代表 ICMP.  仅使用 IP 地址 + 端口号 可以唯一标识进程, 但不能唯一确定一个连接(通信).

因为不同的协议可能会使用相同的端口号, 确保了: 即使端口号相同, 不同协议的流量也不会冲突.

1.2 端口号范围划分

0 - 1023: 知名端口号, HTTP, FTP, SSH等这些广为使用的应用层协议, 他们的端口号都是固定的.

有些服务器是非常常用的, 为了使用方便, 人们约定一些常用的服务器, 都是用以下这些固定的端口号:

  • ssh服务器, 22端口;
  • ftp服务器, 21端口;
  • telnet服务器, 23端口;
  • http服务器, 80端口;
  • https服务器, 443

我们自己写一个程序使用端口号时, 要避开这些知名端口号.

1024 - 65535: 操作系统动态分配的端口号. 客户端程序的端口号, 就是由操作系统从这个范围分配的.

cat /etc/services可以查看所有端口号:

1.3 进程与端口号的关系

一个进程是否可以bind多个端口号? 一个端口号是否可以被多个进程bind?

 一个进程可以bind多个端口号, 但是一个端口号只能唯一被一个进程使用.

比如, 一个 Web 服务器可能同时运行 HTTPHTTPS 服务: 所以同时bind 80 和 443 端口.

1.4 Linux查看网络连接的命令

1. netstat 

netstat:是用来查看网络状态的很经典的重要工具, 虽然在很多 Linux 系统中默认可用, 但它的性能较差, 尤其是在连接数非常多时,运行速度会相对较慢。它的工作原理是通过遍历系统中的网络连接和套接字来获取信息, 这使得它在处理大量连接时可能比较缓慢

语法:netstat [选项]

功能:查看网络状态

常用选项:

  • n 拒绝显示别名, 能显示数字的全部转化成数字
  • l 仅列出有在 Listen (监听) 的服务状态
  • p 显示建立相关链接的程序名
  • t (tcp)仅显示tcp相关选项
  • u (udp)仅显示udp相关选项
  • a (all)显示所有选项,默认不显示LISTEN相关

同时使用 a和l 和单独使用a是一个效果.

2. ss 是一个更现代的工具, 通常认为是 netstat 的替代工具, 但它是Linux环境下的工具, 而netstat可跨平台. ss 使用更高效的方式访问内核中的网络信息, 性能更好. 使用和netstat类似.

2. UDP协议

2.1 UDP协议端格式

2.1.1 端口号 

  1. 端口号是用来标识主机上特定的进程, 从而用来进行端到端的通信的.
  2. 对于发送方来说, 主机上不同的应用进程使用不同的源端口号区分不同应用进程的数据, 然后复用 ip 地址, 使得多个应用能同时通信. 对于接收方而言, 当发送方的数据到达目的主机时, OS通过目的端口号将数据正确地交付给特定的应用进程.
  3. 客户端端口号通常是动态分配的, 服务器的端口号是固定的.

 2.1.2 16位校验和

校验和计算公式是通过 伪首部 + UDP 报文 + 数据 通过16 位分组进行二进制相加, 然后取反码得到. 接收端重新计算校验和, 并与接收到的校验和进行累加. 如果结果是 0xFFFF(全 1),说明数据未损坏. 否则说明数据出错, UDP 直接丢弃该数据包 不重传.

2.1.3 16位UDP长度

这里的16位长度是指 UDP整个报文的长度, 而 TCP 的4位首部长度指的是报头的长度.   

  • UDP如何解决报头和有效载荷的分离问题? UDP报文的报头长度是协议约定好的固定长度8字节
  • 如何解决向上交付的问题? 通过目的端口号将数据向上交付给特定的进程
  • UDP怎么知道报文收全了呢, 即为什么UDP叫面向数据报? 通过16位UDP长度这个自描述字段来控制. 如果接收到的报文小于8字节, 则一定是不完整报文; 如果大于8字节, 则 (报文长度-8)字节 即为数据部分长度. 所以 UDP 就能直接给应用层交付已经分割好的报文, 就叫做面向数据报.

2.2 如何理解报头?

报头在内核中的实现就是一个struct封装的结构化字段:

UDP双方通信的过程中一定同时存在着多个待发送和待接收的报文, 对于这些报文OS一定是要进行管理的. Linux 采用 sk_buff 链表 方式来管理数据包, 宏观上来看可以简单认为是一个char类型数组的缓冲区.  其字段主要有:

  • head:指向分配的缓冲区的起始地址
  • data:指向当前有效数据的起始位置 (可移动)
  • tail:指向当前数据的尾部 (可移动)
  • end:指向缓冲区的末尾, 不能超过此范围
  • next:指向下一个 sk_buff

主要是通过 data 和 tail 指针的移动去添加和删除报头, 维护整个报文的有效部分. 然后通过链表来组织起来.

2,3 UDP的缓冲区

1. UDP没有真正意义上的 发送缓冲区. 这也是 UDP无连接不可靠传输 和 TCP有连接可靠传输的底层差别, 应用层会直接调用 sendto 把数据交给内核, 由内核将数据传给网络层协议进行后续的传输动作; 因为UDP不关心接收方是否成功接收, 所以没有必要维护一个发送缓冲区来存储已发送但未确认的数据, 不像 TCP 可能需要重传丢失的数据 或者 进行一些流量控制.

2. 但UDP要具有接收缓冲区, 以避免接受方的应用层处理不及时导致数据大量丢失, UDP是需要接收缓冲区的, 因为即使UDP是不可靠的协议, 也要保证它能最大程度减少数据丢失, 不能因为应用层处理不过来报文就丢弃掉. 

但是这个接收缓冲区不能保证 收到的和发送的UDP报文 的顺序一致; 如果缓冲区满了, 再到达的UDP数据就会被丢弃;

UDP 的底层报文组织

因此即使UDP没有真正意义上的发送缓冲区, 但底层仍会维护一个链表组织的sk_buff结构体, 其为待发送队列, 而不像 TCP 那样有一个长期维护的发送缓冲区.

当应用层数据进入内核即 sendto()sendmsg() 被调用时,UDP 传输的数据会被封装到 sk_buff 结构体中,  内核会把 sk_buff 直接交给 IP 层进行发送, 而不会像 TCP 那样长期保留它.

2. 4 基于UDP的应用层协议

  • NFS: 网络文件系统
  • TFTP: 简单文件传输协议
  • DHCP: 动态主机配置协议
  • BOOTP: 启动协议(用于无盘设备启动) DNS: 域名解析协议

3. TCP协议

TCP全称为 "传输控制协议(Transmission Control Protocol"). 

3.1 TCP协议段格式

TCP协议同样内置于操作系统中, 它的报头也是一个C语言编写的结构化数据, 只是结构体中的成员变量大小和类型与UDP不同而已。 

 1. 16为源端口和目的端口

第一行的源端口目的端口已经很熟悉, 这保证了有效载荷如何向上交付的问题.

2. 4位首部长度

类似UDP, 这依然是为了解释TCP报头和有效载荷如何分离的问题. 但是它只有4位, 表示 0~15, 连20字节的报头部分都无法表示, 所以它的单位是4字节. 所以TCP头部最大长度是15 * 4 = 60, 而我们注意到报头重选项部分是可选的字段, 也就说明选项字段大小最大为60 - 20 = 40 字节.

除去报头, 剩下的部分就是数据, 从而可以把报头和有效载荷分离出来.

 3. 16位窗口大小 - > 流量控制

之前我们就知道 tcp 有发送缓冲区和接收缓冲区, send/sendto、recv/recvfrom本质上是一个拷贝接口, 它们前者负责将 应用层的数据拷贝到发送缓冲区, 后者负责将 数据从接收缓冲区拷贝到应用层.

缓冲区中数据的发送和接收由OS中关于TCP的实现控制, 用户只负责将数据从缓冲区中放入和拿出. 正是因为数据的传输无需用户参与, 所以TCP的全名叫做传输控制协议, 它控制的就是数据怎么发, 发多少, 出错了怎么办.

网络情况是复杂不稳定的, 很可能出现发送方发送大量数据, 而接受方来不及接收, 这就需要流量控制. 而如何进行流量控制?

首先发送方就需要知道接收方的接收能力, 也就是接收缓冲区的窗口大小, 如何知道? 

3. 2 确认应答(ACK)机制 -->插叙

 首先我们要清楚TCP通信为了保证可靠性, 具有确认应答机制:

1. 最简单的一种机制是停止等待ARQ, 也就是发送方的每一个报文都需要接收方的ACK应答.

也就是说, 在通信中我们无法确定最新一条消息是否被可靠, 因为暂时没有得到应答, 但换句话说, 如果有应答, 对于发送方, 就能100%保证历史最近的上一条消息对方已经收到

但这样信道利用率太低, 效率慢.

2. 连续ARQ

为了提高传输效率, 发送方可连续发送多个分组, 进行流水线型的传输. 不必每发完一个分组就停顿下来等待对方的确认, 这样可使信道上一直有数据不间断地传送. 

具体在介绍序号再谈

所以在发送方发送的数据报文 和 接收方返回的应答报文 中将自己的接收缓冲区剩余的大小填充到报文16位窗口大小字段中. 发送方通过这个字段就能确定出我应该给对方发送多少的数据, 从而就能进行双向的流量控制.        

 如果网络拥堵的情况下, 接收方窗口大小为0, 发送条件不满足, 而用户一直在send/write, 将发送缓冲区写满, 也就是生产者一直在生产(用户进程), 消费者不消费(OS进程), 所以此时进程阻塞本质就是进程在等待OS发送数据. 对于接收方也是同理, 如果接收缓冲区为空, 用户进程一直在read/recv, 即生产者不生产, 消费者却要消费, 从而也导致进程阻塞. 上面两种情况也正是生产者消费者模型进程同步的情况. 

 4. 32位序号32位确认序号

32位序号

在停止等待ARQ协议中, 我们能清楚感知到可靠性的保证, 因为一个报文对应一个ACK. 而在连续ARQ协议中, 接收方接收到的报文顺序可能是乱序的, 乱序是不可靠的一种表现, 所以为了保证报文按序到达, 每一个报文都会有32位的序号, 接收方会通过报头的序号进行排序.  

32位确认序号

但是假如发送方发送10个报文, 却只接受到9个ACK, 发送发无法知道是哪个报文没发出去, 因此就需要有32位确认序号, 让发送方知道自己的哪些报文被对方接收了.

但是实际确认序号对于报文的确认机制并不是一对一的, 而是累计确认, 表示确认序号之前的所有报文都已经被对方全部收到了. 比如:

假设接收方收到了数据包 1、2、3,但数据包 4 丢失了, 数据包 5 被成功接收, 则接收方会发送 ACK 4, 表示“我已经收到序列号为 1、2、3 的数据, 接下来期望接收数据包 4.

这样做的优点是?

  1. 一次ACK可以确认多个数据包, 可以减少ACK的发送, 减少网络开销
  2. 如果ACK在发送途中丢失, 如果之后接收到了更大的ACK, 发送方只需关心最大的这个ACK即可, 也就是允许容忍少量的ACK丢失.

这里还会产生一个别的问题, 为什么序号和确认序号不能共用重复的字段? 因为看似数据报文和应答报文(ACK)是独立的.

这就涉及到了捎带应答机制, TCP通信是全双工的, 客户端和服务端在不停地互相发消息. 假如此时客户端发给服务端一个"你好"报文.

服务端此时即想发送ACK, 又也想发送"你好"报文, 就可以把报文中ACK字段置为1, 设置好ACK序号, 充当ACK报文; 数据部分写上"你好", 设置好序号字段, 充当数据报文. 此时这个报文即包含要向对方发送的数据, 又是对对方历史报文的确认!

 从 停止等待ARQ->连续ARQ协议 和 捎带应答我们可以看出, TCP保证可靠性的同时, 还会进行各种提高效率的设定. 

 5. 6位标志位

首先明确这些标志位其中哪一位 置1 就代表该报文具有其对应的报文属性.

为什么会有这些标志位?

以CS通信为例, server同时会在和多个client通信, 这些client的报文可能是建立连接(三次握手), 断开连接(四次挥手), 发送数据, 异常数据等等, 这就意味着server一定会同时收到各种类型的报文. 所以就需要标志位去标识不同的报文类型.

下面单独介绍每一个标志位:

a. URG标志位 和  16位紧急指针

URG也叫紧急指针标志位, 该标志位为1, 表示报文中存在紧急数据, 16位紧急指针有效.

16位紧急指针本质是紧急数据在有效载荷中的偏移量, 而且这个紧急数据仅有 1 字节. 这也就意味着TCP通信中允许有紧急数据插队, 但不允许大量的插队. 

紧急数据能直接不按序号插队被访问吗? 不完全是.

正常情况下, 发送端的普通数据会按照序号依次存入(FIFO)接收缓冲区, 应用层再从缓冲区中读取数据. 而 紧急数据虽然紧急, 但也仍然会进入 TCP 接收缓冲区, 并不会绕过缓冲区直接传递给应用层. 不同的是, TCP 会使用 URG 标志和紧急指针标记紧急数据的位置, 并通知应用层应尽快处理.

比如在Linux下, 应用层recv函数中的flags选项可以选择使用 MSG_OOB 选项立即读取紧急数据, 也可以按照正常顺序读取整个数据流. 最终, 是否优先处理紧急数据取决于应用层, 而不是 TCP 本身直接推送数据到应用层.

紧急指针用于什么场景呢?

1. 比如客户端想给服务器上传大量的数据, 但是已经上传了部分数据时, 客户端突然想终止或暂停上传, 这时可以发送一个紧急数据(比如为0为终止, 为1为暂停等等)来控制服务端的行为, 以防服务器还在按序接收客户端之前上传的数据.

2. 在服务器管理的场景中, 可能服务器的负载比较重, 需要对其进行阶段性的检测. 对于发送方来说我可能会担心服务器是否负载太大卡住不动, 发送方发送一个紧急数据(约定好的)去询问服务器状态, 然后服务器做出响应回复此时状态.

大部分情况下, 我们都不会使用16位紧急指针

 b. PSH标志位

首先我们清楚TCP为了提高网络传输效率, 可能会等缓冲区积累足够多的数据后, 再一起发送较大的数据块, 提高带宽利用率(Nagle 算法).

PSH的作用?

TCP 提供了 PSH 标志, 当发送端设置 PSH=1, 就表示发送方TCP应把发送缓存中的数据发送出去,而不需等待其他额外的数据, 而接受端在收到PSH标志后, 应该把已经接受到的数据提交给应用程序, 而不需等待其他可能的数据.

告知对方尽快把数据向应用层进行交付, 但并不会催促接收方清空缓冲区.

“立即交付给应用层” 和 “不会催促清空缓冲区” 的区别

这两个说法看似矛盾, 实则不冲突, 因为TCP 的数据交付和缓冲区管理是两个不同的概念

(1) PSH 让 TCP “立即交付” 已收到的数据

  • 正常情况下, TCP 可能会等一会儿, 积累更多数据后再交付给应用层, 以提高吞吐量
  • 但当 PSH = 1 时, TCP 不会等待, 而是立即把数据交付给应用层, 即使数据量不大.

(2) 但 TCP 不会强迫应用层清空缓冲区

  • TCP 只是提供数据, 但应用层何时消费数据是它自己的事
  • 如果应用层处理慢, 缓冲区可能依然会积压数据, TCP 不会因为 PSH=1 就清空缓冲区.

PSH的场景?

1. 比如在一些实时交互场景下, 如SSH、Telnet, 当在Linux输入指令时, 希望尽量低延迟的响应, 会在报文中加上PSH, 提示接收方“请立即处理这部分数据, 不要等缓冲区更多数据”. 而如果应用层不关心低延迟, 而更关注吞吐量, PSH 可能不会被设置, 例如文件传输等

2. PSH可以应用在流量控制的一种极端场景下: 零窗口状态.

当客户端和服务器通信时, 服务器的接收缓冲区满, 服务器会在报文中告诉客户端 TCP 窗口大小为 0 时, 此时客户端暂停数据发送, 进入“零窗口状态”. 当服务器的应用层处理了接收缓冲区的数据, 腾出缓冲区空间, 客户端何时能知道可以恢复正常通信了呢?

有两种措施:

1. 服务器发送窗口更新报文

服务器发现缓冲区有可用空间后, 会发送一个窗口更新报文, 告诉客户端新的窗口大小. 这个报文是一个 普通的 ACK, 但它的窗口大小不再是 0, 而是新的可用窗口大小

2. 客户端定期发送 “窗口探测报文” 检测窗口是否恢复.

如果服务器长时间不发送窗口更新报文, 客户端不会无限等待, 而是会定期发送 “窗口探测报文”来确认窗口是否恢复. 服务器会回复最新的窗口大小, 如果窗口仍然是 0, 客户端继续等待. 如果窗口变大了, 客户端会恢复数据传输.

在第二种情况下, PSH 就可以应用, 表示客户端很急切想要知道服务器的窗口大小信息, 也是一种需要低延时的情景, 这里是为了顺带补充TCP流量控制.

c. RST标志位

该标志位为1, 表示双方链接建立的认知不一致, 此时需要重置连接, 它用于终止一个已经建立的连接

以TCP三次握手为例, 我们要清楚TCP的三次握手是可能失败的, TCP保证连接的可靠性不是保证百分之百把数据能发送给对方, 而是在无故障(无外力干涉, 比如拔网线)的情况下, 保证数据发送的确定性, 即发送的数据对方是否收到.

对于前两次握手而言, 我们并不担心确定性的问题, 因为第一次报文丢失, 客户端就不会收到服务器的第二次握手(应答), 第二次报文丢失也是同理. 其实最担心的是第三次报文的丢失.

因为最新一次的消息是没有应答的, 即第三次握手的确定性无法保证, 所以客户端在第三个报文发出去的时候默认对方接收到了, 这其实是一个 "赌对方收到了报文" 的选择. 

假如第三次握手报文丢失, 但此时C和S对于连接的认知是: C认为我已经建立好了连接, 而S还在等待第三次握手, 认为连接没有建立好. 这就出现了双方链接建立的认知不一致:

此时, 服务器就给客户端发送一个RST标志位为1的报文, 告诉对方你这个连接不合法. 请你将其释放, 如果你还想和我通信, 请重新与我建立新链接

 这是一种极端的情况. RST的情况有很多:

1. 提前关闭连接, 即在已关闭的 socket 上收到数据

服务器或客户端已经 close() 了 socket, 但对方仍然在发送数据, 这时系统会返回 RST 让对方知道这个 socket 已经不存在了. 比如服务器设定了超时, 连接超过一定时间未使用, 就 close() 了, 客户端如果在此时发送数据, 会收到 RST.

2. 端口未打开

客户端尝试连接 一个没有监听的端口,服务器收到 SYN 后发现该端口没有进程在监听,就会返回 RST 关闭连接

3. 请求超时

如果一方因为 网络问题、主机崩溃 或其他原因长时间没有响应,而另一方仍在通信,可能会收到 RST.

4. 服务器为了缓解负载压力主动发送 RST

当大量客户端同时访问(比如选课系统), 服务器负载过高, 可能会主动丢弃一些连接, 直接发送 RST 让客户端立即断开, 而不是正常走 FIN 关闭流程.

 6. 16位校验码

这个字段和UDP校验码的作用一样, 都是为了对报文进行差错控制.


3.3 超时重传

3.3.1 超时重传的两种情况

1. 数据报丢失

主机A发送数据给B之后, 可能因为网络拥堵等原因, 数据无法到达主机B, 如果主机A在一个特定时间间隔内没有收到B发来的确认应答, 就会进行超时重传.

2. ACK丢失

如果是主机B的ACK丢失, 也要超时重传, 因为主机A无法分清ACK的丢失究竟是数据对视还是ACK丢失.

但是这种情况下, 主机B会收到两个相同的报文, 这样主机B就要根据报文序号进行去重操作.

总结: 接收方的超时重传和接收方的去重, 就保证了异常数据通信下的可靠性.

 3.3.2 超时重传这会有一个问题:

超时重传的时间间隔是多长? 超时重传的次数是多少?

最理想的情况下, 找到一个最小的时间, 保证 "确认应答一定能在这个时间内返回". 但是这个时间的长短, 随着网络环境的不同, 是有差异的.

  • 如果超时时间设的太长, 会影响整体的重传效率;
  • 如果超时时间设的太短, 有可能会频繁发送重复的包

TCP为了保证无论在任何环境下都能比较高性能的通信, 因此会动态计算这个最大超时时间.

超时重传的时间间隔和次数

  • 时间间隔:在Linux(BSD Unix和Windows 也遵循,因为都遵守相关协议)中, 超时以500ms为一个单位进行控制, 每次判定超时重发的超时时间是500ms的整数倍. 首次重发若得不到应答, 等待2 * 500ms后再重传; 若还得不到应答, 等待4 * 500ms重传, 依此类推, 以指数形式递增。
  • 重传次数:标准中未规定固定的重传次数, 当累计到一定重传次数, TCP 会认为网络或者对端主机出现异常, 强制关闭连接, 具体次数会因系统和配置不同而有差异。

这里并不是数据链路层CSMA/CD协议中的二进制指数退避算法, 它们的不同点:

1. 应用场景不同

TCP: 作用于传输层, 优先保证可靠性, 通过指数退避减少重传对拥塞的影响

CSMA/CD: 作用于数据链路层, 强调公平性, 通过随机退避让所有节点公平竞争信道资源, 解决共享介质的信道争用问题

2. 触发条件不同

TCP: 触发条件是超时未收到确认应答(如网络拥塞、链路故障)

CSMA/CD: 触发条件是检测到物理层冲突(多个节点同时发送数据)

3. 时间机制差异

TCP: 以固定单位(500ms)成倍增加等待时间,如 1×500ms → 2×500ms → 4×500ms

CSMA/CD: 退避时隙数是随机选择的(例如冲突次数为 n 时,从 [0, 2^n-1] 中随机选一个时隙等待), 而非固定倍数

3. 4 连接管理机制

在正常情况下, TCP要经过三次握手建立连接, 四次挥手断开连接:

三次握手的基本流程是:

1. 服务器使用 socket 创建套接字, bind 绑定端口号, 使用listen将套接字设为监听状态, 然后调用 accept 等待客户端连接. 客户端创建 socket 套接字, 然后 connect 向服务器发起链接请求.

三次握手期间发送的报文其实都是TCP的报头且无有效载荷, 里面只不过是标志位不同.

2. 第一次握手: 客户端 connect 即为发起了第一次握手, 客户端给服务端发送SYN报文, 向服务器请求建立连接, 并初始化链接的状态为 SYN-SEN, 此时客户端是"半连接状态"

3. 第二次握手: 服务端的 accept 在收到客户端的连接请求后, 也构造一个报文. 报头中的 ACK 和SYN 标志位为1, ACK 表示确认应答同意客户端的链接请求, SYN 表示我也向对方(客户端)发起链接建立的请求, 并初始化链接的状态为 SYS_RCVD, 此时服务器处于"半连接状态". 所以第二次握手实际上是捎带应答, 因此 TCP的三次握手 也叫 "四次握手+一次捎带应答".

4. 第三次握手: 客户端的 connect 在收到服务器端的报文后, 发现自己的链接请求得到同意并且服务器也要与自己建立链接, 所以给服务端一个ACK为1的应答, 将自己的套接字状态设为ESTABLISHED, 此时客户端在发出第三次握手后就处于了"全连接"状态; 然后服务器的 accept 收到ACK报文, 知道自己的链接请求被接受了, 所以服务器也将自己维护的链接状态设为ESTABLISHED, 此时也处于"全连接状态"

此时三次握手完成,双方的套接字状态都为ESTABLISHED, 链接成功建立. 而且三次握手的流程中, 我们发现三次握手的申请者会优先建立链接, 因为客户端认为发出第三次握手就算链接建立完成, 服务端只有收到了第三次握手才算链接建立完成.

系统层面理解

首先我们清楚, CS任意一方的都会允许同时存在很多个已经完成三次握手的链接, 所以OS就需要对多个链接进行管理, 所以tcp协议中就会有对应的结构自字段去描述链接, 同时在内部去用特定的数据结构(链表)去管理起来.

比如, 可能会有这样类似的结构, 包含源/目的ip和端口, 序列号,确认号,窗口大小,缓冲区等, 对于服务器来说, 有n个链接就有n个结构体对象去描述, 对链接的管理就变成对链表的增删查改, 客户端也是如此. 所谓的逻辑上描述的链接在OS底层就是数据结构.

struct tcp_connection 
{
    
    uint32_t local_ip;      // 本地 IP 地址(IPv4)
    uint16_t local_port;    // 本地端口号
    uint32_t remote_ip;     // 远程 IP 地址(IPv4)
    uint16_t remote_port;   // 远程端口号
 
    //传输控制字段 
    uint32_t seq_num;       // 当前发送序列号
    uint32_t ack_num;       // 期望接收的确认号
    uint32_t window_size;   // 接收窗口大小(流量控制)
    uint32_t mss;           // 最大报文段大小(MSS)
 
    //缓冲区与数据管理 
    buffer_t send_buf;      // 发送缓冲区(存储待发送数据)
    buffer_t recv_buf;      // 接收缓冲区(存储已接收数据)
 
    //状态机与计时器
    enum tcp_state state;   // 连接状态(如 ESTABLISHED、CLOSE_WAIT 等)
    timer_t retransmit_timer; // 超时重传计时器
 
    //其他控制信息 
    //....
    
    int urgdataptr; //紧急指针偏移
    struct tcp_connect* next; //下一个节点
};

 比如紧急指针, 当前服务器收到了一个URG报文, 响应的字段urgdataptr就修改为: 接收缓冲区的大小(n) + 当前报文的紧急指针字段(m) = n + m. 相应的, 如果接收缓冲区数据被处理, urgdataptr就减去被处理的数据大小(q) = n + m - q.

再比如链接的状态变化,

客户端第一次握手时建立的链接就将state初始化为SYN_SENT.

服务器在第二次握手时初始化自己的连接状态为SYN_RECV.

然后第三次握手成功, 客户端服务端先后修改 state 为 ESTABLISHED. 服务端的accept检测到state为ESTABLISHED, 就创建对应的 struct file 返回对应的套接字.

所以这里我们就知道, TCP为了可靠性为什么会有成本, TCP是有连接的, 底层维护链接需要时间+空间的成本, 而UDP是无连接的, 不需要维护链接.

为什么要进行三次握手 

先说为什么不是一次和两次握手?

缓解资源浪费与拒绝服务攻击(如SYN Flood)

  • 一次握手: 如果只需要一次握手(客户端发送 SYN,服务器直接确认并建立连接), 服务器在收到客户端的 SYN 请求后, 必须立即分配资源来维护连接状态. 这就意味着, 在恶意攻击(如 SYN Flood)下, 攻击者可以伪造大量的 SYN 包, 服务器就必须为每一个无效的请求分配资源, 可能导致资源耗尽, 甚至拒绝正常用户的连接请求, 造成 拒绝服务(DoS)

  • 两次握手: 两次握手仍然存在类似的安全风险. 攻击者发送伪造的 SYN 包, 虽然服务器会响应 SYN-ACK,但攻击者根本不需要回应ACK, 而且服务器还是要为每个伪造连接分配资源. 这会导致服务器的资源被占用, 无法处理合法请求.

为什么要三次握手?

    1. 以最小成本验证全双工 

    TCP通信是全双工的, 通信要保证双向的信道畅通, 对于客户端和服务器都需要确定自己具备 收数据和发数据的能力, 这是通信最基本的要求. 所以这样就能解释三次握手的各自标志位的含义, 对于客户端来说拥有一次发SYN(第一次握手)和收ACK(第二次握手)的过程, 对于服务器来讲也有一次发SYN(第二次握手)和收ACK(第三次握手)的过程, 这就解释了三次握手是如何验证全双工的. 而一次握手和两次握手都只能保证一方, 无法保证双方.

    为什么说是“最小成本”?

    什么是最小成本, 是要保证全双工的前提下, 尽量减少握手次数. 保证全双工其实本应该四次握手才能确定的事情, 由于中间服务器本来的两次握手可以由捎带应答进行合并成一次, 这就保证了最小成本. 因此因此 TCP的三次握手 也叫 "四次握手+一次捎带应答".

    2. 奇数次握手, 客户端优先把链接建立好, 然后服务器才建立.   

    • 防止恶意滥用: 服务器需要等到客户端的确认后才开始建立连接,这保证了服务器在连接建立的过程中不会承担单方面的资源消耗风险,从而防止了恶意程序向服务器发送大量连接请求,导致其资源被滥用

    资源消耗平衡: 三次握手的意义是推迟了服务器建立链接的顺序, 客户端在发出第三次握手就认为链接建立完成, 而服务器是受到第三次握手才认为链接建立完成, 这种设计避免了服务器在收到大量无效的连接请求时先行消耗资源, 服务器的链接建立完成意味着客户端也和服务器一样承担了对等的责任, 保证了服务器在连接建立的过程中不会承担单方面的资源消耗风险. 因此这样做能防范单机程序恶意向服务器挂靠链接.

    但三次握手并不能解决SYN洪水的问题.

    Syn-Flood攻击成立的关键在于服务器资源是有限的,而服务器收到请求会分配资源。

    现在的问题就是服务器如何在不分配资源的情况下

    1. 验证之后可能到达的ACK的有效性,保证这是一次完整的握手
    2. 获得SYN报文中携带的TCP选项信息

     三次握手成功之后, 我们双方就有了链接, 就可以开始通信了 read/write.

     四次挥手

    为什么TCP要四次挥手呢? 是否可以是三次挥手?

    首先从图中可以看到, TCP的四次挥手和三次挥手有点像, 只是TCP的第二次握手是捎带应答, 握手可以捎带应答, 是因为客户端向服务器发起建立连接的请求是不可拒绝的, 因此SYN和ACK在这个场景下是合理的.

    我们要明确: 四次挥手的本质是双方各自独立地关闭发送方向, 而不是直接关闭整个连接.

    如果客户端单方面想向服务器断开连接可以被允许的, 但这并不意味着服务器要向客户端断开连接. 如果服务器在收到 FIN立即回复 FIN+ACK,等于它也马上关闭发送方向, 但99%的情况下服务器并不想立即和客户端断开链接. 因为服务器可能对客户端的通信还没有结束

    但是三次挥手也是可能发生的, 此时的场景下双方对于链接的断开欲望都很强烈.      

    如何理解断开链接?

    因为TCP是全双工通信, 所以在数据传输角度有 C->S 和 S->C 

    C->S断开链接: "Server, 我给你发送的数据发完了, 我不再给你发送了". 但这并不影响 S->C 方向上的行为.

    所以有一个系统调用接口 shutdown(fd, how)

    shutdown(fd, SHUT_RD);   // 关闭读
    shutdown(fd, SHUT_WR);   // 关闭写
    shutdown(fd, SHUT_RDWR); // 关闭读+写
    

    shutdown会使链接处于半关闭, shutdown(fd, SHUT_WR)告诉对方 "我不再发送数据", 但仍能读取对方的响应数据, shutdown() 不会释放 socket 资源, 只是让某个方向停止通信, socket 仍然存在, 直到 close() 被调用. 而close()会关闭 TCP 连接, 还释放 socket 资源, 文件描述符会无效.

    对于四次挥手的状态变化, 会衍生出一系列的问题:

    问题1: 随着时间推移, Linux系统网络应用越来越卡顿, 为什么?

    在一个多线程的 tcp_echo_server 服务器中, 在为一个客户端提供完服务之后, 故意不 close, 让链接处于未释放的状态

    此时如果客户端主动 close 断开链接, 而服务器未 close:

    • 客户端 发送 FIN,进入 FIN_WAIT_1
    • 服务器 收到 FIN, 发送 ACK,进入 CLOSE_WAIT
    • 客户端 收到 ACK, 进入 FIN_WAIT_2

     但此时服务器迟迟不 close连接会长时间停留在 CLOSE_WAIT, 客户端处于FIN_WAIT_2:

    现在用本机充当客户端和服务器, 用telnet访问本机服务器, 并主动quit:

    发现确实存在一个 CLOSE_WAIT 和一个 FIN_WAIT2 状态: 

    这样做随着主动退出的客户端增多, 服务器的 CLOSE_WAIT 状态会不断积累, 导致大量半关闭连接, 也就是底层的链接结构体未释放, 资源泄漏, 随着服务器资源不断被消耗, 服务器就会非常卡顿.

    问题2 : 为什么有的时候服务器主动断开链接后, 再次启动服务器会出现地址复用的 bind 错误?

    现在重复上面的场景, 启动服务器, telnet客户端链接服务器, 但此时服务器主动断开链接.

    TCP协议规定: 主动断开链接的一方会处于 TIME_WAIT 状态, 所以此时服务器会处于TIME_WAIT, 并且发现bind端口号失败了:

     为什么端口号会bind失败呢? 因为此时服务器处于TIME_WAIT, 所以此链接就尚未被彻底释放, 因此服务器的ip+port就还在被使用, 而重启server 去bind(ip, port)时, 由于 ip+port 只能被唯一的进程使用, 因此bind会失败.

    过了一段时间TIME_WAIT状态自动消失, 重新bind就可以成功了:

    setsockeopt接口

    问题3: 为什么主动断开链接的一方会处于 TIME_WAIT 状态? TIME_WAIT的意义?

    之前的一张图中为什么这里查不到客户端的状态? 因为此时客户端(telnet)是被动断开链接的一方, 在完成了四次挥手之后就释放了链接, 但既然四次挥手已经完成, 为什么服务器却还处于TIME_WAIT状态一段时间, 不直接CLOSED呢? 

    主要原因是: 网络中, 可能还会存在尚未到达对方的报文. 

    但是正常的报文不可能在四次挥手中还没到达对方, 所以可能有一些历史的陈旧报文被阻塞在路由器中, 后来发送方也确实超时重传了, 但那个历史报文依然存在在网络中只是来的很迟, 如果此时接收方不TIME_WAIT, 而是在双方再次建立链接的时候接收到这个历史报文, 那就会干扰到链接的建立.  因此主动断开链接的一方要进行等待

    3.1 为什么是"主动断开链接的一方"?

    因为主动断开的一方已经关闭了单方向的通信, 所以 "历史报文" 大概率是对方发过来的.

    3.2 为什么要等待?

    是为了让历史报文从网络中消散. 也就是在TIME_WAIT状态下收到历史报文后丢弃. 这样就能极大概率减少历史报文对新链接建立的影响.

    问题4: TIME_WAIT 的时间是多长?

    TCP协议规定,主动关闭连接的一方要处于TIME_ WAIT状态, 等待两个MSL(maximum segment lifetime)的时间后才能回到 CLOSED状态

    4.1 为什么是 TIME_WAIT 的时间是 2MSL?

    MSL的定义是: 一个TCP段在网络中从源到目的地的最长生存时间(即数据包在网络中最大可能的延迟时间), 注意MSL不是超时时间, 而是报文在网络中从A->B的最大存活时间.

    可以通过 cat /proc/sys/net/ipv4/tcp_fin_timeout 查看msl的值, RFC1122中规定为两分钟,但是各操作系统的实现不同, 当前ubuntu下默认是60秒:

    总结:

    1. 因为这样能保证在两个传输方向上的尚未被接收或迟到的报文段(历史报文)都已经消失

    2. 同时也能在理论上保证最后一个报文的可靠到达(假设最后一个ACK丢失, 那么服务器会超时重发一个FIN. 这时虽然客户端的进程不在了, 但是TCP连接还在, 仍然可以重发LAST_ACK);

    4.2 序号

    首先, 历史报文干扰新连接的情况本身就相对较少, 现在通过TIME_WAIT状态, 历史报文对新连接的干扰进一步减小. 更为重要的是, 之前说TCP协议通过序号来保证可靠性如去重和按序到达, 这意味着即便历史报文被延迟到达, 它们的序号也极大概率不会与新连接的序号发生冲突. 因此, 历史报文对新连接的影响是有限的, 几乎不可能. 

    而且链接的随机起始序号是在TCP三次握手期间由双方协商的, 这不仅能保证TCP通信的可靠性, 防止旧连接的历史数据干扰新连接, 还能增强安全性, 防止序列号预测攻击.


    3.5 滑动窗口与流量控制

    滑动窗口是 "TCP 并发发送大量暂时不要 ACK 的数据段, 从而提高发送效率的解决方案, 也是流量控制的解决方案."

    3.5.1 滑动窗口在哪里?

    滑动窗口是发送缓冲区的一个区域:

    3.5.2 如何理解滑动? 滑动窗口的 "滑动" 本质就是下标的移动

    3.5.3 滑动窗口的大小由谁来决定? 滑动窗口的大小, 由接收方的接收能力决定. (虽然最终的发送还要和拥塞窗口进行比较, 但一码归一码)

    3.5.4 滑动窗口如何正确的更新?

    接收方给发送方的ACK报头中会有 ACK序号窗口大小 字段.

    而 win_start = ACK序号; win_end = win_start + 窗口大小

    那最开始的时候, 滑动窗口的大小是如何确定的呢? 是在TCP的三次握手期间协商的.

    3.5.5 滑动窗口的大小是如何变化的?

    假如接受方接收了数据, 把数据一直存在接收缓冲区中但是不取走, 那么它返回的窗口大小就会一直减小, 直到零, 所以接收方的接受能力决定了窗口大小. 此时滑动窗口左侧(win_start)一直右移, 而win_end却一直不变.

     此时接收方拿走了接收缓冲区的数据, 然后重新发送报文通知发送方, 更新窗口大小.

     上面对于流量窗口大小的控制行为其实就是流量控制, 接收方能力弱/强 -> 窗口大小 小/大 ->发送慢/快

    3.5.6 滑动窗口不能向左滑动, 只能向右滑动, 那如果越界了怎么办? 

    主要思路是把缓冲区想象为一个环形结构, 新数据覆盖掉已经发送过的旧数据即可

    3.5.7 ACK丢失不用担心, 因为有累计确认机制. 那如果报文丢失了怎么办?

    1. 最左侧报文丢失

    由于连续ARQ中, 报文的ACK序号规定为该序号之前的全部数据都全部收到, 所以最左侧的报文丢失, 右侧的报文都应该返回最左侧丢失报文的起始序号:

    此时就触发了 快重传机制 :

    当发送方连续收到 3 个重复的 ACK时, 就可以推测某个数据包丢失了, 此时发送方将立即重传丢失的数据包, 而不必等到超时. 此例发送方会立即重传 SYN = 2000的报文.

    如果是前两个报文都丢失了, 发送方只会返回两个ACK, 无法触发快重传, 所以只能触发超时重传. 因为此时虽然收到了ACK = 1001, 但这个 ACK 并没有前进, 超时计时器依旧在运行, 最终超时重发报文.

    总结: 因此发送方把数据发出去, 一段时间内, 已经发送的数据是不能被移除的, 应该被暂时保存到滑动窗口之中, 因此滑动窗口内的数据由两部分组成: 可以随时发送的 和 已发送但未确认的. 直到收到对应的ACK报文, 滑动窗口右移, 删除指定报文.

    2. 中间报文丢失和最右侧报文丢失

    中间报文和最右侧报文丢失本质都会转换为最左侧报文丢失的问题, 假如ACK=4001对应的报文丢失了, 前两个报文的ACK都正常返回ACK=2001, 3001, 滑动窗口右移. 而第三个报文丢失, 会返回ACK=3001, 此时该报文就是最左侧报文. 

    更详细的图解:

    3.5.8 滑动窗口里有很多可以直接发送的数据, 为什么要分为多个数据段发送, 不全部一次发送?

    IP层MTU

     流量控制

    接收端处理数据的速度是有限的. 如果发送端发的太快, 导致接收端的缓冲区被打满, 这个时候如果发送端继续发送,就会造成丢包, 继而引起丢包重传等等一系列连锁反应. 因此TCP支持根据接收端的处理能力, 来决定发送端的发送速度. 这个机制就叫做流量控制(Flow Control); 实现的策略就是滑动窗口

    • 接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 "窗口大小" 字段, 通过ACK端通知发送端;
    • 窗口大小字段越大, 说明网络的吞吐量越高;
    • 接收端一旦发现自己的缓冲区快满了, 就会将窗口大小设置成一个更小的值通知给发送端;发送端接受到这个窗口之后, 就会减慢自己的发送速度;
    • 如果接收端缓冲区满了, 就会将窗口置为0; 这时发送方不再发送数据, 但是需要定期发送一个窗口探测数据段, 使接收端把窗口大小告诉发送端.

    补充: 实际上, TCP首部40字节选项中还包含了一个窗口扩大因子M, 实际窗口大小是 16位窗口字段的值左移 M 位;

    3.6 拥塞控制

    之前介绍了TCP为了提高可靠性和性能的措施:

    可靠性:

    • 校验和✅
    • 序列号(按序到达)✅
    • 确认应答✅
    • 超时重发✅
    • 连接管理✅
    • 流量控制✅
    • 拥塞控制

    提高性能:

    • 滑动窗口✅
    • 快速重传✅
    • 延迟应答
    • 捎带应答✅

    但是这些都是TCP为了保证主机之间通信可靠性和性能所做的, 而拥塞控制还考虑了网络的情况.

    如果是少量的报文丢失, 超时重传即可, 但如果出现了大面积的丢包, 发送方会判定: 和我相关的网络出现了问题, 此时就不能超时重传了. 因为既然要重传网络的所有主机都要重传, 只会加剧网络问题.

    慢启动

    虽然TCP有了滑动窗口这个大杀器, 能够高效可靠的发送大量的数据. 但是如果在刚开始阶段就发送大量的数据, 仍然可能引发问题. 因为网络上有很多的计算机, 可能当前的网络状态就已经比较拥堵. 在不清楚当前网络状态下, 贸然发送大量的数据,是很有可能引起雪上加霜的.

    TCP引入 慢启动 机制, 先发少量的数据, 探探路, 摸清当前的网络拥堵状态, 之后再尽快找到速率上限进行正常的网络通信

    相关名词 

    • MSS:最大报文段长度, TCP双方发送的报文段中, 包含的数据部分的最大字节数
    • cwnd:拥塞窗口, TCP发送但还没有得到确认的报文的序号都在这个区间
    • RTT:往返时间, 发送方发送一个报文, 到接收这个报文的确认报文所经历的时间
    • ssthresh:慢启动阈值, 慢启动阶段, 若cwnd的大小达到这个值, 将转换到拥塞避免模式

    拥塞窗口 

    拥塞窗口(cwnd), 发送开始的时候, 定义拥塞窗口大小为1MSS. 此后每次收到一个ACK应答, 拥塞窗口加1MSS;  

    拥塞窗口只是一个数字, 意义是当发送的数据量超过拥塞窗口时, 有很大的概率引起网络拥塞.

    cwnd与滑动窗口的关系: 滑动窗口决定了发送方单次发送数据量的多少, 但除了接收方的接受能力, 实际发送还需要考虑网络状况, 因此每次发送数据包的时候, 实际发送的窗口大小 = min{ 拥塞窗口大小, 接收端的窗口大小}

    因此我们不需要担心指数级增长会使对方来不及接受.   

    慢启动工作原理

    由于TCP是一次性将窗口内的所有报文发出, 所以所有报文都到达并被确认的时间, 近似的等于一个RTT. 所以在这个阶段, 拥塞窗口cwnd的长度将在每个RTT后以指数级别增长, 即发送速率将以指数级别增长. 比如下图的[0, 4]轮次: 

    • 初始cwnd=1MSS, 所以可以发送一个TCP最大报文段, 成功确认后, cwnd = 2MSS
    • 此时可以发送两个TCP最大报文段, 成功接收后, cwnd = 4MSS
    • 此时可以发送四个TCP最大报文段, 成功接收后, cwnd = 8MSS

     那这个过程什么时候改变呢, 这又分几种情况:

    • 第一种超时: 若在慢启动的过程中, 发生了数据传输超时,则此时TCPssthresh的值设置为cwnd / 2,然后cwnd重新设置为1MSS,重新开始慢启动过程, 这个过程可以理解为试探上限 (图中未给出)
    • 第二种达到门限值ssthresh:第一步试探出来的上限ssthresh将用在此处。若cwnd的值增加到>= ssthresh时,此时若继续使用慢启动的翻倍增长方式可能有些鲁莽,所以这个时候结束慢启动,改为拥塞避免模式(图中给出)
    • 第三种触发快重传: 若发送方接收到了某个报文的三次冗余确认(即触发了快重传的条件), 则进入到快速恢复阶段; 同时, ssthresh = cwnd / 2,毕竟发生快速重传也可以认为是发生拥塞导致的丢包,然后cwnd = ssthresh + 3MSS 或 ssthresh (图中只给出了拥塞避免的快重传, 但都是类似)

    指数增长

    像上面这样的拥塞窗口增长速度, 是指数级别的. "慢启动" 的慢只是初始时慢, 但是增长速度非常快. 为什么要以 2^n 指数增长?

    因为这符合网络通信场景的要求, 前期慢一些探测网络状况, 而中后期要尽快找到上限进行正常的网络通信

    拥塞避免 

    但为了不增长的那么快, 因此要引入一个慢启动的阈值(ssthresh), 当拥塞窗口超过ssthresh的时候, 不再按照指数方式增长, 而是按照线性方式增长, 这个过程叫做拥塞避免.

    线性增长的过程什么时候结束, 分为两种情况:

    • 第一种: 在这个过程中, 发生了超时, 则表示网络拥塞, 这时候, ssthresh被修改为cwnd / 2,然后cwnd被置为1MSS,并进入慢启动阶段;
    • 第二种:若发送方接收到了某个报文的三次冗余确认(即触发了快速重传的条件),此时也认为发生了拥塞,则ssthresh 被修改为 cwnd / 2,然后cwnd被置为 ssthresh + 3MSS,并进入快速恢复模式;
    快速恢复

    参考: 计算机网络——TCP的拥塞控制(超详细) - 特务依昂 - 博客园

    部分图源: 计算机网络: TCP的拥塞控制,四种拥塞算法_tcp拥塞控制算法-CSDN博客

    3.7 延迟应答 

    如果A给B发消息, B可能不会立即给A进行应答, 会延迟一段时间发送ACK, 这段延迟的时间后可能就可以给A发送一个更大的接收窗口大小.

    为什么需要延迟 ACK?

    在 TCP 传输中, 接收方需要向发送方发送 ACK 确认, 同时更新接收窗口大小(rwnd). 如果 接收端立刻返回 ACK, 此时的窗口大小可能会比实际可用的空间小, 从而限制发送端的数据流量.

    解释: 因为此时接收端处理报文的时间小于RTT, 等到下一次接收方准备接收数据时, 缓冲区早已腾空, 此时ACK的窗口大小就可以扩大, 从而可以要求主机A发送更多的数据, 提高TCP通信的效率.

    即使最终实际的窗口大小可能受拥塞窗口影响, 但这种策略的意义在于有概率提高TCP通信的效率. 

    如何控制延迟 ACK?

    TCP 采用了 两种策略 来平衡传输效率和时延:

    1. 数量限制: 每隔 N 个数据包 发送一次 ACK(通常 N=2)
    2. 时间限制: 如果超过 最大超时时间(一般 200ms), 必须发送 ACK

    因为窗口越大, 网络吞吐量就越大, 传输效率就越高. 延迟应答是为了优化TCP通信的性能, 其目标是在保证网络不拥塞的情况下尽量提高传输效率;

    3.8 面向字节流

    TCP报文只有4位首部长度, 我们并不确定数据的长度具体是多少. 因为TCP并不关心报文的边界问题, 协议只要能把报文和有效载荷分离, 然后把有效载荷放入缓冲区队列中即可, 而报文的分割是由应用层去解决的. 所以此时的缓冲区就像一个流动的数组, 不断地有数据被拿出去, 不断有数据被放入队列., 从而展现出一种流动性

    由于缓冲区的存在, TCP程序的读和写不需要一一匹配, 例如:

    • 写100个字节数据时, 可以调用一次write写100个字节, 也可以调用100次write, 每次写一个字节;
    • 读100个字节数据时, 也完全不需要考虑写的时候是怎么写的, 既可以一次 read 100个字节, 也可以一次read一个字节, 重复100次
    粘包问题

    在TCP的协议头中, 没有如同UDP一样的 "报文长度" 这样的字段, 但是有一个序号这样的字段. 站在传输层的角度, TCP是一个一个报文过来的. 按照序号排好序放在缓冲区中. 但是站在应用层的角度, 看到的只是一串连续的字节数据, 不清楚报文之间的边界

    所以解决粘包问题就是要清楚报文之间的边界, 下面的方案可能进行多种组合:

    1. 特殊字符(比如http协议每一行数据结尾的\n)
    2. 定长报文(比如规定每个报文的长度一定是 n 个字节)
    3. 报文+自描述字段(比如http协议里的content-length字段, 描述了有效载荷的长度)

    3.9 TCP异常情况

    进程终止: 进程如果在通信时终止/崩溃了, 由于网络套接字的本质其实就是文件, 所以进程终止了那么OS底层为该进程维护的资源就要释放, 包括socket底层的文件会被自动关闭(释放文件描述符), 用网络语义来说就是操作系统会自动向对方发起四次挥手. 因此进程终止时TCP链接会被正常的自动关闭.

    机器重启: 举个例子, 当我们电脑有大量进程运行时, 此时关机电脑会提示用户"是否先关闭进程", 因此电脑关机前是要先终止进程的, 因此这和进程终止的情况相同. (所以电脑刚开机时就关机速度回很快, 因此此时没有大量的应用进程需要关闭)

    机器掉电/网线断开:  当客户端的网线掉了, 此时服务器不知道客户端此时掉线了, 由于TCP有一定的保活策略, 会不定时给客户端发送探测报文, 检查客户端的状态. 如果对方不在 , TCP自己也内置了一个保活定时器, 定时结束会关闭链接.  但是实际上这种保活策略大多数是在应用层做的, 此时这种策略就叫心跳机制.

    再假设一个场景, 客户端断线之后自己通常会关闭链接, 假如客户端重新向服务器请求一个新链接, 但是服务器并不清楚情况, 仍在给客户端发送消息, 那么客户端就发送RST要求链接重置, 此时服务器的写入失败, 会收到SIGPIPE信号.

    总结: 因此我们看到TCP对于异常的处理是能掌控的住的.


    4. TCP/UDP对比

    我们强调TCP是可靠连接, 但TCP不是一定优于UDP, TCP和UDP之间的优点和缺点, 不能简单绝对的进行比较

    • TCP用于可靠传输的情况, 应用于文件传输, 重要状态更新等场景;
    • UDP用于对高速传输和实时性要求较高的通信领域, 例如, 早期的QQ, 视频传输等. 另外UDP可以用于广播;

    归根结底, TCP和UDP都是程序员的工具, 什么时机用, 具体怎么用, 还是要根据具体的需求场景去判定

    5. 补充

    理解 listen 的第二个参数 backlog

    backlog: 这个参数指的是全链接队列的个数, 即已经建立连接(established)并等待被accept的 sockets的队列的长度.

    底层的tcp会在自己的连接中, 维护一个全连接队列, 队列中有效节点的个数是有上限的为= backlog+ 1, 一般不会太长, 但也不能没有.

    客户端状态正常, 但是服务器端出现了 SYN_RECV 状态, 而不是 ESTABLISHED 状态这是因为, Linux内核协议栈为一个tcp连接管理使用两个队列:

    1. 半链接队列(用来保存处于SYN_SENT和SYN_RECV状态的请求)

    2. 全连接队列(accpetd队列)(用来保存处于established状态,但是应用层没有调用accept取走的请求)

    而全连接队列的长度会受到 listen 第二个参数的影响. 全连接队列满了的时候, 就无法继续让当前连接的状态进入 established 状态了

    待完善

    6. 文件+socket+系统+网络

    为什么socket也有文件描述符? 为什么UDP有单独的接口sendto/sendmsg? 先来从系统级别理解一下网络通信.

    CPU并不是轮询地去访问网卡处理网络数据, 而是:

    1. 数据包到达网卡之后, 网卡主动发送中断信号给CPU

    2.此时CPU读取中断号并跳转到中断向量表去执行网卡中断程序

    3. 这个服务程序就是硬件厂商写好的网卡驱动程序, 程序把网卡中存储的数据读取到内存接收缓冲区中. 这样数据就读取上来了.

    4. 此时数据就被sk_buff存储, 被OS管理起来.

    1. 当我们创建了socket之后, OS底层一定要创建一个struct file并维护在进程PCB的文件描述符表中:

    2. 除此之外, 还要创建一个 struct socket 结构体, 而struct file中有一个成员 void* private_data可以用来指向任意类型的数据成员, 在创建socket时就指向这个struct socket结构体.

    还可以看到其中的ops存储了各种网络套接字的接口(bind connect accept 等).  

    3. 其中还有一个成员struct sock, 选取其中的部分字段, 比如sk_backlog, 和 sk_receive_queue接收缓冲区 和 sk_write_queue 发送缓冲区:

    因此应用层在读取传输层数据时, 通过 struct file -> struct socket -> struct sock -> struct sk_buff_head sk_receive_queue 拿到了数据.

    4. 其实上面的struct sock指向的并不直接是一个struct sock成员, 而是更重要的 tcp_sock 和udp_sock.

    如图, tcp_sock嵌套inet_connection_sock -> inet_sock -> sock , 而udp_sock则是嵌套inet_sock->sock:

    所以 struct socket 中的 struct sock* 指针实际上指向的并不是 struct sock, 而是 struct sock 的具体协议实现 struct tcp_sock 或 struct udp_sock. 它们的结构设计遵循内核协议栈的面向对象思想(C 语言的伪多态), 结构体成员是嵌套关系, 且为前者的第一个数据成员, 所以我们可以在需要时通过特定的宏实现强制类型转换来访问协议特定的字段.