
基于UDP的服务器端/客户端
6.1 理解UDP
在第章介绍协议栈时提到,传输层主要有和两种协议。本章主要介绍结构更简单的。
UDP套接字的特点
如果把比作打电话,那么就更像寄信。寄信时只要写好收件地址并投入邮筒,并不会先和收件人建立一条连接,也无法立即确认信件是否到达;邮寄途中甚至可能丢失。同样不保证数据一定到达,也不保证到达顺序,因此是一种不可靠的、面向消息的传输协议。
单看可靠性,显然更好。但为了实现可靠传输,需要建立和断开连接,还要通过序号、确认应答、超时重传和滑动窗口等机制进行流控制。不发送类似的确认消息,也不会像那样为字节流维护序号,协议结构更简单,通信开销也更小。
NOTE这里说比快,指的是协议处理过程更简单,并不是任何场景下使用都会获得明显的速度提升。若连接长期保持且连续传输大量数据,建立、断开连接的开销会被摊薄,两者的速度差距可能并不明显。
UDP内部工作原理
负责把数据包从源主机送到目标主机,但一台主机中可能同时运行多个网络程序,仅凭地址还无法判断数据最终属于哪个应用。最重要的作用,就是根据数据报中的端口号,将到达目标主机的数据交给正确的套接字。

UDP的高效使用
虽然不保证可靠传输,但并不意味着数据会频繁丢失。应该根据数据本身的特点选择协议:
- 压缩文件、程序文件等数据只要缺少一部分就可能无法使用,更适合通过可靠传输。
- 实时音频或视频偶尔丢失少量数据,通常只会产生短暂杂音或画面抖动,但若为了重传而等待,反而会破坏实时性,此时更适合。
- 每次交换的数据量较小、却需要频繁建立和断开连接时,可以省去连接设置与清除过程。
因此,与没有优劣之分,我们要在可靠性、实时性和协议开销之间来对协议的选择做出不同取舍。
6.2 实现基于UDP的服务器端/客户端
UDP中没有连接
服务器端需要调用进入等待连接状态,再通过受理客户端请求;客户端则需要调用建立连接。套接字不维持连接状态,因此不需要和,基本过程只有创建套接字和收发数据。
这也意味着中的服务器端和客户端并没有十分严格的界限。通常把固定地址、持续等待并提供服务的一方称为服务器端,把主动向该地址发送请求的一方称为客户端。
服务器端和客户端均只需一个套接字
连接是一对一的。服务器端除监听套接字外,还会为每个已连接客户端创建单独的连接套接字。套接字更像邮筒,一个邮筒可以把信寄往多个地址,也能接收不同寄件人的信,所以无论与多少台主机通信,通常都只需一个套接字。

图中同一个套接字可以和其他主机上的多个套接字交换数据。为了区分每个数据报的目标和来源,每次收发数据时都要处理地址信息。
基于UDP的数据I/O函数
套接字建立连接后已经保存了对方地址,调用时只需给出数据即可。未连接的套接字没有固定通信对象,所以发送数据时要同时指定目标地址。
#include <sys/socket.h>
ssize_t sendto(int sock, const void *buff, size_t nbytes, int flags, const struct sockaddr *to, socklen_t addrlen);//成功时返回传输的字节数,失败时返回-1。是套接字文件描述符,与表示待发送数据及其长度,没有特殊选项时传入,与则给出目标地址信息及结构体长度。
接收端同样不能预先确定数据来自哪台主机,所以除了接收数据,还会返回发送端的地址信息。
#include <sys/socket.h>
ssize_t recvfrom(int sock, void *buff, size_t nbytes, int flags, struct sockaddr *from, socklen_t *addrlen);//成功时返回接收的字节数,失败时返回-1。用于保存发送端地址,传入地址结构体长度并在调用后得到实际长度。服务器端收到数据后,可以直接将这组地址信息交给,把结果发回原客户端。
基于UDP的回声服务器端/客户端
回声服务器端仍需通过为套接字分配固定的和端口,但不再调用与。其核心结构如下:
serv_sock = socket(PF_INET, SOCK_DGRAM, 0);bind(serv_sock, (struct sockaddr *)&serv_adr, sizeof(serv_adr));
while (1){ clnt_adr_sz = sizeof(clnt_adr); str_len = recvfrom(serv_sock, message, BUF_SIZE, 0, (struct sockaddr *)&clnt_adr, &clnt_adr_sz); sendto(serv_sock, message, str_len, 0, (struct sockaddr *)&clnt_adr, clnt_adr_sz);}客户端创建套接字后,通过指定服务器地址,再由接收服务器返回的数据:
sock = socket(PF_INET, SOCK_DGRAM, 0);
sendto(sock, message, strlen(message), 0, (struct sockaddr *)&serv_adr, sizeof(serv_adr));
adr_sz = sizeof(from_adr);str_len = recvfrom(sock, message, BUF_SIZE, 0, (struct sockaddr *)&from_adr, &adr_sz);服务器端的可以接收任意客户端的数据,并取得对应客户端地址;客户端的则在每次发送时明确指定服务器地址。二者依靠数据报中携带的地址信息完成一来一回的通信。
UDP客户端套接字的地址分配
所有参与网络通信的套接字最终都要拥有地址和端口号。客户端在调用时由操作系统自动分配地址,而上面的客户端既没有调用,也没有调用。
原因在于,若首次调用时套接字尚未分配地址,操作系统会自动使用本机,并从尚未使用的端口中选择一个端口。这个地址会保留到套接字关闭,因此客户端通常不必显式调用。如果程序需要固定客户端端口,也可以主动调用完成分配。
6.3 UDP的数据传输特性和调用connect函数
存在数据边界的UDP套接字
以字节流为单位,不保留每次之间的边界,所以发送端调用一次,接收端可能需要多次;发送端的多次,也可能被一次读出。
则保留数据边界。一次发送的内容构成一个完整的数据报(),接收端一次只处理一个数据报。因此,若发送端调用次,接收端也需要调用次才能取完这个数据报。
WARNING“存在数据边界”不表示接收缓冲可以随意设置得很小。若传入的缓冲区小于数据报,超出的部分可能被截断或丢弃,之后再调用一次也不能像读取字节流那样继续取得同一数据报的剩余部分。
已连接与未连接的UDP套接字
默认的套接字没有注册固定目标,称为未连接套接字()。每次调用时,大致需要完成以下过程:
- 向套接字注册目标和端口号。
- 传输数据。
- 删除刚才注册的目标地址信息。
这样做的好处是同一个套接字可以不断更换目标地址。但如果程序要长时间与同一台主机通信,每次重复注册和删除地址就会带来不必要的开销。这时可以为套接字调用,将其变为已连接套接字()。
sock = socket(PF_INET, SOCK_DGRAM, 0);
memset(&serv_adr, 0, sizeof(serv_adr));serv_adr.sin_family = AF_INET;serv_adr.sin_addr.s_addr = inet_addr(argv[1]);serv_adr.sin_port = htons(atoi(argv[2]));
connect(sock, (struct sockaddr *)&serv_adr, sizeof(serv_adr));这里的并不会像一样进行三次握手,也没有真正建立一条可靠连接。它只是把默认通信对象的和端口注册到本地套接字中。注册完成后,除了继续使用、外,也可以像套接字一样使用、:
write(sock, message, strlen(message));str_len = read(sock, message, sizeof(message) - 1);已连接套接字省去了反复指定目标地址的过程,也会把接收范围限定为已注册的通信对象,适合与同一主机频繁交换数据的场景。
6.4 基于Windows的实现
平台下同样使用和收发数据,其参数顺序及含义与版本基本一致:
#include <winsock2.h>
int sendto(SOCKET s, const char *buf, int len, int flags, const struct sockaddr *to, int tolen);//成功时返回传输的字节数,失败时返回SOCKET_ERROR。
int recvfrom(SOCKET s, char *buf, int len, int flags, struct sockaddr *from, int *fromlen);//成功时返回接收的字节数,失败时返回SOCKET_ERROR。移植时仍需先通过初始化,以保存套接字句柄,关闭时调用,程序结束前调用。若创建的是已连接套接字,则可以使用、代替中的、。除此之外,回声服务器端/客户端的整体逻辑不变。
优雅地断开套接字连接
7.1 基于TCP的半关闭
前面一直通过或断开套接字,但这两个函数会同时关闭数据发送和接收能力。在普通的请求/响应结束后这样做通常没有问题,可一旦对方仍有数据需要传来,单方面完全关闭就可能造成数据丢失。
单方面断开连接带来的问题
假设主机和主机正在双向通信,主机发送完自己的最后一段数据后立即调用。此时主机不仅不能再发送,也不能继续接收;如果主机还有必须返回的数据,这些数据就无法再由主机读取。

解决方法并不是无限延长等待时间,而是只关闭其中一个方向的数据流:可以停止发送但保留接收,也可以停止接收但保留发送。这种只关闭一部分连接的方式称为半关闭()。
套接字与流
两台主机建立连接后,会形成可以双向交换数据的状态。但一条流中的数据只能沿一个方向移动,所以双向通信实际上由两条方向相反的流组成:
- 主机的输出流连接主机的输入流。
- 主机的输出流连接主机的输入流。

用shutdown函数实现半关闭
中通过选择要关闭的流:
#include <sys/socket.h>
int shutdown(int sock, int howto);//成功时返回0,失败时返回-1。是需要操作的套接字文件描述符,决定关闭方式:
| 参数 | 含义 |
|---|---|
| SHUT_RD | 关闭输入流,之后无法继续接收数据 |
| SHUT_WR | 关闭输出流,之后无法继续发送数据 |
| SHUT_RDWR | 同时关闭输入流和输出流 |
传入后,即使输入缓冲又收到数据也会将其丢弃。传入后,输出缓冲中已经存在的数据仍会继续发往对方,随后向对方传递。则相当于分别关闭输入和输出两个方向。
为什么需要半关闭
考虑一个文件传输程序:服务器端把文件发给客户端,客户端接收完成后再向服务器端返回。这里存在两个问题:
- 客户端事先不知道文件长度,无法判断应该在何时停止调用。
- 服务器端发送完文件后仍要接收客户端的确认消息,不能立刻完全关闭套接字。
若约定某个特殊字符表示文件结束,这个字符可能恰好出现在文件内容中;而关闭输出流时会向对方传递,在读完剩余数据后返回,因此更适合表示文件传输结束。
服务器端不能直接调用,否则自己的输入流也会关闭,无法再接收确认消息。正确做法是调用:
shutdown(clnt_sd, SHUT_WR);这样服务器端完成以下三件事:先把输出缓冲中的剩余数据全部发出,再向客户端传递,同时保留输入流继续接收客户端最后的响应。
基于半关闭的文件传输
文件传输的数据流如下:服务器端受理连接后发送文件数据,发送完成后对输出流执行半关闭;客户端通过返回判断文件已经传完,再发送确认消息。

服务器端核心逻辑:
while (1){ read_cnt = fread(buf, 1, BUF_SIZE, fp); write(clnt_sd, buf, read_cnt);
if (read_cnt < BUF_SIZE) break;}
shutdown(clnt_sd, SHUT_WR);read(clnt_sd, buf, BUF_SIZE);printf("Message from client: %s\n", buf);客户端则持续接收,直到读取到:
while ((read_cnt = read(sd, buf, BUF_SIZE)) != 0) fwrite(buf, 1, read_cnt, fp);
puts("Received file data");write(sd, "Thank you", 10);这里不能把一次返回的数据直接看作完整文件。客户端仍需循环读取并按照实际返回的字节数写入文件;当服务器端执行输出流半关闭、且缓冲数据全部读完后,客户端的才返回。之后客户端仍能通过自己的输出流发送,服务器端也仍能从保留的输入流读到它。
NOTE用于控制连接的收发方向,用于释放文件描述符和套接字资源。半关闭完成剩余通信后,双方仍应在适当位置调用。
7.2 基于Windows的实现
同样通过完成半关闭,函数形式基本相同:
#include <winsock2.h>
int shutdown(SOCKET sock, int howto);//成功时返回0,失败时返回SOCKET_ERROR。只是第二个参数的常量名称有所不同:
| Linux | Windows | 含义 |
|---|---|---|
| SHUT_RD | SD_RECEIVE | 关闭输入流 |
| SHUT_WR | SD_SEND | 关闭输出流 |
| SHUT_RDWR | SD_BOTH | 同时关闭输入流和输出流 |
因此服务器端在发送完文件后调用:
shutdown(hClntSock, SD_SEND);recv(hClntSock, buf, BUF_SIZE, 0);客户端循环调用,当返回时结束文件接收,再通过返回确认消息。
