Linux网络编程---UDP 群聊系统
一、整体设计目标
两个文件配合工作,实现一个 UDP 群聊系统:
- 一个服务端(
UdpServer)监听 8080 端口,接收任何客户端发来的消息 - 多个客户端(
UdpClient)连接到服务端 - 任一客户端发送的消息都会被服务端转发给所有在线客户端
UDP 的核心特点:
- 无连接:客户端和服务端之间没有"握手"过程
- 数据报:每条消息有明确边界,不会粘包
- 不可靠:消息可能丢失、重复、乱序
- 全双工:一个 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> |
套接字操作 | socket、bind、sendto、recvfrom |
<netinet/in.h> |
IPv4 地址结构 | sockaddr_in |
<arpa/inet.h> |
IP 字符串和整数互转、字节序转换 | inet_addr、htons、ntohs、inet_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_t。func_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 != 3:argv[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:IPv4inet_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: ..."和用户输入 - 这是终端重定向的实际应用