ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

UDP vs TCP:套接字编程的底层真相

UDP vs TCP:套接字编程的底层真相

Linux 网络学习:UDP 与 TCP 套接字

引言

在 Linux 网络编程里,套接字(socket)是应用程序与内核网络协议栈之间的接口。程序拿到一个套接字描述符,就可以像操作文件一样读写网络数据。真正让初学者容易混淆的地方有两个:UDP 的数据报到底怎样到达对端,TCP 为什么需要listen和accept,以及这两个协议为什么不能只靠一个“发送函数”就解决所有问题。

下面分两篇整理 UDP 和 TCP,从回显程序讲到聊天室、线程池和应用层协议。


第一篇:UDP 套接字

1. UDP 的通信模型

UDP(User Datagram Protocol,用户数据报协议)把一次发送看成一个独立的数据报。发送方给出数据、目标 IP 和目标端口,内核把这三个信息交给 UDP 层;接收方通过recvfrom一次取出一个数据报,同时得到发送方的地址。

它不建立连接,也不保证数据一定到达、按顺序到达或只到达一次。这个特性让 UDP 的接口很直接:服务器创建一个数据报套接字,绑定一个固定端口,然后等待数据;客户端知道服务器的 IP 和端口后,直接调用sendto。

可以把 UDP 想成寄快递。每个包裹上都写着收件地址,快递员把包裹送到目的地;寄件人不需要先和收件人建立一条专线。包裹之间也没有天然的连续关系,应用程序要不要重传、编号、去重,都由自己决定。

2. 套接字、地址和字节序

创建 IPv4 UDP 套接字的核心代码是:

intsockfd=socket(AF_INET,SOCK_DGRAM,0);

AF_INET表示 IPv4 地址族,SOCK_DGRAM表示数据报语义。返回值是文件描述符;小于 0 说明创建失败。

IPv4 地址通常放在sockaddr_in中:

structsockaddr_inaddr{};addr.sin_family=AF_INET;addr.sin_port=htons(port);addr.sin_addr.s_addr=inet_addr("0.0.0.0");

端口和地址在网络上传输时采用网络字节序,通常是大端;主机内部的整数顺序可能不同,所以要使用htons(host to network short)把主机端口转成网络端口,使用ntohs把收到的端口转回主机端口。IP 字符串和四字节地址之间,则可以用inet_addr和inet_ntoa完成转换。

0.0.0.0与INADDR_ANY表示监听本机的任意 IPv4 网卡。服务器一般这样绑定,局域网地址、回环地址和其他网卡地址都可以把数据交给同一个端口。

3. UDP 服务器的固定流程

UDP 服务器的流程可以压缩成四步:

  1. socket创建套接字。
  2. 填充本地端口和地址,并用bind交给内核。
  3. recvfrom接收数据和对端地址。
  4. sendto根据对端地址回复数据。

bind很关键。服务器端口必须固定,客户端才能找到它。下面是回显服务器的处理循环:

intsockfd=socket(AF_INET,SOCK_DGRAM,0);sockaddr_in local{};local.sin_family=AF_INET;local.sin_port=htons(8888);local.sin_addr.s_addr=INADDR_ANY;if(bind(sockfd,reinterpret_cast<sockaddr*>(&local),sizeof(local))<0){perror("bind");return1;}while(true){charbuffer[1024]{};sockaddr_in peer{};socklen_t len=sizeof(peer);ssize_t n=recvfrom(sockfd,buffer,sizeof(buffer)-1,0,reinterpret_cast<sockaddr*>(&peer),&len);if(n<=0){continue;}buffer[n]='\0';std::string reply="server echo# ";reply+=buffer;sendto(sockfd,reply.c_str(),reply.size(),0,reinterpret_cast<sockaddr*>(&peer),len);}

这里没有listen,也没有accept。UDP 内核只关心“这个数据报应该交给哪个本地端口”,服务器收到数据后可以直接回复。

4. 客户端为什么通常不手动 bind

UDP 客户端需要知道服务器地址,却通常不需要指定自己的端口。第一次调用sendto时,如果套接字还没有本地地址,内核会选择一个临时端口并完成隐式绑定。这个端口只要在当前通信中唯一即可,客户端退出后通常可以再次变化。

客户端的关键片段如下:

intsockfd=socket(AF_INET,SOCK_DGRAM,0);sockaddr_in server{};server.sin_family=AF_INET;server.sin_port=htons(server_port);server.sin_addr.s_addr=inet_addr(server_ip.c_str());sendto(sockfd,message.c_str(),message.size(),0,reinterpret_cast<sockaddr*>(&server),sizeof(server));charbuffer[1024]{};sockaddr_in peer{};socklen_t len=sizeof(peer);ssize_t n=recvfrom(sockfd,buffer,sizeof(buffer)-1,0,reinterpret_cast<sockaddr*>(&peer),&len);

如果客户端手动绑定固定端口,多个客户端就可能因为端口冲突而启动失败。只有当业务明确要求固定源端口,例如某些服务发现或协议约定,才需要显式bind。

5. UDP 聊天室:一个套接字服务多个用户

聊天室进一步说明了 UDP 的特点。服务器只需要一个 UDP 套接字,却可以服务多个客户端,因为每个数据报都自带发送方地址。服务器维护一个用户地址列表,收到新地址时加入列表,然后遍历列表调用sendto广播。

核心逻辑大致如下:

voidBroadcast(intsockfd,conststd::string&message){for(constInetAddr&user:users){sendto(sockfd,message.c_str(),message.size(),0,reinterpret_cast<constsockaddr*>(user.GetNetAddress()),user.Len());}}

代码中的用户身份实际上是IP + port。同一台机器上启动两个客户端,它们通常会拿到不同的临时端口,因此可以被服务器区分。服务器还需要避免重复加入同一个地址,并在用户离线时删除地址。

客户端把接收和发送拆成两个线程;服务器把广播任务放进线程池,让接收循环尽快返回。

6. UDP 编程中容易忽略的地方

第一,UDP 保留数据报边界。一次sendto对应一个数据报,接收端一次recvfrom取一个数据报;如果缓冲区太小,超出的部分可能被截断。因此消息大小和缓冲区上限要有明确约定。

第二,recvfrom返回的地址是本次数据报的来源地址,不能把上一次收到的地址当成当前用户。聊天室广播时要使用每个用户自己的sockaddr_in。

第三,返回值必须检查。sendto或recvfrom小于 0 都代表系统调用失败。

第四,UDP 没有连接关闭事件。在线状态需要心跳、超时或业务消息来维护;如果业务要求可靠性,还要自己设计序号、确认和重传。


第二篇:TCP 套接字

1. TCP 的通信模型

TCP(Transmission Control Protocol,传输控制协议)面向连接,提供可靠的字节流。通信双方先建立连接,再通过连接读写数据;TCP 会负责确认、重传、排序和流量控制。

TCP 更像打电话。双方先拨号并接通,接通后可以连续说话;“说了几句话”并不是网络层天然知道的边界,应用程序需要自己规定一条消息从哪里开始、到哪里结束。

TCP 服务器有两个不同角色的套接字:监听套接字负责接收连接,连接套接字负责和某一个客户端通信。listen之后的套接字不能直接拿来读业务数据,必须通过accept得到新的服务套接字。

2. TCP 服务器的五步

服务器初始化通常是:

  1. socket(AF_INET, SOCK_STREAM, 0)创建流式套接字。
  2. bind固定本地 IP 和端口。
  3. listen把套接字变成监听状态,并设置等待队列长度。
  4. accept取出一个已经完成连接的新套接字。
  5. 对新套接字read/recv和write/send,通信结束后close。

对应的初始化代码如下:

intlistensock=socket(AF_INET,SOCK_STREAM,0);sockaddr_in local{};local.sin_family=AF_INET;local.sin_port=htons(8888);local.sin_addr.s_addr=INADDR_ANY;bind(listensock,reinterpret_cast<sockaddr*>(&local),sizeof(local));listen(listensock,16);

接收连接的循环:

while(true){sockaddr_in client{};socklen_t len=sizeof(client);intsockfd=accept(listensock,reinterpret_cast<sockaddr*>(&client),&len);if(sockfd<0){continue;}service(sockfd,client);close(sockfd);}

listen的第二个参数是等待队列的参考长度,不是“最多只能连接多少客户端”。真正能服务多少连接,还取决于进程数、线程数、文件描述符上限、内存和业务处理速度。

3. 为什么 accept 会返回新的文件描述符

监听套接字可以理解为前台接待员,只负责接收新客户;accept返回的服务套接字才是与某一位客户的专线。每来一个客户端,就多一个服务套接字,但监听套接字继续留在accept循环中。

这也是 TCP 与 UDP 服务器结构差异最大的地方:UDP 一个套接字可以收到所有客户端的数据报,TCP 则通过多个服务套接字区分不同连接。客户端地址可以通过accept的sockaddr_in得到,再用ntohs和inet_ntoa转成可读的 IP 与端口。

4. TCP 客户端的 connect

TCP 客户端创建SOCK_STREAM套接字后,使用connect发起连接:

intsockfd=socket(AF_INET,SOCK_STREAM,0);sockaddr_in server=MakeAddress(server_ip,server_port);if(connect(sockfd,reinterpret_cast<sockaddr*>(&server),sizeof(server))<0){perror("connect");return1;}

客户端一般不显式绑定本地端口。connect前后,内核会为客户端选择合适的本地地址和临时端口,形成四元组:本地 IP、本地端口、服务器 IP、服务器端口。服务器上的每条 TCP 连接都由这个四元组唯一确定。

建立连接后,双方可以使用read/write或recv/send。练习中的回显客户端从标准输入读取一行,调用write发给服务器,再调用read读取回显结果。

5. read 返回值代表什么

对 TCP 服务套接字调用read时,返回值要分三种情况处理:

  • 大于 0:本次读到了若干字节。
  • 等于 0:对端已经有序关闭连接,通常称为读到文件结束。
  • 小于 0:发生错误,具体原因需要结合errno判断。

read一次读到的字节数不等于对端一次write的字节数。一次写入可能被分成多次读出,多次写入也可能合并到一次读取中,这就是常说的粘包和拆包现象。TCP 只保证字节顺序,不知道你的业务消息边界。

6. 从短连接到长连接

最早的回显服务器在处理完一次read和write后就关闭服务套接字,这属于短连接。它结构简单,但每次请求都要重新建立连接。

把read/write/close放进循环,就能得到长连接:

while(true){charbuffer[1024]{};ssize_t n=read(sockfd,buffer,sizeof(buffer)-1);if(n<=0){break;}buffer[n]='\\0';write(sockfd,buffer,n);}close(sockfd);

长连接适合交互式命令、聊天室、远程终端等场景,但服务端必须保存连接状态,并考虑客户端长时间不发数据时如何释放资源。

7. 一个连接交给谁处理

练习代码展示了多进程、多线程和线程池三种模型。多进程中父进程负责accept,子进程负责业务;多线程中每条连接交给一个线程;线程池则由主线程接入连接,再把任务放进队列。线程池能限制线程数量,把接入和业务解耦,但仍要正确管理文件描述符和共享数据。

连接数继续增大时,可以用select、poll或epoll观察多个文件描述符。它们解决事件等待问题,不会替代 TCP 连接语义,也不会自动定义消息协议。

8. 应用层协议:给字节流划分消息

TCP 编程最容易留下的坑是只写一个read,然后假设读到的就是完整请求。正确的做法是定义应用层协议。练习中的计算器协议采用“长度 + 分隔符 + JSON + 分隔符”的格式:

长度\\r\\nJSON内容\\r\\n

例如,一条请求可以表示为:

31\\r\\n{"left":10,"right":20,"oper":"+"}\\r\\n

接收端维护一个字符串缓冲区。每次recv把新字节追加到缓冲区,然后按下面的顺序处理:

  1. 查找第一个\\r\\n,得到长度字段。
  2. 判断缓冲区是否已经包含完整的 JSON 和结尾分隔符。
  3. 如果不完整,保留缓冲区,等待下一次接收。
  4. 如果完整,提取一条消息,删除已经消费的字节。
  5. 循环处理缓冲区中剩余的完整消息。

这种做法既能处理半包,也能处理一次读到多条消息。JSON 只是序列化格式,长度字段才负责告诉接收端消息边界。真实项目还要检查长度上限、非法字符和数字转换异常,避免恶意数据让内存或 CPU 无限消耗。

9. TCP 编程中容易忽略的地方

服务重启时可在bind前设置SO_REUSEADDR。write和send也可能只发送一部分数据,大消息要循环发送;非阻塞套接字还要处理EAGAIN。

shutdown可以只关闭读或写的一半,close才释放文件描述符。线程池任务不要引用已经离开作用域的地址和字符串。TCP 只保证字节可靠到达,协议错误和业务失败仍要由应用处理。


UDP 与 TCP 的选择

对比项UDPTCP
通信方式无连接、数据报面向连接、字节流
数据边界保留一次发送的边界没有消息边界,需要应用层分帧
可靠性不保证到达、顺序和唯一性内置确认、重传和排序
服务端核心调用socket、bind、recvfrom、sendtosocket、bind、listen、accept、recv/send
客户端常见行为第一次发送时由内核自动分配源端口connect时建立连接并分配源端口
适合场景DNS、实时状态、广播、简单发现HTTP、远程登录、文件传输、可靠请求

选择协议时,先看数据丢失能否接受,以及消息边界和连接状态是否明确。实时场景可选 UDP,可靠请求选 TCP;UDP 的可靠性要自带序号、确认和重传。

当这些问题都能解释清楚时,套接字就不再只是几组函数调用:UDP 的核心是“带地址的数据报”,TCP 的核心是“连接上的有序字节流”,而服务器的并发模型和应用层协议则决定了这两种传输能力能否变成可靠的程序。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进