这一节的目标是完成一个最简单的尽量可靠 UDP 程序,实现的功能如下:
- 客户端给服务端发送数据,服务端收到数据后立即将数据原样发送回去,进行确认。
- 客户端收到服务端的确认后,检查是不是自己刚刚发送出去的数据,如果是表明服务端收到,继续发送一下个。
- 如果客户端在约定的时间(比如 1s 内)没有收到服务端的确认,则重传一次。
- 如果客户端重传 3 次服务端还没有确认,放弃重传,发送下一个数据。
上面这些功能非常简单,只是实现起来稍微有些麻烦,但是相比那些开源的 RUDP (Reliable UDP) 库,这已经简单的不能再简单了。当然,你可以在此基础上加入一些其它的特性,比如自己计算 RTO (超时重传时间)。
本小节程序们于文件夹:/unp/program/advcudp/retransmission
git 托管地址:https://gitee.com/ivan_allen/unp
1. 功能预览
为了先让同学们有个直观感受,先来看看这个程序完成后的样子,见图 1。
图1 一个简单的 RUDP 程序
图 1 中左侧是客户端程序(在主机 flower 上,后面简称 C 端),右侧是服务器程序(在主机 mars,后面简称 S 端)上。C 端程序从标准输入读取数据,并发送给 S 端,S 端收到数据后立即回射给 C 端。S 端程序有个特性,它会随机丢弃收到的数据(模拟网络环境差),并不会应答 C 端。
图 1 左侧,当我在输入 “This” 并由 C 端发送出去后,C 端等了 1 秒后没有收到 S 的回复,于是又重新发了一次。后面又有一次在发送 “udp” 的时候,C 端又重传了 2 次,才发送成功。
相对于图 2 右侧,同时可以看到 S 端丢弃了 “This”, “is”, “udp” 这些报文,并在屏幕上打印 “discard from 114.244.52.202(seq = 7, timestampe = 1000)” 类似的字样。
2. 传输协议设计
实现的要点在于客户端收到确认后,如何才能知道服务器确认的是哪一包数据呢?基于此,需要给数据打上序列号,设计的包格式如下:
| HEADER | MSG |
传输的消息由 header + msg 两部分构成,这个 header 是一个结构体,大小固定为 8 字节。msg 是真正要发送的消息(字节数据)。header 定义如下:
struct echo_head {
int seq; // 序号
int ts; // 时间戳,基准时间是 C 端报文发出去那一刻开始为 0 时刻。
};
3. 程序实现
3.1 客户端
void doClient(int sockfd) {
int ret, len, nr, nw;
struct sockaddr_in servaddr;
char outbuf[4096];
char inbuf[4096];
ret = resolve(g_option.hostname, g_option.port, &servaddr); // 解析主机名,填充 sock addr
if (ret < 0) ERR_EXIT("resolve");
while(1) {
nr = iread(STDIN_FILENO, outbuf, 4096); // read from stdin
if (nr < 0) {
ERR_EXIT("iread");
}
else if (nr == 0) break;
// 重点是这个函数,重传机制实现就在这里头,后面会介绍。
// 它的功能就是将数据发送出去,如果 1 秒内没收到确认,就重传。重传三次后就不再重传。
nr = send_recv(sockfd, outbuf, nr, inbuf, 4096, (struct sockaddr*)&servaddr, sizeof(servaddr));
if (nr < 0) {
ERR_EXIT("send_recv");
}
// 将收到的数据打印到屏幕。
iwrite(STDOUT_FILENO, "recv:", strlen("recv:"));
nw = iwrite(STDOUT_FILENO, inbuf, nr);
if (nr < 0) {
ERR_EXIT("iwrite");
}
}
}
3.2 服务端实现
void doServer(int sockfd) {
char buf[4096];
int nr, nw;
struct sockaddr_in cliaddr;
socklen_t len;
/*
struct echo_head {
int seq; // 序号
int ts; // 时间戳
};
*/
struct echo_head *hdr;
char *data;
while(1) {
// 接收客户端的数据
nr = recvfrom(sockfd, buf, 4096, 0, (struct sockaddr*)&cliaddr, &len);
if (nr < 0) {
if (errno == EINTR) continue;
ERR_EXIT("recvfrom");
}
hdr = (struct echo_head*)buf;
data = buf + sizeof(struct echo_head);
// 随机丢弃
if (rand() % 100 < 50) {
WARNING("discard from %s(seq = %d, timestamp = %d):\n", inet_ntoa(cliaddr.sin_addr),
hdr->seq, hdr->ts);
continue;
}
LOG("from %s(seq = %d, timestamp = %d):\n", inet_ntoa(cliaddr.sin_addr),
hdr->seq, hdr->ts);
iwrite(STDOUT_FILENO, data, nr - sizeof(struct echo_head));
// 将数据原样发送回去
nw = sendto(sockfd, buf, nr, 0, (struct sockaddr*)&cliaddr, len);
if (nw < 0) {
if (errno == EINTR) continue;
ERR_EXIT("sentdo");
}
}
}
3.3 send_recv 函数实现
/*
* sockfd: 套接字描述符
* outbuf: 发送缓冲区
* outlen: 发送缓冲区大小
* inbuf: 接收缓冲区
* inlen: 接收缓冲区大小
* dstaddr: 目标套接字地址
* addrlen: 套接字地址长度
* return: 返回 0 成功,小于 0 失败
*/
int send_recv(int sockfd, char* outbuf, int outlen,
char* inbuf, int inlen, struct sockaddr* dstaddr, socklen_t addrlen) {
// 初始化。
int nr, nw, maxfd, ret, rtt_count;
int64_t base;
struct msghdr msgsend, msgrecv;
struct iovec iovsend[2];
struct iovec iovrecv[2];
fd_set rfds;
sigset_t sigmask, emptyset;
FD_ZERO(&rfds);
maxfd = sockfd;
// 构造信号集。sigmask 集只包含 SIGALRM 信号。emptyset 什么也没有。
sigemptyset(&sigmask);
sigemptyset(&emptyset);
sigaddset(&sigmask, SIGALRM);
// 阻塞 SIGALRM 信号
ret = sigprocmask(SIG_BLOCK, &sigmask, NULL);
if (ret < 0) ERR_EXIT("sigprocmask");
registSignal(SIGALRM, handler);
// 构造 iovec 如果不记得 iovec 是干嘛的,回去补习。网络编程第 70 篇有详细介绍。
iovsend[0].iov_base = &sendhdr;
iovsend[0].iov_len = sizeof(struct echo_head);
iovsend[1].iov_base = outbuf;
iovsend[1].iov_len = outlen;
msgsend.msg_name = dstaddr;
msgsend.msg_namelen = addrlen;
msgsend.msg_iov = iovsend;
msgsend.msg_iovlen = 2;
msgsend.msg_control = 0;
msgsend.msg_controllen = 0;
msgsend.msg_flags = 0;
iovrecv[0].iov_base = &recvhdr;
iovrecv[0].iov_len = sizeof(struct echo_head);
iovrecv[1].iov_base = inbuf;
iovrecv[1].iov_len = inlen;
msgrecv.msg_name = NULL;
msgrecv.msg_namelen = 0;
msgrecv.msg_iov = iovrecv;
msgrecv.msg_iovlen = 2;
msgrecv.msg_control = 0;
msgrecv.msg_controllen = 0;
msgrecv.msg_flags = 0;
// 用于统计重传次数
rtt_count = 0;
++sendhdr.seq;
base = now();
sendagain:
sendhdr.ts = (now() - base)/ 1000;
LOG("send, seq = %d, ts = %d\n", sendhdr.seq, sendhdr.ts);
nw = sendmsg(sockfd, &msgsend, 0);
if (nw < 0) ERR_EXIT("sendmsg");
// 这里使用 SIGALRM 信号中断来模拟超时。
// 当然你完全可以使用 pselect 自带的超时来完成此功能。就当这次的作业吧。
alarm(1); // 设置超时时间
do {
FD_SET(sockfd, &rfds);
ret = pselect(maxfd + 1, &rfds, NULL, NULL, NULL, &emptyset);
if (ret < 0) {
// 超时重传
if (errno == EINTR) {
++rtt_count;
// 重传三次不成功宣告失败。
if (rtt_count < 3) {
WARNING("send again: ");
goto sendagain;
}
else {
errno = EHOSTUNREACH;
alarm(0); // 清理定时器
return -1;
}
}
ERR_EXIT("pselect");
}
nr = recvmsg(sockfd, &msgrecv, 0);
if (nr < 0) ERR_EXIT("recvmsg");
// 接收到的数据长度不够 or 接收的报文乱序,则重新接收
} while(nr < sizeof(struct echo_head) || sendhdr.seq != recvhdr.seq);
// 清理定时器
alarm(0);
return nr - sizeof(struct echo_head);
}
4. 总结
知道 RUDP 的基本原理,并能够实现一个最简单的 RUDP 程序。
我们实现的 RUDP 实在是太简单了,它没有自己去动态计算 RTT、RTO 这些值,你完全可以自由的去扩展它。除此之外,还可以改进的地方有重传策略,比如可以模仿 TCP 的快速重传策略。
还有其它的特性,比如拥塞控制,流量控制,这些可能都是需要你去考虑的。如果你完整的实现了这些内容,其实又是在走 tcp 的路,为啥不直接使用 tcp 呢?话又说回来,RUDP 可定制度更高,弹性更好,很多时候,需要采取折中方案的时候,这是个不错的选择。