声明:禁止以任何形式转载本文章。本文章仅供个人学习记录与交流探讨,文章中提供的思路只是一种解决方案,代码也并非完整代码,如有需要,请自行设计协议并完成编程任务。
食用本文章之前,推荐阅读:C++实现UDP可靠传输(一)
目录
在上篇文章中,我们实现了一个简单的基于UDP协议的可靠传输,主要使用rdt3.0协议,完成了差错检测和确认重传功能。但这个协议的设计还存在一些缺陷,例如,流量控制采用停等机制可能造成延时过长,没有设置拥塞控制等。在这篇文章中,我们将在上篇文章的基础上改进:
在停等机制中,我们只能一次发送一个数据包,等到该数据包被确认后才能发送下一个数据包,这种情况下链路利用率不高,存在性能问题。我们可以对此进行改进,采用流水线机制,也就是在确认未返回之前允许发送多个数据包。
但是,我们不能一直发送数据包,否则接收端的缓冲区可能溢出,也可能中途某个数据包丢失,造成失序。本文章中,主要介绍使用GBN算法实现流量控制的方法。
GBN算法的基本思想如下:
也就是说,使用GBN算法和上一篇文章中的程序设计不同之处在于:
发送端的有限状态机如下:
接收端的有限状态机如下:
首先来设置一些全局变量,之后我们会看到它们的用途。
- int resendCount = 0; // 重传次数
- int base = 2; // 窗口最左边
- unsigned char nextSeq = 0; // 要发送的下一个序列号
- bool resend = false; // 是否超时重传
- bool restart = false; // 是否重新开始计时
- bool wait = false; // 是否因窗口不够而需等待
- long int lenCopy = 0; // 文件数据偏移量的拷贝,供重传时使用
我们现在采用流水线机制发送消息,因此必须要使用线程来负责计时和接收消息。大家先记住这个需求,我们先写主体部分,也就是发送消息的函数,写完后就明白两个线程应该怎么写了。
我们先来看看发送消息的函数相比上一篇文章中有什么变化。
首先当然是窗口的设置。我们需要设置一个固定窗口大小window,窗口的空间就在base到window(不含)之间。
其次是发送消息的部分。发送消息时,我们需要判断这条消息的序列号是否在窗口空间内,如果是则发送,如果不是,则等待。等到什么时候呢?直到收到ACK消息或超时重传。这就需要用到两个线程了。
wait标志位表示是否等待窗口,在接收消息的线程中,如果收到正确的ACK消息,线程会更新窗口大小,这时会将wait设置为false。resend标志位表示是否超时重传,在计时的线程中,如果发现超时,我们需要将resend设置为true。
- // 若下一个序列号在窗口内,发送消息
- // 注意base+window>=256的情况
- if (((base + window) < 256 && (int)nextSeq < base + window) ||
- ((base + window) >= 256 && ((int)nextSeq >= base || (int)nextSeq < (base + window) % 256))) {
- if (sendto(sockSender, sendBuf, h.len + sizeof(h), 0, (SOCKADDR*)&addrReceiver, sizeof(SOCKADDR)) == -1) {
- cout << "Error: fail to send messages!" << endl;
- return false;
- }
- else {
- nextSeq = (nextSeq + 1) % 256;
- sentLen += (int)h.len;
- }
- }
- // 若下一个序列号不在窗口内,等待ACK
- else {
- cout << "Waiting for window!" << endl;
- wait = true;
- while (wait & !resend) {
- Sleep(100);
- }
- }
最后是超时重传的部分。记住计时的部分我们会单独写在计时线程中,线程会判断是否超时并设置resend标志位。在发送消息的函数中,如果resend标志位被设置为true,那么我们需要重传所有未被确认的分组,即从base到nextSeq的数据包。
看过上一篇文章的读者应该记得,在发送消息的函数中,我写了一个for循环,从k=0开始,发送packNo个数据包。在这里我设计的超时重传方式是更新k和其它变量,让for循环跳到base所对应的k处,相当于重传。大家也可以采取其它方式,例如直接在这里调用sendto函数。
- // 超时重传
- if (resend) {
- if (resendCount >= maxResend) {
- cout << "Fail to send messages!" << endl;
- return false;
- }
- if ((nextSeq - 1) < base) {
- k -= ((int)nextSeq + 256 - (int)base + 1);
- }
- else {
- k -= ((int)nextSeq - (int)base + 1);
- }
- nextSeq = base;
- sentLen = lenCopy;
- resend = false;
- resendCount++;
- }
明确了发送消息的函数有什么变化后,我们可以来编写计时线程和接收消息的线程。
计时线程比较简单,我们设置了restart标志位,只要在其他的线程中正确设置这个标志位,即计时线程就可以正常运行。我们还设置了resend标志位,这是负责通知主线程应该超时重传了。细心的读者可能发现,我总是通过标志位来统一线程,这是因为我希望线程尽可能保持简单,毕竟线程一旦出错很难debug。
- DWORD WINAPI timer(LPVOID lparam) {
- clock_t start = clock();
- while (true) {
- if (restart) {
- start = clock();
- restart = false;
- }
- if (clock() - start > maxTime) {
- resend = true;
- start = clock();
- }
- }
- return 0;
- }
接收消息的线程只负责一件事,就是接收到正确的ACK消息后更新窗口,所以核心其实只有一句:base = (h2.seq + 1) % 256。但是为了程序能够正常运转,我们还需要正确的设置其他的全局变量,例如重新开始计时、停止等待窗口等。特别需要注意的是,需要更新lenCopy,这个值是base所对应的文件的偏移量,在超时重传的时候会用到。大家也可以采用其它的方法标记,只要超时重传能够从base对应的地方读取文件即可。
- DWORD WINAPI recvThread(LPVOID IpParameter) {
- SOCKET sockSender = *(SOCKET*)IpParameter;
- int addrlen = sizeof(SOCKADDR);
- char recvBuf[1024];
- memset(recvBuf, 0, sizeof(recvBuf));
- while (true) {
- if (recvfrom(sockSender, recvBuf, 1024, 0, (SOCKADDR*)&addrClient, &addrlen) > 0) {
- HeadMsg h2;
- memcpy(&h2, recvBuf, sizeof(h2));
- // 收到消息需要验证校验和
- if (h2.type == ACK && !checkSumVerify((u_short*)&h2, sizeof(h2))) {
- lenCopy += (h2.seq - base + 1) * maxSize;
- base = (h2.seq + 1) % 256;
- restart = true;
- resendCount = 0;
- wait = false;
- }
- // 如果报文损坏,什么都不做,重传只靠超时
- }
- }
- return 0;
- }
至此,我们就基本完成了GBN算法实现的流量控制机制。下面我们来看拥塞控制。
为了防止主机发送的数据过多或过快,造成网络中的路由器(或其他设备)无法及时处理,从而引入时延或丢弃,我们在发送消息时,还需要进行拥塞控制。TCP协议采用基于窗口的方法,通过拥塞窗口的增大或减小控制发送速率。本文章中,我们也借鉴这样的做法,使用New RENO算法实现拥塞控制。
先来看看RENO算法的原理。文字描述不太清楚,大家还是看有限状态机:
RENO算法定义了三个阶段:
这三个阶段之间的关系如下:
其中,在快速恢复阶段,收到新的ACK消息时,New RENO算法和RENO算法的不同之处在于,它会检查这个New ACK是否为已经发送的最后一个数据包的序列号,也就是说是否所有发送的数据包都被确认,如果不是,将会继续保持快速恢复,而不会进入拥塞避免。这样程序就不会频繁地在快速恢复和拥塞避免两个阶段反复横跳,造成阈值多次折半的问题。
另外,需要注意的一点是,流量控制和拥塞控制都是基于窗口的机制,二者在窗口大小的计算上是独立的。流量控制的窗口大小取决于接收端的缓冲区,只是我们上面为了简单将其设置为固定大小;拥塞控制的窗口大小是基于网络的情况。而实际发送窗口取决于接收通告窗口和拥塞控制窗口中的较小值。
有了之前的程序,实现New RENO算法只要在此基础上稍加修改即可。
设置全局变量如下:
- const int rwnd = 10; // 接收通告窗口大小,固定值
- double cwnd = 1; // 拥塞窗口大小
- int ssthresh = 8; // 阈值
- unsigned char lastAck = 0; // 上一个ACK序列号
- int dupAck = 0; // 重复收到的ACK次数
- int renoState = 0; // RENO状态机的状态,0为慢启动,1为拥塞控制,2为快速恢复
计时线程需要加上超时后阈值和拥塞控制窗口大小的变化情况,以及更新RENO状态机的状态。
- if (clock() - start > maxTime) {
- // 超时后阈值减半,窗口大小为1
- ssthresh = cwnd / 2;
- cwnd = 1;
- dupAck = 0;
- // 超时则进入慢启动阶段
- if (renoState != 0)
- renoState = 0;
- }
接收到正确的ACK消息后,需要加上RENO状态机的三个状态的处理(参照有限状态机)。
- // 慢启动
- if (renoState == 0) {
- // 判断是否为new ACK,是的话更新窗口空间
- if (h2.seq == lastAck) {
- dupAck++;
- }
- else {
- dupAck = 0;
- lastAck = h2.seq;
- cwnd++;
- }
- // 窗口大小超过阈值,进入拥塞避免阶段
- if (cwnd >= ssthresh)
- renoState = 1;
- // 重复ACK超过3次,进入快速恢复阶段
- if (dupAck == 3) {
- ssthresh = cwnd / 2;
- cwnd = ssthresh + 3;
- renoState = 2;
- }
- }
- // 拥塞避免
- else if (renoState == 1) {
- if (h2.seq == lastAck) {
- dupAck++;
- }
- else {
- dupAck = 0;
- lastAck = h2.seq;
- cwnd += 1.0 / cwnd;
- }
- // 重复ACK超过3次,进入快速恢复阶段
- if (dupAck == 3) {
- ssthresh = cwnd / 2;
- cwnd = ssthresh + 3;
- renoState = 2;
- }
- }
- // 快速恢复
- else {
- if (h2.seq == lastAck) {
- cwnd++;
- }
- // 接收到new ACK,需要判断是否发送的消息都被接收
- // 如果不是,保持快速恢复;如果是,进入拥塞避免阶段
- else {
- if (h2.seq == (nextSeq - 1 + 256) % 256) {
- renoState = 1;
- lastAck = h2.seq;
- }
- }
- }
在发送消息的函数中,window应该代表实际发送窗口的大小,而这取决于接收通告窗口和拥塞控制窗口中的较小值。本文章将rwnd设置为固定窗口大小,读者可以将其设置为可变的,由接收端发送来的消息确定,这就需要在消息头中加上相关的字段表示rwnd。
- // 实际发送窗口取决于接收通告窗口和拥塞控制窗口中的较小值
- int window = 0;
- if (rwnd < cwnd)
- window = rwnd;
- else
- window = cwnd;
运行程序,可以看到三次握手的过程,成功建立连接。发送端输出消息提示用户输入需要传输的文件或者断开连接。
我们随便选择一个命令,发送端很快开始发送文件,并输出相关信息。从左到右依次为,发送的数据长度(字节)、消息类型、序列号、校验和、窗口的情况(base为窗口左端,tail为窗口右端)、CWND(拥塞控制的窗口大小)、拥塞控制的阈值。注意,接收通告窗口大小固定为10,方便测试并观察。
可以看到一开始拥塞控制处于慢启动阶段,每次收到ACK消息窗口都增加1。直到cwnd> ssthresh,程序进入拥塞避免阶段,窗口大小增长减慢。
直到cwnd=9时,实际的窗口大小还是base+cwnd。cwnd>10后,实际的窗口大小取接收通告窗口和拥塞控制窗口中的较小值,即接收通告窗口10,此后不论cwnd如何增长,实际的窗口大小都为10。
突然,观察到cwnd=11, ssthresh=8,显然这不是拥塞避免阶段了,发生了什么?
查看接收端,原来刚刚接收端接收到了损坏的数据包(通过写程序人为破坏),因此丢弃了序列号为49的数据包,并且一直在等待它。而发送端还不知道,依然在流水线地发送后面的数据包,所以接收端将后面来的数据包都丢弃了,并返回ACK=48的消息。发送端一直收到重复的ACK消息,重复ACK次数为3时,发送端会进入快速恢复阶段,ssthresh=cwnd/2, cwnd=ssthresh+3,和我们在发送端观察到的一致。
可以看到这时候窗口为49-59(不含),发送端发送完序列号为58的数据包后,开始等待窗口。超时后,发送端重传所有未被确认的数据包,也就是从序列号49到序列号58。这时,拥塞控制从快速恢复阶段进入慢启动阶段,阈值减半,cwnd=1。
查看接收端,发送端重传的数据报被接收端正常收到。
最后,所有数据包都发送完毕并被成功确认后,发送端输出提示消息,并输出传输时间和吞吐率。
查看接收端,同样也输出了相关信息。接收消息完毕后,成功写入文件。
发送端断开连接,可以看到四次挥手的过程,成功断开连接。