目录
概述
使用 UDP 编写的一些常见的应用程序有:DNS(域名系统)、NFS(网络文件系统)、SNMP(简单网络管理协议)。
客户不与服务器建立连接,而是只管使用 sendto 函数给服务器发送数据报,其中必须指定目的地的地址作为参数。服务器不接受来自客户的连接,而只管调用 recvfrom 函数,等待来自某个客户的数据到达。
recvfrom、sendto 函数
这两个函数类似于 read 和 write
#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
返回:成功返回读或写的字节数,出错返回 -1。
sockfd:描述符,buf:读或写缓冲区指针,len:读或写字节数,flags:暂且置为 0,后两个参数为地址和地址长度。
写一个长度为 0 的数据报是可行的。在 UDP 情况下,这会形成一个只包含一个 IP 首部和一个 8 字节 UDP 首部而没有数据的 IP 数据报。这也意味着对于数据报协议,recvfrom 返回 0 值是可接受的:它并不像 TCP 套接字上 read 返回 0 值那样表示对端已关闭连接。既然 UDP 是无连接的,因此也就没有诸如关闭一个 UDP 连接之类事情。如果 recvfrom 的 src_addr 参数为一个空指针,那么相应的长度参数(addrlen)也必须是一个空指针,表示我们并不关心数据发送者的协议地址。
UDP 回射服务器程序
程序模型如下
#include "unp.h"
void dg_echo(int sockfd, SA *pcliaddr, socklen_t clilen)
{
int n;
socklen_t len;
char mesg[MAXLINE];
for ( ; ; ) {
len = clilen;
Recvfrom(sockfd, mesg, MAXLINE, 0, pcliaddr, &len);
Sendto(sockfd, mesg, n, 0, pcliaddr, len);
}
}
int main(int argc, char **argv)
{
int sockfd;
struct sockaddr_in servaddr, cliaddr;
sockfd = Socket(AF_INET, SOCK_DGRAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(SERV_PORT);
Bind(sockfd, (SA *) &servaddr, sizeof(servaddr));
dg_echo(sockfd, (SA *) &cliaddr, sizeof(cliaddr));
}
一般来说,大多数 TCP 服务器是并发的,而大多数 UDP 服务器是迭代的。TCP 服务器调用 fork 为每个客户建立一个处理进程,而 UDP 往往是单个服务器就得处理所有客户。事实上每个 UDP 套接字都有一个接收缓冲区,到达该套接字的每个数据报都进入这个套接字接收缓冲区。当进程调用 recvfrom 时,缓冲区中的下一个数据报以 FIFO 顺序返回给进程。这些数据报会在缓冲区中排好队。缓冲区大小是有限制的,可以通过 SO_RCVBUF 选项修改。这个例程中的服务进程仅有一个套接字,它要接收所有客户的数据报,而所有这些数据报都放在一个接收缓冲区中。
UDP 回射客户端程序
#include "unp.h"
#define SERV_PORT 7
void dg_cli(FILE *fp, int sockfd, const SA *pservaddr, socklen_t servlen)
{
int n;
char sendline[MAXLINE], recvline[MAXLINE + 1];
socklen_t len;
struct sockaddr * preply_addr;
preply_addr = Malloc(servlen);
while (Fgets(sendline, MAXLINE, fp) != NULL) {
Sendto(sockfd, sendline, strlen(sendline), 0, pservaddr, servlen);
len = servlen;
n = Recvfrom(sockfd, recvline, MAXLINE, 0, preply_addr, &len);
if (len != servlen || memcmp(pservaddr, preply_addr, len) != 0) {
printf("reply from %s (ignored)\n",
Sock_ntop(preply_addr, len));
continue;
}
recvline[n] = 0; /* null terminate */
Fputs(recvline, stdout);
}
}
int main(int argc, char **argv)
{
int sockfd;
struct sockaddr_in servaddr;
if (argc != 2)
err_quit("usage: udpcli <IPaddress>");
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(SERV_PORT);
Inet_pton(AF_INET, argv[1], &servaddr.sin_addr);
sockfd = Socket(AF_INET, SOCK_DGRAM, 0);
dg_cli(stdin, sockfd, (SA *) &servaddr, sizeof(servaddr));
exit(0);
}
如果进程首次调用 sendto 时它没有绑定一个本地端口,那么内核就在此时为它选择一个临时端口。recvfrom 的后两个参数是 NULL 表示我们不关心应答数据报由谁来发送,但是会有风险:任何进程都可向本客户的 IP 地址和端口发送数据报。本例程加了个验证,即 memcmp 接收套接字是否和 server 套接字相同。但是如果服务器有两个 IP 地址(这是可能的),那么回复消息的 IP 是另外一个 IP 的话,那么 memcmp 就返回非 0。所以本例程是有局限性的。
解决服务器有多个 IP 地址的方法是:
- 如果是客户端的话,得到由 recvfrom 返回的 IP 地址后,客户通过在 DNS 中查找服务器主机名来验证该主机的域名,而不是 IP 地址。
- 在 UDP 服务器上的话,给每个 IP 创建一个套接字,用 bind 捆绑每个 IP 到其套接字上,然后在所有套接字上使用 select 等待任何一个变得可读,再从可读的套接字给出应答。
UDP 客户-服务器程序小节
UDP 的 connect 函数
异步错误:由于 UDP 不是面向连接的,所以我们可以向一个没有开启服务的服务器发送消息,而且 sendto 会成功返回,但是随后才能得到一个 ICMP 的 “port unreachable” 的错误,这就是异步错误。
一个基本规则是:对于一个 UDP 套接字,由它引发的异步错误却并不返回给它,除非它已连接。
UDP 的 connect 函数不会有三路握手过程。内核只是检查是否存在立即可知的错误(如一个显然不可到达的目的地),记录对端的 IP 地址和端口号(取自传递给 connect 的套接字地址结构),然后立即返回到调用进程。
调用该函数后:
(1)我们再也不能给输出操作指定目的 IP 地址和端口号。此时使用 write 或 send,而不是 sendto,如果要使用 sendto,则第 5 个参数设为 NULL,第 6 个参数设为 0。
(2)我们不必使用 recvfrom 以获悉数据报发送者,而改为 read、recv 或 recvmsg。一个已连接的 UDP 套接字能且仅能与一个对端交换数据报。确切地说,是与一个 IP 地址交换数据报,因为 connect 到多播或广播地址是可能的。
(3)由已连接 UDP 套接字引发的异步错误会返回给它们所在的进程,而未连接 UDP 套接字不接收任何异步错误。
但是,值得注意的是,使用 connect 迫使该套接字只能与唯一的对端进行通信。所以看下图:
对于右边数第二个客户,不能对一个套接字使用 connect。
注意:
(1)给一个 UDP 套接字多次调用 connect 有以下两个目的:
- 指定新的 IP 地址和端口号;
- 断开套接字。在已连接 UDP 套接字上调用 connect 的进程才能再次调用 connect 断开连接。调用时,把套接字地址结构的地址族成员设置为 AF_UNSPEC,调用时可能会返回 EAFNOSUPPORT 错误,不过没有关系。
(2)性能比较
当一个套接字使用 sendto 对一个未使用 connect 进行连接的套接字发送两次数据时,内核执行步骤为:
- 连接套接字
- 输出第一个数据报
- 断开套接字连接
- 连接套接字
- 输出第一个数据报
- 断开套接字连接
使用了 connect 的套接字,步骤如下:
- 连接套接字
- 输出第一个数据报
- 输出第二个数据报
所以性能上使用 connect 要更好。因为临时连接的操作会耗费每个 UDP 传输的 1/3 的时间。
#include "unp.h"
// 使用 connect 的客户端程序
void dg_cli(FILE *fp, int sockfd, const SA *pservaddr, socklen_t servlen)
{
int n;
char sendline[MAXLINE], recvline[MAXLINE + 1];
Connect(sockfd, (SA *) pservaddr, servlen);
while (Fgets(sendline, MAXLINE, fp) != NULL) {
Write(sockfd, sendline, strlen(sendline));
n = Read(sockfd, recvline, MAXLINE);
recvline[n] = 0; /* null terminate */
Fputs(recvline, stdout);
}
}
UDP 使用 connect 函数去连接一个没有开启服务的服务器也不会返回错误,内核只是检查是否存在立即可知的错误,像这种异步错误仍然不会立即出现。
UDP 缺乏流量控制
UDP 没有流量控制并且是不可靠的。接收缓冲区满时,数据就会被丢弃,虽然可以增大接收缓冲区,但是并不能解决问题。
UDP 中的外出接口的确定
已连接(使用 connect)的 UDP 套接字可以用来确定用于某个特定目的地的外出接口。未调用 bind 显式绑定的的套接字,在使用 connect 时,内核会选择本地 IP 地址,这个本地 IP 地址通过为目的 IP 地址搜索路由表得到外出接口。调用 connect 并不给对端发送任何消息,它完全是一个本地操作。
使用 select 函数的 UDP 回射服务器程序
#include "unp.h"
int main(int argc, char **argv)
{
int udpfd, nready, maxfdp1;
char mesg[MAXLINE];
fd_set rset;
ssize_t n;
socklen_t len;
struct sockaddr_in cliaddr, servaddr;
void sig_chld(int);
/* create UDP socket */
udpfd = Socket(AF_INET, SOCK_DGRAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(SERV_PORT);
Bind(udpfd, (SA *) &servaddr, sizeof(servaddr));
Signal(SIGCHLD, sig_chld); /* must call waitpid() */
FD_ZERO(&rset);
maxfdp1 = udpfd + 1;
for ( ; ; ) {
FD_SET(udpfd, &rset);
if ( (nready = select(maxfdp1, &rset, NULL, NULL, NULL)) < 0) {
if (errno == EINTR)
continue; /* back to for() */
else
err_sys("select error");
}
if (FD_ISSET(udpfd, &rset)) {
len = sizeof(cliaddr);
n = Recvfrom(udpfd, mesg, MAXLINE, 0, (SA *) &cliaddr, &len);
Sendto(udpfd, mesg, n, 0, (SA *) &cliaddr, len);
}
}
}