一、项目背景
上一个项目我们实现了基于 UDP 的英译汉翻译服务器,核心是单方向的请求 - 响应 (客户端发单词,服务端回释义)。本次将实现第二个实战项目 ------简单 UDP 双向通信程序 ,实现客户端与服务端的双向实时通信 ,充分利用 UDP 的全双工特性(一个 Socket 描述符同时支持收发)
该项目同样采用 C/S 架构,基于 UDP 协议实现,核心功能是:客户端和服务端可互相发送任意文本消息,一方发送后,另一方能实时接收并打印,实现简单的 "一对一" 网络聊天
二、项目整体设计
1. 架构设计
- 服务端:监听固定 UDP 端口,循环接收客户端的文本消息,实时打印并支持输入消息返回给客户端,实现 "双向应答"
- 客户端 :连接服务端的 IP 和端口,通过多线程实现同时收发(一个线程负责读取用户输入并发送,一个线程负责接收服务端消息并打印),避免单线程下 "收消息阻塞发消息" 或 "发消息阻塞收消息"
- 通信协议:UDP(无连接,全双工,实时性高,适合简单双向通信)
2. 核心设计亮点
客户端多线程设计
UDP Socket 是全双工的,但单线程下,若调用recvfrom()(阻塞)接收消息,此时用户输入的消息无法及时发送;若循环读取用户输入,又无法及时接收服务端的消息。因此客户端必须通过多线程解决该问题:
- 发送线程 :单独负责读取用户从标准输入的文本消息,调用
sendto()发送到服务端 - 接收线程 :单独负责调用
recvfrom()接收服务端的消息,实时打印到终端 - 两个线程共享同一个 Socket 描述符,UDP 协议支持该操作,无资源竞争问题
服务端单线程循环收发
服务端采用单线程无限循环,交替执行 "接收消息" 和 "发送消息",适用于 "一对一" 通信场景:先接收客户端的消息并打印,再读取服务端的输入并发送给客户端,实现双向应答
3. 核心功能需求
- 服务端:
- 绑定固定 UDP 端口,支持本地 / 远程客户端连接
- 实时接收客户端的文本消息,打印消息和发送方的 IP + 端口
- 支持服务端输入文本消息,发送给客户端
- 处理网络异常(如客户端断开、发送 / 接收失败)
- 客户端:
- 通过命令行指定服务端 IP 和端口
- 多线程实现:发送线程读取用户输入,接收线程接收服务端消息
- 实时打印服务端发送的消息
- 支持输入指定指令(如
quit)退出程序,退出时关闭 Socket 和线程 - 处理网络异常(如服务端关闭、发送 / 接收失败)
三、核心技术点解析
1. UDP 全双工通信的本质
UDP Socket 的全双工特性 体现在:一个通过socket(AF_INET, SOCK_DGRAM, 0)创建的 Socket 描述符(fd),既可以调用sendto()发送数据,也可以调用recvfrom()接收数据,无需额外创建新的 Socket。
这是因为 UDP 是无连接的,Socket 描述符不与任何特定的客户端绑定,每次sendto()都可以指定不同的目标地址,每次recvfrom()都可以接收不同客户端的数据,且收发操作互不干扰。
2. 客户端多线程实现(C++11 线程库)
本次项目采用C++11 标准的 std::thread实现多线程,无需依赖第三方库(如 pthread),跨平台性更好。
核心要点:
- 发送线程和接收线程共享 Socket 描述符和服务端地址结构,通过值传递或全局变量实现(本次采用全局变量,简化代码);
- 线程退出:通过全局标志位 (volatile bool)控制线程循环,当用户输入
quit时,设置标志位为 false,线程退出; - 线程等待:主线程通过
join()等待发送线程和接收线程执行完毕后,再关闭 Socket,避免资源泄漏。
3. 数据报边界处理
UDP 是面向数据报 的协议,数据报具有边界性 :服务端 / 客户端每次recvfrom()只能接收一个完整的数据报,若数据报大小超过缓冲区,超出部分会被丢弃。
因此本次项目做了两点处理:
- 设置足够大的接收缓冲区(如 4096 字节),满足普通文本消息的传输;
- 每次发送消息后,无需额外添加分隔符,
recvfrom()的返回值即为实际接收的字节数,直接打印即可。
4. 网络异常处理
增加对常见网络异常的处理,提高程序的健壮性:
- 检查
socket()/bind()的返回值,失败时打印错误信息并退出; - 检查
sendto()/recvfrom()的返回值,失败时打印错误信息,继续循环而非退出; - 处理 IP 地址格式错误、端口号非法等命令行参数异常;
- 客户端处理用户输入中断(如 Ctrl+C),优雅退出线程和 Socket。
四、完整代码实现
1. 服务端代码(udp_echo_server.cpp)
实现单线程循环收发,双向通信:
#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cerrno>
#include <cstdlib>
using namespace std;
// 全局常量
const uint16_t DEFAULT_PORT = 8888;
const int BUFF_SIZE = 4096;
// 用法提示
void Usage(const string& proc) {
cout << "Usage: " << proc << " [port]" << endl;
cout << "Example: " << proc << " 8888" << endl;
}
int main(int argc, char* argv[]) {
// 解析命令行参数,指定监听端口
uint16_t port = DEFAULT_PORT;
if (argc == 2) {
port = atoi(argv[1]);
if (port < 1024 || port > 65535) {
cerr << "端口号必须在1024-65535之间" << endl;
Usage(argv[0]);
return 1;
}
} else if (argc > 2) {
Usage(argv[0]);
return 1;
}
// 1. 创建UDP Socket
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
cerr << "创建Socket失败:" << strerror(errno) << endl;
return 2;
}
// 2. 绑定IP和端口(INADDR_ANY绑定所有网卡)
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = INADDR_ANY;
if (bind(sockfd, (struct sockaddr*)&local, sizeof(local)) < 0) {
cerr << "绑定端口" << port << "失败:" << strerror(errno) << endl;
close(sockfd);
return 3;
}
cout << "UDP通信服务端启动成功,监听端口:" << port << endl;
cout << "等待客户端连接...(输入quit退出)" << endl;
char recv_buff[BUFF_SIZE] = {0};
string send_msg;
struct sockaddr_in peer; // 存储客户端地址信息
socklen_t peer_len = sizeof(peer);
bool is_running = true;
while (is_running) {
// 3. 接收客户端消息(阻塞)
memset(recv_buff, 0, sizeof(recv_buff));
ssize_t recv_len = recvfrom(sockfd, recv_buff, sizeof(recv_buff)-1, 0, (struct sockaddr*)&peer, &peer_len);
if (recv_len < 0) {
cerr << "接收消息失败:" << strerror(errno) << ",继续等待..." << endl;
continue;
}
// 获取客户端IP和端口
string peer_ip = inet_ntoa(peer.sin_addr);
uint16_t peer_port = ntohs(peer.sin_port);
// 客户端退出判断
if (string(recv_buff) == "quit") {
cout << "客户端[" << peer_ip << ":" << peer_port << "]已退出" << endl;
continue;
}
// 打印客户端消息
cout << "[" << peer_ip << ":" << peer_port << "]:" << recv_buff << endl;
// 4. 服务端输入消息,发送给客户端
cout << "我:";
getline(cin, send_msg);
if (send_msg == "quit") {
cout << "服务端准备退出..." << endl;
is_running = false;
// 发送退出指令给客户端
sendto(sockfd, send_msg.c_str(), send_msg.size(), 0, (struct sockaddr*)&peer, peer_len);
continue;
}
if (send_msg.empty()) {
cout << "消息不能为空,重新输入..." << endl;
continue;
}
// 发送消息到客户端
ssize_t send_len = sendto(sockfd, send_msg.c_str(), send_msg.size(), 0, (struct sockaddr*)&peer, peer_len);
if (send_len < 0) {
cerr << "发送消息失败:" << strerror(errno) << endl;
}
}
// 5. 关闭Socket
close(sockfd);
cout << "服务端已退出,Socket已关闭" << endl;
return 0;
}
2. 客户端代码(udp_echo_client.cpp)
多线程实现同时收发,双向通信:
#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cerrno>
#include <cstdlib>
#include <thread>
#include <atomic>
using namespace std;
// 全局变量:多线程共享
atomic<bool> g_running(true); // 线程运行标志位,原子变量保证线程安全
int g_sockfd = -1; // 共享的Socket描述符
struct sockaddr_in g_server; // 共享的服务端地址结构
const int BUFF_SIZE = 4096; // 缓冲区大小
// 用法提示
void Usage(const string& proc) {
cout << "Usage: " << proc << " server_ip server_port" << endl;
cout << "Example: " << proc << " 127.0.0.1 8888" << endl;
}
// 接收线程函数:循环接收服务端消息并打印
void RecvThread() {
char recv_buff[BUFF_SIZE] = {0};
struct sockaddr_in temp;
socklen_t temp_len = sizeof(temp);
while (g_running) {
memset(recv_buff, 0, sizeof(recv_buff));
// 接收服务端消息(阻塞)
ssize_t recv_len = recvfrom(g_sockfd, recv_buff, sizeof(recv_buff)-1, 0, (struct sockaddr*)&temp, &temp_len);
if (recv_len < 0) {
if (g_running) { // 非主动退出时打印错误
cerr << "\n接收消息失败:" << strerror(errno) << endl;
}
continue;
}
// 服务端退出判断
if (string(recv_buff) == "quit") {
cout << "\n服务端已退出,客户端将自动退出..." << endl;
g_running = false;
break;
}
// 打印服务端消息(换行避免覆盖输入提示)
cout << "\n[服务端]:" << recv_buff << endl;
cout << "我:";
fflush(stdout); // 刷新输出缓冲区,保证提示正常显示
}
}
// 发送线程函数:循环读取用户输入并发送到服务端
void SendThread() {
string send_msg;
while (g_running) {
cout << "我:";
getline(cin, send_msg);
// 客户端主动退出
if (send_msg == "quit") {
cout << "客户端准备退出..." << endl;
g_running = false;
// 发送退出指令给服务端
sendto(g_sockfd, send_msg.c_str(), send_msg.size(), 0, (struct sockaddr*)&g_server, sizeof(g_server));
break;
}
if (send_msg.empty()) {
cout << "消息不能为空,重新输入..." << endl;
continue;
}
// 发送消息到服务端
ssize_t send_len = sendto(g_sockfd, send_msg.c_str(), send_msg.size(), 0, (struct sockaddr*)&g_server, sizeof(g_server));
if (send_len < 0) {
cerr << "发送消息失败:" << strerror(errno) << endl;
if (errno == ECONNREFUSED) { // 服务端关闭
g_running = false;
break;
}
}
}
}
int main(int argc, char* argv[]) {
// 校验命令行参数
if (argc != 3) {
Usage(argv[0]);
return 1;
}
string server_ip = argv[1];
uint16_t server_port = atoi(argv[2]);
if (server_port < 1024 || server_port > 65535) {
cerr << "端口号必须在1024-65535之间" << endl;
return 1;
}
// 1. 创建UDP Socket
g_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (g_sockfd < 0) {
cerr << "创建Socket失败:" << strerror(errno) << endl;
return 2;
}
// 2. 填充服务端地址结构
memset(&g_server, 0, sizeof(g_server));
g_server.sin_family = AF_INET;
g_server.sin_port = htons(server_port);
g_server.sin_addr.s_addr = inet_addr(server_ip.c_str());
if (g_server.sin_addr.s_addr == INADDR_NONE) {
cerr << "服务端IP地址格式错误" << endl;
close(g_sockfd);
return 3;
}
cout << "UDP通信客户端启动成功,连接服务端:" << server_ip << ":" << server_port << endl;
cout << "输入消息即可发送,输入quit退出..." << endl;
// 3. 创建发送线程和接收线程
thread send_thread(SendThread);
thread recv_thread(RecvThread);
// 4. 等待线程执行完毕
send_thread.join();
recv_thread.join();
// 5. 关闭Socket
close(g_sockfd);
cout << "客户端已退出,Socket已关闭" << endl;
return 0;
}
五、编译与运行
1. 编译代码
# 编译服务端
g++ -o udp_echo_server udp_echo_server.cpp -std=c++11
# 编译客户端(多线程必须加-pthread)
g++ -o udp_echo_client udp_echo_client.cpp -std=c++11 -pthread
2. 启动服务端
# 方式1:使用默认端口8888
./udp_echo_server
# 方式2:指定端口,如9999
./udp_echo_server 9999
服务端启动成功输出:UDP通信服务端启动成功,监听端口:8888 + 等待客户端连接...(输入quit退出)
3. 启动客户端
本地测试(同一机器):
./udp_echo_client 127.0.0.1 8888
远程测试(服务端在阿里云服务器):
./udp_echo_client 阿里云公网IP 8888
注意 :阿里云服务器需在防火墙中放行对应 UDP 端口(如 8888)
4. 运行效果
服务端:
UDP通信服务端启动成功,监听端口:8888
等待客户端连接...(输入quit退出)
[127.0.0.1:41568]:你好,服务端!
我:你好,客户端!
[127.0.0.1:41568]:UDP双向通信测试
我:测试成功!
[127.0.0.1:41568]:quit
客户端[127.0.0.1:41568]已退出
我:quit
服务端准备退出...
服务端已退出,Socket已关闭
客户端:
UDP通信客户端启动成功,连接服务端:127.0.0.1:8888
输入消息即可发送,输入quit退出...
我:你好,服务端!
[服务端]:你好,客户端!
我:UDP双向通信测试
[服务端]:测试成功!
我:quit
客户端准备退出...
客户端已退出,Socket已关闭
六、核心代码解析
1. 客户端原子变量 g_running
使用std::atomic<bool>而非普通bool作为线程运行标志位,原因是:原子变量保证多线程下的读写操作是原子的,避免数据竞争和内存可见性问题。
若使用普通bool,发送线程修改g_running后,接收线程可能无法及时看到修改后的结果,导致线程无法正常退出;原子变量则可以保证修改后立即对其他线程可见。
2. 接收线程的 fflush (stdout)
接收线程打印服务端消息后,执行fflush(stdout)刷新输出缓冲区,目的是:保证 "我:" 的输入提示能正常显示在终端,避免被服务端消息覆盖。
因为终端的输出是行缓冲的,若没有换行或手动刷新,输出内容可能会留在缓冲区中,无法及时显示。
3. 服务端的 peer_len 参数
recvfrom()的peer_len是输入输出参数 :入参是peer结构体的大小,出参是实际的客户端地址结构大小。首次调用时需初始化peer_len = sizeof(peer),后续循环可复用,无需重新初始化。
4. 退出指令的同步
客户端 / 服务端输入quit时,会先发送quit指令给对方,再设置g_running = false,保证对方能接收到退出指令,实现 "优雅退出",避免一方退出后另一方仍阻塞在接收状态。
本次项目基于 UDP Socket 实现了简单的双向通信程序:
- UDP Socket 的全双工特性,一个 Socket 描述符可同时支持收发操作
- 客户端多线程设计的核心思想,解决单线程下的阻塞问题,实现同时收发
std::thread和std::atomic的使用,实现 C++11 多线程编程和线程安全- UDP 面向数据报的边界处理和常见网络异常的处理方法
- C/S 架构下双向通信的核心流程,为后续实现群聊聊天室奠定基础
下一篇博客将实现本次项目的进阶版 ------UDP 群聊聊天室,支持多个客户端同时在线聊天,服务端实现消息广播,是 UDP Socket 编程的综合实战,敬请期待啦~~