网络通信领域的发展日新月异,UDP协议因其轻量/高效的特性被广泛应用于实时数据传输,然而其不提供可靠性保证的特点也限制了其在某些场景下的应用.本实验旨在基于UDP协议实现可靠传输,为在UDP协议下保证数据可靠性提供一种有效的解决方案.
UDP(User Datagram Protocol)是一种无连接的/不可靠的传输协议,它不提供像TCP(Transmission Control Protocol)那样的可靠性和错误处理机制.然而,有时候在UDP上实现一些可靠传输的机制可能是有意义的,具体取决于应用的特定要求和场景.
为了实现基于 UDP 的可靠传输, 基本思路是由 server 向 client 发送数据包, client 向 server 回复对应数据包的 ACK 确认. 如果一段时间内 server 没有收到来自 client 的 ACK 确认, 那么 server 认为该数据包 client 没有收到, 再次发送, 直到收到 ACK 为止
整个过程的结束标志是 client 认为收到了所有的数据, 因此需要 server 提前告知 client 一共需要接收的数据包个数 或者 完整文件的大小 以便 client 通过默认配置信息计算出来数据包个数
server 需要知道的信息是RTT, 因为 server 需要接收来自 client 的 ACK 确认, 以保证发送的数据包全部到达, 因此需要设置一个 ACK 的超时时间, 发送数据后 RTT 内没有按时收到来自 client 的 ACK 确认则认为该数据包丢失, 重发. 在实际实现中选择的超时时间可以比 1RTT 大一些, 比如 MAX_RTT_MULTIPLIER * RTT, 即多等待一会儿 ACK 确认帧再重发
如前文所述, 需要 server 提前告知 client 一共需要接收的数据包个数, 这样只要 client 确认接收到了所有的数据就可以主动断开连接. 因此模拟 TCP 建立链接时的三次握手来完成 client 和 server 之间的数据通信
三次握手的基本流程如下图所示
SYN 数据包. 数据包的内容不重要, 只是为了通知 server 说明 client 已经就绪, 可以开始通信. 发送之后 client 进入 SYN_SENT 状态SYN 之后立即回复一个 SYN_ACK 数据包, 表示以及接收到了 client 的连接, SYN_ACK 数据包需要包含客户端一共需要接收多少个数据包 max_package_count, 发送后进入 SYN_RCVD 状态等待来自 client 的回信SYN_ACK 之后就可以确认两件事情, server 收到了第一次发送的 SYN 以及总共需要接收的数据包个数. client 最后再回复一个 ACK 确认, 进入 ESTABLISH 状态ACK 之后可以确认 client 已经接收到之前发送的 SYN_ACK 了, 此时 server 可以确认双方建立了连接, server 进入 ESTABLISH 状态并开始不断地发送数据包. 数据包到达 client 之后 client 就知道最后发送的 ACK 已经被 server 收到. 此时通信双方都可以确认双方建立了连接, client 开始接收数据, 并在内部维护一个计数器 counter 用来记录收到的包的数量考虑到网络环境不好的情况下可能会出现丢包, 以三次握手的第一次为例, 发送的 SYN 数据包因为某种原因丢失或损坏了, client 采用与 TCP 相同的策略, 设置一个接收的超时时间 1s, 当 1s 之后仍然没有接收到来自 server 的 SYN_ACK 则认为该数据包已经丢失.
此时重发 SYN, 并翻倍超时时间, 一共尝试重传 6 次, 如果均失败则认为网络信道质量太差, 断开连接. 这里的重传次数与超时时间翻倍策略与 Linux 的 TCP 实现采用相同的配置.
三次握手都可能会出现在建立连接的过程中丢包的情况, 如下图黄色的 retry 部分所示. 此时均采用这种策略处理建立连接时的丢包问题.
根据实验要求, 基本假设如下图所示
其中 client 的 IP 为 202.100.10.2, server 的 IP 为 202.100.10.3.
双方建立两组 socket 套接字进行通信
SYN ACK FINSYN_ACK 以及后续的数据包两组的 socket 在程序中都是单向传输的, client 所有向 server 发送的数据都会通过 control_socket 发送, server 向 client 发送的数据也都会通过 data_socket.
尽管一对 socket 是全双工的, 但这里区分了控制信号和数据包, 主要是为了更好的利用多线程的并行性进行发送和接收
为了提高效率, 本程序采用了多线程的处理方式.
SERVER_SEND_THREAD_NUMBER 表示服务器发送端线程数量;SERVER_ACK_HANDLE_THREAD_NUMBER 表示服务器处理 ACK 的线程数量;CLIENT_RECEIVE_THREAD_NUMBER 表示客户端接收线程数量;CHUNK_SIZE 表示分块大小;首先读取整个文件, 将文件按照 SERVER_SEND_THREAD_NUMBER 进行等分, 无法整除的多余的部分交附加给最后一个线程发送. 每个线程会获取到自己需要发送的文件数据的偏移量(seek_pos)以及需要发送的大小(thread_send_size), 如下图所示
其后每个线程根据偏移量找到对应的起始发送位置, 并按照 CHUNK_SIZE 进行分块. CHUNK_SIZE 分块的目的主要是考虑网卡的 MTU, 普通的以太网卡,一帧最多能够传输 1500 字节的数据, 如果待发送的数据超过帧的最大承载能力,就需要先对数据进行分片,然后再通过若干个帧进行传输.
笔者实验过程中发现一旦设置了比较大的 CHUNK_SIZE 在使用 tc 加入延迟或者丢包会出现数据包发不出去的情况, 因此最终选择的 CHUNK_SIZE 为 1KB. 其实如果网络条件很好的话那么改成更大的比如 32KB 会快很多
每个数据块在 seek_pos 的基础上根据 CHUNK_SIZE 可以计算得到每个数据块对应的 seek_pos, 通过 data_socket 发送的时候在数据段部分额外附加一个 8 字节的 seek_pos 字段, 用于标记该数据的偏移量
客户端接收到数据包之后可以根据头部的 8 字节获取到该数据块对应的偏移量, 统一保存在一个 file_data 数组中, 接受完全部的数据包之后根据 seek_pos 进行排序, 将总数据写入文件即可.
同时 client 还会发送一个 ACK 数据包给server, 表示 client 收到了来自 server 的数据, ACK 数据包只包含一个 8 字节的 seek_pos
由于本程序使用多线程并行的发送, 所以没有滑动窗口的窗口大小的设计. 所以没有考虑流量控制. 将因为流量过大导致的丢包问题全部扔给选择重传解决
在网络条件不好的情况下可能会出现丢包的情况, 可能是 server 发送的 data 丢失了, 也可能是 client 回复的 ACK 丢失了, 不过二者在 server 端的表现相同, 都是发出的数据包在一定时间内没有收到 ACK 确认应答, 因此需要重发包. 本程序采用选择重传的方式来处理超时的数据包
在初次建立连接三次握手的时候, server 可以根据两次 SYN ACK 的间隔计算得到 RTT 的值
server 的每个线程发送数据包的时候会记录 偏移量(seek_pos), 数据块大小(package_size), 以及数据包发送时间(send_time). 同时 server 设置了一个检查定时器的线程, 每隔一段时间 (rtt) 会检查所有定时器. MAX_RTT_MULTIPLIER 表示超过 RTT 的时间倍数, 如果检查定时器发现时间间隔超过了 MAX_RTT_MULTIPLIER * RTT 则认为丢包
SERVER_TIMEOUT_RESEND_THREAD_NUMBER 表示服务端超时重发的线程数量, 对于所有超时的定时器, 将其中的数据取出放入一个线程安全的队列(Queue), 所有重发线程从队列中取数据重发数据包, 如下图所示
所有发送进程发送完对应的数据之后也加入并成为超时重发线程
server 在接收 ACK 的时候也会通过 ACK 到达时间 和 数据包发送时间 做差重新计算 RTT, 如下图所示. 每一个数据包都可以计算出对应的 RTT
ADJUST_RTT_THRESHOLD 表示调整 RTT 的次数阈值
RTT * MAX_RTT_MULTIPLIER 则认为网络拥堵, 调大 RTTRTT / MAX_RTT_MULTIPLIER 则认为网络流畅, 调小 RTTclient 内部维护一个计数器, 当接收到的数据等于需要接收的包的数量之后向 server 发送 FIN
正常来说 TCP 采用了四次挥手来确认双方都断开连接, 但是本程序中就不考虑 server 的程序了, 直接多次发送 FIN 数据包告知 server 以及完成传输后 client 直接退出. server 如果收到了 FIN 则也停止发送