极客时间已完结课程限时免费阅读

14丨UDP也可以是“已连接”?

14丨UDP也可以是“已连接”?-极客时间

14丨UDP也可以是“已连接”?

讲述:冯永吉

时长09:03大小8.28M

你好,我是盛延敏,这里是网络编程实战的第 14 讲,欢迎回来。
在前面的基础篇中,我们已经接触到了 UDP 数据报协议相关的知识,在我们的脑海里,已经深深印上了“UDP 等于无连接协议”的特性。那么看到这一讲的题目,你是不是觉得有点困惑?没关系,和我一起进入“已连接”的 UDP 的世界,回头再看这个标题,相信你就会恍然大悟。

从一个例子开始

我们先从一个客户端例子开始,在这个例子中,客户端在 UDP 套接字上调用 connect 函数,之后将标准输入的字符串发送到服务器端,并从服务器端接收处理后的报文。当然,向服务器端发送和接收报文是通过调用函数 sendto 和 recvfrom 来完成的。
#include "lib/common.h"
# define MAXLINE 4096
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, "usage: udpclient1 <IPaddress>");
}
int socket_fd;
socket_fd = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERV_PORT);
inet_pton(AF_INET, argv[1], &server_addr.sin_addr);
socklen_t server_len = sizeof(server_addr);
if (connect(socket_fd, (struct sockaddr *) &server_addr, server_len)) {
error(1, errno, "connect failed");
}
struct sockaddr *reply_addr;
reply_addr = malloc(server_len);
char send_line[MAXLINE], recv_line[MAXLINE + 1];
socklen_t len;
int n;
while (fgets(send_line, MAXLINE, stdin) != NULL) {
int i = strlen(send_line);
if (send_line[i - 1] == '\n') {
send_line[i - 1] = 0;
}
printf("now sending %s\n", send_line);
size_t rt = sendto(socket_fd, send_line, strlen(send_line), 0, (struct sockaddr *) &server_addr, server_len);
if (rt < 0) {
error(1, errno, "sendto failed");
}
printf("send bytes: %zu \n", rt);
len = 0;
recv_line[0] = 0;
n = recvfrom(socket_fd, recv_line, MAXLINE, 0, reply_addr, &len);
if (n < 0)
error(1, errno, "recvfrom failed");
recv_line[n] = 0;
fputs(recv_line, stdout);
fputs("\n", stdout);
}
exit(0);
}
我对这个程序做一个简单的解释:
9-10 行创建了一个 UDP 套接字;
12-16 行创建了一个 IPv4 地址,绑定到指定端口和 IP;
20-22 行调用 connect 将 UDP 套接字和 IPv4 地址进行了“绑定”,这里 connect 函数的名称有点让人误解,其实可能更好的选择是叫做 setpeername
31-55 行是程序的主体,读取标准输入字符串后,调用 sendto 发送给对端;之后调用 recvfrom 等待对端的响应,并把对端响应信息打印到标准输出。
在没有开启服务端的情况下,我们运行一下这个程序:
$ ./udpconnectclient 127.0.0.1
g1
now sending g1
send bytes: 2
recvfrom failed: Connection refused (111)
看到这里你会不会觉得很奇怪?不是说好 UDP 是“无连接”的协议吗?不是说好 UDP 客户端只会阻塞在 recvfrom 这样的调用上吗?怎么这里冒出一个“Connection refused”的错误呢?
别着急,下面就跟着我的思路慢慢去解开这个谜团。

UDP connect 的作用

从前面的例子中,你会发现,我们可以对 UDP 套接字调用 connect 函数,但是和 TCP connect 调用引起 TCP 三次握手,建立 TCP 有效连接不同,UDP connect 函数的调用,并不会引起和服务器目标端的网络交互,也就是说,并不会触发所谓的“握手”报文发送和应答。
那么对 UDP 套接字进行 connect 操作到底有什么意义呢?
其实上面的例子已经给出了答案,这主要是为了让应用程序能够接收“异步错误”的信息。
如果我们回想一下第 6 篇不调用 connect 操作的客户端程序,在服务器端不开启的情况下,客户端程序是不会报错的,程序只会阻塞在 recvfrom 上,等待返回(或者超时)。
在这里,我们通过对 UDP 套接字进行 connect 操作,将 UDP 套接字建立了“上下文”,该套接字和服务器端的地址和端口产生了联系,正是这种绑定关系给了操作系统内核必要的信息,能够将操作系统内核收到的信息和对应的套接字进行关联。
我们可以展开讨论一下。
事实上,当我们调用 sendto 或者 send 操作函数时,应用程序报文被发送,我们的应用程序返回,操作系统内核接管了该报文,之后操作系统开始尝试往对应的地址和端口发送,因为对应的地址和端口不可达,一个 ICMP 报文会返回给操作系统内核,该 ICMP 报文含有目的地址和端口等信息。
如果我们不进行 connect 操作,建立(UDP 套接字——目的地址 + 端口)之间的映射关系,操作系统内核就没有办法把 ICMP 不可达的信息和 UDP 套接字进行关联,也就没有办法将 ICMP 信息通知给应用程序。
如果我们进行了 connect 操作,帮助操作系统内核从容建立了(UDP 套接字——目的地址 + 端口)之间的映射关系,当收到一个 ICMP 不可达报文时,操作系统内核可以从映射表中找出是哪个 UDP 套接字拥有该目的地址和端口,别忘了套接字在操作系统内部是全局唯一的,当我们在该套接字上再次调用 recvfrom 或 recv 方法时,就可以收到操作系统内核返回的“Connection Refused”的信息。

收发函数

在对 UDP 进行 connect 之后,关于收发函数的使用,很多书籍是这样推荐的:
使用 send 或 write 函数来发送,如果使用 sendto 需要把相关的 to 地址信息置零;
使用 recv 或 read 函数来接收,如果使用 recvfrom 需要把对应的 from 地址信息置零。
其实不同的 UNIX 实现对此表现出来的行为不尽相同。
在我的 Linux 4.4.0 环境中,使用 sendto 和 recvfrom,系统会自动忽略 to 和 from 信息。在我的 macOS 10.13 中,确实需要遵守这样的规定,使用 sendto 或 recvfrom 会得到一些奇怪的结果,切回 send 和 recv 后正常。
考虑到兼容性,我们也推荐这些常规做法。所以在接下来的程序中,我会使用这样的做法来实现。

服务器端 connect 的例子

一般来说,服务器端不会主动发起 connect 操作,因为一旦如此,服务器端就只能响应一个客户端了。不过,有时候也不排除这样的情形,一旦一个客户端和服务器端发送 UDP 报文之后,该服务器端就要服务于这个唯一的客户端。
一个类似的服务器端程序如下:
#include "lib/common.h"
static int count;
static void recvfrom_int(int signo) {
printf("\nreceived %d datagrams\n", count);
exit(0);
}
int main(int argc, char **argv) {
int socket_fd;
socket_fd = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(SERV_PORT);
bind(socket_fd, (struct sockaddr *) &server_addr, sizeof(server_addr));
socklen_t client_len;
char message[MAXLINE];
message[0] = 0;
count = 0;
signal(SIGINT, recvfrom_int);
struct sockaddr_in client_addr;
client_len = sizeof(client_addr);
int n = recvfrom(socket_fd, message, MAXLINE, 0, (struct sockaddr *) &client_addr, &client_len);
if (n < 0) {
error(1, errno, "recvfrom failed");
}
message[n] = 0;
printf("received %d bytes: %s\n", n, message);
if (connect(socket_fd, (struct sockaddr *) &client_addr, client_len)) {
error(1, errno, "connect failed");
}
while (strncmp(message, "goodbye", 7) != 0) {
char send_line[MAXLINE];
sprintf(send_line, "Hi, %s", message);
size_t rt = send(socket_fd, send_line, strlen(send_line), 0);
if (rt < 0) {
error(1, errno, "send failed ");
}
printf("send bytes: %zu \n", rt);
size_t rc = recv(socket_fd, message, MAXLINE, 0);
if (rc < 0) {
error(1, errno, "recv failed");
}
count++;
}
exit(0);
}
我对这个程序做下解释:
11-12 行创建 UDP 套接字;
14-18 行创建 IPv4 地址,绑定到 ANY 和对应端口;
20 行绑定 UDP 套接字和 IPv4 地址;
27 行为该程序注册一个信号处理函数,以响应 Ctrl+C 信号量操作;
32-37 行调用 recvfrom 等待客户端报文到达,并将客户端信息保持到 client_addr 中;
39-41 行调用 connect 操作,将 UDP 套接字和客户端 client_addr 进行绑定
43-59 行是程序的主体,对接收的信息进行重新处理,加上”Hi“前缀后发送给客户端,并持续不断地从客户端接收报文,该过程一直持续,直到客户端发送“goodbye”报文为止。
注意这里所有收发函数都使用了 send 和 recv。
接下来我们实现一个 connect 的客户端程序:
#include "lib/common.h"
# define MAXLINE 4096
int main(int argc, char **argv) {
if (argc != 2) {
error(1, 0, "usage: udpclient3 <IPaddress>");
}
int socket_fd;
socket_fd = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERV_PORT);
inet_pton(AF_INET, argv[1], &server_addr.sin_addr);
socklen_t server_len = sizeof(server_addr);
if (connect(socket_fd, (struct sockaddr *) &server_addr, server_len)) {
error(1, errno, "connect failed");
}
char send_line[MAXLINE], recv_line[MAXLINE + 1];
int n;
while (fgets(send_line, MAXLINE, stdin) != NULL) {
int i = strlen(send_line);
if (send_line[i - 1] == '\n') {
send_line[i - 1] = 0;
}
printf("now sending %s\n", send_line);
size_t rt = send(socket_fd, send_line, strlen(send_line), 0);
if (rt < 0) {
error(1, errno, "send failed ");
}
printf("send bytes: %zu \n", rt);
recv_line[0] = 0;
n = recv(socket_fd, recv_line, MAXLINE, 0);
if (n < 0)
error(1, errno, "recv failed");
recv_line[n] = 0;
fputs(recv_line, stdout);
fputs("\n", stdout);
}
exit(0);
}
我对这个客户端程序做一下解读:
9-10 行创建了一个 UDP 套接字;
12-16 行创建了一个 IPv4 地址,绑定到指定端口和 IP;
20-22 行调用 connect 将 UDP 套接字和 IPv4 地址进行了“绑定”
27-46 行是程序的主体,读取标准输入字符串后,调用 send 发送给对端;之后调用 recv 等待对端的响应,并把对端响应信息打印到标准输出。
注意这里所有收发函数也都使用了 send 和 recv。
接下来,我们先启动服务器端程序,然后依次开启两个客户端,分别是客户端 1、客户端 2,并且让客户端 1 先发送 UDP 报文。
服务器端:
$ ./udpconnectserver
received 2 bytes: g1
send bytes: 6
客户端 1:
./udpconnectclient2 127.0.0.1
g1
now sending g1
send bytes: 2
Hi, g1
客户端 2:
./udpconnectclient2 127.0.0.1
g2
now sending g2
send bytes: 2
recv failed: Connection refused (111)
我们看到,客户端 1 先发送报文,服务端随之通过 connect 和客户端 1 进行了“绑定”,这样,客户端 2 从操作系统内核得到了 ICMP 的错误,该错误在 recv 函数中返回,显示了“Connection refused”的错误信息。

性能考虑

一般来说,客户端通过 connect 绑定服务端的地址和端口,对 UDP 而言,可以有一定程度的性能提升。
这是为什么呢?
因为如果不使用 connect 方式,每次发送报文都会需要这样的过程:
连接套接字→发送报文→断开套接字→连接套接字→发送报文→断开套接字 →………
而如果使用 connect 方式,就会变成下面这样:
连接套接字→发送报文→发送报文→……→最后断开套接字
我们知道,连接套接字是需要一定开销的,比如需要查找路由表信息。所以,UDP 客户端程序通过 connect 可以获得一定的性能提升。

总结

在今天的内容里,我对 UDP 套接字调用 connect 方法进行了深入的分析。之所以对 UDP 使用 connect,绑定本地地址和端口,是为了让我们的程序可以快速获取异步错误信息的通知,同时也可以获得一定性能上的提升。

思考题

在本讲的最后,按照惯例,给你留两个思考题:
可以对一个 UDP 套接字进行多次 connect 操作吗? 你不妨动手试试,看看结果。
如果想使用多播或广播,我们应该怎么去使用 connect 呢?
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 10

提建议

上一篇
13 | 小数据包应对之策:理解TCP协议中的动态数据传输
下一篇
15 | 怎么老是出现“地址已经被使用”?
unpreview
 写留言

精选留言(32)

  • 程序水果宝
    2019-09-02
    对于recvfrom函数,我们可以看成是TCP中accept函数和read函数的结合,前三个参数是read的参数,后两个参数是accept的参数。对于sendto函数,则可以看成是TCP中connect函数和send函数的结合,前三个参数是send的参数,后两个参数则是connect的参数。所以udp在发送和接收数据的过程中都会建立套接字连接,只不过每次调用sendto发送完数据后,内核都会将临时保存的对端地址数据删除掉,也就是断开套接字,从而就会出现老师所说的那个循环
    展开
    41
  • Geek_63bb29
    2020-08-21
    老师,面试过程中问道udp如何实现可靠性,这个怎么答呀。要求具体每部实现

    作者回复: 我能想到的: 1.udp可以增加消息编号; 2.对每个消息编号提供ACK,在udp应用层增加应答机制; 3.没有应答的增加重传机制 4.增加缓存,ACK完的才从缓存中清除

    共 2 条评论
    19
  • Sancho
    2020-07-09
    老师,你好。我有两个疑问: 1.不进行connect操作,UDP套接字与服务端的地址和端口就没有产生关系,那recvfrom是怎么收到对应的报文呢? 2.UDP的connect操作,会引发内核的ICMP报文发送?如果不是,ICMP是在什么时机下发送的?

    作者回复: 1.是通过sendto函数来绑定服务端地址的,之后再通过recvfrom引用到之前的socket,这样收到的报文就是指定的服务地址和端口了; 2.不是connect导致ICMP报文,而是对应的地址和端口不可达时,一个 ICMP 报文会返回。connect只是将这个信息传递变得可能了。

    9
  • GeekAmI
    2019-10-22
    问题1:亲测可以; 问题2:可以参考https://yq.aliyun.com/articles/523036。

    作者回复: 👍

    共 2 条评论
    9
  • 🐗Jinx
    2020-12-20
    对于广播的话,先把广播的option打开。然后再 connect 255.255.255.255 对吗?

    作者回复: 是的。UDP的广播地址是固定的为255.255.255.255。

    5
  • Liam
    2019-09-02
    按照老师的说法,只有connect才建立socket和ip地址的映射;那么,如果不进行connect,收到信息后内核又是如何把数据交给对应的socket呢

    作者回复: 在答疑篇里统一回复了。

    共 4 条评论
    5
  • 沉淀的梦想
    2019-09-04
    还是不太理解为什么UDP的sendto方法会有一个"连接"过程的性能损耗,直接按照目标地址发过去不就可以了吗?我的理解是操作系统会先用ICMP协议探一探目标地址是否存在,然后再用UDP协议发送具体的数据,不知道理解的对不?

    作者回复: 我不觉得会发ICMP来探一谈。ICMP是用的时候才触发的。 这里我想表达的是操作系统协议栈在每次sendto的时候都会需要一个地址初始化的过程,如果这个过程省略掉了,是可以得到一点点性能的提升的。当然,其实这个是没有那么大的。

    4
  • 传说中的成大大
    2019-09-02
    udp 连接套接字 这个是什么过程? 断开套接字这又是什么过程呢?

    作者回复: 没有断开,这里都是一个系统调用,告诉了一些系统内核信息而已。

    2
  • 德鲁小叔
    2021-12-16
    发现在输入完goodbye之后,服务端执行exit,后面client再去请求的时候又会被阻塞而不是返回错误,是因connect是单次操作吗?

    作者回复: 是因为之前发送成功了,所以没有ICMP不可达的报文收到,因而进入了阻塞状态。

    1
  • kylexy_0817
    2021-12-05
    1、因为UDP调用connect并不会真正创建链接,所以多次调用都不会有问题。 2、connect到路由器中的广播地址
    1
  • duckman
    2020-12-07
    connect 将一个socket绑定到一个udp的客户端进程,其他的udp客户端进程想要再次绑定该socket(4元组)发送数据的时候就会报错。所以connect起到了 "声明式"独占的作用?

    作者回复: 也可以这么说。

    1
  • 一个戒
    2020-11-29
    老师,请问第一个程序中,在没有开启服务端的情况下开启客户端,不会在第20行connect的时候就error报错了吗?为什么还能接收标准输入并send出去?

    作者回复: 这个是UDP哦,不是TCP。 通常在服务器端不开启的情况下,UDP客户端程序是不会报错的,程序只会阻塞在 recvfrom 上,等待返回(或者超时)。UDP的connect并不是真正的conncect操作,它只是给UDP 套接字建立了“上下文”。

    1
  • fhs
    2020-05-10
    请问一下,在客户端的代码中,在send之前进行了connect。测试发现,不启动服务端的情况下,使用使用客户端send的函数的返回值是输入的字节数,既然文章说到"因为对应的地址和端口不可达,一个ICMP报文会返回给操作系统内核,该ICMP报文含有目的地址和端口等信息",那为什么send函数返回是成功,而recv就返回失败呢?send的时候不应该也能知道icmp报文返回错误而返回错误么?

    作者回复: 这个要从函数设计的角度来说,在UDP通信中, sendto的目的是将报文通过网络传送给对端,并不考虑是否能发送成功,仅仅考虑的是把报文通过缓冲区发送出去;而recvfrom则是一个阻塞调用,它是需要知道是否成功的,包括超时,包括ICMP报文返回错误。

    1
  • 钱佳慧
    2023-02-22 来自江苏
    老师,对于问题一我有个疑问,udp能否同时connect多条连接呢?
  • 钱佳慧
    2023-02-22 来自江苏
    udp的多播跟组播跟connect有关吗?
  • Frankey
    2022-04-23
    连接套接字是需要一定开销的,比如需要查找路由表信息。 这个路由表信息是什么?

    作者回复: 网络层 A->B B->C,这些是需要路由信息的。

  • MuteX
    2021-11-23
    提一个细节,sendto/recvfrom和send/recv的返回值类型是ssize_t,也就是long,而文章例子中很多地方用的是size_t,也就是unsigned long,很显然会导致问题。

    作者回复: 疏漏了。

  • DonaldTrumpppppppppp
    2021-05-23
    不调用connect时,有个评论问题老师你是这么答的。1.是通过sendto函数来绑定服务端地址的,之后再通过recvfrom引用到之前的socket,这样收到的报文就是指定的服务地址和端口了。 难道recvfrom不是收到后再填充from的地址的吗,还可以指定从某个服务端收数据?任何一个服务端只要知道了客户端的ip和端口就能发,客户端没有拒绝的权力吧

    作者回复: 这是对UDP而言的,通过sendto函数客户端指定了发送的服务端地址,再通过recvfrom就可以从之前指定的服务端地址来接收数据了。这里,没有说可以任意从某个服务端收数据的,而是从之前指定的服务端收数据的。

    共 2 条评论
  • 凌空飞起的剪刀腿
    2021-05-20
    还是不太理解为什么UDP的sendto方法会有一个"连接"过程的性能损耗,直接按照目标地址发过去不就可以了吗?我的理解是操作系统会先用ICMP协议探一探目标地址是否存在,然后再用UDP协议发送具体的数据,不知道理解的对不? 我也同意这个说法,可能客户端提前connect可以解决不用sendto以后,是否可以到达服务器,省的堵塞在recv函数里面了,同时使用connect可以提前判断服务器是否存在,至于是否会节省性能这需要抓包分析
    展开

    作者回复: 好吧,大家都有这个疑问,我只是在说,每次调用sendto都需要进行地址初始化,其实这个是没有那么大的。我们就把性能损耗这个"圆"过去吧 😢

  • Geek_de83f6
    2021-01-03
    为什么我在第一个实例中,没有打开服务器,然后客户端connect并且send了之后,在recv的地方阻塞了,并没有收到connect失败的消息呢?

    作者回复: 看看你是不是没有权限run这个程序,或者你的本地socket路径不可以创建。总之是哪里出错了。

    共 2 条评论