TCP 服务并发对我们来说已经不陌生了,你有各种手段处理,比如多进程,多线程,IO 复用 + 单/多线程。但是 UDP 处理并发,如果不仔细思考一下,可能你会觉得这没什么嘛,还不是和 TCP 差不多。
1. TCP & UDP 多进程并发
这里以多进程 TCP 服务器并发作为参照,看看和 UDP 的区别在哪里。
- tcp
如果你不记得多进程并发模型,还请回去再复习一下《并发服务器(多进程)》,下面是多进程并发模型的伪代码。
// 片段 1
void server_routine() {
//先执行 bind, listen,伪代码略去
while(1) {
sockfd = accept(listenfd);
// 让子进程去处理 IO
pid = fork();
if (pid == 0) {
// child
close(listenfd);
doServer(sockfd);
close(sockfd);
exit(0);
}
// father;
close(sockfd);
}
close(listenfd)
}
- udp
如果是 udp,那伪代码是什么样的呢?
// 片段 2 (危险!请勿模仿!)
void server_routine() {
while(1) {
recvfrom(sockfd, &clientAddress);
pid = fork(); // 执行 fork() 吗?
if (pid == 0) {
doServer(sockfd); // ??? 可以吗
exit(0);
}
}
}
看起来似乎不是那么好实现,所以大家就无视代码片段 2 了的实现吧。接下来再想想,为什么 udp 没法像 tcp 那样,通过 fork 子进程来单独处理客户请求?
对比一下区别:
- TCP 有一个监听套接字 listenfd,使用 accept 可以返回一个新的连接套接字 sockfd,子进程 fork 后,只要拿这个新的 sockfd 就可以处理用户请求了。
- UDP 只有一个套接字,所有用户请求发来的数据,都可以在这个套接字上读取到,直接 fork 是不可以的。
2. 解决方案
除非……
UDP 收到用户数据后,自己也创建了一个新的套接字描述符,然后用这个新的描述符来处理用户请求,用户以后应该往这个新的套接字上发数据。
问题来了,用户不知道服务器创建的新的描述符。有点抝口,用代码来说话。
// 片段 3
void server_routine() {
while(1) {
// 无论如何,新用户的第一份数据一定是在这里收到
recvfrom(sockfd, buf, &clientAddress);
int newSockfd = socket(UDP);
pid = fork();
if (pid == 0) {
doServer(newSockfd);
exit(0);
}
close(newSockfd);
}
}
void doServer(int conn) {
while(1) {
// 问题来了,客户端不知道这个新的 conn 套接字 bind 的地址是多少
// 所以客户端后面的数据,还是得发送到 server_routine 函数的 sockfd 上
recvfrom(conn, buf, &clientAddress)
sendto(conn, buf, &clientAddress)
}
}
看起来好像卡在什么地方了,就像片段 2 中的注释,客户端不知道新的 conn 套接字 bind 的地址。没关系,客户端不知道,那么服务器就自己告诉客户端吧。客户端不知道,服务器知道啊!
再修改一次,伪代码如下:
// 片段 4
void server_routine() {
// 先执行 bind, listen,伪代码略去
// 假设服务绑定的是 8000 号端口
int port = 8001;
while(1) {
// 无论如何,新用户的第一份数据一定是在这里收到
recvfrom(sockfd, buf, &clientAddress);
int newSockfd = socket(UDP);
newServerAddress = "0.0.0.0:{{port}}"; // 每次有客户端过来,就绑定一个新端口
bind(newSockfd, &newServerAddresss); // bind 新地址
sendto(sockfd, &newServerAddress, &clientAddress); // 把服务器的新绑定地址发给客户端
port++;
pid = fork();
if (pid == 0) {
doServer(newSockfd);
exit(0);
}
close(newSockfd);
}
}
void doServer(int conn) {
while(1) {
// 静静的等待客户端往此套接字地址上发数据吧……
recvfrom(conn, buf, &clientAddress)
sendto(conn, buf, &clientAddress)
}
}
3. 似乎不是那么完美
看过了上面的解决方案,你肯定又要吐槽了,每次来了个新客户端进来,port 端口就 + 1,有两个问题:
- 特喵的你就不怕端口被别人占过了么?
- 这样加下去迟早药丸啊!端口地址最大 65535,告诉我,65536 你怎么办?有人说会绕回。。。绕回到 0 开始,你去绑定 21, 22, 80 吗?你是 IANA (Internet Assigned Numbers Authority) 它爹吗?
先回答第一个问题,先不要着急,自己选端口 bind 有问题,那就让 OS 帮我们选一个合适的吧!如何让 OS 动态分配端口呢?很简单,指定你的绑定的 port 端口号为 0 就行了。比如:
struct sockaddr_in servaddr;
servaddr.sin_family = AF_INET;
servaddr.sin_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(0);
接下来再执行 bind 就 ok 了。还差最后一步,怎么知道动态分配的端口是多少呢?(我说你事是不是有点多啊)。。。提示一下,getsockname 这个函数还记得吧,如果又忘记了,参考这一篇《面向连接的 UDP》,顺便你也复习一下面向连接的 UDP,哦,忘记了说了,git 里的源码我使用了面向连接的 UDP。
问题 2,如果并发数过大,端口迟早要用完,抱歉,除了加网卡,加机器,没有办法了。
4. 编写多进程并发 UDP 服务的步骤
4.1 第一种方案
- 主进程接收数据报
- 主进程创建新套接字,并 bind 新套接字地址(动态分配端口)
- 获取动态分配的端口,并把新端口从旧套接字发送给客户端
- fork 子进程,处理从新套接字发来的请求
- 主进程返回第 1 步
4.2 第二种方案
- 主进程接收数据报
- 主进程创建新套接字,并 bind 新套接字地址(动态分配端口)
- 从新套接字发送一些数据给客户端,这样客户端就知道新套接字地址是多少了
- fork 子进程,处理从新套接字发来的请求
- 主进程返回第 1 步
5. 任务
学完了本节,你需要做的一件事情就完成第 4 节里的需求。方法很多,坑也很多。在我提供的实现里,有两份源码,一份是 concurrent(对应 4.2),另一份是 concurrent_nat(对应 4.1)。如果你完全是按照第 4.1 节的步骤来实现的话,完全没问题,如果你选则了另一种方案 4.2,会出现问题(然鹅,好多博文似乎使用了 4.2 的步骤)。后面准备了思考题。
具体任务内容先看图 1.
图1 并发 udp(点击图片查看大图)
图 1 中第一、二窗口是两个 udp 客户端,在内网上。第三个窗口是位于某云主机上的服务,也就是说使用了公网地址。客户端往服务器发数据,服务端返回客户所发送的所有历史数据,就是这么简单。
git 地址在这里:https://gitee.com/ivan_allen/unp
源码路径是: /unp/program/advcudp,当然我希望你自己能实现一遍。
6. 思考题
你应该知道我要问什么了。为什么 4.2 的方案不行?提示两个点:
- 客户端突然收到一个奇妙端口发来的数据,奇不奇怪?
- 如果客户端在内网,服务器在公网或者在另一个网络(在另一个网络是指客户端和服务器不在同一个网关后面),服务器通过新套接字发送给客户端的数据,客户端能收到吗?
上面的问题牵扯到 NAT 相关的知识,有点难度,同学们多多查阅资料,在评论里回答问题。