计算机网络大作业期中报告

项目一览与摘要

项目特色

该项目在应用层实现了TCP协议中的众多功能以实现传输的可靠和高效:动态调整RTT,流量控制,阻塞控制,快速重传。

同时,还实现了并发接收和并发传输机制,能够同时接收或发送若干个文件。发送还支持文件夹发送,即保留文件夹结构不变地情况下发送里面的所有文件。

实现了基于md5断点续传功能,在传输发生中断时,下次传输时用户可以选择从上次中断之处继续传输,而不用从头传输,大大节省不必要的流量。

该项目还有完善的日志记录功能,会将程序运行过程中产生的正常信息,警告信息,错误信息分别保存下来,方便查错。

该项目实现了服务端和客户端双方收发文件的功能。客户端既能发送文件给服务端,也能从服务端下载文件到本地。

该项目实现了数据传输的报文数据段大小由客户端决定,服务端适应的机制,使得服务端能够适应不同客户端的传输需求。

该项目还有绘图功能,能够将发送过程中的参数变化绘制成图表,方便分析。

经实测实现了可靠和高效传输的效果。

此图为从连接校园网的终端传输一个10M的文件到阿里云服务器的结果,传输期间触发了一次超时重传,236次快速重传,总耗时34秒。

本项目基于python语言编写。

项目结构:

.
├── Client.py # 客户端程序
├── config
│   ├── config.py # 配置信息
│   ├── __init__.py
│   ├── Logger.py # 日志信息
│   ├── Receiver.py # 接收文件类
│   ├── Sender.py # 发送文件类
│   └── util.py # 功能函数,包括文件核对等
└── Server.py # 服务端程序

需求分析

众所周知,UDP是不可靠协议,但能够对应用层数据的发送控制更为精准,无连接建立,无连接状态使得UDP能较少地占用终端资源,且UDP分组的首部开销小,仅有8字节。而TCP是通用的可靠传输协议,适用范围很广,其首部有20字节的开销。因此对于文件传输来说,有许多信息是没有必要的。因此实现专门适用于文件传输的可靠协议是有必要的。而要用一个不可靠协议实现可靠传输,就如同TCP一样要在不可靠的IP协议下实现可靠传输一样,我们要将能够确保可靠的机制在UDP的上一层:应用层实现。于是我们需要参考TCP实现可靠传输的一些机制,在应用层上实现。同时为了改善用户体验,实现一些基本的需求比如断点续传,文件夹发送等功能。

设计思想

本项目基于建造者(Builder)模式设计。

服务端(Server)和客户端(Client)都有发送和接收文件的功能,两者在这两个功能上行为一致,只有起初的发送请求等行为不同。

于是服务端能够根据自身的身份(发送者还是接收者),创建相应的SenderReceiver进行发送或接收文件。同理客户端也能如此。

同时为实现并发传输及可靠性,服务端有个主进程,监听客户端的请求,并创建一个子服务进程,处理该请求,而主进程继续监听请求。

客户端同样也有个主进程,负责扫描发送的文件(夹),对每一个要发送的文件,创建一个子客户进程向服务端发送请求。当所有子进程结束后,主进程将根据子进程发送文件时产生的数据进行汇总,绘制图表。

子服务进程在处理请求,以及子客户进程在执行请求时,会根据自身的身份(发送者还是接收者),创建相应的类,并调用类方法执行。

核心算法

报文首部

签名sign (16bit) 窗口大小rwnd(16bit)
报文序号(32bit)
数据段(MSS)

其中rwnd对于接收方发送的报文而言,就是缓冲区的剩余大小,单位是MSS.

对于发送方发送的报文而言,用于特殊标记,如终止传输报文,请求端口报文等。

ACK报文,数据段为空。报文序号表示确认的报文序号。

注意,此处的ACK报文序号与TCP中的ACK不同。此处的ACK序号为接收到的最后一个报文序号,并非TCP里的接收到的最后一个报文序号加一。可以理解此处的ACKTCP里的ACK的关系为ACK=ACKTCP1ACK = ACK_{TCP} - 1

除了请求报文的MSS是双方约定好之外,握手和数据传输报文的MSS大小由客户端告知服务端,在请求报文中告知。

握手报文中,数据段的数据之间采用分隔符spliter(定义在config/config.py文件中)区分。

可靠机制

为实现可靠机制,需要对发出去的每一份报文收到相应的ACK确认报文,此处实现和TCP一致。

同时,为防止无关报文的干扰,传输通信的报文都有一个唯一确定的签名sign进行核验,不通过的报文将被丢弃。

报文出了签名外,还标有序号,以确保不被乱序收到。

动态调整RTT

实现自Jacobson / Karels 算法

SRTT=SRTT+α(RTTSRTT)SRTT = SRTT + \alpha ( RTT – SRTT ) —— 计算平滑 RTT

DevRTT=(1β)DevRTT+β(RTTSRTT)DevRTT = ( 1-\beta ) * DevRTT + \beta * ( | RTT - SRTT | ) ——计算平滑 RTT 和真实的差距(加权移动平均)

RTO=μSRTT+DevRTTRTO= \mu * SRTT + \partial * DevRTT

其中α=0.125,β=0.25,μ=1,=4\alpha = 0.125, \beta = 0.25, \mu = 1, \partial = 4,取自RFC6298。RTT是测量值,为一份报文从发出到接收到ACK报文所经历的时间。

流量控制

考虑到服务端和客户端性能上的差异,接收方收到的包并不会马上写入文件,而是会放到缓冲区里,而当缓冲区满时,接收方就不能接收新的数据包,防止缓冲区溢出,而此时如果发送方继续发送报文则会导致丢失,因此当客户端和服务端性能差异过大时,要避免不必要的发包。

实现机制和TCP类似,接收方每一次ACK回复报文中会带有当前缓冲区剩余长度,发送方会根据ACK报文中的剩余长度,动态调整自己的发送行为,必要时暂停发送。

阻塞控制

除了考虑服务端和客户端之外,还要考虑当前网络状况,如出现频繁丢包现象时不应保持持续发包,以降低发送成功率,加剧网络阻塞。

实现机制和TCP类似,发送方维护一个发送状态,有诸如慢启动阻塞避免快速恢复等状态,以及一个阻塞窗口,和流量控制里的流量窗口(接收方缓冲区剩余长度)共同控制发送方的发送行为。

快速重传

限于接收方采用按序收包的策略,当出现包丢失时,能够通过接收方发送的多次冗余ACK得知。

实现机制和TCP类似,当发送方接收到三次及以上冗余ACK后,会立刻重发仍未被确认的数据包。

断点续传

由于文件是从头开始发送,中间传输故障时,我们能够知晓接收方收到的数据是文件开头一定大小的。

客户端和服务端在进行握手时,接收方会向发送方发送自身文件的大小size和md5码,接收方接收到后,计算发送文件前size大小的md5码,与接收方发送的md5码进行比对。如果一致,说明文件一致,则向用户询问是否续传还是重传。

并发传输

运用多进程,使得每个进程负责一个文件的收发,达到并发机制。

软件架构

本项目共有四个模块,十分清晰简单。其类成员方法如下所示。

classDiagram
    class Sender{
    +file
    +send()
    +update_cwnd()
    +resend()
    +update_RTO()
    +receive()
    +summary()
    +start()
    }
    class Receiver{
    +file
    +receive()
    +write()
    +start()
    }
    class Server{
    +Shakehand()
    +start()
    }
    class Client{
    +Getport()
    +Shakehand()
    +start()
    }
    Sender --* Server
    Sender --* Client
    Receiver --* Server
    Receiver --* Client

流程图

这里展示客户端发送文件给服务端,续传方式。

Participant User
Participant Client
Participant SubServer
Participant Server

Client->Server: 我有个请求\n报文数据段大小为MSS\n报文签名为sign
Note right of Server: Package 0
Server-->Client: OK\n请发送请求到这个端口
Client->SubServer :这是文件名、\n文件大小、md5码
Note right of SubServer: 收到文件名,\n获取自身的文件大小\n和md5码
Note left of Client: Package 1
SubServer --> Client: OK,这是我这里存在的\n文件的部分数据大小\n和md5
Note left of Client: 核对服务端发来\n文件信息
Client -> User: 发现服务端\n有部分数据\n是否仅发送剩余数据
User --> Client :OK
Client -> SubServer : 我将续传发送剩余数据
Note right of SubServer: Package 2
SubServer --> Client : OK
Note left of Client : 开始发送文件
Client -> SubServer : Data Package 3
Client -> SubServer : Data Package 4
SubServer --> Client : ACK 3
SubServer --> Client : ACK 4
Client -> SubServer : Data Package 5
Client -> SubServer : Data Package 6
SubServer --> Client : ACK 5
SubServer --> Client : Lost ACK 6
Note left of Client : Timeout \nwhen receiving \nACK 6
Client -> SubServer : Data Package 6
SubServer --> Client : ACK 6
Client -> SubServer : Lost Data Package 7
Client -> SubServer : Data Package 8
Client -> SubServer : Data Package 9
Client -> SubServer : Data Package 10
SubServer --> Client : ACK 6
SubServer --> Client : ACK 6
SubServer --> Client : ACK 6
Note left of Client : 快速重传
Client -> SubServer : Data Package 7
Client -> SubServer : Data Package 8
Client -> SubServer : Data Package 9
Client -> SubServer : Data Package 10
SubServer --> Client : ACK 7
SubServer --> Client : ACK 8
SubServer --> Client : ACK 9
SubServer --> Client : ACK 10, buffer is full
Note left of Client : wait a second
Client -> SubServer : Is buffer still full?
SubServer --> Client : no, continue, please.
Client -> SubServer : Fin Package 11
SubServer --> Client : FIN ACK 11
Note right of SubServer : 检查接收文件的md5与\n从一开始从客户端接收的md5\n是否一致
Note right of SubServer : Close
Client -> User : I FINISH! \nHere is result.
User --> Client: Greate Jobs!
Note left of Client : Close

其余方式与此图非常类似。

关于异常处理,在发送文件前的握手阶段,如果有任何报文丢失的话,接收报文方将触发超时机制,重传上一份报文。连续超时5次则握手失败,关闭进程。

签名重复情况。

Participant Client
Participant Server

Client->Server: 我有个请求\n报文数据段大小为MSS\n报文签名为sign
Note right of Server: 发现签名已被其他请求使用,\n且来自不同的ip:port
Server-->Client: RESET,签名重复,请更换签名
Client->Server: 好的,我有个请求\n报文数据段大小为MSS\n新报文签名为Sign
Server-->Client: OK,请发送请求到这个端口

收到任何签名sign不对的报文均丢弃。

收到的任何序号不对的报文将丢弃,并重发上一个报文。

发送数据过程中出现的异常处理和TCP的异常处理一致,上面的流程图有所展示。

Sender内部的函数调用关系如下图所示。

digraph G {

subgraph cluster_0 {
style=filled;
color=lightgrey;
node [style=filled,color=white];
Start -> Send_Data
Start -> Receive_ACK
Send_Data -> pkg_Buffer
Receive_ACK -> resend
Receive_ACK -> update_RTO
Receive_ACK -> update_cwnd
resend -> pkg_Buffer
label = "Sender";
}

Client -> Start
SubServer -> Start

}

Receiver内部的函数调用关系如下图所示。

digraph F{

subgraph cluster_1 {
node [style=filled];
Write -> Data_Buffer
Receive_Data -> Data_Buffer
Start -> Write
Start -> Receive_Data
label = "Receiver";
color=blue
}

Client -> Start
SubServer -> Start
}

SenderReceiver的交互如下图所示。

digraph F{

subgraph cluster_0 {
style=filled;
color=lightgrey;
node [style=filled,color=white];
Send_Data
Receive_ACK
label = "Sender";
}
subgraph cluster_1 {
style=filled;
node [style=filled,color=white];
Receive_Data
label = "Receiver";
color=lightgrey
}
Receive_Data -> Receive_ACK
Send_Data -> Receive_Data

}

测试结果与讨论

发送的MSS报文大小会影响到传输效果。

在实测中,MSS太小时,总发包数量过多,尽管一次发包数增加,但无法弥补总包数增多带来的时间上的增加。MSS太大时,接收方难以快速处理接收到的数据包,导致丢包发生,且包太大时,经过中间路由会导致数据包乱序到达,由于接收方必须按序接收,这容易触发发送方的快速重传机制。

MSS过小,比如256Byte时,两次传输一个3MB的文件,发送方记录的数据图表如下:

MSS1024Byte时,其记录如下:

这两张图中cwnd曲线很好地符合教科书上的曲线特点。

这是发送方最后输出的信息。

这是接收方最后输出的信息,可以看到文件传输无误。

但当MSS调整为1024 * 10Byte时,发送方记录的数据图表如下:

可以看到,cwnd呈周期性震动,在接收方里的错误信息可以看到

接收方在接收数据时出现了周期性地数据包丢失,亦或者说是乱序收到,由于接收方必须按序接收,一旦乱序就会重传ACK报文,易让发送方产生多次没必要的超时重传。

从发送方的结果可以看出,消耗的时间反而更长了,因为期间产生了过多的快速重传机制。

刚刚传输了一次qwq文件,再次传输时会提示文件已存在,询问用户是否续传:

输入1表示续传,而由于文件已经完整,因而就只发了一个终止FIN包,传输就结束了。

MSS调整为1024 * 5Byte,结果如下:

可以看到,快速重传的次数减少了,但由于发送的数据包的数量增加,总耗时与上面差不多。

仔细观察出现warning的情况,可以推断出并不是包丢了,而是数据包在传输过程中顺序被打乱了,这就导致了之后一连串数据包的错位,此时cwnd的作用就出现了,不断减小的窗口能够很快缓解数据包乱序带来的现象,但这在一定程度上消耗了一定的时间和网络资源来解决这个问题。

不过好在本程序支持断点续传,此时我们重新启动传输,也可以解决此问题。

但同样MSS,在内网传输时,结果如下:

可以看出内网的网络环境明显优于外网。

总结

通过这次在公网进行文件传输的测试,可以得知外网环境较为复杂,当传输数据包大小较大时,由于外网传输路径的复杂性,期间的路由器等网络设备对数据包的处理可能容易造成乱序,进而影响应用层数据包的接收。同时在出现乱序的现象,我们也看到cwnd阻塞控制所起到的作用:它迅速减少同时发包数量,防止数据包乱序现象的持续。因为此时已经是乱序了,接收端接收到的乱序包混杂了很多重发包,如果这又造成多次ACK重发的话,会进一步触发发送方的快速重传机制,导致这种现象的加剧。

从实验中我们也发现,丢包现象相对于数据包乱序现象发生次数较小,如果接收方采用选择重传的机制,即不要求数据包要按序收到,允许在一段小的时间内乱序,进而将数据包根据序号重排,这或许能有效减少此种乱序现象导致的回退N步方式的快速重传机制,能有效改善传输的时间。但由于需要缓存收到的数据包,将数据包重组排序,这在一定程度上会占用服务端的CPU资源,当有许多个并发任务传输时对传输的稳定性可能是有一定的影响。

从这次实验也可以看出来,为了传输的可靠性先人做了许多的措施,在这次代码的编写时,也根据实际情况,对TCP类似机制的实现做了微调,尽管不能完美的解决问题,我们只能接受一定程度上的缺陷,尽可能实现好的效率。在起初的程序设计中,曾为了实现高效可靠的传输斟酌了各种可能的方法,例如考虑握手的实现方式,考虑每一步如果出现差错如何处理,实现选择重传还是回退N步等,超时重传的时间计量应该如何进行,是每个包一个时间还是一个ACK接收间隔一个时间等等,虽然没想到一个能完美无缺的方案,但我们还是得选一个缺陷允许接受的方案实现。或许在未来,网络物理条件改善,在物理链路层能保证可靠传输的话,应该就不需要这些了。