今天翻到一篇不错的技术分享,看完之后自己也琢磨了一下,把思路梳理记录下来。
🔥个人主页:Cx330🌸
❄️个人专栏:《C语言》《LeetCode刷题集》《数据结构-初阶》《C++知识分享》
《优选算法指南-必刷经典100题》《Linux操作系统》:从入门到入魔
🌟心向往之行必能
🎥Cx330🌸的简介:
目录
1.3.1 服务端必须显式 bind vs 客户端无需/不建议显式 bind 深度剖析
1.3.3 延伸硬核考点:INADDR_ANY 与网络字节序转换——为什么 0.0.0.0 不需要 htonl?
③ 为什么 0.0.0.0(INADDR_ANY)可以不调用 htonl?
3.1.1 服务端头文件 UdpEchoServer.hpp
3.1.2 服务端主函数 UdpEchoServer.cpp
4.4 通用 UDP 服务端实现 UdpServer.hpp
Q1:在 UDP 编程中,调用 sendto() 成功返回了一个大于 0 的整数,是否意味着对端客户端一定收到了该数据报?
Q2:在 TCP 编程中,读函数返回 0 代表连接断开(EOF);那么在 UDP 的 recvfrom() 中,返回 0 同样代表对端关闭了连接吗?
Q3:为什么服务端必须显式调用 bind() 绑定固定端口,而客户端绝不建议显式 bind()?
Q4:在绑定服务器网络地址属性时,为什么端口必须调用 htons() 转换字节序,而绑定的 INADDR_ANY 却可以直接赋值,不需要调用 htonl()?
Q5:云服务器(如阿里云、腾讯云等)在部署 UDP 服务端时,为什么绝对不能 bind 它的公网 IP?
Q6:在写 recvfrom() 循环接收数据时,以下代码片段存在什么严重的隐藏 Bug?
Q7:为什么在多线程并发(如线程池架构)的 UDP 服务端开发中,绝对禁止用 inet_ntoa 函数?
Q8:在 UDP 服务端开发中,如果 recvfrom 指定的本地接收缓冲区 buf 只有 500 字节,但客户端发来了一个 1000 字节的数据报,会发生什么?
前言:
在网络编程的广阔天地中,UDP(用户数据报协议)因其无连接、不可靠但极具传输高效率的特性,在实时游戏、流媒体及高性能分布式系统中被广泛采用。
作为一名 C++ 领域的技术博主,今天我将结合 Linux 系统编程的核心接口,带大家一步步由浅入深,从零构建一个完整的 UDP 通讯框架。大家不仅会编写基础的 Echo 回显服务 (V1),还会实现优雅的字典翻译服务 (V2)
本文不仅涵盖了 Socket API、sockaddr 的深度绑定机制,还会探讨 inet_ntoa 这一类古老地址转换函数所带来的“多线程安全地雷”,以及如何利用 C++ 的 remove_if 算法完美擦除过期用户。这是一篇妥妥的干货,建议收藏后反复研读!
一. UDP 网络编程基础
在动手写代码之前,大家必须先搞懂 UDP 协议的本质特性,以及 UDP Socket 编程的完整流程,这是所有代码实现的理论基础。
1.1 UDP 协议三大核心特性
UDP(User Datagram Protocol,用户数据报协议)是传输层协议,和 TCP 同属网络分层模型的传输层,但其核心设计与 TCP 完全相反,三大核心特性如下:
特性详细说明与 TCP 的核心差异无连接通信前无需建立连接,知道对方的 IP 和端口即可直接发送数据,不存在三次握手、四次挥手的过程TCP 必须先通过三次握手建立连接,才能传输数据不可靠传输不给出确认应答、超时重传、序列号、乱序重排等机制,只保证把数据尽力发送出去,不保证数据一定到达、不重复、按序到达TCP 通过一系列机制保证数据可靠、不丢失、不重复、按序交付面向数据报数据以独立的报文为单位传输,收发次数严格匹配,报文之间有明确的边界,发送端一次发一个报文,接收端必须完整接收整个报文TCP 面向字节流,数据无边界,发送端发 1000 字节,接收端可以分多次读取,需要上层自行处理数据边界
重要提醒:不可靠是 UDP 的特性,而非缺点。UDP 舍弃了可靠性保障,换来了极致的低延迟和极小的头部开销(UDP 头部仅 8 字节,TCP 头部最少 20 字节),这也是实时场景选择 UDP 的核心原因。
1.2 UDP Socket 核心工作流程图解
UDP 是无连接的协议,所以其编程模型比 TCP 轻松很多,服务端和客户端的核心流程如下:
服务端核心流程
- 创建 Socket 文件描述符:调用
socket()函数,创建一个基于 IPv4、数据报类型的 UDP 套接字; - 绑定地址与端口:调用
bind()函数,将套接字与固定的 IP 地址、端口号绑定,让客户端知道请求的目标地址; - 循环接收与发送数据:调用
recvfrom()阻塞等待客户端数据,收到数据后执行业务处理,再调用sendto()将处理结果回发给客户端; - 关闭套接字:服务停止时,调用close()关闭套接字。
- 创建 Socket 文件描述符:同服务端,调用
socket()创建 UDP 套接字; - 填充服务端地址信息:定义
sockaddr_in结构体,填充服务端的 IP、端口,作为数据发送的目标; - 循环发送与接收数据:调用
sendto()向服务端发送数据,再调用recvfrom()等待服务端的响应 - 关闭套接字:通信收尾时,调用
close()关闭套接字。
【服务端 (Server)】 【客户端 (Client)】
+-------------------+ +-------------------+
| 1. socket() | | 1. socket() |
| 创建UDP套接字 | | 创建UDP套接字 |
+---------+---------+ +---------+---------+
| |
+---------v---------+ |
| 2. bind() | | (客户端通常不需要显式bind)
| 绑定固定的Port | | (首次sendto时由OS随机分配)
+---------+---------+ |
| |
+---------v---------+ |
| 3. recvfrom() | + 6. recvfrom()
| 将响应发送回 | (发送响应数据) | 接收服务端回包
| 客户端的目标地址 | |
+---------+---------+ +---------v---------+
| | 7. close() |
(回到步骤3) | 关闭套接字资源 |
| +-------------------+
v
1.3 核心前置知识点
在代码实现前,必须先吃透这几个高频踩坑的核心知识点:
1.3.1 服务端必须显式 bind vs 客户端无需/不建议显式 bind 深度剖析
在网络编程的学习中,几乎每个初学者都会发出一个疑问:为什么服务端非要调用 bind(),而客户端却从来不写?客户端确实没有绑定端口吗? 其实,客户端也必须绑定端口,但它是“隐式绑定”。这里面的设计逻辑关系到网络通信的底层哲学。
服务端为什么必须【显式 bind】?
服务端的基本角色是“被动给出服务者”,这就决定了它的行为特征:
1. 服务端的物理地址必须是“众所周知”(Well-known)的: 在网络通信中,永远是客户端主动发起请求。如果客户端想要发数据给服务端,它首先必须得知道服务端的具体 IP 和 Port。
- 举个例子,这就好比大家要去“工商银行办事”,工商银行的地址(IP)和营业窗口(Port)必须是常年固定且对外公开的。
- 如果服务端不进行显式
bind,它的端口在每次启动时都会由操作系统随机分配。今天启动是52301,明天启动是41908。由于客户端无法提前预知这个随机端口,根本无法向其发送请求,服务也就无从谈起了。
客户端为什么【无需/不建议显式 bind】?
客户端的角色是“服务的主动请求者”,对于客户端,核心考量是安全、防冲突与轻量化:
1. 防止端口冲突(Address already in use): 客户端是运行在普通用户电脑上的。用户在同一台设备上,可能会开多个相同的客户端(比如多开网页、多开游戏客户端),或者不同的应用程序可能恰好抢夺同一个固定端口。
- 假设客户端在代码里显式绑定了
8888端口。当用户在一台电脑上开启第一个客户端实例时运行正常。一旦用户试图打开第二个实例,或者此时电脑上正好有个后台程序已经占用了8888端口,第二个客户端进程在初始化调用bind()时,就会立刻因为 “Address already in use” (端口冲突) 报错而直接崩溃退出! - 这种脆弱的用户体验对客户端软件来说是不可接受的。
sendto()(或 TCP 的 connect())时,操作系统发现该套接字没有绑定端口,会自动从系统当前的 临时端口范围 (Ephemeral Ports,通常在 1024 ~ 65535) 中动态挑选一个当前无人占用的空闲端口,静默地为该套接字进行绑定。
- 这样一来,不管你开了多少个客户端,或者电脑后台运行了多么麻烦的软件,操作系统都能确保每个客户端拿到一个唯一的、不发生冲突的端口。
recvfrom 的输出参数 peer 即可获取该客户端的临时端口,从而直接向其发回包,完全不需要客户端提前“死绑定”。
维度
服务端(Server)
客户端(Client)
角色定位
被动等待,主动给出服务
主动发起请求,被动接收响应
端口要求
必须固定、公开、众所周知(Well-known)
必须动态、随机、防冲突(Ephemeral)
绑定方式
显式绑定:程序员必须手动写代码进行 bind()
隐式绑定:由操作系统在首次发包时动态随机指派
冲突后果
端口被占则服务器无法正常启动(属于严重故障)
若显式 bind 固定端口,极易造成用户多进程冲突、程序闪退
寻址机制
客户端通过预先得知的服务地址直接寻址
服务端接收报文时通过 recvfrom() 底层提取,实现动态回包
1.3.2 为什么推荐绑定 INADDR_ANY?
在 V1 版本的服务器初始化代码中,我们见到了如下绑定设置:
local.sin_addr.s_addr = INADDR_ANY; // 其底层值定义为 0x00000000(即 IP 0.0.0.0)
在底层源码中,这个宏在 <netinet/in.h> 中定义为:
#define INADDR_ANY ((in_addr_t) 0x00000000)
别小看这行代码,这在 C++ 服务端开发面试中是一个极高频的考点。我们必须从以下三个核心维度深度剖析它的核心作用:
如果我们显式地将服务端 bind 了某一个确定的 IP,比如 192.168.1.100:
- 局限性:该套接字将只监听来自
eth0网卡的数据。 - 痛点:若客户端通过本地回环
127.0.0.1或者外网网卡发送数据到服务端的相同端口,操作系统的 TCP/IP 协议栈会检测到目标 IP 与绑定的192.168.1.100不符,直接在底层将数据包丢弃!
【客户端报文 1】 --- (发往 127.0.0.1:8888) -------> 【本地回环网卡】 ---> [协议栈检测 IP 匹配失败] ---> (丢弃 ❌)
【客户端报文 2】 --- (发往 192.168.1.100:8888) ----> 【内网网卡 eth0】 -> [协议栈检测 IP 匹配成功] ---> (接收并处理 ✔)
而当我们 bind 了 INADDR_ANY 时:
- 底层机制:我们是在告诉操作系统的网络内核:“只要是发往这个端口(比如 8888)的数据包,不管它是从本地回环网卡、内网网卡还是外网网卡流进来的,我的服务器统统要收下!”
- 这样做直接省去了繁琐的、判断数据到底是从服务器具体哪张网卡上面流入的过程,使服务器具备了完美的整体监听能力。
假设不用 INADDR_ANY,我们就必须在代码中硬编码(Hardcode)写死当前主机的特定 IP(如 "192.168.3.15"),或者通过解析繁琐的本地配置文件来读取。
- 运维灾难:一旦这套代码需要迁移到另一台测试机、甚至是生产环境集群时,因为每台物理机/容器的私有 IP 都是不同的,你的程序一定会因为 IP 找不到而启动报错,运维必须逐个去修改部署配置文件。
- 高可用迁移:采用
INADDR_ANY,同一套编译出来的服务器可执行二进制文件不需要任何修改,就可以无缝迁移、分发并运行在任意一台网络环境中,极大地提高了代码的灵活性和自动化部署能力。
1.3.3 延伸硬核考点:INADDR_ANY 与网络字节序转换——为什么 0.0.0.0 不需要 htonl?
在 C++ 套接字编程中,我们写过如下两行核心转换代码:
local.sin_port = htons(_port); // 端口转换:host to network short
local.sin_addr.s_addr = INADDR_ANY; // IP 绑定:直接赋值
这里潜藏着另一个极具技术细节的面试问题:为什么端口需要调用 htons,而绑定的 INADDR_ANY 却可以直接赋值,不需要调用 htonl?
① 什么是字节序?
在多字节整数(如 16 位短整型端口、32 位长整型 IP)存入内存时,由于 CPU 硬件架构设计不同,存在两种排序流派:
- 小端字节序(Host Byte Order,主机序列):x86/x64 架构 CPU 默认用。低位字节存放在低内存地址。比如数值
0x1234在小端内存中的布局为:[34] [12](地址由低到高)。 - 大端字节序(Network Byte Order,网络序列):TCP/IP 协议栈统一的标准规范。高位字节存放在低内存地址。例如相同数值
0x1234在大端内存中的布局为:[12] [34]。
② 核心转换 API 系列
为了跨平台兼容性,C++ 网络编程提供了标准的系统级转换函数:
htons(Host to Network Short):将 16 位主机序短整型(如 Port)转为网络字节序。htonl(Host to Network Long):将 32 位主机序长整型(如 IPv4 地址)转为网络字节序。ntohs(Network to Host Short) 与ntohl(Network to Host Long):网络字节序转主机本地字节序。
③ 为什么 0.0.0.0(INADDR_ANY)可以不调用 htonl?
由于 IP 地址是一个 32 位长整型数,常规 IP 在绑定时必须经过字节序转换(例如通过 inet_addr("192.168.1.100"),其内部已包含大端转换逻辑)。
然而对于 INADDR_ANY 来说:
1. 数值特殊性:INADDR_ANY 的数值在十进制中是 0,底层 16 进制表现为 0x00000000(全 0)。
2. 大端与小端的物理对称:
- 在小端系统上,其四个字节在内存中的存放顺序为:
0x00, 0x00, 0x00, 0x00。 - 在大端系统上,其四个字节在内存中的存放顺序依然为:
0x00, 0x00, 0x00, 0x00。
htonl(0x00000000) 在任何机器上运算出来的物理结果,仍然是 0x00000000。
延伸结论: 在整个 IPv4 地址空间中,除了 0.0.0.0 之外,还有一个特殊的广播 IP —— 255.255.255.255 (INADDR_NONE / 全 1) 同样不需要在乎主机字节序,因为其 16 进制形式为 0xffffffff,每个字节全为 0xff,在大端和小端中的表示也是绝对对称的。
最佳编码规范:出于严谨的代码语义化考量,部分团队的规范仍然推荐写 htonl(INADDR_ANY)。尽管它没有任何实际计算开销,但在代码可读性上明确了“这是一个需要转换为网络字节序的 IP 数据”。
1.4 核心 API 详解
我们先把 UDP 编程最核心的 4 个 API 的参数、返回值、注意事项讲清楚,后续代码实现会反复用到:
// 1. 创建套接字
#include
int socket(int domain, int type, int protocol);
domain:地址族,AF_INET表示 IPv4,AF_INET6表示 IPv6;type:套接字类型,SOCK_DGRAM表示数据报套接字(UDP),SOCK_STREAM表示流式套接字(TCP);protocol:协议编号,UDP 场景固定填 0,系统会自动匹配 UDP 协议;- 返回值:成功返回非负文件描述符,失败返回 - 1 并设置 errno。
// 2. 绑定地址与端口
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockfd:socket ()返回的文件描述符;addr:填充好的地址结构体指针,需要强转为通用struct sockaddr*类型;addrlen:地址结构体的长度;- 返回值:成功返回 0,失败返回 - 1 并设置 errno。
// 3. 接收数据
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
sockfd:socket 文件描述符;buf:接收数据的缓冲区;len:缓冲区的最大长度;flags:接收标志,常规场景填 0,代表阻塞接收;src_addr:输出型参数,用于存储发送端(客户端)的地址信息;addrlen:输入输出型参数,传入时是 src_addr 的长度,返回时是实际写入的地址长度;- 返回值:成功返回实际接收的字节数,失败返回 - 1 并设置 errno。
// 4. 发送数据
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
sockfd:socket 文件描述符;buf:待发送的数据缓冲区;len:待发送数据的长度;flags:发送标志,常规场景填 0;dest_addr:目标接收端的地址结构体;addrlen:地址结构体的长度;- 返回值:成功返回实际发送的字节数,失败返回 - 1 并设置 errno。
二. 前置基础设施:工具类 实现
工业级的网络代码,不会把所有逻辑耦合在主流程中,我们先实现 2 个基础工具类,为后续的服务开发提供支撑:线程安全互斥锁、高性能日志系统。这两个代码我们之前写过了,这里就轻松介绍并且回顾一下,大家如果想的话这里其实还可以扩展一个禁止拷贝的基类,就跟我们上面那个图中的差不多。
2.1 互斥锁封装 Mutex.hpp
多线程环境下,日志打印、数据收发都涉及临界资源的访问,我们封装 POSIX 互斥锁,并通过 RAII 机制实现锁的自动管理,避免手动加解锁导致的死锁问题。
#ifndef MUTEX_HPP
#define MUTEX_HPP
#include
#include
// 互斥锁封装类:提供加锁/解锁及获取原始锁的接口
class Mutex
{
public:
// 构造函数:初始化互斥锁
Mutex()
{
pthread_mutex_init(&_lock, nullptr);
}
// 析构函数:销毁互斥锁
~Mutex()
{
pthread_mutex_destroy(&_lock);
}
// 加锁操作
void Lock()
{
pthread_mutex_lock(&_lock);
}
// 解锁操作
void UnLock()
{
pthread_mutex_unlock(&_lock);
}
// 获取原始互斥锁指针,用于需要原生 pthread_mutex_t 的接口
pthread_mutex_t* Origin()
{
return &_lock;
}
private:
pthread_mutex_t _lock; // POSIX 互斥锁
};
// RAII 风格的锁守卫类:构造时加锁,析构时解锁,自动管理锁的生命周期
class LockGuard
{
public:
// 构造函数:接收一个 Mutex 指针,并立即加锁
LockGuard(Mutex* lockptr) : _lockptr(lockptr)
{
_lockptr->Lock();
}
// 析构函数:自动解锁
~LockGuard()
{
_lockptr->UnLock();
}
private:
Mutex* _lockptr; // 指向被管理的互斥锁
};
#endif
代码解析:
Mutex类:封装了 POSIX 互斥锁的初始化、销毁、加锁、解锁操作,提供面向对象的接口;LockGuard类:经典 RAII 实现,对象创建时自动加锁,离开作用域时析构自动解锁,即使代码抛出异常,也能保证锁被释放,彻底杜绝死锁风险;- 线程安全:后续日志系统、多线程服务都会基于这两个类保证临界资源的安全访问。
2.2 线程安全日志系统 logger.hpp
工业级服务必须有完善的日志系统,用于问题排查、运行状态监控。我们实现一个支持控制台 / 文件双输出、多等级日志、线程安全、自动格式化的日志系统,基于策略模式设计,方便后续扩展。
```
ifndef LOGGER_HPP
define LOGGER_HPP
include
include
include
include
include
include
include
include
include
include "Mutex.hpp"
namespace LogModule
{
// 1. 获取时间
std::string GetTimeStamp()
{
time_t currentTime = time(nullptr); // 默认获取当前时区的时间
// 我们希望把这个时间转换成年-月-日 时:分:秒
struct tm dataTime;
// 使用线程安全的版本 localtime_r,防止在多线程并发获取时间时
// 因为共享静态全局变量而导致的时间数据覆盖错乱。
localtime_r(¤tTime, &dataTime);
char dataTimeStr[128];
// 使用 snprintf 保证缓冲区不溢出,%02d 确保时间位宽不足时自动补0(如09秒)
snprintf(dataTimeStr, sizeof(dataTimeStr), "%4d-%02d-%02d %02d:%02d:%02d",
dataTime.tm_year + 1900, // tm_year 是从1900年开始计算的偏移量
dataTime.tm_mon + 1, // tm_mon 范围是 [0, 11],需加1修正
dataTime.tm_mday,
dataTime.tm_hour,
dataTime.tm_min,
dataTime.tm_sec
);
return dataTimeStr;
}
// 2. 日志等级 -- 枚举类型(整数)转换成字符串类型
// 使用 enum class 强类型枚举,避免命名污染,提高类型检查的严谨性
enum class LogLevel
{
DEBUG,
INFO,
WARNING,
ERROR,
FATAL
};
/**
@brief 辅助函数:将枚举常量映射为可读字符串
解决强类型枚举无法直接通过 std::cout 打印的问题
*/
std::string LogLevel2String(LogLevel level)
{
switch(level)
{
case LogLevel::DEBUG:
return "DEBUG";
case LogLevel::INFO:
return "INFO";
case LogLevel::WARNING:
return "WARNING";
case LogLevel::ERROR:
return "ERROR";
case LogLevel::FATAL:
return "FATAL";
default:
return "UNKNOWN";
}
}
// 3. 刷新策略
// 基类: 策略模式
// 设计意图:将“日志消息的生成”与“日志消息的输出去向”解耦,方便后续扩展网络、数据库等输出端
class LogStrategy
{
public:
// 虚析构函数:确保通过基类指针释放子类对象时,子类的资源(如文件句柄)能被正确释放
virtual ~LogStrategy() = default; // 不在这里析构
// 纯虚函数:定义统一的刷新接口规范
virtual void SyncLog(const std::string &message) = 0; // 强制子类对其进行重写
};
// 策略1: 控制台日志策略
// 子类
class ConsoleLogStrategy: public LogStrategy
{
public:
ConsoleLogStrategy(){}
~ConsoleLogStrategy(){}
void SyncLog(const std::string &message) override // 检查重写的错误
{
// 显示器在多线程下是“临界资源”,加锁防止多线程输出字符交织(Interleaving)
LockGuard logGuard(&_mutex);
std::cout
暂时整理到这里。以上都是个人理解,可能有疏漏,欢迎指正。
评论 (0)
暂无评论