【Linux之旅】Linux UDP Socket 编程实战:从 Echo 服务器到多线程聊天室
在上一篇网络基础概念中,我们理清了协议分层、IP/MAC 分工、端口号和 Socket 的基本概念。本篇正式进入 Socket 编程实战,选择从 UDP 协议切入——它比 TCP 简单得多:无需连接、无需握手、直发直收。先掌握 UDP 的编程模型,后续再学 TCP 时会发现很多接口是共通的,只是 TCP 多了"连接管理"这一层复杂度。

请君浏览
前言
回顾:Linux 网络基础全解析:从协议分层到 Socket 编程
在上一篇网络基础概念中,我们理清了协议分层、IP/MAC 分工、端口号和 Socket 的基本概念。本篇正式进入 Socket 编程实战,选择从 UDP 协议切入——它比 TCP 简单得多:无需连接、无需握手、直发直收。先掌握 UDP 的编程模型,后续再学 TCP 时会发现很多接口是共通的,只是 TCP 多了"连接管理"这一层复杂度。
本文采用渐进式版本迭代的方式:V1 实现最简单的 Echo 服务器 → V2 重构为英译汉字典服务器,分离业务逻辑 → V3 引入线程池,升级为多线程聊天室。每个版本都有完整代码和设计思路分析,最后补充地址转换函数与
inet_ntoa的线程安全问题。读完本文,你将能够独立实现一个基于 UDP 的多线程聊天室,并理解每一行代码背后的设计决策。
一、UDP 编程模型概述
UDP(User Datagram Protocol)的编程模型可以概括为三步:

| 步骤 | 服务端 | 客户端 |
|---|---|---|
| 1 | socket() 创建套接字 |
socket() 创建套接字 |
| 2 | bind() 绑定知名端口 |
不显式 bind(OS 自动分配) |
| 3 | recvfrom() / sendto() 收发 |
sendto() / recvfrom() 收发 |
核心接口只有两个——sendto() 和 recvfrom()。UDP 是无连接的,每次发送都要带上目标地址,每次接收都要输出对方地址(为回复做准备)。
UDP 编程本质上就是一个"收发消息"的循环:收一个包,处理,回复(如果需要)。不像 TCP 需要 listen/accept/connect,UDP 的代码结构要简单得多。
二、V1:UDP Echo 服务器
先从最简单的 Echo(回显)服务器开始——收到什么就回什么。麻雀虽小,五脏俱全。
2.1 nocopy 工具类
先写一个禁止拷贝的基类,后续服务器类都继承它,避免 socket fd 被意外拷贝导致 double close:
// nocopy.hpp
#pragma once
class nocopy
{
public:
nocopy() {}
nocopy(const nocopy &) = delete;
const nocopy& operator=(const nocopy &) = delete;
~nocopy() {}
};
2.2 InetAddr 地址信息封装
struct sockaddr_in 是 C 风格结构体,取 IP 和端口时需要调用 inet_ntoa / ntohs。封装一个 InetAddr 类简化操作:
// InetAddr.hpp
#pragma once
#include <iostream>
#include <cstring>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
class InetAddr
{
public:
// 构造函数 1:从 sockaddr_in 解析(recvfrom 后用)
InetAddr(struct sockaddr_in &addr) : _addr(addr)
{
_port = ntohs(_addr.sin_port);
char buf[INET_ADDRSTRLEN];
_ip = inet_ntop(AF_INET, &_addr.sin_addr, buf, sizeof(buf));
}
// 构造函数 2:从 IP + 端口构造(bind / sendto 前用)
InetAddr(const std::string &ip, uint16_t port) : _ip(ip), _port(port)
{
memset(&_addr, 0, sizeof(_addr));
_addr.sin_family = AF_INET;
_addr.sin_port = htons(_port);
inet_pton(AF_INET, _ip.c_str(), &_addr.sin_addr);
}
std::string Ip() { return _ip; }
uint16_t Port() { return _port; }
struct sockaddr_in &GetAddr() { return _addr; }
std::string PrintDebug()
{
return _ip + ":" + std::to_string(_port);
}
~InetAddr() {}
private:
std::string _ip;
uint16_t _port;
struct sockaddr_in _addr;
};
封装它的意义在于:构造函数 1 用于
recvfrom后解析对方地址,构造函数 2 用于bind/sendto前构造地址,把htons/inet_addr/bzero这些繁琐操作藏在类内部。GetAddr()则对外暴露底层sockaddr_in,直接传给bind/sendto等系统调用。
2.3 错误码枚举
// Comm.hpp
#pragma once
enum {
Usage_Err = 1,
Socket_Err,
Bind_Err
};
2.4 UdpServer 核心实现
// UdpServer.hpp
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "nocopy.hpp"
#include <cstdio>
#include "Comm.hpp"
#include "InetAddr.hpp"
// 简单日志宏:直接打印到 stderr,无需额外日志库
#define LOG_FATAL(fmt, ...) fprintf(stderr, "[FATAL] " fmt "\n", ##__VA_ARGS__)
#define LOG_INFO(fmt, ...) fprintf(stderr, "[INFO] " fmt "\n", ##__VA_ARGS__)
#define LOG_DEBUG(fmt, ...) fprintf(stderr, "[DEBUG] " fmt "\n", ##__VA_ARGS__)
const static uint16_t defaultport = 8888;
const static int defaultfd = -1;
const static int defaultsize = 1024;
class UdpServer : public nocopy
{
public:
UdpServer(uint16_t port = defaultport)
: _port(port), _sockfd(defaultfd) {}
void Init()
{
// 1. 创建 socket(SOCK_DGRAM = UDP)
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0)
{
LOG_FATAL( "socket error, %d : %s", errno, strerror(errno));
exit(Socket_Err);
}
LOG_INFO( "socket success, sockfd: %d", _sockfd);
// 2. bind 绑定端口(0.0.0.0 = INADDR_ANY,绑定所有可用 IP)
InetAddr local("0.0.0.0", _port);
int n = ::bind(_sockfd, (struct sockaddr *)&local.GetAddr(), sizeof(local.GetAddr()));
if (n != 0)
{
LOG_FATAL( "bind error, %d : %s", errno, strerror(errno));
exit(Bind_Err);
}
}
void Start()
{
char buffer[defaultsize];
for (;;)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
ssize_t n = recvfrom(_sockfd, buffer, sizeof(buffer) - 1,
0, (struct sockaddr *)&peer, &len);
if (n > 0)
{
InetAddr addr(peer);
buffer[n] = 0;
std::cout << "[" << addr.PrintDebug() << "]# " << buffer << std::endl;
// Echo 回去
sendto(_sockfd, buffer, strlen(buffer), 0,
(struct sockaddr *)&peer, len);
}
}
}
~UdpServer() {}
private:
uint16_t _port;
int _sockfd;
};
Start() 函数拆解——UDP 服务器的核心循环只有四件事:
// ① recvfrom — 阻塞等待客户端消息,同时获取对方地址
ssize_t n = recvfrom(_sockfd, buffer, sizeof(buffer) - 1, 0,
(struct sockaddr *)&peer, &len);
// ② 处理消息 — Echo 服务器直接原样返回,V2/V3 在这里调用业务回调
buffer[n] = 0;
std::cout << "[" << addr.PrintDebug() << "]# " << buffer << std::endl;
// ③ sendto — 将响应发回给客户端(peer 保存了客户端地址)
sendto(_sockfd, buffer, strlen(buffer), 0,
(struct sockaddr *)&peer, len);
// ④ 循环 — UDP 无需 close 客户端连接,直接回到 recvfrom 等待下一条
| 拆解 | 做了什么 | 关键点 |
|---|---|---|
① recvfrom |
阻塞等待 + 获取对方地址 | peer 和 len 是输出参数,len 传入前必须初始化为 sizeof(peer) |
| ② 业务处理 | Echo 直接回原串 | V2/V3 中替换为回调,此处是唯一会随需求变化的步骤 |
③ sendto |
将结果发回 | 使用 recvfrom 获得的 peer 地址,保证"谁发的回给谁" |
| ④ 循环 | 回到 recvfrom |
UDP 无连接状态,不需要 close 某个客户端 fd |
关键 API 速查——UDP 编程的四个核心函数:
#include <sys/types.h>
#include <sys/socket.h>
// 创建套接字
int socket(int domain, int type, int protocol);
// domain: 协议族 — AF_INET(IPv4) / AF_INET6(IPv6) / AF_UNIX(本地)
// type: 套接字类型 — SOCK_DGRAM(UDP) / SOCK_STREAM(TCP)
// protocol: 具体协议,传 0 表示由 domain+type 自动决定
// 返回值: 成功返回文件描述符(fd),失败返回 -1,设置 errno
// 绑定地址和端口
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
// sockfd: socket() 返回的 fd
// addr: 要绑定的本地地址(IP+端口),传参时强转为 sockaddr*
// addrlen: 地址结构体大小(sizeof(sockaddr_in))
// 返回值: 成功 0,失败 -1(常见:EADDRINUSE 端口被占用)
// 接收数据(同时获取发送方地址,UDP专用)
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
// buf: 接收缓冲区
// len: 缓冲区大小
// flags: 标志位,通常传 0
// src_addr: 输出参数,保存发送方的地址信息
// addrlen: 输入输出参数,传入 src_addr 的大小,传出实际大小
// 返回值: 成功返回实际接收的字节数,失败返回 -1
// 发送数据(指定目标地址,UDP专用)
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
// dest_addr: 目标地址(IP+端口)
// addrlen: 地址结构体大小
// 返回值: 成功返回实际发送的字节数,失败返回 -1
四点记忆诀窍:
| 步骤 | 接口 | 要点 |
|---|---|---|
| 创建 socket | socket(AF_INET, SOCK_DGRAM, 0) |
SOCK_DGRAM = UDP,返回的是 fd |
| 绑定端口 | bind() + INADDR_ANY |
服务端绑定固定端口,客户端才知道发给谁 |
| 接收数据 | recvfrom() |
后两个参数是输出,告诉你"谁发的" |
| 发送数据 | sendto() |
后两个参数是输入,指定"发给谁" |
[此处插入图片]
AI提示词:终端运行截图风格的技术插图。模拟Linux终端窗口,上半部分显示"服务端启动日志:socket success, sockfd: 3",下半部分显示"客户端发送hello后收到的回显:[127.0.0.1:xxxxx]# hello → server echo# hello"。终端黑底绿字风格,模拟真实运行结果。
INADDR_ANY 是什么? 它的值是
0x00000000,表示"绑定本机所有可用 IP"。当服务器有多张网卡(多个 IP)时,使用INADDR_ANY可以接收发往任意网卡的数据,而不需要写死某个 IP。云服务器上也不建议 bind 具体 IP。
字节序转换函数——端口号和 IP 地址填入结构体前必须转为网络字节序:
#include <arpa/inet.h>
uint16_t htons(uint16_t hostshort); // Host TO Network Short — 端口号(16位)
uint32_t htonl(uint32_t hostlong); // Host TO Network Long — IP 地址(32位)
uint16_t ntohs(uint16_t netshort); // Network TO Host Short — 接收后转回
uint32_t ntohl(uint32_t netlong); // Network TO Host Long — 接收后转回
记忆:
h=主机,n=网络,s=short/16位,l=long/32位。htons= 把 16 位数据从主机序转网络序。忘了写htons()是最经典的新手错误。
2.5 服务端入口 Main.cpp
// Main.cpp
#include "UdpServer.hpp"
#include <memory>
void Usage(const std::string &process)
{
std::cout << "Usage: " << process << " port" << std::endl;
}
// ./udp_server 8888
int main(int argc, char *argv[])
{
if (argc != 2) { Usage(argv[0]); return Usage_Err; }
uint16_t port = std::stoi(argv[1]);
std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(port);
usvr->Init();
usvr->Start();
return 0;
}
V1 和 V2 的 Main.cpp 结构几乎一样——区别只在于 V2 的
UdpServer构造函数多了一个func_t回调参数。这就是回调分离业务的优势:main()几乎不用改。
2.6 UdpClient 实现与 bind 问题
// UdpClient.cpp
#include <iostream>
#include <cstring>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include "InetAddr.hpp"
void Usage(const std::string &process)
{
std::cout << "Usage: " << process << " server_ip server_port" << std::endl;
}
// ./udp_client server_ip server_port
int main(int argc, char *argv[])
{
if (argc != 3) { Usage(argv[0]); return 1; }
std::string serverip = argv[1];
uint16_t serverport = std::stoi(argv[2]);
// 1. 创建 socket
int sock = socket(AF_INET, SOCK_DGRAM, 0);
if (sock < 0) { std::cerr << "socket error: " << strerror(errno) << std::endl; return 2; }
std::cout << "create socket success: " << sock << std::endl;
// 2. 构造服务端地址
InetAddr server(serverip, serverport);
while (true)
{
std::string inbuffer;
std::cout << "Please Enter# ";
std::getline(std::cin, inbuffer);
ssize_t n = sendto(sock, inbuffer.c_str(), inbuffer.size(),
0, (struct sockaddr *)&server.GetAddr(), sizeof(server.GetAddr()));
if (n > 0)
{
char buffer[1024];
struct sockaddr_in temp;
socklen_t len = sizeof(temp);
ssize_t m = recvfrom(sock, buffer, sizeof(buffer) - 1,
0, (struct sockaddr *)&temp, &len);
if (m > 0) { buffer[m] = 0; std::cout << "server echo# " << buffer << std::endl; }
else break;
}
else break;
}
close(sock);
return 0;
}
UdpClient 主流程拆解:
| 步骤 | 代码 | 说明 |
|---|---|---|
| ① 创建 socket | socket(AF_INET, SOCK_DGRAM, 0) |
与服务端一样,SOCK_DGRAM = UDP |
| ② 填充服务端地址 | inet_addr(serverip) + htons(serverport) |
告诉 sendto 发给谁——IP 和端口都要转网络序 |
| ③ 发送 | sendto(sock, ..., &server, sizeof(server)) |
首次调用时 OS 自动 bind 随机端口 |
| ④ 接收回复 | recvfrom(sock, ..., &temp, &len) |
temp 保存服务端的回复地址(通常与 server 一致) |

客户端要不要 bind? 一定要 bind,但不需要显式 bind。原因很简单:服务端端口是众所周知的(HTTP 80、SSH 22),客户端端口无所谓是多少。如果客户端自己 bind,一旦端口冲突就启动不了。交给 OS 在首次
sendto()时自动分配随机端口,既省心又能支持大量客户端同时运行。
[此处插入图片]
AI提示词:终端截图风格插图。两个并排的Linux终端窗口——左窗口"服务端"显示日志"[127.0.0.1:44444]# hello",右窗口"客户端"显示交互"Please Enter# hello → server echo# hello"。黑底绿字终端风格,窗口标题栏分别标注"./udp_server 8888"和"./udp_client 127.0.0.1 8888"。
三、V2:UDP 字典服务器
V1 的 Echo 服务器只是一个框架——网络代码和业务逻辑混在一起。V2 的目标是分离关注点:网络层只负责收发,业务层负责处理请求,通过回调函数串联。
3.1 Dict 字典加载
先从数据说起——准备一个 dict.txt,格式为 单词: 释义:
apple: 苹果
banana: 香蕉
hello: 你好
goodbye: 再见
...
Dict 类负责加载和查询:
// Dict.hpp
#pragma once
#include <iostream>
#include <string>
#include <fstream>
#include <unordered_map>
const std::string sep = ": ";
class Dict
{
public:
Dict(const std::string &confpath) : _confpath(confpath)
{
LoadDict();
}
std::string Translate(const std::string &key)
{
auto iter = _dict.find(key);
if (iter == _dict.end()) return std::string("Unknown");
else return iter->second;
}
~Dict() {}
private:
void LoadDict()
{
std::ifstream in(_confpath);
if (!in.is_open()) { std::cerr << "open file error" << std::endl; return; }
std::string line;
while (std::getline(in, line))
{
if (line.empty()) continue;
auto pos = line.find(sep);
if (pos == std::string::npos) continue;
std::string key = line.substr(0, pos);
std::string value = line.substr(pos + sep.size());
_dict.insert(std::make_pair(key, value));
}
in.close();
}
std::string _confpath;
std::unordered_map<std::string, std::string> _dict;
};
3.2 UdpServer 重构——回调分离业务
核心改动:用 std::function 将业务处理"注入"服务器:
// 定义回调函数类型:接收请求字符串,输出响应字符串
using func_t = std::function<void(const std::string &req, std::string *resp)>;
class UdpServer : public nocopy
{
public:
UdpServer(func_t func, uint16_t port = defaultport)
: _func(func), _port(port), _sockfd(defaultfd) {}
// Init() 同上,略...
void Start()
{
char buffer[defaultsize];
for (;;)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
ssize_t n = recvfrom(_sockfd, buffer, sizeof(buffer) - 1,
0, (struct sockaddr *)&peer, &len);
if (n > 0)
{
InetAddr addr(peer);
buffer[n] = 0;
std::cout << "[" << addr.PrintDebug() << "]# " << buffer << std::endl;
std::string value;
_func(buffer, &value); // 回调业务方法,不关心具体做什么
sendto(_sockfd, value.c_str(), value.size(), 0,
(struct sockaddr *)&peer, len);
}
}
}
private:
func_t _func; // 回调函数——这是与 V1 的唯一本质区别
uint16_t _port;
int _sockfd;
};
3.3 Main 整合
// Main.cpp
#include "UdpServer.hpp"
#include "Dict.hpp"
#include <memory>
Dict gdict("./dict.txt");
void Execute(const std::string &req, std::string *resp)
{
*resp = gdict.Translate(req);
}
// ./udp_server 8888
int main(int argc, char *argv[])
{
if (argc != 2) { Usage(argv[0]); return Usage_Err; }
uint16_t port = std::stoi(argv[1]);
std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(Execute, port);
usvr->Init();
usvr->Start();
return 0;
}

让我们来看一下运行结果:
[此处插入图片]
AI提示词:终端截图风格插图。左窗口"服务端"显示日志"[127.0.0.1:54321]# apple → 查询成功",右窗口"客户端"显示交互"请输入您要查的单词: apple → apple 意思是 苹果"。黑底绿字,窗口标题为"./udp_server 8888"和"./dict_client 127.0.0.1 8888"。
V1 → V2 的核心升级: 服务器不再硬编码处理逻辑(“收到什么就回什么”),而是接受一个
func_t回调。Init()和Start()完全不变,业务代码(Execute→Dict::Translate)完全独立。这为后续 V3 继续升级业务逻辑留出了扩展空间。
3.4 封装版 UdpSocket(补充参考)
将原始 socket 操作封装为 UdpSocket 类,进一步降低使用门槛:
// udp_socket.hpp
class UdpSocket {
public:
bool Socket() {
fd_ = socket(AF_INET, SOCK_DGRAM, 0);
return fd_ >= 0;
}
bool Bind(const std::string& ip, uint16_t port) {
sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = inet_addr(ip.c_str());
addr.sin_port = htons(port);
return bind(fd_, (sockaddr*)&addr, sizeof(addr)) == 0;
}
bool RecvFrom(std::string* buf, std::string* ip = nullptr, uint16_t* port = nullptr) {
char tmp[10240] = {0};
sockaddr_in peer;
socklen_t len = sizeof(peer);
ssize_t read_size = recvfrom(fd_, tmp, sizeof(tmp) - 1, 0, (sockaddr*)&peer, &len);
if (read_size < 0) return false;
buf->assign(tmp, read_size);
if (ip) *ip = inet_ntoa(peer.sin_addr);
if (port) *port = ntohs(peer.sin_port);
return true;
}
bool SendTo(const std::string& buf, const std::string& ip, uint16_t port) {
sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = inet_addr(ip.c_str());
addr.sin_port = htons(port);
return sendto(fd_, buf.data(), buf.size(), 0, (sockaddr*)&addr, sizeof(addr)) >= 0;
}
private:
int fd_ = -1;
};
在此基础上实现 DictServer 只需几十行——创建 UdpServer 绑定端口,注册 Translate 回调,启动即可。封装的价值在于:把繁琐的地址转换(inet_addr、htons、ntohs、inet_ntoa)藏在内部,上层只需传 string。
四、V3:UDP 多线程聊天室
V2 是一对一的字典查询。V3 升级为一对多的聊天室——任一用户发言,所有人可见。
4.1 架构升级:引入线程池
V3 的核心变化:消息处理和消息转发不再串行执行,而是交由线程池异步处理:

// V3 UdpServer 关键新增
#include "ThreadPool.hpp"
using task_t = std::function<void()>;
class UdpServer : public nocopy
{
public:
UdpServer(uint16_t port = defaultport) : _port(port), _sockfd(defaultfd)
{
pthread_mutex_init(&_user_mutex, nullptr);
}
void Init()
{
// 1. socket & bind(同 V1/V2,略)
// 2. 启动线程池
ThreadPool<task_t>::GetInstance()->Start();
}
// ...
};
4.2 在线用户管理
每次收到消息时,将发送方加入在线用户列表。用互斥锁保护,因为主线程和线程池的工作线程可能同时访问:
void AddOnlineUser(InetAddr addr)
{
LockGuard lockguard(&_user_mutex);
for (auto &user : _online_user)
{
if (addr == user)
return; // 已在列表中,不重复添加
}
_online_user.push_back(addr);
LOG_DEBUG( "%s:%d is add to onlineuser list...",
addr.Ip().c_str(), addr.Port());
}
注意 InetAddr 增加了 operator==,方便比对两个用户是否相同:
bool operator==(const InetAddr &addr)
{
return this->_ip == addr._ip && this->_port == addr._port;
}
4.3 消息广播 Route
核心是 Route 函数——遍历在线用户列表,给每人发一份:
void Route(int sock, const std::string &message)
{
LockGuard lockguard(&_user_mutex);
for (auto &user : _online_user)
{
sendto(sock, message.c_str(), message.size(), 0,
(struct sockaddr *)&user.GetAddr(), sizeof(user.GetAddr()));
LOG_DEBUG( "server send message to %s:%d, message: %s",
user.Ip().c_str(), user.Port(), message.c_str());
}
}
InetAddr 也增加了
GetAddr()方法,返回保存的sockaddr_in引用,方便sendto直接使用。
Start() 主循环与 V1/V2 的区别——将转发任务提交到线程池:
void Start()
{
char buffer[defaultsize];
for (;;)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
ssize_t n = recvfrom(_sockfd, buffer, sizeof(buffer) - 1,
0, (struct sockaddr *)&peer, &len);
if (n > 0)
{
InetAddr addr(peer);
AddOnlineUser(addr); // 登记上线
buffer[n] = 0;
// 拼接消息:[IP:Port]# 内容
std::string message = "[" + addr.Ip() + ":" + std::to_string(addr.Port()) + "]# " + buffer;
// 将广播任务提交到线程池,主线程立即回到 recvfrom 继续收消息
task_t task = std::bind(&UdpServer::Route, this, _sockfd, message);
ThreadPool<task_t>::GetInstance()->Push(task);
}
}
}
为什么用线程池而不是在 recvfrom 循环里直接 Route? 如果某次
sendto阻塞(网络拥塞或接收方缓冲区满),主循环停顿,所有其他用户的消息都收不到了。线程池将转发异步化,主线程永远只负责recvfrom,转发交给工作线程并行执行。
4.4 多线程客户端
V3 的客户端也升级了——UDP 是全双工的,用一个 socket 同时收发不会冲突:
// 两个独立线程:一个只管发,一个只管收
void SenderRoutine(ThreadData &td)
{
while (true)
{
std::string inbuffer;
std::cout << "Please Enter# ";
std::getline(std::cin, inbuffer);
auto server = td._serveraddr.GetAddr();
sendto(td._sockfd, inbuffer.c_str(), inbuffer.size(), 0,
(struct sockaddr *)&server, sizeof(server));
}
}
void RecverRoutine(ThreadData &td)
{
char buffer[4096];
while (true)
{
struct sockaddr_in temp;
socklen_t len = sizeof(temp);
ssize_t n = recvfrom(td._sockfd, buffer, sizeof(buffer) - 1,
0, (struct sockaddr *)&temp, &len);
if (n > 0) { buffer[n] = 0; std::cerr << buffer << std::endl; }
else break;
}
}
// main: 创建 socket → 启动发消息线程和收消息线程 → Join 等待
UDP 是全双工的: 一个 sockfd 可以同时读和写,且不需要像 TCP 那样先建立连接。两个线程操作同一个 fd(一个只读、一个只写)是安全的,不会产生数据竞争。

这里我们可以看到输入和输出的消息混在了一起,看着不是很美观,我们可以通过指令将输出的消息和输入的消息分开,这样就不影响发消息和收到的消息混杂在一起。
五、补充:地址转换函数
UDP 编程中频繁涉及字符串 IP ↔ 32 位整数的转换,选择正确的工具函数很重要。
5.1 函数对比
#include <arpa/inet.h>
// 字符串 → 二进制(推荐)
int inet_pton(int af, const char *src, void *dst);
// af: 地址族 — AF_INET(IPv4) / AF_INET6(IPv6)
// src: 点分十进制字符串,如 "192.168.1.1"
// dst: 输出参数,指向 in_addr(IPv4) 或 in6_addr(IPv6)
// 返回值: 成功 1,src 格式无效 0,失败 -1
// 二进制 → 字符串(推荐)
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
// src: 指向 in_addr 或 in6_addr
// dst: 调用者提供的输出缓冲区
// size: 缓冲区大小(INET_ADDRSTRLEN=16 / INET6_ADDRSTRLEN=46)
// 返回值: 成功返回 dst 指针,失败返回 NULL
// 字符串 → 二进制(旧版,不推荐)
in_addr_t inet_addr(const char *cp);
// 返回值: 成功返回 32 位网络字节序地址,失败返回 INADDR_NONE(-1)
// 致命缺陷: 合法地址 255.255.255.255 与 INADDR_NONE 冲突
// 二进制 → 字符串(旧版,不推荐)
char *inet_ntoa(struct in_addr in);
// 返回值: 指向函数内部静态缓冲区的指针(线程不安全!)
| 函数 | 方向 | 支持 IPv6 | 线程安全 | 推荐 |
|---|---|---|---|---|
inet_pton |
字符串 → 二进制 | ✅ | ✅ | ✅ |
inet_ntop |
二进制 → 字符串 | ✅ | ✅ | ✅ |
inet_addr |
字符串 → 二进制 | ❌ | ✅ | ❌ |
inet_ntoa |
二进制 → 字符串 | ❌ | ❌ | ❌ |
5.2 inet_ntoa 的线程安全问题
inet_ntoa 返回一个 char*,指向函数内部的静态缓冲区。多次调用会互相覆盖:
// 问题演示
char* ptr1 = inet_ntoa(addr1.sin_addr); // ptr1 指向静态区
char* ptr2 = inet_ntoa(addr2.sin_addr); // ptr2 也指向同一静态区
// 此时 ptr1 的内容已经被 ptr2 覆盖!
多线程场景下问题更严重——两个线程同时调用 inet_ntoa,读取到对方的转换结果:

// 多线程测试 inet_ntoa
void* Func1(void* p) {
struct sockaddr_in* addr = (struct sockaddr_in*)p;
while (1) {
char* ptr = inet_ntoa(addr->sin_addr); // 可能拿到 Func2 的结果
printf("addr1: %s\n", ptr);
}
}
void* Func2(void* p) {
struct sockaddr_in* addr = (struct sockaddr_in*)p;
while (1) {
char* ptr = inet_ntoa(addr->sin_addr); // 可能拿到 Func1 的结果
printf("addr2: %s\n", ptr);
}
}
最佳实践:在多线程环境下,统一使用
inet_ntop,由调用者提供缓冲区。 这样每个线程有自己独立的内存空间,不存在竞态问题。inet_pton同理,优于inet_addr。
六、常见问题与避坑指南
以下是 UDP Socket 编程中最容易踩的坑,提前知道能省不少调试时间:
6.1 忘记 htons() 导致端口号错乱
现象: 服务器绑定 8080 端口,客户端连不上;用 netstat 查看发现监听在一个奇怪的端口上。
原因: 8080 在小端机器上的内存布局是 0x901f0000(主机序),直接填入 sin_port 后,网络序下被解读为 0x00001f90 = 8080 吗?不对,实际是字节反转后的其他值。
解决: 写入 sockaddr_in 的端口号和 IP 地址必须通过 htons()/htonl() 转换。读取时必须通过 ntohs()/ntohl() 转回。
// ❌ 错误
local.sin_port = 8080;
// ✅ 正确
local.sin_port = htons(8080);
6.2 recvfrom 的 addrlen 参数未初始化
现象: recvfrom 返回 -1,errno 为 EINVAL(无效参数)。
原因: addrlen 是输入输出参数——传入时必须等于地址结构体的大小,recvfrom 内部据此判断缓冲区是否足够。
解决: 每次调用前都要赋值:
socklen_t len = sizeof(peer); // 必须在每次 recvfrom 前重新赋值
recvfrom(sockfd, buf, size, 0, (sockaddr*)&peer, &len);
6.3 客户端 bind 了固定端口导致多实例冲突
现象: 同一台机器只能启动一个客户端实例,第二个启动时报 Address already in use。
原因: 手动 bind 端口后,该端口被独占。客户端端口本应让 OS 随机分配,支持任意多个实例。
解决: 客户端不要显式 bind()。sendto() 首次调用时 OS 会自动分配可用端口。
6.4 inet_ntoa 多线程下返回错误 IP
现象: 在多线程程序中打印客户端 IP,偶尔出现 IP 对不上的情况。
原因: inet_ntoa 返回的是内部静态缓冲区的指针,另一个线程调用时会覆盖上一线程的结果。
解决: 使用可重入的 inet_ntop,由调用者提供缓冲区:
// ❌ 多线程不安全
char* ip = inet_ntoa(peer.sin_addr);
// ✅ 线程安全
char ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &peer.sin_addr, ip, sizeof(ip));
6.5 sendto 返回 -1 但不代表对方没收到
现象: sendto 返回 -1(比如 ENETUNREACH),但实际上对方可能已经收到了数据。
原因: UDP 是无连接协议,sendto 只负责将数据提交到本地协议栈的发送队列,返回 -1 通常表示本地发送失败(如路由不可达),不代表对方收没收到。
解决: sendto 成功 ≠ 对方收到;sendto 失败 ≠ 对方没收到。真正需要可靠性的场景,应该选用 TCP 或在应用层实现确认机制。
六、UDP 服务器生产部署注意事项
前面三个版本的代码完成度已经足够学习原理,但离生产环境还差几步。以下是 UDP 服务器在真实部署中的常见问题和解决方案。
6.1 发送速率控制——避免"洪泛"丢包
UDP 没有拥塞控制,你可以用 sendto 以任意速率发送数据。但如果发送速率超过了接收方的处理能力或中间路由器的带宽,数据报就会被静默丢弃。实际部署中应该实现应用层的速率控制:
// 简单的速率限制器——每秒最多发 rate_limit 个包
class RateLimiter {
int _rate_limit; // 每秒最多发包数
int _tokens; // 当前令牌数
std::chrono::steady_clock::time_point _last_refill;
public:
RateLimiter(int rate) : _rate_limit(rate), _tokens(rate) {
_last_refill = std::chrono::steady_clock::now();
}
bool AllowSend() {
auto now = std::chrono::steady_clock::now();
auto elapsed = std::chrono::duration<double>(now - _last_refill).count();
_tokens = std::min(_rate_limit, _tokens + (int)(elapsed * _rate_limit));
_last_refill = now;
if (_tokens > 0) { _tokens--; return true; }
return false; // 令牌用完,应该等待
}
};
// 用法:发送前调用 AllowSend(),true 才 sendto
6.2 重传和超时处理——应用层的可靠性补偿
UDP 本身不重传,但你的业务可能需要。典型的做法是:序列号 + 定时器。
// 发送端:给每个数据报编号
struct UdpPacket {
uint32_t seq; // 序列号
char data[1472]; // 载荷(MTU 友好尺寸)
};
// 接收端:检查序列号,发现缺失后请求重传
std::set<uint32_t> received_seqs;
void OnReceive(const UdpPacket &pkt) {
if (received_seqs.count(pkt.seq) == 0) {
received_seqs.insert(pkt.seq);
// 检查是否有缺失...如果缺了,发 NACK 请求重传
}
}
这个思路和 TCP 的确认应答、超时重传如出一辙——只不过 TCP 在内核层做了这一切,而你要在应用层做。QUIC 协议(HTTP/3 的底层)也是这样在 UDP 之上重建了可靠性机制,但做得比 TCP 更精细——解决了 TCP 的队头阻塞问题。
6.3 负载均衡——多进程 bind 同一端口
UDP 服务端也可以用多进程提升并发能力(和 TCP V2 多进程类似),结合 SO_REUSEPORT:
int opt = 1;
setsockopt(_sockfd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));
// fork 多个工作进程
for (int i = 0; i < num_workers; i++) {
if (fork() == 0) {
// 子进程:继续 bind 同一端口,内核按哈希分发数据报到各进程
StartWorker();
exit(0);
}
}
UDP 的
SO_REUSEPORT分发比 TCP 更自然——每个数据报都是独立的,内核可以独立分发而不需要考虑连接状态。这使得 UDP 的水平扩展比 TCP 简单。
6.4 监控指标——哪些数据应该记录
| 指标 | 含义 | 采集方式 |
|---|---|---|
| 接收数据报总数 | recvfrom 成功次数 |
每次 recvfrom > 0 计数器 +1 |
| 发送数据报总数 | sendto 成功次数 |
每次 sendto > 0 计数器 +1 |
| 接收失败次数 | recvfrom 返回 -1 的次数 |
每次 recvfrom < 0 计数器 +1 |
| 活跃用户数 | 在线用户列表大小 | _online_user.size() |
| 消息处理延迟 | 从收到到发出广播的时间差 | chrono 计时 |
| 接收缓冲区丢包 | 内核 RcvbufErrors | 读取 /proc/net/snmp |
监控是运维的眼睛。如果用户反馈"消息经常收不到",你第一反应是去查接收缓冲区丢包指标——而不是猜代码哪里写错了。
总结
从 V1 到 V3 的演进,本质上是一套网络框架的迭代思路:
| 版本 | 功能 | 核心升级 |
|---|---|---|
| V1 Echo | 回显服务器 | 掌握 socket/bind/recvfrom/sendto 基本流程 |
| V2 DictServer | 英译汉字典 | 用回调函数分离网络代码与业务逻辑 |
| V3 ChatRoom | 多线程聊天室 | 引入线程池异步处理 + 在线用户管理 + 广播 |
动手试试
- 在 V1 Echo 服务器基础上,改写
Start()的回显逻辑:将收到的字符串全部转为大写再返回(提示:只需修改 buffer 的内容,sendto之前加一行for循环)。- 给 V3 聊天室增加私聊功能——解析消息格式
@目标IP:目标Port 消息内容,修改Route()使其只发给目标用户而不是广播(提示:在Route中增加一个参数判断,比较在线列表中每个用户的地址与目标地址)。
预告: 下一篇我们将进入 TCP Socket 编程,探索 listen/accept/connect 和连接生命周期管理的全新复杂度。
尾声
本章讲解就到此结束了,若有纰漏或不足之处欢迎大家在评论区留言或者私信,同时也欢迎各位一起探讨学习。感谢您的观看!
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)