3495 字
9 分钟
TCP&IP网络编程学习札记_4
2026-07-24
浏览量 47 · 访客 6

基于UDP的服务器端/客户端#

6.1 理解UDP#

在第44章介绍TCP/IPTCP/IP协议栈时提到,传输层主要有TCPTCPUDPUDP两种协议。本章主要介绍结构更简单的UDPUDP

UDP套接字的特点#

如果把TCPTCP比作打电话,那么UDPUDP就更像寄信。寄信时只要写好收件地址并投入邮筒,并不会先和收件人建立一条连接,也无法立即确认信件是否到达;邮寄途中甚至可能丢失。UDPUDP同样不保证数据一定到达,也不保证到达顺序,因此是一种不可靠的、面向消息的传输协议

单看可靠性,TCPTCP显然更好。但为了实现可靠传输,TCPTCP需要建立和断开连接,还要通过序号、确认应答、超时重传和滑动窗口等机制进行流控制。UDPUDP不发送类似ACKACK的确认消息,也不会像TCPTCP那样为字节流维护序号,协议结构更简单,通信开销也更小。

NOTE

这里说UDPUDPTCPTCP快,指的是协议处理过程更简单,并不是任何场景下使用UDPUDP都会获得明显的速度提升。若连接长期保持且连续传输大量数据,TCPTCP建立、断开连接的开销会被摊薄,两者的速度差距可能并不明显。

UDP内部工作原理#

IPIP负责把数据包从源主机送到目标主机,但一台主机中可能同时运行多个网络程序,仅凭IPIP地址还无法判断数据最终属于哪个应用。UDPUDP最重要的作用,就是根据数据报中的端口号,将到达目标主机的数据交给正确的UDPUDP套接字。

替换文本

UDP的高效使用#

UDPUDP虽然不保证可靠传输,但并不意味着数据会频繁丢失。应该根据数据本身的特点选择协议:

  1. 压缩文件、程序文件等数据只要缺少一部分就可能无法使用,更适合通过TCPTCP可靠传输。
  2. 实时音频或视频偶尔丢失少量数据,通常只会产生短暂杂音或画面抖动,但若为了重传而等待,反而会破坏实时性,此时更适合UDPUDP
  3. 每次交换的数据量较小、却需要频繁建立和断开连接时,UDPUDP可以省去连接设置与清除过程。

因此,TCPTCPUDPUDP没有优劣之分,我们要在可靠性、实时性和协议开销之间来对协议的选择做出不同取舍。

6.2 实现基于UDP的服务器端/客户端#

UDP中没有连接#

TCPTCP服务器端需要调用listenlisten进入等待连接状态,再通过acceptaccept受理客户端请求;客户端则需要调用connectconnect建立连接。UDPUDP套接字不维持连接状态,因此不需要listenlistenacceptaccept,基本过程只有创建套接字收发数据

这也意味着UDPUDP中的服务器端和客户端并没有十分严格的界限。通常把固定地址、持续等待并提供服务的一方称为服务器端,把主动向该地址发送请求的一方称为客户端。

服务器端和客户端均只需一个套接字#

TCPTCP连接是一对一的。服务器端除监听套接字外,还会为每个已连接客户端创建单独的连接套接字。UDPUDP套接字更像邮筒,一个邮筒可以把信寄往多个地址,也能接收不同寄件人的信,所以无论与多少台主机通信,通常都只需一个套接字。

替换文本

图中同一个UDPUDP套接字可以和其他主机上的多个UDPUDP套接字交换数据。为了区分每个数据报的目标和来源,UDPUDP每次收发数据时都要处理地址信息。

基于UDP的数据I/O函数#

TCPTCP套接字建立连接后已经保存了对方地址,调用writewrite时只需给出数据即可。未连接的UDPUDP套接字没有固定通信对象,所以发送数据时要同时指定目标地址。

#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。

socksockUDPUDP套接字文件描述符,buffbuffnbytesnbytes表示待发送数据及其长度,flagsflags没有特殊选项时传入00totoaddrlenaddrlen则给出目标地址信息及结构体长度。

接收端同样不能预先确定数据来自哪台主机,所以recvfromrecvfrom除了接收数据,还会返回发送端的地址信息。

#include <sys/socket.h>
ssize_t recvfrom(int sock, void *buff, size_t nbytes, int flags,
struct sockaddr *from, socklen_t *addrlen);
//成功时返回接收的字节数,失败时返回-1。

fromfrom用于保存发送端地址,addrlenaddrlen传入地址结构体长度并在调用后得到实际长度。服务器端收到数据后,可以直接将这组地址信息交给sendtosendto,把结果发回原客户端。

基于UDP的回声服务器端/客户端#

UDPUDP回声服务器端仍需通过bindbind为套接字分配固定的IPIP和端口,但不再调用listenlistenacceptaccept。其核心结构如下:

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_DGRAMSOCK\_DGRAM套接字后,通过sendtosendto指定服务器地址,再由recvfromrecvfrom接收服务器返回的数据:

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);

服务器端的recvfromrecvfrom可以接收任意客户端的数据,并取得对应客户端地址;客户端的sendtosendto则在每次发送时明确指定服务器地址。二者依靠数据报中携带的地址信息完成一来一回的通信。

UDP客户端套接字的地址分配#

所有参与网络通信的套接字最终都要拥有IPIP地址和端口号。TCPTCP客户端在调用connectconnect时由操作系统自动分配地址,而上面的UDPUDP客户端既没有调用bindbind,也没有调用connectconnect

原因在于,若首次调用sendtosendto时套接字尚未分配地址,操作系统会自动使用本机IPIP,并从尚未使用的端口中选择一个端口。这个地址会保留到套接字关闭,因此UDPUDP客户端通常不必显式调用bindbind。如果程序需要固定客户端端口,也可以主动调用bindbind完成分配。

6.3 UDP的数据传输特性和调用connect函数#

存在数据边界的UDP套接字#

TCPTCP以字节流为单位,不保留每次writewrite之间的边界,所以发送端调用一次writewrite,接收端可能需要多次readread;发送端的多次writewrite,也可能被一次readread读出。

UDPUDP则保留数据边界。一次sendtosendto发送的内容构成一个完整的数据报DatagramDatagram),接收端一次recvfromrecvfrom只处理一个数据报。因此,若发送端调用33sendtosendto,接收端也需要调用33recvfromrecvfrom才能取完这33个数据报。

WARNING

“存在数据边界”不表示接收缓冲可以随意设置得很小。若传入recvfromrecvfrom的缓冲区小于数据报,超出的部分可能被截断或丢弃,之后再调用一次recvfromrecvfrom也不能像读取TCPTCP字节流那样继续取得同一数据报的剩余部分。

已连接与未连接的UDP套接字#

默认的UDPUDP套接字没有注册固定目标,称为未连接套接字unconnected UDP socketunconnected\ UDP\ socket)。每次调用sendtosendto时,大致需要完成以下过程:

  1. UDPUDP套接字注册目标IPIP和端口号。
  2. 传输数据。
  3. 删除刚才注册的目标地址信息。

这样做的好处是同一个套接字可以不断更换目标地址。但如果程序要长时间与同一台主机通信,每次重复注册和删除地址就会带来不必要的开销。这时可以为UDPUDP套接字调用connectconnect,将其变为已连接套接字connected UDP socketconnected\ UDP\ socket)。

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));

这里的connectconnect并不会像TCPTCP一样进行三次握手,也没有真正建立一条可靠连接。它只是把默认通信对象的IPIP和端口注册到本地UDPUDP套接字中。注册完成后,除了继续使用sendtosendtorecvfromrecvfrom外,也可以像TCPTCP套接字一样使用writewritereadread

write(sock, message, strlen(message));
str_len = read(sock, message, sizeof(message) - 1);

已连接UDPUDP套接字省去了反复指定目标地址的过程,也会把接收范围限定为已注册的通信对象,适合与同一主机频繁交换数据的场景。

6.4 基于Windows的实现#

WindowsWindows平台下同样使用sendtosendtorecvfromrecvfrom收发UDPUDP数据,其参数顺序及含义与LinuxLinux版本基本一致:

#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。

移植时仍需先通过WSAStartupWSAStartup初始化WinsockWinsock,以SOCKETSOCKET保存套接字句柄,关闭时调用closesocketclosesocket,程序结束前调用WSACleanupWSACleanup。若创建的是已连接UDPUDP套接字,则可以使用sendsendrecvrecv代替LinuxLinux中的writewritereadread。除此之外,回声服务器端/客户端的整体逻辑不变。

优雅地断开套接字连接#

7.1 基于TCP的半关闭#

前面一直通过closecloseclosesocketclosesocket断开套接字,但这两个函数会同时关闭数据发送和接收能力。在普通的请求/响应结束后这样做通常没有问题,可一旦对方仍有数据需要传来,单方面完全关闭就可能造成数据丢失。

单方面断开连接带来的问题#

假设主机AA和主机BB正在双向通信,主机AA发送完自己的最后一段数据后立即调用closeclose。此时主机AA不仅不能再发送,也不能继续接收;如果主机BB还有必须返回的数据,这些数据就无法再由主机AA读取。

替换文本

解决方法并不是无限延长等待时间,而是只关闭其中一个方向的数据流:可以停止发送但保留接收,也可以停止接收但保留发送。这种只关闭一部分连接的方式称为半关闭HalfcloseHalf-close)。

套接字与流#

两台主机建立TCPTCP连接后,会形成可以双向交换数据的状态。但一条流中的数据只能沿一个方向移动,所以双向通信实际上由两条方向相反的流组成:

  • 主机AA的输出流连接主机BB的输入流。
  • 主机BB的输出流连接主机AA的输入流。

替换文本

用shutdown函数实现半关闭#

LinuxLinux中通过shutdownshutdown选择要关闭的流:

#include <sys/socket.h>
int shutdown(int sock, int howto);
//成功时返回0,失败时返回-1。

socksock是需要操作的套接字文件描述符,howtohowto决定关闭方式:

参数含义
SHUT_RD关闭输入流,之后无法继续接收数据
SHUT_WR关闭输出流,之后无法继续发送数据
SHUT_RDWR同时关闭输入流和输出流

传入SHUT_RDSHUT\_RD后,即使输入缓冲又收到数据也会将其丢弃。传入SHUT_WRSHUT\_WR后,输出缓冲中已经存在的数据仍会继续发往对方,随后向对方传递EOFEOFSHUT_RDWRSHUT\_RDWR则相当于分别关闭输入和输出两个方向。

为什么需要半关闭#

考虑一个文件传输程序:服务器端把文件发给客户端,客户端接收完成后再向服务器端返回"Thank you""Thank\ you"。这里存在两个问题:

  1. 客户端事先不知道文件长度,无法判断应该在何时停止调用readread
  2. 服务器端发送完文件后仍要接收客户端的确认消息,不能立刻完全关闭套接字。

若约定某个特殊字符表示文件结束,这个字符可能恰好出现在文件内容中;而TCPTCP关闭输出流时会向对方传递EOFEOFreadread在读完剩余数据后返回00,因此EOFEOF更适合表示文件传输结束。

服务器端不能直接调用closeclose,否则自己的输入流也会关闭,无法再接收确认消息。正确做法是调用:

shutdown(clnt_sd, SHUT_WR);

这样服务器端完成以下三件事:先把输出缓冲中的剩余数据全部发出,再向客户端传递EOFEOF,同时保留输入流继续接收客户端最后的响应。

基于半关闭的文件传输#

文件传输的数据流如下:服务器端受理连接后发送文件数据,发送完成后对输出流执行半关闭;客户端通过readread返回00判断文件已经传完,再发送确认消息。

替换文本

服务器端核心逻辑:

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);

客户端则持续接收,直到读取到EOFEOF

while ((read_cnt = read(sd, buf, BUF_SIZE)) != 0)
fwrite(buf, 1, read_cnt, fp);
puts("Received file data");
write(sd, "Thank you", 10);

这里不能把一次readread返回的数据直接看作完整文件。客户端仍需循环读取并按照实际返回的字节数写入文件;当服务器端执行输出流半关闭、且缓冲数据全部读完后,客户端的readread才返回00。之后客户端仍能通过自己的输出流发送"Thank you""Thank\ you",服务器端也仍能从保留的输入流读到它。

NOTE

shutdownshutdown用于控制连接的收发方向,closeclose用于释放文件描述符和套接字资源。半关闭完成剩余通信后,双方仍应在适当位置调用closeclose

7.2 基于Windows的实现#

WindowsWindows同样通过shutdownshutdown完成半关闭,函数形式基本相同:

#include <winsock2.h>
int shutdown(SOCKET sock, int howto);
//成功时返回0,失败时返回SOCKET_ERROR。

只是第二个参数的常量名称有所不同:

LinuxWindows含义
SHUT_RDSD_RECEIVE关闭输入流
SHUT_WRSD_SEND关闭输出流
SHUT_RDWRSD_BOTH同时关闭输入流和输出流

因此服务器端在发送完文件后调用:

shutdown(hClntSock, SD_SEND);
recv(hClntSock, buf, BUF_SIZE, 0);

客户端循环调用recvrecv,当返回00时结束文件接收,再通过sendsend返回确认消息。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

TCP&IP网络编程学习札记_4
https://mkrari.cn/posts/tcp_ip_learn_4/
作者
Mkrari
发布于
2026-07-24
许可协议
CC BY-NC-SA 4.0