QQ20260517-131759

前言

回顾:Linux 网络基础全解析:从协议分层到 Socket 编程

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

本文采用渐进式版本迭代的方式:V1 实现最简单的 Echo 服务器 → V2 重构为英译汉字典服务器,分离业务逻辑 → V3 引入线程池,升级为多线程聊天室。每个版本都有完整代码和设计思路分析,最后补充地址转换函数与 inet_ntoa 的线程安全问题。读完本文,你将能够独立实现一个基于 UDP 的多线程聊天室,并理解每一行代码背后的设计决策。

一、UDP 编程模型概述

UDP(User Datagram Protocol)的编程模型可以概括为三步:

QQ20260513-115957

步骤 服务端 客户端
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 阻塞等待 + 获取对方地址 peerlen输出参数,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 一致)

QQ20260519-215046

客户端要不要 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;
}

QQ20260519-215333

让我们来看一下运行结果:

[此处插入图片]
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() 完全不变,业务代码(ExecuteDict::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_addrhtonsntohsinet_ntoa)藏在内部,上层只需传 string

四、V3:UDP 多线程聊天室

V2 是一对一的字典查询。V3 升级为一对多的聊天室——任一用户发言,所有人可见。

4.1 架构升级:引入线程池

V3 的核心变化:消息处理和消息转发不再串行执行,而是交由线程池异步处理:

QQ20260520-220707

// 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(一个只读、一个只写)是安全的,不会产生数据竞争。

QQ20260526-172129

这里我们可以看到输入和输出的消息混在了一起,看着不是很美观,我们可以通过指令将输出的消息和输入的消息分开,这样就不影响发消息和收到的消息混杂在一起。

五、补充:地址转换函数

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,读取到对方的转换结果:

QQ20260520-221023

// 多线程测试 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,errnoEINVAL(无效参数)。

原因: 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 多线程聊天室 引入线程池异步处理 + 在线用户管理 + 广播

动手试试

  1. 在 V1 Echo 服务器基础上,改写 Start() 的回显逻辑:将收到的字符串全部转为大写再返回(提示:只需修改 buffer 的内容,sendto 之前加一行 for 循环)。
  2. 给 V3 聊天室增加私聊功能——解析消息格式 @目标IP:目标Port 消息内容,修改 Route() 使其只发给目标用户而不是广播(提示:在 Route 中增加一个参数判断,比较在线列表中每个用户的地址与目标地址)。

预告: 下一篇我们将进入 TCP Socket 编程,探索 listen/accept/connect 和连接生命周期管理的全新复杂度。

尾声

本章讲解就到此结束了,若有纰漏或不足之处欢迎大家在评论区留言或者私信,同时也欢迎各位一起探讨学习。感谢您的观看!

更多内容可见主页

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐