Linux网络编程—UDP 群聊系统

Linux网络编程---UDP 群聊系统

一、整体设计目标

两个文件配合工作,实现一个 UDP 群聊系统

  • 一个服务端(UdpServer)监听 8080 端口,接收任何客户端发来的消息
  • 多个客户端(UdpClient)连接到服务端
  • 任一客户端发送的消息都会被服务端转发给所有在线客户端

UDP 的核心特点

  1. 无连接:客户端和服务端之间没有"握手"过程
  2. 数据报:每条消息有明确边界,不会粘包
  3. 不可靠:消息可能丢失、重复、乱序
  4. 全双工:一个 Socket 既能收也能发

二、UdpServer详细解析

2.1 文件头部 - 头文件与依赖

cpp 复制代码
#pragma once

#include <iostream>        // std::cout / std::cerr
#include <sys/socket.h>    // socket(), bind(), sendto(), recvfrom()
#include <sys/types.h>     // 一些系统类型定义
#include "Log.hpp"         // 自定义日志模块
#include <string>          // std::string
#include <strings.h>       // bzero()
#include <cstring>         // strerror()
#include <netinet/in.h>    // sockaddr_in, INADDR_ANY
#include <arpa/inet.h>     // inet_addr(), htons(), ntohs(), inet_ntoa()
#include <unistd.h>        // close()
#include <functional>      // std::function(用于回调)
#include <unordered_map>   // 哈希表(存放在线客户端)
头文件 提供什么能力 用在哪个 API
<sys/socket.h> 套接字操作 socketbindsendtorecvfrom
<netinet/in.h> IPv4 地址结构 sockaddr_in
<arpa/inet.h> IP 字符串和整数互转、字节序转换 inet_addrhtonsntohsinet_ntoa
<strings.h> 字节操作 bzero 清零结构体
<unistd.h> 关闭文件描述符 close(_sockfd)

2.2 全局声明与类型定义

cpp 复制代码
using func_t = std::function<std::string(const std::string &, const std::string &, uint16_t)>;

意义 :定义一个函数类型别名,把可调用对象类型简化为func_tfunc_t 表示一个"接收消息、客户端IP、客户端端口,返回处理后字符串"的函数。这是给将来做"业务逻辑回调"预留的接口。

cpp 复制代码
enum
{
    SOCKET_ERROR = 1,  // socket() 失败时 exit 的状态码
    BIND_ERROR   = 2,  // bind()   失败时 exit 的状态码
};

意义 :自定义错误码。exit(非零值) 表示异常退出,0 表示正常退出。不同的错误用不同的非零值,方便排查。

cpp 复制代码
uint16_t DEFAULT_PORT = 8080;
std::string DEFAULT_IP = "0.0.0.0";
const int SIZE_BUFFER = 1024;

意义

  • DEFAULT_PORT:默认监听 8080 端口
  • DEFAULT_IP = "0.0.0.0":特殊地址,表示"绑定本机所有网卡"。客户端发到 8080 端口的数据都会被接收
  • SIZE_BUFFER:接收消息的缓冲区大小,1024 字节

2.3 UdpServer类成员变量详解

cpp 复制代码
private:
    int _sockfd;                                    // Socket 文件描述符
    std::string _ip;                                // 要绑定的 IP 地址
    uint16_t _port;                                 // 要绑定的端口
    bool _isrunning;                                // 服务器运行状态
    std::unordered_map<std::string, struct sockaddr_in> _online_client;  // 在线用户表
成员 含义 用途
_sockfd socket() 返回的整数 后续所有 sendto/recvfrom 都要用这个 fd
_ip 服务器要绑定的 IP 一般是 0.0.0.0
_port 服务器要绑定的端口 客户端按这个端口发数据
_isrunning 控制 Run() 循环是否继续 将来可用于优雅退出
_online_client key=IP字符串, value=完整地址结构 记录谁在线,方便广播时查找地址

unordered_map 的设计原因

  • key 用 IP 字符串是为了方便去重
  • value 用完整的 sockaddr_in 是因为 sendto() 需要这个结构体作为参数
  • 用哈希表查找 O(1),高效

2.4 构造函数

cpp 复制代码
UdpServer(const uint16_t &port = DEFAULT_PORT, const std::string &ip = DEFAULT_IP)
    : _port(port), _ip(ip), _isrunning(false), _sockfd(0)
{}

接口设计

  • 默认参数:可以不传参数,使用默认端口 8080 和默认 IP 0.0.0.0
  • 传引用而不是传值:避免不必要的拷贝
  • 成员初始化列表:直接初始化成员变量

初始化值

  • _isrunning = false:服务器还没启动
  • _sockfd = 0:Socket 还没创建,0 是 fd 的初始值

为什么构造函数不做实际工作?

  • 构造函数只做"轻量级"的工作(设置成员变量)
  • 创建 socket、绑定地址等可能失败的操作放在 Init()
  • 失败时可以通过 exit() 或抛异常处理,而构造函数没有合适的错误处理方式

2.5 Init() 函数 - 初始化 Socket

cpp 复制代码
void Init()
{
    // 第1步:创建 UDP Socket
    _sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if(_sockfd < 0)//调用失败
    {
        logger(FATAL, "socket create error, sockfd: %d", _sockfd);
        exit(SOCKET_ERROR);
    }
    logger(INFO, "socket create success, sockfd: %d", _sockfd);
    
    // 第2步:构造地址结构体
    struct sockaddr_in local;
    bzero(&local, sizeof(local));            // 清零结构体(保险做法)
    local.sin_family = AF_INET;              // IPv4 协议族
    local.sin_port = htons(_port);           // 主机序→网络序
    local.sin_addr.s_addr = inet_addr(_ip.c_str());  // 字符串IP→网络序整数
    
    // 第3步:绑定地址
    if(bind(_sockfd, (const struct sockaddr *)&local, sizeof(local)) < 0)
    {
        logger(FATAL, "bind error, errno: %d, errstring: %s", errno, strerror(errno));
        exit(BIND_ERROR);
    }
    logger(INFO, "bind success, port: %d, ip: %s", _port, _ip.c_str());
}
第1步:socket() 系统调用
cpp 复制代码
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
参数 含义
domain AF_INET IPv4 协议族(也可用 PF_INET,两者值相同)
type SOCK_DGRAM 数据报服务,对应 UDP
protocol 0 让系统根据前两个参数自动选择协议

返回值

  • 成功:返回一个非负整数(文件描述符)
  • 失败:返回 -1,并设置 errno

为什么用 SOCK_DGRAM

  • SOCK_STREAM → TCP(流式)
  • SOCK_DGRAM → UDP(数据报)
  • UDP 每次收发是一个完整的"包",TCP 是连续字节流
第2步:构造地址结构体 sockaddr_in
cpp 复制代码
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());  // IP 地址

这是一套 IPv4 套接字地址结构体(sockaddr_in)的标准初始化流程 ,在bind()connect()之前,指定服务器的本地IP和端口;通过 "清空内存 -> 设置协议族 -> 转换端口字节序 -> 转换 IP 地址格式" 四个步骤,将人类可读的 _ip_port 转换为操作系统内核能识别的二进制网络地址。

为什么 bzero 清零?

  • sockaddr_in 结构体中有些字段我们没设置(如 sin_zero[8]
  • 未清零的字段可能含有随机值,导致 bind 行为不可预期
  • 清零是稳妥的初始化做法

htons(_port) 是什么?

  • htons = h ost to n etwork short(主机序列转网络序列)
  • 网络传输规定用"大端字节序"(网络字节序)
  • x86 主机用"小端字节序"
  • 必须把端口号从主机序转成网络序,否则数据在网上会出错
  • 类似的还有 htonl(32位)、ntohs/ntohl(反向转换)

inet_addr(_ip.c_str()) 是什么?

  • 把 IP 地址字符串(如 "0.0.0.0")转成网络字节序的 32 位整数
  • 返回值是 in_addr_t 类型
  • 这个函数内部已经完成了字节序转换,无需再调 htonl

为什么需要 sockaddr_in?

  • 旧接口 bind() 接受通用类型 struct sockaddr *
  • 我们用专门的 sockaddr_in(IPv4 用)填好字段,再强转成 sockaddr * 传入
  • 这样设计是为了兼容 IPv4、IPv6、Unix 域套接字等多种地址族
第3步:bind() 绑定
cpp 复制代码
bind(_sockfd, (const struct sockaddr *)&local, sizeof(local));

接口int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

参数 含义
sockfd 要绑定的 Socket
addr 要绑定的地址(通用类型)
addrlen 地址结构体的长度

意义

  • 给 Socket 关联一个具体的 IP 和端口
  • 之后客户端发到 IP:端口 的数据会交给这个 Socket 处理
  • 服务端必须显式 bind,否则客户端不知道往哪发
  • 失败原因常见:端口被占用(EADDRINUSE)、权限不足(< 1024 端口)

(const struct sockaddr *)&local 强转的原因

  • bind 接受通用 sockaddr 类型
  • 我们使用的是 IPv4 专用的 sockaddr_in
  • 必须强转,否则类型不匹配

2.6 CheckClient() - 在线用户管理

cpp 复制代码
void CheckClient(const struct sockaddr_in &client, 
                 const std::string &client_ip, 
                 uint16_t client_port)
{
    auto iter = _online_client.find(client_ip);
    if(iter == _online_client.end())  // 找不到,说明是新客户端
    {
        _online_client[client_ip] = client;  // 把它的地址存起来
        std::cout << "Client " << client_ip << " port: " << client_port 
                  << " add to online user." << std::endl;
    }
}

接口设计

  • 三个参数:完整地址结构、IP 字符串、端口号
  • IP 和端口是冗余的(可以从 client 提取出来),单独传是为了避免每次都要解析

算法

  • 在哈希表里查找这个 IP
  • 找不到 → 新客户端 → 加入表 + 打印提示
  • 找到了 → 老客户端 → 什么都不做(不更新端口,因为同一 IP 复用旧的地址信息)

为什么用 IP 作为 key?

  • 简单:群聊场景下,同一 IP 的多个客户端(不同端口)会被认为是"同一个"
  • 缺点:可能丢失部分客户端(如果同一 IP 上有多个)
  • 改进方案:key 改为 IP + ":" + Port

2.7 Broadcast() - 消息广播

cpp 复制代码
void Broadcast(const std::string &info, 
               const std::string &client_ip, 
               uint16_t client_port)
{
    for(const auto &client : _online_client)
    {
        std::string message = info;
        message += " from ";
        message += client_ip;
        message += " port: ";
        message += std::to_string(client_port);
        sendto(_sockfd, message.c_str(), message.size(), 0, 
               (const sockaddr *)&client.second, sizeof(client.second));
    }
}

接口设计

  • 三个参数:原始消息、消息发送者的 IP、消息发送者的端口
  • 这三个参数用来构造"包含来源信息的消息"

sendto() 系统调用

通过套接字向一个指定的目标地址发送数据

cpp 复制代码
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(特殊场景可用 MSG_DONTWAIT 等)
dest_addr 目标地址(UDP 必须指定)
addrlen 地址长度

关键点

  • 与 TCP 的 send() 不同,UDP 的 sendto 必须指定目标地址
  • 每次 sendto 是一次独立的发送操作,操作系统按数据报处理

消息格式<原始内容> from <发送者IP> port: <发送者端口>

为什么包括发送者信息?

  • UDP 不维护连接,服务端把消息转给所有客户端时,客户端不知道消息是谁发的
  • 所以服务端在转发时要把发送者信息附加在消息里

2.8 Run() - 主事件循环

cpp 复制代码
void Run()
{
    _isrunning = true;
    char inbuffer[SIZE_BUFFER];  // 接收缓冲区
    
    while(_isrunning)
    {
        // 第1步:定义结构体用于获取发送方地址
        struct sockaddr_in client;
        socklen_t len = sizeof(client);
        
        // 第2步:阻塞接收消息
        ssize_t ret = recvfrom(_sockfd, inbuffer, sizeof(inbuffer)-1, 0, 
                               (struct sockaddr *)&client, &len);
        
        // 第3步:处理错误
        if(ret < 0)
        {
            logger(WARNING, "recvfrom error, errno: %d, errstring: %s", 
                   errno, strerror(errno));
            continue;  // 跳过本轮,继续接收
        }
        
        // 第4步:解析发送方地址
        uint16_t client_port = ntohs(client.sin_port);
        std::string client_ip = inet_ntoa(client.sin_addr);
        
        // 第5步:管理在线用户
        CheckClient(client, client_ip, client_port);
        
        // 第6步:广播消息
        std::string info(inbuffer);
        Broadcast(info, client_ip, client_port);
    }
}
详细解析

inbuffer[SIZE_BUFFER]

  • 用于接收客户端发来的数据
  • 1024 字节大小,超出此长度的 UDP 包会被截断
  • UDP 包大小限制:理论上 64KB(IPv4 实际约 1472 字节,超过需要分包)

struct sockaddr_in client; socklen_t len = sizeof(client);

  • client:用于保存发送方的地址(由内核填充)
  • len:传入时是 client 的大小(告诉内核有多少空间),返回时是实际地址长度

recvfrom() 系统调用

cpp 复制代码
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 输入输出参数,传入缓冲区大小,返回实际长度

返回值

  • 成功:收到的字节数
  • 失败:返回 -1,设置 errno
  • 注意:UDP 没有"对端关闭"的概念(ret == 0 没有特殊含义)

sizeof(inbuffer)-1 :预留 1 字节方便后面处理字符串\0

ntohs(client.sin_port)

  • 把网络字节序转回主机字节序
  • 因为我们要把端口号显示或转发给用户,必须是主机序

inet_ntoa(client.sin_addr)

  • 把网络字节序的 IP 整数转回字符串形式
  • 返回值是静态缓冲区中的字符串,不是线程安全

整体流程图

复制代码
        ┌────────────────────┐
        │  启动 while 循环    │
        └────────┬───────────┘
                 ↓
        ┌────────────────────┐
        │   阻塞 recvfrom    │◄──────────────┐
        └────────┬───────────┘               │
                 ↓ (收到数据)                │
        ┌────────────────────┐               │
        │  解析客户端 IP:Port │               │
        └────────┬───────────┘               │
                 ↓                          │
        ┌────────────────────┐               │
        │  CheckClient       │               │
        │  (新用户加入表)     │               │
        └────────┬───────────┘               │
                 ↓                          │
        ┌────────────────────┐               │
        │  Broadcast         │               │
        │  (群发给所有用户)    │               │
        └────────┬───────────┘               │
                 ↓                          │
                (回到循环顶部)────────────────┘

注释代码的演进历史 (位于 Run() 函数末尾):

cpp 复制代码
/*
    inbuffer[ret] = 0;                     // 旧版本:补 '\0' 终止符
    std::string info(inbuffer);
    std::string echo_string = func(info, client_ip, client_port);  // 旧版本:调用回调函数
    
    // 旧版本:简单回显
    std::string echo_string = "server echo: " + info;
    
    // 旧版本:单播回显
    sendto(_sockfd, echo_string.c_str(), ..., (const sockaddr *)&client, len);
*/

这段注释保留了三个历史版本:

  • v1 :调用回调函数(func_t 策略模式)
  • v2:简单回显
  • v3:单播回显(只回给发送方)
  • v4(当前):群发广播给所有在线用户

2.9 析构函数

cpp 复制代码
~UdpServer()
{
    if(_sockfd > 0)
    {
        close(_sockfd);
    }
}

close(_sockfd)

  • 关闭文件描述符
  • 释放 Socket 占用的内核资源
  • 释放绑定的端口
  • 必须做:否则端口会一直被占用,重启时可能 bind 失败(需设置 SO_REUSEADDR

为什么判断 _sockfd > 0

  • 0 也是合法 fd,但这里用 0 表示"未初始化"
  • 实际上 _sockfd == 0 在 Linux 下不会被当作标准输入
  • 这个判断是为了保护:如果 Init 失败导致 _sockfd = -1,不会去 close(-1)

三、UdpClient.cpp 详细解析

3.1 文件头部

cpp 复制代码
#include <iostream>        // cin/cout/cerr
#include <sys/socket.h>    // socket, sendto, recvfrom
#include <sys/types.h>
#include <cstdlib>         // exit
#include <string>
#include <arpa/inet.h>     // inet_addr, htons
#include <unistd.h>        // close
#include <strings.h>       // bzero
#include <pthread.h>       // pthread_create, pthread_join
#include "terminal.hpp"    // OpenTerminal()

与服务端头文件对比

  • 服务端多了 <netinet/in.h><unordered_map>
  • 客户端多了 <pthread.h>(多线程)和 "terminal.hpp"(终端重定向)

3.2 Usage() 函数

cpp 复制代码
void Usage(std::string proc)
{
    std::cout << "\n\rUsage: " << proc << " server_ip server_port\n" << std::endl;
}

功能:打印使用说明

  • 参数 proc 是程序名(一般是 argv[0]
  • 提示用户:./udpclient 192.168.1.100 8080

\n\r 的含义

  • \n 换行
  • \r 回车(让光标回到行首)
  • Windows 风格换行符(Linux 下看起来也正常)

3.3 客户端 bind 的解释

cpp 复制代码
// 客户端需要绑定地址,服务端不需要绑定地址
// 客户端需要 bind,但不需要用户显示 bind,系统会自动绑定一个随机端口
// 一个端口号只能被一个进程使用,客户端的端口号不重要,只需要保证唯一性即可
// 首次发送数据时,系统会自动绑定一个随机端口,后续发送数据时,系统会使用这个端口

关键问题:客户端要不要 bind?

场景 是否 bind 原因
服务端 必须 客户端要按这个地址发数据
客户端 不要 OS 首次 sendto 时自动分配临时端口

为什么客户端不需要显式 bind?

  • 客户端的 IP 一般是确定的(本机),但端口可以让 OS 自动分配
  • OS 自动分配避免了端口冲突(同一台机器上可以运行多个客户端)
  • 服务端不需要知道客户端的端口(它只需要知道服务端的地址来收消息)

"一个端口号只能被一个进程使用"

  • 端口是网络层标识进程的"门牌号"
  • 同一台机器上,两个进程不能同时绑定同一端口
  • 这就是为什么客户端要让 OS 自动分配端口

3.4 ThreadData 结构体

cpp 复制代码
struct ThreadData
{
    struct sockaddr_in server;  // 服务端的地址信息
    int sockfd;                 // Socket 描述符
};

作用:把需要在线程间共享的数据打包成一个结构体。

为什么需要这个结构?

  • pthread 线程函数签名是 void* func(void* arg),只能传一个参数
  • 我们需要传两个数据:服务端地址 + sockfd
  • 把它们打包到结构体里,传结构体指针

两个字段含义

  • server:服务端的 IP 和端口(客户端每次 sendto 都要用)
  • sockfd:Socket 描述符(RecvMsg 和 SendMsg 都要用)

3.5 RecvMsg() - 接收消息线程

cpp 复制代码
void* RecvMsg(void *arg)
{
    OpenTerminal();  // 重定向
    
    ThreadData *thread_data = static_cast<ThreadData *>(arg);
    char buffer[1024];
    
    while(1)  // 无限循环:不停地接收
    {
        struct sockaddr_in temp;       // 用 temp 即可,不需要真的用发送方地址
        socklen_t len = sizeof(temp);
        
        // 阻塞接收
        ssize_t s = recvfrom(thread_data->sockfd, buffer, 1023, 0, 
                             (struct sockaddr *)&temp, &len);
        if(s > 0)
        {
            buffer[s] = 0;             // 加 '\0' 终止符,保证字符串安全
            cerr << "Server say: " << buffer << endl;  // 输出到 stderr(重定向到另一终端)
        }
    }
}

详细解析

OpenTerminal()

  • 调用 terminal.hpp 中的函数
  • 作用:把 stderr 重定向到另一个终端(/dev/pts/1
  • 这样收到的消息会显示在另一个终端窗口,而用户的输入显示在当前终端

static_cast<ThreadData *>(arg)

  • void* 转成 ThreadData*
  • 之所以能用 static_cast,是因为我们传进来时确实是 ThreadData* 类型
  • 如果是 C 风格 (ThreadData*)arg 也行,但 static_cast 更安全

char buffer[1024]

  • 接收缓冲区
  • 大小 1024 字节

struct sockaddr_in temp

  • 用于接收发送方的地址
  • 这里其实用不到发送方地址(因为客户端只跟服务端通信)
  • recvfrom 的参数不能传 NULL,所以定义一个临时变量

ssize_t s = recvfrom(...)

  • 阻塞等待消息
  • 返回值 s 是实际收到的字节数
  • 如果没收到,s <= 0 时不做任何处理,继续循环

buffer[s] = 0;

  • 关键操作 :在收到的数据末尾加 \0
  • 因为 buffer 是 char 数组,C 风格字符串以 \0 结尾
  • 不加 \0 的话,cerr << buffer 会读到未初始化的内存,导致乱码
  • 这是 C/S 模型处理字符串的"标准防御性写法"

cerr << "Server say: " << buffer << endl;

  • cerr 而非 cout,因为 cerr 不带缓冲,会立即刷新
  • endl 也会强制刷新 cout
  • 但相比之下,cerr 更适合实时显示收到的消息

3.6 SendMsg() - 发送消息线程

cpp 复制代码
void* SendMsg(void *arg)
{
    string message;
    ThreadData *thread_data = static_cast<ThreadData *>(arg);
    socklen_t len = sizeof(thread_data->server);
    
    while(1)  // 无限循环:不停地读键盘
    {
        cout << "Please Enter Message: " << endl;     // 提示
        getline(cin, message);                         // 读一整行
        cout << "Client say: " << message << endl;    // 回显
        
        // 发送
        sendto(thread_data->sockfd, message.c_str(), message.size(), 0, 
               (struct sockaddr *)&thread_data->server, len);
    }
}

详细解析

string message;

  • string 而非 char[],因为 string 更安全(自动管理内存)

socklen_t len = sizeof(thread_data->server);

  • 计算地址长度
  • 这个值在循环中是常量,没必要每次重新算
  • 但放循环外面会显得"和 RecvMsg 不对称",所以放外面是没问题的

getline(cin, message);

  • 阻塞等待用户输入一行
  • 读完后按回车,message 包含整行内容(不含换行符)
  • 如果用户直接回车,message 是空字符串

cout << "Client say: " << message << endl;

  • 回显用户输入
  • 让用户知道服务端看到的是这条消息

sendto(...)

  • 把消息发给服务端
  • 注意:每次 sendto 是独立的 UDP 数据报
  • 即使发送 0 字节也是一次有效的数据报(但服务端可能会忽略)

为什么用 getline 而非 cin >> message

  • cin >> message 遇到空格就停(不能读含空格的消息)
  • getline 可以读一整行(直到回车)
  • 群聊场景下消息经常含空格,getline 更合适

3.7 main() 函数

cpp 复制代码
int main(int argc, char *argv[])
{
    // 第1步:检查命令行参数
    if(argc != 3)
    {
        Usage(argv[0]);
        exit(0);
    }
    
    // 第2步:解析命令行参数
    std::string server_ip = argv[1];
    uint16_t server_port = std::stoi(argv[2]);
    
    // 第3步:构造服务端地址
    struct ThreadData thread_data;
    bzero(&thread_data.server, sizeof(thread_data.server));
    thread_data.server.sin_family = AF_INET;
    thread_data.server.sin_addr.s_addr = inet_addr(server_ip.c_str());
    thread_data.server.sin_port = htons(server_port);
    
    // 第4步:创建 Socket
    thread_data.sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if(thread_data.sockfd < 0)
    {
        cout << "socket failed" << endl;
        return 1;
    }
    
    // 第5步:创建收发线程
    pthread_t recv_thread, send_thread;
    pthread_create(&recv_thread, nullptr, RecvMsg, (void *)&thread_data);
    pthread_create(&send_thread, nullptr, SendMsg, (void *)&thread_data);
    
    // 第6步:等待线程结束
    pthread_join(recv_thread, nullptr);
    pthread_join(send_thread, nullptr);
    
    // 第7步:清理资源
    close(thread_data.sockfd);
    return 0;
}

第1步:检查命令行参数

  • argc != 3argv[0] 是程序名,argv[1] 是 IP,argv[2] 是端口,一共 3 个
  • 如果参数不对,打印 Usage 并退出

第2步:解析命令行参数

  • argv[1] 是 C 风格字符串,赋给 std::string 自动转换
  • std::stoi(argv[2]):把字符串转成整数(stoi = string to int)

第3步:构造服务端地址

  • bzero:清零结构体(防御性初始化)
  • sin_family = AF_INET:IPv4
  • inet_addr(server_ip.c_str()):字符串 IP → 网络序整数
  • htons(server_port):端口号主机序 → 网络序

第4步:创建 Socket

  • socket(AF_INET, SOCK_DGRAM, 0):创建 UDP Socket
  • 失败时 return 1(不调用 exit,让 main 正常结束)

第5步:创建两个线程

  • pthread_t recv_thread, send_thread;:声明线程标识符
  • pthread_create(...):创建线程
    • 第 1 个参数:线程标识符的地址
    • 第 2 个参数:线程属性(nullptr = 默认)
    • 第 3 个参数:线程入口函数
    • 第 4 个参数:传给线程函数的参数
  • 两个线程几乎同时启动,各自独立运行

第6步:等待线程结束

  • pthread_join 会阻塞,直到对应线程结束
  • 这里两个线程都是 while(1) 无限循环,理论上不会结束
  • 只有在主进程被 kill 时,线程会一起结束

第7步:清理资源

  • 关闭 Socket
  • 实际上由于两个线程都在阻塞,close 可能不会立即生效
  • 严格来说应该用 pthread_cancel 先取消线程,再 close

四、客户端双线程模型详解

4.1 为什么需要双线程?

复制代码
单线程的问题:
   键盘输入 ◄──┐
               │
               ├── 都在 main 线程
               │
   网络接收 ◄──┘
   
   如果 main 阻塞在 getline(),就没人去 recvfrom() 处理收到的消息
   如果 main 阻塞在 recvfrom(),就没法响应用户输入

双线程解决方案

复制代码
┌─────────────────────────────────────────────┐
│              主线程(main)                  │
│  创建两个线程后,调用 pthread_join 阻塞      │
│  实际工作都在子线程里完成                     │
└─────────────────────────────────────────────┘
       │                              │
       ▼                              ▼
┌──────────────┐              ┌──────────────┐
│ SendMsg 线程 │              │ RecvMsg 线程 │
│              │              │              │
│ 1. cout 提示 │              │ 1. recvfrom  │
│ 2. getline   │              │    阻塞等消息 │
│ 3. sendto    │              │ 2. cerr 输出  │
│ 4. 回到 1    │              │ 3. 回到 1     │
└──────────────┘              └──────────────┘
       │                              │
       ▼                              ▼
   用户键盘输入                 服务端广播消息
   (cin)                       (来自 UDP)

为什么用 stderr 而不是 stdout?

  • 假设用 cout 输出"Server say: ..."
  • 当用户正在输入消息时,cout 突然输出,可能在终端上混乱交错
  • cerr(无缓冲)重定向到另一个终端,主终端只显示"Client say: ..."和用户输入
  • 这是终端重定向的实际应用
相关推荐
OIDCAT2 小时前
国产USB转千兆网卡芯片CH398 VLAN应用
网络·嵌入式硬件·国产·usb转千兆网卡
xinyu3912 小时前
旧版Ubuntu Docker镜像 apt 失败
linux·ubuntu·docker
JZC_xiaozhong2 小时前
OA调价审批后,采购调价单如何自动同步到ERP?解决漏录错价难题
大数据·linux·服务器·前端·数据库·数据孤岛解决方案·数据集成与应用集成
ljs6482739512 小时前
Linux 实用命令:环境变量、文件传输、压缩解压、跨服务器传文件
linux·运维·服务器
库克克2 小时前
【C++】类和对象--this指针详解
java·开发语言·c++
__Witheart__3 小时前
lunch选择的版型与版型对应的配置文件
android·linux
ch0sen1pm3 小时前
加班之余从零写了个 spdlog 风格的日志库
c++
A小调的码农3 小时前
给垃圾平板“一生E本” 装新系统—————Armbian 编译 KYX T7Y (RK3326) 踩坑全记录
linux·嵌入式硬件
ljs6482739513 小时前
Linux 网络常用命令与端口基础详解
linux·网络·php
Ai拆代码的曹操3 小时前
TCP 握手丢包分析:客户端连接超时、服务端无日志的场景排查实践
网络·网络协议·tcp/ip