UDP Socket 编程
零、UDP Socket 编程接口速查
网络编程的基础工具就是几个系统调用和转换函数。这里集中列出 UDP 编程最常用的接口,后续所有代码都基于它们。
0.1 核心系统调用
| 函数 | 头文件 | 作用 |
|---|---|---|
socket() |
<sys/socket.h> |
创建套接字------打开网卡文件,返回 fd |
bind() |
<sys/socket.h> |
将 socket 绑定到本地 IP + 端口 |
sendto() |
<sys/socket.h> |
UDP 发送数据到指定目标 |
recvfrom() |
<sys/socket.h> |
UDP 接收数据,同时获取发送方地址 |
close() |
<unistd.h> |
关闭套接字,释放资源 |
socket()
c
int socket(int domain, int type, int protocol);
domain:AF_INET(IPv4)/AF_UNIX(本地)type:SOCK_DGRAM(UDP 数据报)/SOCK_STREAM(TCP 字节流)protocol:一般传0,前两个参数已确定协议- 返回值:成功返回文件描述符(3, 4, 5...),失败返回
-1
bind()
c
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- 将 socket 与本地地址绑定。服务器必须调用,客户端通常不显式调用(OS 隐式绑定)
addr实际传入struct sockaddr_in*强转后的指针- 返回值:成功
0,失败-1
sendto()
c
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
- UDP 发送:向
dest_addr发送buf中的len字节 flags:一般传0(阻塞发送)- 返回值:实际发送的字节数,失败返回
-1 - 首次调用时,如果 socket 未 bind,OS 自动分配随机端口
recvfrom()
c
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
- UDP 接收:将数据读入
buf,同时用src_addr返回发送方地址 flags:一般传0(阻塞接收)addrlen:值-结果参数------传入src_addr的大小,传出实际填充长度- 返回值:实际接收的字节数,失败返回
-1
0.2 地址结构体
sockaddr_in(IPv4 地址)
cpp
struct sockaddr_in {
sa_family_t sin_family; // 地址族 = AF_INET
in_port_t sin_port; // 端口号(网络字节序)
struct in_addr sin_addr; // IPv4 地址
char sin_zero[8]; // 填充占位,必须置 0
};
struct in_addr {
uint32_t s_addr; // 32 位 IPv4 地址(网络字节序)
};
sin_family:AF_INET,与socket()的domain保持一致sin_port:必须用htons()转换sin_addr.s_addr:必须用inet_addr()或inet_pton()填充,或用INADDR_ANYsin_zero:用bzero()或memset()清零
0.3 字节序转换
| 函数 | 原型 | 作用 |
|---|---|---|
htons |
uint16_t htons(uint16_t) |
H ost to N etwork Short(16 位,用于端口号) |
htonl |
uint32_t htonl(uint32_t) |
H ost to N etwork Long(32 位,用于 IP 地址) |
ntohs |
uint16_t ntohs(uint16_t) |
N etwork to H ost Short |
ntohl |
uint32_t ntohl(uint32_t) |
N etwork to H ost Long |
记法: h = host(主机字节序)、n = network(网络字节序 = 大端)、s = short(16 位)、l = long(32 位)。
无论当前机器是大端还是小端,这些函数保证"该转就转,不该转不动"------大端机上传入什么就返回什么,小端机上做翻转。
知识点: 端口号是 16 位,用
htons/ntohs;IP 地址是 32 位,用htonl/ntohl。sendto/recvfrom内部的数据已经是网络字节序,直接使用即可,不需要重复转换。
0.4 IP 地址转换
| 函数 | 作用 | 特点 |
|---|---|---|
inet_addr() |
点分十进制字符串 → 32 位网络字节序整数 | 简单但不区分错误(255.255.255.255 与 -1 冲突) |
inet_ntoa() |
32 位网络字节序整数 → 点分十进制字符串 | 返回静态缓冲区指针,非线程安全 |
inet_pton() |
字符串 → 网络字节序二进制(支持 IPv4/IPv6) | 现代替代,线程安全,推荐 |
inet_ntop() |
网络字节序二进制 → 字符串 | 现代替代,线程安全,推荐 |
c
// 旧式(简单但有限制)
struct sockaddr_in addr;
addr.sin_addr.s_addr = inet_addr("192.168.1.1"); // 字符串 → 网络序
printf("%s\n", inet_ntoa(addr.sin_addr)); // 网络序 → 字符串
// 新式(推荐,线程安全)
inet_pton(AF_INET, "192.168.1.1", &addr.sin_addr); // 字符串 → 网络序
char ip_str[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &addr.sin_addr, ip_str, sizeof(ip_str)); // 网络序 → 字符串
0.5 头文件汇总
cpp
#include <sys/socket.h> // socket / bind / sendto / recvfrom / sockaddr
#include <netinet/in.h> // sockaddr_in / in_addr / INADDR_ANY
#include <arpa/inet.h> // htons / htonl / ntohs / ntohl / inet_addr / inet_ntoa
#include <unistd.h> // close
#include <string.h> // bzero / memset
一、V1 EchoServer
1.1 前言
在网络基础中,我们走完了从"协议是什么"到"socket 编程预备知识"的整条知识链:协议分层、MAC/IP 地址、端口号、网络字节序、sockaddr 多态设计。现在把这些理论落成代码。
第一个实战项目是 UDP Echo Server (V1 版本)------服务器收到客户端发来的消息,原封不动地回传过去。先写服务端,把服务器封装为一个 UdpServer 类:
Init(): 初始化------打开网卡、填充地址信息、绑定端口Start(): 启动------死循环收包、回传
Echo Server 是网络编程的 "Hello World"。代码虽短,socket 编程的完整流程一步不少。
1.2 socket()------打开网卡文件
一切皆文件------Linux 把这个哲学贯彻到了网络编程的入口。
socket() 的返回值是一个 int。它和 open() 一样,成功返回一个文件描述符 ------3、4、5......(0/1/2 已被标准流占用)。网络在进程眼里,就是一个"文件"的读写。
UDP 服务器固定传 AF_INET + SOCK_DGRAM,protocol 直接填 0(参数详解参见零章)。拿到 fd 后保存在 _sockfd 中,后续所有操作都通过它。
知识点: socket 的本质是打开网卡文件 。内核为这个 socket 分配
struct file对象,进程通过 fd 访问它。后面所有的收发操作,本质上都是在读写这个文件。
1.3 Init()------服务器初始化三步走
作为一个网络通信程序,服务器必须要有自己的网络信息:IP 地址和端口号。Init() 做的事情就是把这些信息"写入"刚才打开的网卡文件。
1.3.1 第一步:创建 socket
cpp
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0)
{
LOG(LogLevel::FATAL) << "Create Socket Error";
exit(SOCKET_ERR);
}
LOG(LogLevel::INFO) << "Create Socket Succeed, sockfd: " << _sockfd;
调用 socket(),拿到一个 fd。负数表示失败,直接退出。
1.3.2 第二步:填充网络信息(sockaddr_in)
现在有了"文件",但还没告诉内核"我是谁"。服务器必须要有固定的端口号------否则客户端不知道该往哪连。
怎么把信息告诉内核?思路是:先在栈上建立结构体填充好,再通过 bind 写入内核。 具体结构体 sockaddr_in 的字段已在零章列出,这里直接动手填:

为什么 socket() 已经填了 AF_INET,sockaddr_in 里还要有 sin_family?
两者作用不同:socket() 的 domain 告诉内核创建什么类型的 socket,而 sin_family 让后续的 bind()、connect() 能独立判断 地址类型------不需要回溯去查 socket 当初的 domain。底层的 AF_INET 是一个宏,用 ## 做符号拼接(a##1 → a1),最终就是一个整数常量。
填充代码:
cpp
struct sockaddr_in local; // 栈上的局部变量,还没设到内核
bzero(&local, sizeof(local)); // 先清零(sin_zero 必须为 0)
local.sin_family = AF_INET; // 地址族
local.sin_port = htons(_port); // 端口号:主机序 → 网络序
local.sin_addr.s_addr = inet_addr(_ip.c_str()); // IP:字符串 → 四字节整数 + 网络序
bzero 清零、htons 转端口、inet_addr 转 IP------这些转换函数的细节见零章字节序和 IP 转换小节。
知识点: 此时
local还只是在用户栈 上的一块内存,并没有设置到内核中。要等到bind()这一步,才会真正把信息写入内核。
1.3.3 第三步:绑定(bind)
cpp
int n = bind(_sockfd, (struct sockaddr*)&local, sizeof(local));
if (n < 0)
{
LOG(LogLevel::FATAL) << "Bind Socket Error";
exit(BIND_ERR);
}
LOG(LogLevel::INFO) << "Bind Socket Succeed, port: " << _port;
bind() 把栈上的网络信息写入内核 ,让这个 socket 与特定的 IP + 端口关联。注意 (struct sockaddr*)&local 的强制转型------这就是网络基础 5.7 节讲的"多态"设计:sockaddr 是基类,sockaddr_in 是子类,函数接收基类指针,内部按需向下转型。
完整 Init() 代码与运行结果
cpp
void Init()
{
// 1. 创建 socket ------ 本质:打开网卡文件
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0)
{
LOG(LogLevel::FATAL) << "Create Socket Error";
exit(SOCKET_ERR);
}
LOG(LogLevel::INFO) << "Create Socket Succeed, sockfd: " << _sockfd;
// 2. 填充网络信息 ------ 先在栈空间建立结构体,填充
struct sockaddr_in local; // 用户栈上的变量,还没设到内核
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = inet_addr(_ip.c_str());
// 3. bind ------ 将网络信息写入内核
int n = bind(_sockfd, (struct sockaddr*)&local, sizeof(local));
if (n < 0)
{
LOG(LogLevel::FATAL) << "Bind Socket Error";
exit(BIND_ERR);
}
LOG(LogLevel::INFO) << "Bind Socket Succeed, port: " << _port;
}
shell
root@ALiServer:1.EchoServer# ./server_udp 127.0.0.1 8888
[2026-07-19 19:00:37.364074] [INFO] [940605] [EchoServer.hpp] [41] - Create Socket Succeed, sockfd: 3
[2026-07-19 19:00:37.364407] [INFO] [940605] [EchoServer.hpp] [57] - Bind Socket Succeed, port: 8888
sockfd 从 3 开始------0/1/2 已被标准输入输出错误流占用。Bind 成功后,服务器就"入驻"了 127.0.0.1:8888,等待客户端数据。
1.4 Start()------收发数据
Init() 解决了"我是谁",Start() 负责"我要干什么"------死循环收包、回传。
1.4.1 recvfrom------接收数据
收数据的时候,我们想知道两件事 :数据本身是什么(buf + len),以及数据是谁发的(src_addr + addrlen,用于后续回复)。flags 设为 0 表示阻塞读取------没数据来就一直等着。
cpp
char inbuffer[128];
struct sockaddr_in peer;
socklen_t len = sizeof(peer); // 输入:告诉内核 peer 有多大
int n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0,
(struct sockaddr *)&peer, &len);
// 输出:len 变成实际填充的地址长度
sizeof(inbuffer) - 1:给\0留一个位置,方便把收到的数据当字符串用peer:输出参数,内核把发送方的地址信息填进去len:值-结果参数------传入时是 peer 的大小,传出时是实际填充的长度
1.4.2 sendto------发送数据
发数据的时候要回答三个问题 :发送什么数据(buf + len)、发给谁(dest_addr + addrlen)、怎么发(flags = 0 阻塞发送)。UDP 是全双工的------同一个 socket 能发的同时也能收。
cpp
inbuffer[n] = 0; // recvfrom 不会自动加 \0,手动补上
std::string echo_string = "Server Echo# ";
echo_string += inbuffer;
sendto(_sockfd, echo_string.c_str(), echo_string.size(), 0,
(struct sockaddr *)&peer, len);
知识点: 对方的 socket 信息(IP + 端口)在
recvfrom阶段就已经拿到了------peer里存的就是。所以sendto可以直接用,不需要额外查询。但注意peer中的sin_port和sin_addr是网络字节序 (大端),sendto可以直接用因为它本来就是网络序;但如果要打印或展示给用户看,需要用ntohs()/inet_ntoa()转回主机序。我们先实现通信,提取信息的事后面再展开。
1.4.3 完整 Start() 代码
cpp
void Start()
{
while (true)
{
char inbuffer[128];
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0,
(struct sockaddr *)&peer, &len);
if (n > 0)
{
inbuffer[n] = 0; // 手动添加字符串结尾
LOG(LogLevel::DEBUG) << "Client Say# " << inbuffer;
std::string echo_string = "Server Echo# ";
echo_string += inbuffer;
sendto(_sockfd, echo_string.c_str(), echo_string.size(), 0,
(struct sockaddr *)&peer, len);
}
else
{
LOG(LogLevel::ERROR) << "Recvfrom Error";
}
}
}
Echo Server 完整数据流:
recvfrom 收包 → 拼 "Server Echo# " 前缀 → sendto 回传。客户端发什么,服务器就回什么------这是最简单也最直观的"证明我能通信"。
1.5 客户端设计
服务端写完后,来实现客户端。客户端要做的事情更简单------不需要显式 bind,直接发数据然后收回应。
1.5.1 为什么客户端不显式 bind?
先反过来想:服务端为什么必须显式 bind? 服务端的端口号必须被所有客户端知道------客户端要找到服务器,需要固定的 IP + 端口号。如果服务端端口号随意变化,客户端就不知道该往哪发数据了。
那客户端呢?客户端的端口号具体是几不重要 ,只要具有唯一性------在同一台机器上能区分不同进程就可以了。事实上,如果客户端显式 bind 一个固定端口,反而会出问题:
- 同一台机器上跑两个客户端实例 → 端口冲突 → bind 失败
- 客户端退出后端口还处于等待回收状态 → 立刻重启 → bind 失败
所以客户端不显式 bind ,而是由 OS 自主选择随机端口:
UDP 客户端在首次调用
sendto发送数据时,OS 底层会自动分配一个未被占用的随机端口,隐式完成 bind。整个过程对客户端代码完全透明。
| 服务端 | 客户端 | |
|---|---|---|
| 是否显式 bind? | ✅ 必须 | ❌ 不要(会引发端口冲突) |
| 端口号要求 | 固定、已知 | 随机、唯一即可 |
| 谁分配端口? | 程序员指定 | OS 自动分配 |
| 不绑定会怎样? | 客户端找不到服务器 | OS 隐式绑定,不绑也能用 |
在大公司内部,服务器的端口号通常是被统一管理的------不是想用哪个就用哪个。
1.5.2 客户端需要什么信息
客户端不需要 bind 自己的地址,但必须知道服务端是谁。这就要求用户告诉客户端两样东西:
- 服务端 IP: 数据发往哪台机器
- 服务端端口: 数据发往哪个进程
市面上的客户端(微信、QQ、浏览器)之所以不需要你手动输入这些,是因为服务端的 IP 和端口内置在客户端代码里------它们本来就是一家公司的产品。
客户端的通信流程很简单:创建 socket → 填充服务端信息 → 获取用户输入 → 发送数据 → 接收回应。客户端可能同时与多个服务端通信(虽然本例只有一个),所以 recvfrom 最好保留第五、六个参数,以便确认回应来自哪个服务端。
1.5.3 完整客户端代码
cpp
#include <iostream>
#include <string>
#include <cstring>
#include <cstdlib>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
static void Usage(const std::string &process)
{
std::cerr << "Usage: \n\t";
std::cerr << process << " server_ip server_port" << std::endl;
}
// ./client_udp server_ip server_port
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage(argv[0]);
return -1;
}
std::string server_ip = argv[1];
uint16_t server_port = std::stoi(argv[2]);
// 1. 创建套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0)
{
std::cerr << "Create Socket Error" << std::endl;
exit(2);
}
// 2. 填充服务端信息
struct sockaddr_in server;
memset(&server, 0, sizeof(server)); // 清零,避免残留值干扰
server.sin_family = AF_INET;
server.sin_port = htons(server_port);
server.sin_addr.s_addr = inet_addr(server_ip.c_str());
while (true)
{
std::string message;
// 3. 获取用户输入
std::cout << "Please Enter# ";
std::getline(std::cin, message);
// 4. 发送数据给 server ------ 首次发送时 OS 自动 bind 随机端口
ssize_t n = sendto(sockfd, message.c_str(), message.size(), 0,
(struct sockaddr *)&server, sizeof(server));
if (n > 0)
{
char inbuffer[128];
struct sockaddr_in tmp;
socklen_t len = sizeof(tmp);
ssize_t m = recvfrom(sockfd, inbuffer, sizeof(inbuffer) - 1, 0,
(struct sockaddr *)&tmp, &len);
if (m > 0)
{
inbuffer[m] = 0; // recvfrom 不会自动加 \0
std::cout << inbuffer << std::endl;
}
}
}
return 0;
}
知识点: 注意
recvfrom保留了第五、六个参数(struct sockaddr *)&tmp和&len。虽然本例中tmp没有进一步使用,但保留这两个参数比填NULL更稳妥------后续如果需要验证回应是否来自期望的服务端,这些信息就在手边。
1.5.4 本地回环测试
先在同一台机器上用 127.0.0.1 测试------这是最快速的验证方式。
127.0.0.1 是本地环回地址(localhost)。数据包不会真正发送到网络上,只在本机的协议栈中跑一圈就回来了------本质上等同于进程间通信。本地环回经常用于网络代码的开发和测试阶段,用来快速验证代码逻辑是否正确。
shell
# 终端 1:启动服务端
root@ALiServer:1.EchoServer# ./server_udp 8080
[2026-07-19 20:11:47.30066] [INFO] [948137] [EchoServer.hpp] [41] - Create Socket Succeed, sockfd: 3
[2026-07-19 20:11:47.30300] [INFO] [948137] [EchoServer.hpp] [57] - Bind Socket Succeed, port: 8080
# 终端 2:启动客户端
root@ALiServer:1.EchoServer# ./client_udp 127.0.0.1 8080
Please Enter# 你好
Server Echo# 你好
Please Enter# 1234
Server Echo# 1234
Please Enter# 2222
Server Echo# 2222
服务端日志同步确认:
shell
[2026-07-19 20:11:57.825800] [DEBUG] [948137] [EchoServer.hpp] [71] - Client Say# 你好
[2026-07-19 20:12:08.24495] [DEBUG] [948137] [EchoServer.hpp] [71] - Client Say# 1234
[2026-07-19 20:12:09.735024] [DEBUG] [EchoServer.hpp] [71] - Client Say# 2222
本地回环测试通过,说明客户端和服务端的代码逻辑都没有问题。
1.6 INADDR_ANY------服务器的最佳实践
1.6.1 公网 IP bind 失败
本地测试通过后,自然想试试跨网络------让服务器绑定公网 IP,接受外部客户端的请求:
shell
root@ALiServer:1.EchoServer# ./server_udp <公网IP> 8080
[2026-07-19 20:16:57.677186] [INFO] [948928] [EchoServer.hpp] [41] - Create Socket Succeed, sockfd: 3
[2026-07-19 20:16:57.677478] [FATAL] [948928] [EchoServer.hpp] [54] - Bind Socket Error
直接 bind 失败。原因有两条:
- 云服务器的公网 IP 通常不绑定在 eth0 上。 以阿里云为例,公网 IP 是弹性 IP,通过 NAT 网关映射到内网 IP。操作系统根本不认这个 IP------
ip addr看不到它------自然无法 bind。 - 云厂商禁止用户显式 bind 公网 IP。 即使公网 IP 在网卡上,出于安全管控考虑,云平台也不允许这种做法。
但不显式 bind 公网 IP,不代表公网 IP 不会被使用。服务器身上挂着多个 IP 地址:
127.0.0.1 ← lo 回环接口
172.24.18.130 ← eth0 内网 IP(真实存在于网卡上)
106.14.7.57 ← 弹性公网 IP(NAT 映射,不在本机)
如果显式 bind 公网 IP,服务器就只收目标地址是公网 IP 的报文------内网 IP 和回环地址发来的包全部忽略。这显然不是我们想要的。
1.6.2 INADDR_ANY:照单全收
最佳实践:不显式 bind 任何具体 IP,改用 INADDR_ANY。
cpp
#define INADDR_ANY ((in_addr_t) 0x00000000) // 即 0.0.0.0
0.0.0.0 不是一个真实 IP,它的语义是:"我不挑地址------任意网卡、任意 IP 的报文,只要端口号对得上,全部接收。"
不管请求是从公网 IP 进来的(NAT 网关转发后到达 eth0 内网 IP),还是从 127.0.0.1 进来的(本地测试),又或从内网 IP 进来的(同子网其他机器),INADDR_ANY 照单全收。
cpp
// ❌ 旧写法:显式绑定指定 IP(云服务器上行不通)
local.sin_addr.s_addr = inet_addr(_ip.c_str());
// ✅ 最佳实践:绑定任意地址------不管哪个 IP,端口号对了就收
local.sin_addr.s_addr = INADDR_ANY;
改动之后,服务器构造函数也随之简化------不再需要 _ip 成员,只传端口号即可:
cpp
UdpServer(uint16_t port = defaultport)
: _port(port), _sockfd(defaultfd)
{
}
最佳实践总结: ① 服务器开发中,特别不建议显式 bind IP 地址 ------直接用
INADDR_ANY。② 端口号仍然必须显式 bind,否则客户端不知道连哪个端口。③ 不管请求从哪个 IP 进来、走哪张网卡,只要端口号对,照单全收。
1.7 补充知识
1.7.1 查看网络状态------netstat
程序跑起来后,怎么确认端口确实被占用了?用 netstat:
shell
# 查看所有 UDP 进程,数字格式显示(不做 DNS 反向解析)
netstat -anup
# 查看所有 TCP 进程
netstat -antp
参数含义:
| 参数 | 含义 |
|---|---|
-a |
显示所有连接和监听端口 |
-n |
以数字形式显示地址和端口(不做域名反查,更快) |
-u |
只显示 UDP |
-t |
只显示 TCP |
-p |
显示进程名和 PID |
一条典型的 TCP 输出:
shell
tcp 0 0 172.24.18.130:22 121.224.20.223:46593 ESTABLISHED 964863/sshd: root@p
172.24.18.130:22:本机内网 IP 的 22 端口121.224.20.223:46593:远端的 IP 和临时端口ESTABLISHED:连接已建立964863/sshd:PID 964863 的 sshd 进程
能通过 SSH 连上这台服务器,正是因为 22 端口上跑着 sshd 服务。端口本质上是"门牌号"------22 号房间住着 sshd,8080 号房间现在住着你的 UdpServer。
知识点:
netstat -anup是调试网络程序最常用的命令之一。程序跑不起来先别急着改代码,用 netstat 看一眼端口到底有没有被占用------是 bind 失败还是防火墙拦截,一行命令就能分清楚。
1.7.2 云服务器与安全组
前面第五章提到"公网 IP bind 失败",这里补充一个相关的实操细节:外部客户端要通过公网 IP 访问你的服务器,除了代码正确,还要在安全组和防火墙开放端口。
外部客户端 → 公网 IP → 云安全组(入方向规则)→ 服务器防火墙 → 你的程序
- 安全组: 在云控制台配置,默认不放行非标准端口
- 服务器防火墙(iptables / firewalld): 在本机配置
调试时如果外部客户端连不上:
- 先用
netstat -anup确认端口在监听 - 再去安全组确认入方向规则有对应端口
- 最后检查本地防火墙
三层检查一层都不能少。
1.7.3 Windows 下的 UDP Socket 编程
以上所有代码都在 Linux 下运行。Windows 下也要做 UDP socket 编程------核心接口完全一致:
socket()、bind()、sendto()、recvfrom()的函数签名和 Linux 一模一样- 数据结构
sockaddr_in、in_addr也一样
唯一的差别在用户层的封装:
- Windows 需要先调用
WSAStartup()初始化 Winsock 库,结束时调用WSACleanup()清理 - 关闭 socket 用
closesocket()而不是close() - 头文件是
<winsock2.h>,还需要链接ws2_32.lib
除此之外,核心逻辑代码原封不动就能跨平台。这也是 POSIX 标准的威力------操作系统不同,API 一致。
二、V2 DictServer
目前 EchoServer 的做法是:收到数据后,服务器自己处理数据(拼一个 "Server Echo#" 前缀),再发回去。
cpp
// 当前做法:服务器自己处理
inbuffer[n] = 0;
std::string echo_string = "Server Echo# ";
echo_string += inbuffer; // 处理逻辑写在服务器里
sendto(_sockfd, echo_string.c_str(), ...);
但这不是好架构。网络通信程序应该分层:
┌──────────────────────────┐
│ 上层:业务逻辑 │ ← 翻译、计算、查询......(与网络无关)
│ (处理数据) │
├──────────────────────────┤
│ 下层:网络传输 │ ← recvfrom / sendto(只负责收发)
│ (UdpServer) │
└──────────────────────────┘
- 下层(UdpServer): 只负责网络 I/O------收数据、发数据。不管数据内容是什么。
- 上层(业务层): 只负责处理数据------收到字符串后做什么。不管数据怎么来、怎么去。
下层收包后把数据交给上层 ,上层处理完把结果交给下层,下层再发回去。这就是"关注点分离"。
V2 的 DictServer 将把这个架构落地:服务端不再自己处理数据,而是回调一个上层传入的处理函数------收到英文单词,查词典翻译成中文,再通过下层发回客户端。
网络层管运输,业务层管内容。各司其职,谁也别跨界。
2.1 词典文件 dict.txt
先用最简单的格式存储词典------每行一条记录,英文和中文用 : 分隔:
shell
apple: 苹果
banana: 香蕉
cat: 猫
dog: 狗
book: 书
pen: 笔
happy: 快乐的
sad: 悲伤的
run: 跑
jump: 跳
teacher: 老师
student: 学生
car: 汽车
bus: 公交车
love: 爱
hate: 恨
hello: 你好
goodbye: 再见
summer: 夏天
winter: 冬天
客户端不用做任何修改------仍然是输入内容、UDP 发送、接收回应。变化全在服务端:它不再"echo",而是查词典。
2.2 架构关键:回调函数
要把网络层和业务层拆开,C/C++ 最直接的方式就是回调函数 。在 UdpServer 中新增一个成员,用于接收用户传来的"处理函数":
cpp
using callback_t = std::function<std::string(std::string)>;
这个类型的语义是:输入一个 string(客户端发来的数据),输出一个 string(处理后的结果)。字典翻译、计算器、大小写转换......只要是"收字符串、回字符串"的业务,都符合这个签名。
回调函数的角色:网络层收到数据后,自己不处理,原样交给回调函数。回调函数处理完后,把结果还给网络层,网络层发回去。网络层不关心业务逻辑,业务层不关心数据怎么来。
2.3 UdpServer 改造
相比 V1 EchoServer,只改了三处:
cpp
class UdpServer
{
public:
// 改动 1:构造函数新增回调参数
UdpServer(callback_t cb, uint16_t port = defaultport)
: _cb(cb), _port(port), _sockfd(defaultfd)
{
}
void Init() { /* 和 V1 完全一样,省略 */ }
void Start()
{
char inbuffer[1024];
while (true)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0,
(struct sockaddr *)&peer, &len);
if (n > 0)
{
uint16_t client_port = ntohs(peer.sin_port);
std::string client_ip = inet_ntoa(peer.sin_addr);
std::string client_address = "[" + client_ip + ":" + std::to_string(client_port) + "]# ";
inbuffer[n] = 0;
LOG(LogLevel::DEBUG) << client_address << inbuffer;
// 改动 2:处理数据 → 交给回调函数
std::string result = _cb(inbuffer);
// 改动 3:发送处理结果(而不是固定 Echo 前缀)
sendto(_sockfd, result.c_str(), result.size(), 0,
(struct sockaddr *)&peer, len);
}
else
{
LOG(LogLevel::ERROR) << "Recvfrom Error";
}
}
}
private:
int _sockfd;
uint16_t _port;
callback_t _cb; // 新增:业务回调
};
| V1 EchoServer | V2 DictServer |
|---|---|
echo_string = "Server Echo# " + inbuffer |
result = _cb(inbuffer) |
| 处理逻辑硬编码在服务器里 | 处理逻辑由外部注入 |
| 换业务 = 改服务器代码 | 换业务 = 换回调函数 |
知识点:
_cb的类型是std::function<std::string(std::string)>。这是 C++ 的函数包装器,可以接收 lambda、函数指针、函数对象------只要签名匹配。正是这个设计让 UdpServer 从"Echo 专用"变成了"通用 UDP 服务器"。
2.4 Dict 字典类
Dict 类负责两件事:加载词典 + 查询翻译。两个核心成员:
cpp
std::string _dict_path; // 词典文件路径
std::unordered_map<std::string, std::string> _dict; // O(1) 查询
2.4.1 加载词典(LoadDict)
cpp
void LoadDict()
{
std::fstream in(_dict_path);
if (!in.is_open())
{
LOG(LogLevel::FATAL) << "Open " << _dict_path << " Error";
exit(1);
}
std::string line;
while (getline(in, line))
{
LOG(LogLevel::DEBUG) << "Load: " << line << " Succeed";
auto pos = line.find(sep); // sep = ": "
if (pos == std::string::npos)
{
LOG(LogLevel::ERROR) << "Format: " << line << " Error";
continue; // 跳过格式错误行
}
std::string k = line.substr(0, pos); // 分隔符之前 = key
std::string v = line.substr(pos + sep.size()); // 分隔符之后 = value
_dict.insert(std::make_pair(k, v));
}
}
逐行解析词典文件,用 : 分割英文和中文,插入 hash map。格式不对的行 continue 跳过,不中断整体加载。
2.4.2 查询翻译(Translate)
cpp
std::string Translate(std::string word)
{
auto iter = _dict.find(word);
if (iter == _dict.end())
{
return "None"; // 查不到的单词返回 "None"
}
else
{
return iter->second; // 命中 → 返回中文释义
}
}
unordered_map::find 是 O(1) 平均复杂度,十万词条也是瞬间返回。
2.4.3 完整 Dict 类
cpp
#pragma once
#include <iostream>
#include <string>
#include <fstream>
#include <unordered_map>
#include "Logger.hpp"
static const std::string default_path = "./dict.txt";
static const std::string sep = ": ";
using namespace NS_LOG_MODULE;
class Dict
{
private:
void LoadDict()
{
std::fstream in(_dict_path);
if (!in.is_open())
{
LOG(LogLevel::FATAL) << "Open " << _dict_path << " Error";
exit(1);
}
std::string line;
while (getline(in, line))
{
LOG(LogLevel::DEBUG) << "Load: " << line << " Succeed";
auto pos = line.find(sep);
if (pos == std::string::npos)
{
LOG(LogLevel::ERROR) << "Format: " << line << " Error";
continue;
}
std::string k = line.substr(0, pos);
std::string v = line.substr(pos + sep.size());
_dict.insert(std::make_pair(k, v));
}
}
public:
Dict(const std::string &path = default_path)
: _dict_path(path)
{
LoadDict();
}
std::string Translate(std::string word)
{
auto iter = _dict.find(word);
if (iter == _dict.end())
{
return "None";
}
else
{
return iter->second;
}
}
~Dict() {}
private:
std::string _dict_path;
std::unordered_map<std::string, std::string> _dict;
};
2.5 主函数:绑定上下两层
改造后的 ServerMain:创建 Dict 对象 → 用 lambda 把 Translate 包装成回调 → 注入 UdpServer:
cpp
int main(int argc, char *argv[])
{
if (argc != 2)
{
Usage(argv[0]);
return USAGE_ERR;
}
// 1. 定义字典(上层:业务逻辑)
Dict dict;
// 2. 构建网络服务(下层:网络传输)
uint16_t server_port = std::stoi(argv[1]);
// 3. 绑定上下两层:lambda 捕获 dict,把 Translate 塞进 UdpServer
std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(
[&dict](std::string word) -> std::string {
return dict.Translate(word);
},
server_port);
usvr->Init();
usvr->Start();
return 0;
}
[&dict]按引用捕获 Dict 对象,lambda 内部调用dict.Translate(word)。UdpServer 完全不知道 Dictionary 的存在------它只看到一个callback_t,调用它,拿到结果,发回去。
2.6 测试验证
shell
# 服务端
root@ALiServer:2.DictServer# ./server_dict 12345
[2026-07-20 22:35:22.317245] [DEBUG] [984463] [Dict.hpp] [30] - Load: apple: 苹果 Succeed
[2026-07-20 22:35:22.317347] [DEBUG] [984463] [Dict.hpp] [30] - Load: banana: 香蕉 Succeed
# ...
[2026-07-20 22:35:22.317543] [DEBUG] [984463] [Dict.hpp] [30] - Load: winter: 冬天 Succeed
[2026-07-20 22:35:22.317621] [INFO] [984463] [DictServer.hpp] [43] - Create Socket Succeed, sockfd: 3
[2026-07-20 22:35:22.317644] [INFO] [984463] [DictServer.hpp] [60] - Bind Socket Succeed, port: 12345
[2026-07-20 22:35:26.303573] [DEBUG] [984463] [DictServer.hpp] [79] - [127.0.0.1:42341]# hello
[2026-07-20 22:35:27.378409] [DEBUG] [984463] [DictServer.hpp] [79] - [127.0.0.1:42341]# apple
[2026-07-20 22:35:28.790237] [DEBUG] [984463] [DictServer.hpp] [79] - [127.0.0.1:42341]# dog
[2026-07-20 22:35:31.952830] [DEBUG] [984463] [DictServer.hpp] [79] - [127.0.0.1:42341]# echo
# 客户端
root@ALiServer:2.DictServer# ./client_dict 127.0.0.1 12345
Please Enter# hello
你好
Please Enter# apple
苹果
Please Enter# dog
狗
Please Enter# echo
None
hello→你好✅ 字典命中dog→狗✅echo→None✅ 字典中没有,返回 "None"
知识点: 客户端代码一行没改 ------V1 EchoServer 的客户端直接拿来用。因为客户端只负责"发字符串、收字符串",服务端内部是 echo 还是查词典,客户端完全不需要知道。这就是分层带来的可替换性。
EchoServer → DictServer 的设计演变: 不是重写,而是解耦 ------把数据处理从网络层剥离出去。V1 的"Server Echo# " + inbuffer变成了_cb(inbuffer)。这一行改动,让服务器从"只能 Echo"变成了"注入什么回调就做什么"。
三、InetAddr------地址管理封装
3.1 为什么需要封装
回头看一眼当前 UdpServer 中和地址相关的代码:
cpp
// Init() 中------填充本机地址
struct sockaddr_in local;
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY;
// Start() 中------提取客户端地址
uint16_t client_port = ntohs(peer.sin_port);
std::string client_ip = inet_ntoa(peer.sin_addr);
std::string client_address = "[" + client_ip + ":" + std::to_string(client_port) + "]# ";
问题很明显:每次操作地址都要手动管理两种表示形式 ------主机字节序(人看的)和网络字节序(机器传的)。htons / ntohs / inet_addr / inet_ntoa 散落在各处,代码臃肿、容易出错。
另一个更根本的动机:要实现在线聊天室 ------服务端收到消息后,需要转发给所有在线客户端。这要求我们把所有客户端管理起来(放进容器),而"管理"的前提是先能干净地描述一个客户端。一个客户端的本质是什么?就是 IP + 端口。
先描述,再组织。描述一个客户端 = 一个 InetAddr 对象。
3.2 InetAddr 类设计
InetAddr 的核心思路:一个对象同时持有两种视角的地址。
┌─ InetAddr ──────────────────┐
│ 主机视角(给人看) │
│ _ip: "127.0.0.1" │
│ _port: 42341 │
│ │
│ 网络视角(给内核用) │
│ _address: sockaddr_in │
│ _len: sizeof(...) │
└──────────────────────────────┘
外界只需要创建 InetAddr 对象,之后用 GetNetAddress() 拿网络地址、用 ToString() 拿显示字符串------不再手写字节序转换。
3.2.1 正向构造:主机地址 → 网络地址
已知 IP 字符串和端口号(服务端 Init 时描述自己,或客户端描述目标服务器):
cpp
InetAddr(const std::string &ip = defaultip, uint16_t port = defaultport)
: _ip(ip), _port(port)
{
bzero(&_address, sizeof(_address));
_address.sin_addr.s_addr = inet_addr(_ip.c_str()); // 字符串 → 四字节网络序
_address.sin_family = AF_INET;
_address.sin_port = htons(_port); // 主机序 → 网络序
_len = sizeof(_address);
}
3.2.2 反向构造:网络地址 → 主机地址
已知 struct sockaddr_in(服务端 recvfrom 后拿到客户端的地址):
cpp
InetAddr(const struct sockaddr_in &address)
: _address(address), _len(sizeof(address))
{
_ip = inet_ntoa(_address.sin_addr); // 四字节网络序 → 字符串
_port = ntohs(_address.sin_port); // 网络序 → 主机序
}
知识点: 两个构造函数的方向正好相反------正向是"我要连谁",反向是"谁在连我"。一个类覆盖了地址操作的两种典型场景。
3.2.3 辅助接口
cpp
socklen_t Len() { return _len; } // 给 bind/sendto 用的地址长度
struct sockaddr_in *GetNetAddress() { return &_address; } // 给 bind/sendto 用的地址指针
std::string ToString() { return "[" + _ip + ":" + std::to_string(_port) + "]"; } // 日志用的显示字符串
bool operator==(const InetAddr &addr) { return (addr._ip == this->_ip) && (addr._port == this->_port); } // 方便容器判断是否存在
完整 InetAddr 代码
cpp
#pragma once
#include <iostream>
#include <string>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
static const std::string defaultip = "0.0.0.0";
static const uint16_t defaultport = 8080;
class InetAddr
{
public:
// 正向构造:主机地址 → 网络地址("我要连谁")
InetAddr(const std::string &ip = defaultip, uint16_t port = defaultport)
: _ip(ip), _port(port)
{
bzero(&_address, sizeof(_address));
_address.sin_addr.s_addr = inet_addr(_ip.c_str());
_address.sin_family = AF_INET;
_address.sin_port = htons(_port);
_len = sizeof(_address);
}
// 反向构造:网络地址 → 主机地址("谁在连我")
InetAddr(const struct sockaddr_in &address)
: _address(address), _len(sizeof(address))
{
_ip = inet_ntoa(_address.sin_addr);
_port = ntohs(_address.sin_port);
}
socklen_t Len() { return _len; }
struct sockaddr_in *GetNetAddress() { return &_address; }
std::string ToString() { return "[" + _ip + ":" + std::to_string(_port) + "]"; }
bool operator==(const InetAddr &addr) { return (addr._ip == this->_ip) && (addr._port == this->_port); }
~InetAddr() {}
private:
// 网络视角(给内核用)
struct sockaddr_in _address;
socklen_t _len;
// 主机视角(给人看)
std::string _ip;
uint16_t _port;
};
3.3 改造 UdpServer
引入 InetAddr 后,Init 和 Start 的代码大幅简化:
cpp
// Init() ------ 改造前 vs 改造后
// 之前:手动 bzero + 逐字段填充
struct sockaddr_in local;
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY;
// 之后:一行搞定
InetAddr local("0.0.0.0", _port);
// bind 也变干净了
int n = bind(_sockfd, (struct sockaddr *)local.GetNetAddress(), local.Len());
// Start() ------ 改造前 vs 改造后
// 之前:手动提取 IP 和端口
uint16_t client_port = ntohs(peer.sin_port);
std::string client_ip = inet_ntoa(peer.sin_addr);
std::string client_address = "[" + client_ip + ":" + std::to_string(client_port) + "]# ";
// 之后:构造函数自动完成转换
InetAddr client_address(peer);
LOG(LogLevel::DEBUG) << client_address.ToString() << inbuffer;
| 改造前 | 改造后 |
|---|---|
bzero + 逐字段赋值 + htons + inet_addr |
InetAddr local("0.0.0.0", _port) |
ntohs + inet_ntoa + 手拼字符串 |
InetAddr client(peer) + .ToString() |
| 字节序转换散落各处 | 封装在构造函数和接口内部 |
知识点: 这不是新功能,而是重构------用封装消除重复代码。InetAddr 把地址相关的所有脏活(字节序转换、格式拼装)收敛到一个类里,UdpServer 只需要用对象。
3.4 InetAddr 完善
实际 V3 代码中的 InetAddr 比第三章初版多了几个关键接口,先补全(后续 ChatServer 会用到):
cpp
// 新增:相等比较(用于 unordered_set 去重)
bool operator==(const InetAddr &addr) const
{
return (addr._ip == this->_ip) && (addr._port == this->_port);
}
// 新增:获取主机字节序的 IP 和 Port(用于 hash 计算)
std::string Ip() const { return _ip; }
uint16_t Port() const { return _port; }
有了
operator==和Ip()/Port()getter,InetAddr 就满足unordered_set的全部要求了。
四、V3 ChatServer------在线聊天室

聊天室的核心数据流:客户端发消息 → 服务器转发给所有在线用户。现在把它落成代码。
4.1 总体架构
┌─── 客户端 A ───┐ ┌─── 客户端 B ───┐
│ sender 线程 │ │ sender 线程 │
│ receiver 线程 │ │ receiver 线程 │
└────┬───────┬────┘ └────┬───────┬────┘
│ send │ recv │ send │ recv
▼ ▲ ▼ ▲
┌──────────────────────────────────────┐
│ UdpServer │
│ recvfrom → handler_addr │
│ → handler_msg │
└──────────┬─────────┬────────────────┘
│ │
┌────────▼──┐ ┌──▼──────────┐
│ Route │ │ ThreadPool │
│ (加锁) │ │ (并发) │
│ Client │ │ Enqueue │
│ Manager │ │ BroadCast │
└───────────┘ └─────────────┘
三大模块各司其职:
- UdpServer: 只收不发业务------收包后回调上层
- Route: 线程安全地管理用户 + 广播
- ThreadPool: 并发执行广播任务,不让主线程阻塞
4.2 ClientManager------管理在线用户
每个在线用户就是一个 InetAddr 对象。用 unordered_set 存储,增删查全是 O(1)。
4.2.1 Hash 仿函数
unordered_set 需要 hash 函数。对 InetAddr 做 hash:
cpp
struct InetAddrHash
{
ssize_t operator()(const InetAddr &addr) const noexcept
{
return std::hash<std::string>{}(addr.Ip()) ^
(std::hash<uint16_t>{}(addr.Port()) << 1);
}
};
IP 的 hash 值和 Port 的 hash 值做 XOR,Port 左移 1 位避免对称冲突。
4.2.2 完整 ClientManager
cpp
class ClientManager
{
public:
using ClientSet = std::unordered_set<InetAddr, InetAddrHash>;
ClientManager() {}
void AddClient(const InetAddr &addr) { _clients.insert(addr); }
void DelClient(const InetAddr &addr) { _clients.erase(addr); }
bool SearchClient(const InetAddr &addr) { return _clients.find(addr) != _clients.end(); }
ClientSet &Clients() { return _clients; }
~ClientManager() {}
private:
ClientSet _clients;
};
正常项目中客户端应该有用户名、状态等字段,这里为了聚焦网络层,直接用 InetAddr 代表一个用户。后续扩展只需把
ClientSet的 value 类型换成User类即可。
4.3 Route------线程安全的路由层
ClientManager 自身不是线程安全的------遍历 _clients 时如果有新用户插入,迭代器会失效。Route 在 ClientManager 外面包了一层锁:
cpp
class Route
{
public:
Route() : _cmpr(std::make_unique<ClientManager>()) {}
// 登记新客户端(首次发包时调用)
void CheckClient(const InetAddr &addr)
{
LockGuard lockguard(_mutex);
_cmpr->AddClient(addr);
}
// 下线客户端
void OfflineClient(const InetAddr &addr)
{
LockGuard lockguard(_mutex);
_cmpr->DelClient(addr);
}
// 广播消息给所有在线客户端
void BroadCast(int sockfd, std::string message)
{
LockGuard lockguard(_mutex);
auto clients = _cmpr->Clients();
for (auto &client : clients)
{
sendto(sockfd, message.c_str(), message.size(), 0,
(struct sockaddr *)client.GetNetAddress(), client.Len());
}
}
~Route() {}
private:
std::unique_ptr<ClientManager> _cmpr;
Mutex _mutex;
};
| 方法 | 作用 | 场景 |
|---|---|---|
CheckClient |
新用户插入 set | 客户端首次发包时登记 |
OfflineClient |
用户从 set 移除 | 客户端断开时清理 |
BroadCast |
遍历 set,逐个 sendto | 收到消息后转发全员 |
知识点: 锁保护的是"用户列表"这一临界资源------遍历和插入不能同时进行。
LockGuard是 RAII 风格的锁守卫,构造时加锁、析构时解锁。
4.4 UdpServer 再进化------三回调
DictServer(V2)用一个 callback_t 解耦了数据处理。ChatServer(V3)更进一步------需要三个回调:
cpp
using _handler_addr_t = std::function<void(const InetAddr &)>; // 处理"谁来了"
using _handler_msg_t = std::function<void(int sockfd, std::string)>; // 处理"说了什么"
using _handler_offline_t = std::function<void(const InetAddr &)>; // 处理"谁走了"
_handler_addr:收到包后先调用------检查发送方是否新用户,是则登记_handler_msg:然后调用------把消息交给上层(Route + ThreadPool)处理
cpp
class UdpServer
{
public:
// 注册两个回调------由上层(main 函数)传入
void RegisterService(_handler_addr_t handler_addr, _handler_msg_t handler_msg, _handler_offline_t handler_offline)
{
_handler_addr = handler_addr;
_handler_msg = handler_msg;
_handler_offline = handler_offline;
}
void Start()
{
char inbuffer[1024];
while (true)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0,
(struct sockaddr *)&peer, &len);
if (n > 0)
{
InetAddr client_address(peer);
inbuffer[n] = 0;
LOG(LogLevel::DEBUG) << client_address.ToString() << inbuffer;
std::string echo_string = client_address.ToString() + inbuffer;
_handler_addr(peer); // 回调 1:登记用户
// 检测离线消息
std::string msg_str = inbuffer;
if (msg_str.find("offline!") != std::string::npos)
{
_handler_offline(peer); // 回调 2:移除用户
}
_handler_msg(_sockfd, echo_string); // 回调 3:处理消息
}
}
}
private:
int _sockfd;
uint16_t _port;
_handler_addr_t _handler_addr;
_handler_msg_t _handler_msg;
_handler_offline_t _handler_offline;
};
V1:硬编码 Echo → V2:单回调解耦 → V3:三回调(登记 / 消息 / 离线),每一代都在降低耦合。
4.5 主函数------三大模块组装
cpp
using task_t = std::function<void()>;
int main(int argc, char *argv[])
{
if (argc != 2)
{
Usage(argv[0]);
return USAGE_ERR;
}
uint16_t server_port = std::stoi(argv[1]);
// 1. 创建线程池(单例,全局共享)
auto thread_pool = ThreadPool<task_t>::Instance();
// 2. 创建路由模块(管理用户 + 广播)
Route r;
// 3. 创建网络模块
UdpServer upsr(server_port);
upsr.Init();
// 4. 注册回调------绑定 Route 和 ThreadPool
upsr.RegisterService(
// 回调 1:登记新用户(直接操作,无需线程池)
[&r](const InetAddr &addr) {
r.CheckClient(addr);
},
// 回调 2:消息广播(投递到线程池,异步并发)
[&r, &thread_pool](int sockfd, std::string msg) {
thread_pool->Enqueue([&r, sockfd, msg]() {
r.BroadCast(sockfd, msg);
});
},
// 回调 3:用户离线(直接操作,无需线程池)
[&r](const InetAddr &addr) {
r.OfflineClient(addr);
}
);
upsr.Start();
return 0;
}
关键设计决策:为什么 CheckClient 不放进线程池,而 BroadCast 要放?
- CheckClient:
unordered_set::insert极快(O(1)),直接在主线程完成,不占用线程池资源 - BroadCast: 遍历所有用户 + N 次
sendto,耗时随在线人数线性增长 → 投递到线程池,主线程立即回去收下一个包
知识点: 即使不用线程池,单个进程轮询也能跑通------模块之间的耦合度已经做得很好了。线程池是"锦上添花"的并发优化,不是"没它不行"的必需品。
4.6 客户端改造------收发分离
V1/V2 的客户端收发是串行的:发一条 → 等回应 → 再发下一条。聊天室不能这样------你发消息的同时,别人也在发,你需要随时能收到。
方案:拆成两个线程。
cpp
// 接收线程:死循环等 recvfrom,收到就打印,sockfd 关闭时退出
Thread recever([&]() {
while (true)
{
char inbuffer[1024];
struct sockaddr_in tmp;
socklen_t len = sizeof(tmp);
ssize_t m = recvfrom(sockfd, inbuffer, sizeof(inbuffer) - 1, 0,
(struct sockaddr *)&tmp, &len);
if (m > 0)
{
inbuffer[m] = 0;
std::cerr << inbuffer << std::endl; // stderr,方便重定向分离
}
}
});
// 发送线程:先取昵称 → 发上线通知 → 循环读输入 → 发消息
Thread sender([&]() {
InetAddr server(server_ip, server_port);
std::cout << "Please Set Your Nick Name# ";
std::string nickname;
std::getline(std::cin, nickname);
std::string online_message = nickname + " online!";
sendto(sockfd, online_message.c_str(), online_message.size(), 0,
(struct sockaddr *)server.GetNetAddress(), server.Len());
while (true)
{
std::string message;
std::cout << "Please Enter# ";
std::getline(std::cin, message);
if (message == "quit") // 输入 quit 退出
{
std::string offline_message = nickname + " offline!";
sendto(sockfd, offline_message.c_str(), offline_message.size(), 0,
(struct sockaddr *)server.GetNetAddress(), server.Len());
break;
}
message = nickname + "#" + message; // 格式:昵称#内容
sendto(sockfd, message.c_str(), message.size(), 0,
(struct sockaddr *)server.GetNetAddress(), server.Len());
}
});
recever.Start();
sender.Start();
sender.Join(); // 等待发送线程退出(用户输入 quit)
recever.Stop(); // pthread_cancel 强制终止接收线程
recever.Join(); // 等待接收线程退出
接收线程用
stderr输出消息,发送线程用stdin读取输入------两条流互不干扰。实际测试时可以用2>fifo把 stderr 重定向到文件,实现"一个终端输入、另一个终端看消息"的效果。
4.7 测试验证
开 4 个终端模拟多人聊天:
shell
# ========== 终端 A:服务端 ==========
root@ALiServer:3.ChatServer# ./server_chat 12345
[DEBUG] [1049210] [ThreadPool.hpp] [143] - ThreadPool Create Success: 5Threads
[INFO] [1049210] [ThreadPool.hpp] [71] - ThreadPool Singleton Created And Started
[INFO] [1049210] [UdpServer.hpp] [58] - Create Socket Succeed, sockfd: 3
[INFO] [1049210] [UdpServer.hpp] [70] - Bind Socket Succeed, port: 12345
# 后续每收到一条消息,就有一个线程处理:
[DEBUG] - [127.0.0.1:56728]usr1 online! → New-Thread-5 Process Task...
[DEBUG] - [127.0.0.1:41749]usr2 online! → New-Thread-1 Process Task...
[DEBUG] - [127.0.0.1:56728]usr1#hello i'm usr1 → New-Thread-2 Process Task...
[DEBUG] - [127.0.0.1:41749]usr2#hello i'm usr2 → New-Thread-3 Process Task...
#...
[DEBUG] [1049210] [UdpServer.hpp] [87] - [127.0.0.1:33253]usr1 offline!
[INFO] [1049210] [ThreadPool.hpp] [54] - New-Thread-4 Process Task...
[DEBUG] [1049210] [UdpServer.hpp] [87] - [127.0.0.1:49893]usr2 offline!
[INFO] [1049210] [ThreadPool.hpp] [54] - New-Thread-5 Process Task...
# ========== 终端 B:usr1 客户端 ==========
root@ALiServer:3.ChatServer# ./client_chat 127.0.0.1 12345 2>fifo1
Please Set Your Nick Name# usr1
Please Enter# hello i'm usr1
Please Enter# how are you, usr2
Please Enter# ok, goodbye
Please Enter# quit
# ========== 终端 C:usr1 的消息流(stderr 重定向到 fifo) ==========
root@ALiServer:3.ChatServer# cat < fifo1
[127.0.0.1:33253]usr1 online!
[127.0.0.1:49893]usr2 online!
[127.0.0.1:33253]usr1#hello i'm usr1
[127.0.0.1:49893]usr2#hello i'm usr2
[127.0.0.1:33253]usr1#how are you, usr2
[127.0.0.1:49893]usr2#i'm fine
[127.0.0.1:33253]usr1#ok, goodbye
[127.0.0.1:49893]usr2#goodbye
# ========== 终端 D:usr2 客户端 ==========
root@ALiServer:3.ChatServer# ./client_chat 127.0.0.1 12345 2>fifo2
Please Set Your Nick Name# usr2
Please Enter# hello i'm usr2
Please Enter# i'm fine
Please Enter# goodbye
Please Enter# quit
# usr2 的 fifo2 同样能看到 usr1 的消息
5 个线程并发处理:usr1 和 usr2 的每条消息都由不同线程执行广播。
2>fifo技巧把收消息和发消息的终端分开------一个窗口专注输入,另一个窗口专注看消息。
4.8 完整消息处理流程
V3 ChatServer 由多个模块协作完成。先看文件清单:
| 文件 | 角色 |
|---|---|
UdpServer.hpp |
网络层------收包、三回调分发 |
InetAddr.hpp |
地址封装------双视角 + hash 支持 |
ClientManager.hpp |
用户容器------unordered_set 增删查 |
Route.hpp |
路由层------加锁保护用户列表 + 广播 |
ThreadPool.hpp |
线程池------单例 + 任务队列 + 工作线程 |
Thread.hpp |
线程封装------Start / Join / Stop |
Mutex.hpp / Cond.hpp |
同步原语------锁 + 条件变量 |
ChatServerMain.cc |
主入口------组装三大模块 |
ChatClientMain.cc |
客户端------双线程收发 |
4.8.1 用户上线流程
客户端 sender 线程 服务端 UdpServer
───────────────── ─────────────────
std::getline → "usr1" recvfrom 阻塞等待...
sendto("usr1 online!") ─────────────────→ 收到包
│
├─ InetAddr client(peer)
│ 反向构造:sockaddr_in → IP + Port
│
├─ _handler_addr(peer)
│ → Route::CheckClient
│ → LockGuard 加锁
│ → ClientManager::AddClient
│ → unordered_set::insert ← O(1)
│ → LockGuard 析构解锁
│
├─ 检测 "offline!"? 否,跳过
│
└─ _handler_msg(sockfd, msg)
→ ThreadPool::Enqueue(task)
→ 工作线程取出 task
→ Route::BroadCast(sockfd, msg)
→ LockGuard 加锁
→ for (client : clients)
→ sendto(client, msg)
→ LockGuard 析构解锁
→ 所有在线用户收到广播
首次上线时
CheckClient调用unordered_set::insert。如果用户已在 set 中,insert 自动忽略------同一个客户端发第二条消息时不会重复登记。
4.8.2 普通消息流程
usr1 输入 "hello" →
sender: message = "usr1#hello"
sendto → 服务端 recvfrom
服务端:
_handler_addr(peer) → insert (已存在,无操作)
检测 "offline!" → 否
_handler_msg(sockfd, msg) → Enqueue → 工作线程 → BroadCast
→ sendto 给 usr1, usr2, usr3...
4.8.3 离线流程
usr1 输入 "quit" →
sender: sendto("usr1 offline!")
sender 线程 break → 退出 while → 线程结束
sender.Join() 返回
main 线程:
recever.Stop() → pthread_cancel → recvfrom 中断 → 接收线程取消
recever.Join() → 返回
进程正常退出
服务端:
收到 "usr1 offline!"
_handler_addr(peer) → insert (已存在,无操作)
检测 "offline!" → 是!
_handler_offline(peer) → Route::OfflineClient → DelClient → erase
_handler_msg(sockfd, msg) → BroadCast "usr1 offline!" 给所有剩余用户
4.8.4 线程模型
主线程(服务端) ThreadPool 工作线程
───────────────── ──────────────────
recvfrom() 阻塞 线程 1: 空闲
收到包 ──→ Enqueue(task) ──→ 线程 1: BroadCast → sendto × N
recvfrom() 继续... 线程 2: BroadCast → sendto × N
线程 3: 空闲等待
线程 4: BroadCast → sendto × N
线程 5: 空闲等待
- 主线程只做收包 + 分发,不阻塞
- 线程池 5 个工作线程并发执行广播
- 每个广播任务遍历在线用户列表,逐个
sendto
主线程收包速度不受广播耗时影响------Enqueue 是 O(1) 操作,投递完立刻回去
recvfrom。
4.8.5 模块依赖图
#mermaid-svg-8VRTV6AQIHIxj0E8{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-8VRTV6AQIHIxj0E8 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-8VRTV6AQIHIxj0E8 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-8VRTV6AQIHIxj0E8 .error-icon{fill:#552222;}#mermaid-svg-8VRTV6AQIHIxj0E8 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-8VRTV6AQIHIxj0E8 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-8VRTV6AQIHIxj0E8 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-8VRTV6AQIHIxj0E8 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-8VRTV6AQIHIxj0E8 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-8VRTV6AQIHIxj0E8 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-8VRTV6AQIHIxj0E8 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-8VRTV6AQIHIxj0E8 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-8VRTV6AQIHIxj0E8 .marker.cross{stroke:#333333;}#mermaid-svg-8VRTV6AQIHIxj0E8 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-8VRTV6AQIHIxj0E8 p{margin:0;}#mermaid-svg-8VRTV6AQIHIxj0E8 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-8VRTV6AQIHIxj0E8 .cluster-label text{fill:#333;}#mermaid-svg-8VRTV6AQIHIxj0E8 .cluster-label span{color:#333;}#mermaid-svg-8VRTV6AQIHIxj0E8 .cluster-label span p{background-color:transparent;}#mermaid-svg-8VRTV6AQIHIxj0E8 .label text,#mermaid-svg-8VRTV6AQIHIxj0E8 span{fill:#333;color:#333;}#mermaid-svg-8VRTV6AQIHIxj0E8 .node rect,#mermaid-svg-8VRTV6AQIHIxj0E8 .node circle,#mermaid-svg-8VRTV6AQIHIxj0E8 .node ellipse,#mermaid-svg-8VRTV6AQIHIxj0E8 .node polygon,#mermaid-svg-8VRTV6AQIHIxj0E8 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-8VRTV6AQIHIxj0E8 .rough-node .label text,#mermaid-svg-8VRTV6AQIHIxj0E8 .node .label text,#mermaid-svg-8VRTV6AQIHIxj0E8 .image-shape .label,#mermaid-svg-8VRTV6AQIHIxj0E8 .icon-shape .label{text-anchor:middle;}#mermaid-svg-8VRTV6AQIHIxj0E8 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-8VRTV6AQIHIxj0E8 .rough-node .label,#mermaid-svg-8VRTV6AQIHIxj0E8 .node .label,#mermaid-svg-8VRTV6AQIHIxj0E8 .image-shape .label,#mermaid-svg-8VRTV6AQIHIxj0E8 .icon-shape .label{text-align:center;}#mermaid-svg-8VRTV6AQIHIxj0E8 .node.clickable{cursor:pointer;}#mermaid-svg-8VRTV6AQIHIxj0E8 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-8VRTV6AQIHIxj0E8 .arrowheadPath{fill:#333333;}#mermaid-svg-8VRTV6AQIHIxj0E8 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-8VRTV6AQIHIxj0E8 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-8VRTV6AQIHIxj0E8 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8VRTV6AQIHIxj0E8 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-8VRTV6AQIHIxj0E8 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8VRTV6AQIHIxj0E8 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-8VRTV6AQIHIxj0E8 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-8VRTV6AQIHIxj0E8 .cluster text{fill:#333;}#mermaid-svg-8VRTV6AQIHIxj0E8 .cluster span{color:#333;}#mermaid-svg-8VRTV6AQIHIxj0E8 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-8VRTV6AQIHIxj0E8 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-8VRTV6AQIHIxj0E8 rect.text{fill:none;stroke-width:0;}#mermaid-svg-8VRTV6AQIHIxj0E8 .icon-shape,#mermaid-svg-8VRTV6AQIHIxj0E8 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8VRTV6AQIHIxj0E8 .icon-shape p,#mermaid-svg-8VRTV6AQIHIxj0E8 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-8VRTV6AQIHIxj0E8 .icon-shape .label rect,#mermaid-svg-8VRTV6AQIHIxj0E8 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8VRTV6AQIHIxj0E8 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-8VRTV6AQIHIxj0E8 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-8VRTV6AQIHIxj0E8 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}#mermaid-svg-8VRTV6AQIHIxj0E8 .net>*{fill:#e1f5fe!important;}#mermaid-svg-8VRTV6AQIHIxj0E8 .net span{fill:#e1f5fe!important;}#mermaid-svg-8VRTV6AQIHIxj0E8 .sync>*{fill:#fff3e0!important;}#mermaid-svg-8VRTV6AQIHIxj0E8 .sync span{fill:#fff3e0!important;}#mermaid-svg-8VRTV6AQIHIxj0E8 .logic>*{fill:#e8f5e9!important;}#mermaid-svg-8VRTV6AQIHIxj0E8 .logic span{fill:#e8f5e9!important;} ChatServerMain
ThreadPool
Route
UdpServer
InetAddr
三回调
Mutex
ClientManager
InetAddrHash
Cond
Thread
ChatClientMain
V3 ChatServer 设计总结: InetAddr 描述用户 → ClientManager 管理集合 → Route 加锁保护 → ThreadPool 并发广播 → UdpServer 三回调解耦(登记 / 消息 / 离线)。一个 UDP 包从网卡到达,经 InetAddr 转换 → UdpServer 收包 → 三回调分发 → Route 加锁 → ClientManager 查改 → ThreadPool 并发广播 → sendto 发回网卡。输入
quit即发送离线通知并退出,服务端自动清理用户。9 个文件,每个只做一件事。