用UDP实现TCP可靠传输的雏形
为什么做这个
大部分原因是之前经历的事情给我带来的部分感触吧,让我决定花时间研究一下这些基础知识。一方面为面试做准备,一方面巩固自己作为计算机科班的基础知识水平。
TCP 和 UDP 的区别
- 连接方式:
TCP 是面向连接(connection-oriented)的,UDP 是无连接(connectionless)的,这是最主要的区别,也是造成 TCP 稳定、UDP 不稳定的主要原因之一。 - 可靠性:
TCP 传输前需要经历三次握手,结束时需要四次挥手,可靠性强。 - 流量控制与拥塞控制:
TCP 具备流量控制(flow control)与拥塞控制(congestion control)机制,防止网络过载和数据丢失;UDP 两者都不具备,相对自由。 - 传输方式:
TCP 传输的是字节流(byte stream),接收端按序重组;UDP 传输的是数据报(datagram),不排序,收到什么就是什么。 - 传输效率:
TCP 由于需要建立连接、确认、重传等操作,效率相对低但可靠性高;UDP 开销小、传输速度快,适合竞技游戏这类时延敏感应用。
用 C++ 实现 TCP 客户端与服务端
与 UDP 相比,使用 TCP 时,套接字需要额外调用 listen() 来监听对应的端口。
相关代码
1 | if(listen(listen_fd,SOMAXCONN)==SOCKET_ERROR){ |
由于不用关心内部的各种机制,基本不需要额外的代码,只需关注收发。唯一值得提及的是:TCP 是字节流传输,数据不会自己断开,需要提前约定好收发协议,确定每次的内容长度,然后手动截断。
相关代码
1 | // Client |
这里每次先读 4 个字节的长度前缀,确认数据长度后再读对应长度的数据,做到不粘包读取。
用 C++ 实现 UDP 模拟 TCP 连接
UDP 使用时直接 bind 对应 IP 地址就可以开始工作,无需 listen,十分便捷;而且 UDP 基于数据报形式传输,也无需考虑粘包,一次传输多少,对方就能拿到多少。而要实现 TCP 的稳定传输,首先要规划好传输协议格式。
协议头
由于只是简单模拟,我设计了如下的报文头来模拟 TCP:
| 偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 0 | 2 | checksum |
校验和(必须落在偶数偏移) |
| 2 | 1 | flags |
标志位:SYN / ACK / FIN |
| 3 | 4 | seq |
序号(sequence number) |
| 7 | 4 | ack |
确认号(acknowledgement number) |
| 11 | 2 | window |
接收窗口,未具体实现逻辑 |
然后就只需要模拟 TCP 的过程,完成三次握手、四次挥手,以及中间的数据传输即可。由于这些步骤有许多重复的部分,我中途借助 AI 完成了部分工作。接下来讲述我的开发过程以及中途遇到的问题。
雏形
首先,我先用 UDP 简单地做了一个收发文本内容的回显,然后在此基础上逐步迭代。这一块和前面 TCP 基本没什么区别,就不再赘述。
校验和的实现
报文头的前两个字节存放校验和字段。每次接收数据报时调用一次校验和算法,判断原数据是否出现偏差。校验和的具体原理是:把所有数据的二进制累加求和,与传输前计算的值比对,以此校验数据是否可靠。
校验和代码段
1 | uint16_t inetChecksum(uint8_t *buf,int len){ |
我考虑过 32 位累加溢出的问题,但这不会导致校验和判断出错:因为校验方式是对称的,两边计算都会溢出,最后结果仍然一致。不过,由于它只是单纯的和运算,无法判断数据顺序是否正确,也防不住恶意篡改(改变数据但和不变)。
超时设置
由于 recvfrom 这个函数是阻塞的,如果中途数据丢失就会无限等待,因此需要 setsockopt 设置超时时间,时间到了自动返回 -1。但是写死的超时时间,要么浪费性能(多等),要么太短导致频繁超时,因此需要动态设置超时时间。于是我使用了 TCP 经典的 RTO 超时重传。
RTO超时重传
RTO(Retransmission TimeOut,重传超时)是决定何时触发重传的倒计时器,其设定是动态且自适应的。建立 TCP 连接时,重传计时器初始化为 3 秒(3000 毫秒),这是一个常见的默认起点。此后,RTO 会根据网络往返时间 RTT(Round-Trip Time,往返时间)实时调整。TCP 使用指数加权移动平均法计算平滑 RTT SRTT(Smoothed RTT,平滑往返时间)和 RTT 的偏差 RTTVAR(RTT Variance,往返时间方差),并按 RTO = SRTT + 4 × RTTVAR 这一经典公式估算下一次的超时时间。
1 | double SRTT = 0; // Smoothed RTT,平滑往返时间 |
由于使用的是已知的算法模型,没有太多需要赘述的地方,只要按 RFC 的内容写出代码即可。唯一需要注意的是:RTO 采样更新只在一次传输成功时进行,因为重传时收到的 ACK 无法确认对应的是哪一次发送,有可能是之前网络延迟导致的旧 ACK(这正是 Karn 算法要解决的问题)。这样 RTO 才能收敛;而 RTO 的扩增则直接在超时的时候 ×2(即指数退避)即可,但要注意给一个最大上限,否则会无限指数增大。
Go-Back-N 重传
说到重传,就不得不提 GBN(Go-Back-N,回退 N 步)这个特点:TCP 的超时重传中,只要当前窗口内有一个包超时,那么所有的包都要重传。实现逻辑没有什么特别好说道的地方。
Go-Back-N 代码
1 | int reSendJud(SOCKET fd,sockaddr_in addr){ |
三次握手
三次握手算是很经典的 TCP 知识了。需要三次握手,是为了在不可靠的网络上建立相对可靠的连接。那为什么不是两次?非要三次?

主要原因有两方面:
- 三次握手确保双方都知道对方的起始序列号(ISN,Initial Sequence Number,初始序列号);两次握手的话,只有客户端能确认服务端收到了自己的起始序列号。
- 三次握手可以防止过期到达的 SYN 让服务端进入单方面的连接状态,浪费资源。
总结:三次握手给双方都提供了判断对方是否收到自己信息的方式,而两次握手只有客户端知道对方是否收到,服务端没法确认。
四次挥手
四次挥手的相关问题:为什么是四次?CLOSE_WAIT、TIME_WAIT 分别有什么作用?

- CLOSE_WAIT 阶段:被动关闭的一方继续发送剩余数据的阶段。
- TIME_WAIT 阶段:主动关闭的一方留出一段时间,保证对方已经收到 ACK;若对方未收到,会重传 FIN,此时还能再补一次 ACK,防止对方卡在这个阶段。同时,也能确保新连接建立时,旧连接的残余报文已在网络中消亡,不产生其他影响。
- 为什么是四次?可以两两来看:每一方发出的信息都需要得到对方确认,防止遗漏。
握手代码
客户端:
1 | int findServer(SOCKET fd, sockaddr_in addr){ |
服务端:
1 | ConnResult acceptClient(SOCKET fd, sockaddr_in &peer, char *buf){ |
挥手代码
客户端:
1 | int closeConn(SOCKET fd, sockaddr_in addr){ |
服务端:
1 | if (buf[FLAGS_INDEX] & flags::FIN){ |
总结
TCP 相较于 UDP 最大的区别就是:TCP 会进行双端的信息交互,共同维护连接状态。本来打算把tcp做完的,但是发现每次添加新东西的时候有好多内容要改,然后做到流控这里就放弃了,半途而废这一块