计算机网络 第三次实验

第三次实验 RDT通信程序设计

一、停-等协议

接收端

​ 接收端需要修改的代码如下:

​ 首先,需要接收RDT数据

1
2
3
// step 1. 接收RDT数据包
char rdt_pkt[RDT_PKT_LEN];
int pkt_len = recv(sockfd,rdt_pkt,RDT_PKT_LEN,0);//已修改!!!

​ 之后对其解封装

1
2
3
4
// step 2. 解封装RDT数据包
char rdt_data[RDT_DATA_LEN];
int seq_num, flag;
int data_len =unpack_rdt_pkt(rdt_data,rdt_pkt,pkt_len,&seq_num,&flag);//已修改!!!

​ 然后检查此数据包是否为期待的数据包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// step 3. 检查此数据包是否为期待的数据包 : seq_num==exp_seq_num
int ackflag=RDT_CTRL_ACK;
if(seq_num!=exp_seq_num){
printf("pack #%d received! ",seq_num);
if(seq_num<exp_seq_num){//已经接收到,仍需返回ACK
printf("already exist\n");
}
else {
printf("not exist but wrong\n");//接收到了更后面的包,不予理会
continue;
}
}
else {
printf("pack #%d received! length:%dbytes\n",seq_num,data_len);
exp_seq_num++;
fwrite(rdt_data,sizeof(char),data_len/sizeof(char),fp);
total_recv_byte+=data_len;
}

​ 最后封装一个新的ACK包并发送

1
2
3
4
5
6
7
8
9
10
11
12
13
// step 4. 封装一个新的RDT数据包(ACK包)
char reply_rdt_pkt[RDT_PKT_LEN];
int reply_pkt_len=pack_rdt_pkt(NULL,reply_rdt_pkt,0,seq_num,ackflag);//已修改!!!

// step 5. 调用不可靠数据传输发送新的RDT数据包(ACK包)
udt_send(sockfd,reply_rdt_pkt,reply_pkt_len,0);//已修改

if (flag == RDT_CTRL_END) {
udt_send(sockfd,reply_rdt_pkt,reply_pkt_len,0);//保险用
udt_send(sockfd,reply_rdt_pkt,reply_pkt_len,0);
udt_send(sockfd,reply_rdt_pkt,reply_pkt_len,0);
break;
}

​ 需要注意的是这里有一个问题:接收方对于结束包的ACK如果丢包了,由于接收端已经关闭,则发送方会永远的发送结束包导致无法结束进程,由于每一个ACK包总是有几率丢包,我们理论上无法阻止这种情况,但是可以尽量避免。例如在这里采用了对结束包发送4次的方法,由于丢包率为$10\%$,理论上四包全丢将会是$0.01\%$的概率,在实验中已经足够避免异常。现实中更加常用的办法将在思考题3中讨论。

发送端

​ 发送端需要修改的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// step 1. 封装RDT数据包
char rdt_pkt[RDT_PKT_LEN];
int pkt_len = pack_rdt_pkt(rdt_data,rdt_pkt,data_len,seq_num,flag);//已修改!!!

// step 2. 发送RDT数据包,重传直到收到ACK
while (1) {
// step 2-1. 调用不可靠数据传输发送新的RDT数据包
printf("[Sender]Packet #%d: %d bytes. Send count #%d\n", seq_num, pkt_len, counter++);
udt_send(sockfd,rdt_pkt,pkt_len,0);//已修改!!!

// step 2-2. 一直等待到文件描述符集合中某个文件有可读数据,或者到达超时时限: poll()
/*已修改!!!!!!!!!!!!!!!!!!!!!!!!!*/
struct pollfd pollfd = {sockfd, POLLIN};
if(poll(&pollfd,1,RDT_TIME_OUT)>0){//fds长度为1
char rdt_pkt_rcv[RDT_PKT_LEN];
int pkt_len_rcv=recv(sockfd,rdt_pkt_rcv,RDT_PKT_LEN,0);//接收ACK
char rdt_data_rcv[RDT_DATA_LEN];
int seq_num_rcv, flag_rcv;
unpack_rdt_pkt(rdt_data_rcv,rdt_pkt_rcv,pkt_len_rcv,&seq_num_rcv,&flag_rcv);//已修改!!!
if(flag_rcv==RDT_CTRL_ACK&&seq_num_rcv==seq_num) break;//正确的ACK包
};
}

// step 3. 发送成功,更新seq_num和total_send_byte
/* 已修改!!!!!!!!!!!!!!!!!!!! */
seq_num++;
total_send_byte+=data_len;

二、回退N协议

接收端

​ 回退N协议的接收端与停-等协议的接收端是相同的。

发送端

​ 发送端需要修改的代码如下:

​ 滑动窗口

1
2
3
4
5
6
7
8
9
10
11
12
13
// step 1. 如果滑动窗口最左端的包已经收到ACK,则将滑动窗口滑动到下一个没收到ACK的包的位置。
/* TODO */
while(1){
int i=send_window.left;
StatePkt *ptr_pkt=&send_window.rdt_pkts[i% send_window.len];
if(ptr_pkt->state==RDT_PKT_ST_ACKED&&i==ptr_pkt->pkt_seq){//收到ACK
printf("[main thread] pack #%d acked,left++\n",ptr_pkt->pkt_seq);
send_window.left++;//左移窗口
}
else {
break;//检测到没有收到ACK的包,停止滑动
}
}

​ 填充数据包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// step 2. 将新数据包装入滑动窗口右端空缺位置。
int pkt_to_send = 0; // 需要发送的数据包个数
/* TODO */
for(int i=send_window.right;send_window.right-send_window.left<send_window.len;i++){//一直填充到最大的发送窗口
if (feof(fp)) break;//没有数据了就结束
StatePkt *ptr_pkt=&send_window.rdt_pkts[i %send_window.len];
char rdt_data[RDT_DATA_LEN];
int data_len = fread(rdt_data, sizeof(char), RDT_DATA_LEN, fp);
int pkt_len = pack_rdt_pkt(rdt_data,ptr_pkt->rdt_pkt,data_len,i,RDT_CTRL_DATA);
ptr_pkt->state=RDT_PKT_ST_INIT;//填充数据包
ptr_pkt->pkt_seq=i;
ptr_pkt->pkt_len=pkt_len;
send_window.right++;//滑动窗口
pkt_to_send++;
}

​ 这里需要注意的是,由于初始化时send_window.left=send_window.left,而且最后结束时仍以send_window.left==send_window.left为判断条件,因此不能将窗口每次都整体滑动,而是应该在满足窗口长度小于最大窗口长度的前提下根据需要分别滑动窗口左右边界。

总结

  • 思考题1:这个问题实际上在前一次实验报告中已经有了详尽的讨论,在这里再做一次摘要。能够使用send()recv()函数是因为套接字在收发前使用了connect()函数。这里的connect()函数与TCP中不同,它实际上并没有和目标地址建立连接,只不过是为套接字绑定了一个目标地址,其之后无论收发都会以这个地址为目标。也正是由于这个特性,此时发送端和接收端都需要绑定一个不变的端口,因为它们都需要预先知道对方的地址,这与上一次实验中只需要对服务器端使用bind绑定是不同的。我们可以在net.c中两个socket的初始化函数里找到以下部分:

    1
    2
    3
    4
    5
    6
    7
    //发送端
    bind(sockfd, (struct sockaddr *)&send_addr, sizeof(struct sockaddr));
    connect(sockfd, (struct sockaddr *)&recv_addr, sizeof(struct sockaddr));

    //接收端
    bind(sockfd, (struct sockaddr *)&recv_addr, sizeof(struct sockaddr));
    connect(sockfd, (struct sockaddr *)&send_addr, sizeof(struct sockaddr));

    这也佐证了我们之前的分析。并且也如前一次报告中所提到的,send函数成功返回并不代表接收方已经收到,甚至也不代表已经发送完成,只是成功将其送至系统内发送缓冲区。

  • 思考题2:在传统停-等协议里,发送端为每个包设置一定时器,若在定时器超时后没有收到正确的响应则重传;在本实验中则略有不同,发送端为每个包的每次发送开展一个有超时时间$t$的轮询,若达到超时时间后或者接收到的不是正确的ACK响应则重传。值得注意的是,若没有接收到ACK,则这个$t$就是超时重传的超时时间,若收到了错误的ACK,则此时重传的时间实际上是小于$t$的,所以在本实验中,超时时间实际上是在变化的。

  • 思考题3:双方正确结束通信理论上是永远无法保证的。因为我们无论引入再多的确认机制,在极端的情况下所有的确认都有可能丢失,最后总有一方会被永远的挂在通信中无法结束。因此人们选择了“三次握手”的法则进行连接的释放。假设A方发起了通信的结束信号,则B在接收到该信号后需要给A回复一个结束信号,A在收到该结束信号后发送一个响应信号并结束通信,B在收到响应后也结束通信。当A没有收到结束信号或者B没有收到响应信号时,它们都会进行超时重传。同时,为了防止一方已关闭连接而另一方仍然在重传,需要对重传次数设限,超限后连接将自动断开。又为了防止所有重传真的都丢失了,设定在连接不活跃一段时间后自动关闭连接。这样一个协议,不能保证正确结束通信,但是至少可以防止一方被永远挂起的情况。

  • 思考题4:这是为了防止旧的接收窗口和新的接收窗口重叠,导致数据包被错误识别。考虑一个2位序号的滑动窗口协议,接收和发送窗口大小最大都是3。当接收与发送都是[0,1,2]时,发送端数据全部到达接收,接收端滑动窗口为[3,0,1],而返回的ACK全部丢失了,发送端选择重新传输旧的[0,1,2],由于此时[0,1]有重叠,它们又被当做新的数据包被接收了,再加上发送窗口收到[0,1]的应答后又会继续发送[2,3,0],包含接收端的[3],接收端又能正常的滑动窗口。因此这个协议并不会阻塞在某处,而是神不知鬼不觉的传输了错误的数据包。为了避免这种现象,我们需要接收方滑动窗口后与发送方上一次发送的窗口不重叠。由此我们可以得到$RW.right+(SW.right-RW.left)-SEQ≤SW.left$,即$RWS+SWS≤SEQ$,式中RW,SW,SEQ分别表示接收、发送窗口和序号长度。为了高效不浪费还应考虑$SWS≥RWS$,因此可以得到接收窗口的大小必须小于或等于序列号空间大小的一半。

  • 我的收获与总结

    1. 学习了RDT协议的相关知识,进行了RDT通信程序的设计,了解了传输层的滑动窗口协议。
    2. 对socket中的函数有了更深的理解,学习利用多线程进行通信。
      • 本次实验可以很直观的看到回退N协议与停等协议在有噪信道上的效率。在两张实验记录图中记录下了两个协议传输同样大小的文件所用时间:约2.43秒(停-等协议)和约10.93秒(回退N)协议,这说明由于回退N协议在丢包率较高时需要大量重传已经发送成功的数据,其实际上的运行效率甚至不如停-等协议。
      • 我注意到回退N协议输出了大量的内容,为了排除printf()函数带来的影响,我去掉了除显示发送时间外的所有输出语句,得到如下结果:约2.40秒(停-等协议)和约8.86秒(回退N)协议,说明此时回退N效率确实不如停-等协议。
      • 再来看看丢包率为0的情况:约0.29秒(停-等协议)和约0.0244秒(回退N)协议,这时就能很直观的体会到回退N协议的高效了。