socket套接字-UDP(下)
socket套接字-UDP(中)https://blog.csdn.net/Small_entreprene/article/details/147567115?fromshare=blogdetail&sharetype=blogdetail&sharerId=147567115&sharerefer=PC&sharesource=Small_entreprene&sharefrom=from_link在我之前搭建的翻译服务基础上,下一步计划开发一个简单的 UDP 群聊系统。之前实现的翻译服务器,主要是接收客户端消息,调用翻译函数处理后返回结果,实现了服务器与单个客户端的简单交互。现在,我打算在此基础上进行功能拓展,实现一个简单的群聊功能。
前言:UDP群聊功能的实现原理
在实现翻译功能时,服务器通过 sockfd
文件描述符接收客户端的消息,同时也能通过这个文件描述符向客户端发送消息。这意味着服务器可以收集并保存所有访问客户端的信息。当有新的消息到达时,服务器就可以将保存的客户端信息用于消息的转发。这其实就是群聊功能的基本实现原理。
但如果仅用一个 UDP 服务器来实现群聊,就会出现问题。因为服务器同时承担接收和发送消息的任务,这可能影响收发效率,尤其是在高并发情况下。为了解决这个问题,我们可以在服务器收到消息后,将消息和客户端信息放入一个队列中。然后,提前创建一批线程,这些线程专门负责从队列中取出消息和客户端信息进行处理。在取出消息的同时,也将 sockfd
文件描述符传递给线程。这样,当客户端向服务器发送消息时,服务器会将消息构建成一个任务放入队列,后端线程再从队列中取出任务,通过 sockfd
将消息转发给所有在线用户。基于线程池的这种实现方式,就可以搭建一个聊天室。在这种架构下,UDP 服务器扮演生产者的角色,负责将消息入队;而后端线程则是消费者,它们取出消息并完成转发。这种转发消息的服务器本质上就是一个典型的生产者 - 消费者模型。
换句话说就是:
对于翻译功能,我们既可以通过sockfd文件描述符读,又可以通过文件描述符写,这样就服务端就可以将这些访问对象的信息收集起来,然后服务端将对应的客户端信息保存起来,等再有人发消息,服务端将这些信息发送出去,这不就是群聊的实现原理了嘛!不过实现群聊,只拿一个udp_Server服务端来做的话,不太好,因为服务器既是用来接收的,也是用来发送的,收发效率上有点不太好,udp_server将来收到信息message和clientinfo,放入到对应的一个队列当中,然后提前创建处出一批线程,udp_server收到了对应的消息,就将对应的消息直接入队列,然后由于后端所对应的线程来拿取消费message+clientinfo,于此同时将sockfd也顺带给该线程。这样,就是将来一个客户端给服务器发送消息,服务端收到消息就将消息构建成一个任务,放到队列当中,然后由一个线程把消息,通过sockfd来将信息转发给所有的在线用户,此时我们就可以基于一个线程池实现聊天室,图像右边也就是实现了一个转发消息的服务器Server了,这里udp_server就是一个典型的生产者,后端的这一批线程就是典型的消费者,我们多对应的转发消息的服务器Server本质就是一个典型的生产者消费者模型!
下面有几个我们应该提前知道的知识点:
我们所对应的文件描述符,在一个进程中的所用线程是共享的!
UDP是支持全双工的,那么支持多线程同时读写呢?
可以的!
我们线程池中线程要执行的任务是消息转发,也就是消息路由,我们就需要先实现route功能!
那么下面,我们就开始UDP群聊的实现叭!
一、群聊系统实现:单线程的初步尝试
群聊系统的核心在于消息的广播。服务器需要将一个客户端发送的消息转发给所有在线的客户端。为了实现这一功能,服务器需要维护一个在线客户端列表,记录每个客户端的网络地址信息。
1.1 单线程实现的探索与挑战
在最初的版本中,Route
类实现了群聊的核心功能,包括管理在线客户端列表和消息的广播:
#pragma once
#include <iostream>
#include <string>
#include <vector>
#include "InetAddr.hpp"
#include "Log.hpp"using namespace LogModule;
class Route
{
private:bool IsExist(InetAddr &peer){for (auto &user : _online_user){if (user == peer){return true;}}return false;}void AddUser(InetAddr &peer){LOG(LogLevel::INFO) << "新增一个在线用户: " << peer.StringAddr();_online_user.push_back(peer);}void DeleteUser(InetAddr &peer){for (auto iter = _online_user.begin(); iter != _online_user.end(); iter++){if (*iter == peer){LOG(LogLevel::INFO) << "删除一个在线用户:" << peer.StringAddr() << "成功";_online_user.erase(iter);break;}}}public:Route(){}void MessageRoute(int sockfd, const std::string &message, InetAddr &peer){if (!IsExist(peer)){AddUser(peer);}std::string send_message = peer.StringAddr() + "# " + message; // 127.0.0.1:8080# 你好// TODOfor (auto &user : _online_user){sendto(sockfd, send_message.c_str(), send_message.size(), 0, (const struct sockaddr *)&(user.NetAddr()), sizeof(user.NetAddr()));}// 这个用户一定已经在线了if (message == "QUIT"){LOG(LogLevel::INFO) << "删除一个在线用户: " << peer.StringAddr();DeleteUser(peer);}}~Route(){}private:// 首次给我发消息,等同于登录std::vector<InetAddr> _online_user; // 在线用户
};
我们实现Route.hpp基本框架之后,我们可以测试
单线程的致命缺陷:阻塞导致消息延迟
单线程客户端的现象:UdpClient.cc
关键问题分析:
-
输入阻塞接收:用户输入时(
std::cin
),无法处理接收的消息。 -
接收阻塞输入:等待消息时(
recvfrom
),用户无法发送新消息。 -
实时性丧失:在输入长消息或网络延迟时,其他用户的消息会严重延迟显示。
实际场景模拟:
# 用户A在终端输入长消息(耗时10秒)
输入> 这是一个非常非常长的消息,需要慢慢输入...# 在此期间:
# - 用户B发送了3条消息
# - 用户C退出群聊# 用户A只有在完成输入后,才能看到其他消息
[群消息] 用户B: 紧急通知!
[群消息] 用户B: 会议取消了!
[群消息] [系统] 用户C 退出群聊
[群消息] 用户B: 有人看到吗?
阻塞导致消息延迟的解决
我们如果将客户端实现多线程的话:
关键代码对比:UdpClient.cc
单线程:
// 输入与接收互相阻塞
while (true) {std::string input;std::cout << "输入> ";std::getline(std::cin, input); // 阻塞点1sendto(...);char buffer[1024];recvfrom(...); // 阻塞点2std::cout << buffer << "\n";
}
这会导致消息要cout输出,但是被getline阻塞等待用户输出,其他用户发送的消息要getline后才被接收recvfrom,这就导致串行式的结果!
多线程:
// 发送线程(专注输入)
void* SendThread(void*) {while (running) {std::string input;std::getline(std::cin, input); // 独立阻塞sendto(...);}
}// 接收线程(专注输出)
void* RecvThread(void*) {while (running) {int n = recvfrom(...); // 独立阻塞std::cout << buffer << "\n";}
}
这个时候我们客户端进程为什么使用多线程是因为:
-
输入阻塞不可接受:用户需要随时中断输入查看新消息
-
网络延迟不可控:
recvfrom
可能因网络抖动长时间阻塞 -
实时性是群聊的生命线:消息必须立即显示,不能等待任何操作
为了能够实现消息的分开打印和发送,我们可以利用文件操作,一个通过cin和cout来实现消息的输入,然后群聊天按照cerr的文件中显示,使测试更加美观,更好观察(后面相关协议学习后,我们就不需要这样通过cerr和cout来实现了)
// 接收线程(专注输出)
void* RecvThread(void*) {while (running) {int n = recvfrom(...); // 独立阻塞std::cerr << buffer << "\n";//cout--->>>cerr}
}
我们可以通过重定向的方式来实现输出输入到指定文件:
查看终端设备:
$ tty
/dev/pts/num # 当前终端设备路径:我们也可以通过echo '1' > /dev/pts/num 来测试
启动客户端B(显示终端):将标准错误重定向到/dev/pts/num的对应显示终端上终端
# 将消息显示到独立终端
./udpclient 113.45.250.155 8080 2>/dev/pts/num
-
接收线程 :负责从服务器接收消息并将其输出到控制台。通过
recvfrom
函数不断监听套接字,接收服务器发送的消息。 -
发送线程 :负责从标准输入读取用户输入的消息,并将其发送到服务器。通过
sendto
函数将消息发送到服务器的地址和端口。
通过这种多线程改造,客户端能够同时处理消息的发送和接收,提供更加流畅的用户体验。
自此,我们就解决了第一张图的左边部分!!!
1.2单线程实现的探索与挑战---效率问题
当让,这个单进程带来的问题是由于客户端的单线程造成的!对于我们实现转发消息的服务器Server,我们前面知道单线程带来的是效率低,为了实现简单,我们是先从单线程入手,后续在加入多线程线程池,对应代码会有相应的修改。
二、群聊系统优化:引入线程池
2.1 引入线程池的动机
为了提高服务器的并发处理能力和响应速度,我们决定引入线程池技术。线程池可以预先创建一批线程,这些线程在服务器启动时就处于等待状态,随时准备处理任务。当服务器接收到客户端的消息时,将消息和客户端信息构建成一个任务,放入任务队列中。线程池中的线程从队列中取出任务并执行,实现消息的异步处理。从udp_server到Server(转发消息的服务器)的进化:
增加线程池之前:
std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(port,
[&r](int sockfd, const std::string &message, InetAddr &peer)
{ r.MessageRoute(sockfd, message, peer); });
增加线程池之后:
// 1. 路由服务
Route r;// 2. 线程池
auto tp = ThreadPool<task_t>::GetInstance();// 3. 网络服务器对象,提供通信功能
std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(port,
[&r, &tp](int sockfd, const std::string &message, InetAddr &peer){task_t t = std::bind(&Route::MessageRoute, &r, sockfd, message, peer);tp->Enqueue(t); }
);
三、多线程的引入与挑战
在引入多线程后,我们面临了一些新的挑战,比如线程安全问题和任务调度问题。
3.1 多线程环境下的线程安全问题
在引入多线程后,Route
类的 MessageRoute
方法可能被多个线程同时调用。这会导致对 _online_user
列表的并发访问,从而引发数据不一致的问题。例如,一个线程可能正在遍历列表发送消息,而另一个线程可能同时添加或删除用户,这会导致不可预测的行为。
3.2 解决方案:引入互斥锁
为了解决这个问题,我们在 Route
类中引入了互斥锁(Mutex
),确保对共享资源的访问是线程安全的:
#include "Mutex.hpp"class Route
{
private:Mutex _mutex; // 互斥锁,保护对在线用户列表的访问public:void MessageRoute(int sockfd, const std::string &message, InetAddr &peer){LockGuard lockguard(_mutex); // 使用RAII方式管理锁,确保在函数退出时自动释放if (!IsExist(peer)){AddUser(peer);}std::string send_message = peer.StringAddr() + "# " + message;for (auto &user : _online_user){sendto(sockfd, send_message.c_str(), send_message.size(), 0, (const struct sockaddr *)&(user.NetAddr()), sizeof(user.NetAddr()));}if (message == "QUIT"){LOG(LogLevel::INFO) << "删除一个在线用户: " << peer.StringAddr();DeleteUser(peer);}}
};
引入互斥锁:
- 在
Route
类中添加了一个Mutex
成员变量_mutex
。 - 在
MessageRoute
方法的入口处,使用LockGuard
获取锁,确保在方法执行期间对_online_user
列表的独占访问。 LockGuard
使用RAII机制,在其生命周期结束时自动释放锁,避免死锁。
线程安全的用户管理:
在添加和删除用户时,同样需要在 AddUser
和 DeleteUser
方法中使用锁来保护对 _online_user
列表的访问,防止并发修改导致的问题。
代码健壮性提升:
加锁后,即使在多线程环境下,也能保证对在线用户列表的操作是原子的和一致的,避免了竞态条件和数据损坏的风险。
通过这些修改,我们确保了 Route
类在多线程环境下的线程安全性。这不仅解决了高并发场景下的数据一致性问题,还为系统的进一步扩展和优化奠定了基础。
四、代码注释与详细解释
4.1 Route.hpp 路由管理模块
#pragma once
#include <iostream>
#include <string>
#include <vector>
#include "InetAddr.hpp" // 自定义网络地址类头文件
#include "Log.hpp" // 自定义日志记录类头文件
#include "Mutex.hpp" // 自定义互斥锁类头文件using namespace LogModule; // 引入日志模块命名空间
using namespace MutexModule; // 引入互斥锁模块命名空间// 消息路由类 - 管理在线用户并负责消息分发
class Route
{
private:// 检查指定的网络地址是否已存在于在线用户列表中bool IsExist(InetAddr &peer){// 遍历在线用户列表for (auto &user : _online_user){// 如果找到匹配的网络地址,返回true表示存在if (user == peer){return true;}}// 如果遍历完整个列表都未找到匹配项,返回false表示不存在return false;}// 向在线用户列表中添加新用户void AddUser(InetAddr &peer){// 记录用户登录的日志信息LOG(LogLevel::INFO) << "新增一个在线用户: " << peer.StringAddr();// 将新用户的网络地址添加到在线用户列表_online_user.push_back(peer);}// 从在线用户列表中删除指定用户void DeleteUser(InetAddr &peer){// 遍历在线用户列表的迭代器for (auto iter = _online_user.begin(); iter != _online_user.end(); iter++){// 如果找到匹配的网络地址if (*iter == peer){// 记录用户退出的日志信息LOG(LogLevel::INFO) << "删除一个在线用户:" << peer.StringAddr() << "成功";// 从列表中移除该用户_online_user.erase(iter);break; // 跳出循环}}}public:// 构造函数 - 初始化路由对象Route(){}// 消息路由处理函数// 参数:sockfd(套接字描述符)、message(消息内容)、peer(发送方网络地址)void MessageRoute(int sockfd, const std::string &message, InetAddr &peer){// 创建互斥锁守护对象,确保线程安全LockGuard lockguard(_mutex);// 检查发送方是否已存在于在线用户列表if (!IsExist(peer)){// 如果不存在,则添加为新用户AddUser(peer);}// 格式化要发送的消息,包含发送方地址和原始消息std::string send_message = peer.StringAddr() + "# " + message;// 向所有在线用户广播消息for (auto &user : _online_user){// 使用套接字发送消息给每个在线用户sendto(sockfd, send_message.c_str(), send_message.size(), 0,(const struct sockaddr *)&(user.NetAddr()), sizeof(user.NetAddr()));}// 如果收到的消息是"QUIT",处理用户退出逻辑if (message == "QUIT"){// 记录用户退出的日志信息LOG(LogLevel::INFO) << "删除一个在线用户: " << peer.StringAddr();// 从在线用户列表中移除该用户DeleteUser(peer);}}// 析构函数 - 释放路由对象资源~Route(){}private:// 在线用户列表 - 存储所有当前在线用户的网络地址std::vector<InetAddr> _online_user;// 互斥锁 - 用于保护对在线用户列表的并发访问Mutex _mutex;
};
4.2 InetAddr.hpp 网络地址封装
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <sys/socket.h>
#include <sys/types.h>
#include <arpa/inet.h>
#include <netinet/in.h>// 网络地址操作类 - 负责在主机字节序和网络字节序之间进行转换
class InetAddr
{
public:// 构造函数 - 从网络地址结构体初始化InetAddr(struct sockaddr_in &addr) : _addr(addr){// 将网络字节序的端口号转换为主机字节序_port = ntohs(_addr.sin_port);// 准备存储点分十进制 IP 地址的缓冲区char ipbuffer[64];// 将网络字节序的 IPv4 地址转换为点分十进制字符串inet_ntop(AF_INET, &_addr.sin_addr, ipbuffer, sizeof(ipbuffer));// 存储转换后的 IP 地址字符串_ip = ipbuffer;}// 构造函数 - 从主机字节序的 IP 和端口初始化InetAddr(const std::string &ip, uint16_t port) : _ip(ip), _port(port){// 清除地址结构体memset(&_addr, 0, sizeof(_addr));// 设置地址族为 IPv4_addr.sin_family = AF_INET;// 将点分十进制 IP 地址字符串转换为网络字节序的 IPv4 地址inet_pton(AF_INET, _ip.c_str(), &_addr.sin_addr);// 将主机字节序的端口号转换为网络字节序_addr.sin_port = htons(_port);}// 获取存储的端口号(主机字节序)uint16_t Port() { return _port; }// 获取存储的 IP 地址字符串std::string Ip() { return _ip; }// 获取内部的网络地址结构体引用const struct sockaddr_in &NetAddr() { return _addr; }// 重载等于运算符,用于比较两个网络地址是否相同bool operator==(const InetAddr &addr){// 比较 IP 地址字符串和端口号是否都相同return addr._ip == _ip && addr._port == _port;}// 生成包含 IP 和端口的字符串表示形式std::string StringAddr(){// 拼接 IP 地址和端口号return _ip + ":" + std::to_string(_port);}// 析构函数~InetAddr(){}private:// 内部存储的网络地址结构体(网络字节序)struct sockaddr_in _addr;// 存储的 IP 地址字符串(点分十进制格式)std::string _ip;// 存储的端口号(主机字节序)uint16_t _port;
};
4.3 UdpServer.hpp UDP服务端封装
#pragma once#include <iostream>
#include <string>
#include <functional>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "Log.hpp" // 自定义日志记录类头文件
#include "InetAddr.hpp" // 自定义网络地址类头文件using namespace LogModule; // 引入日志模块命名空间// 定义用于处理消息的回调函数类型
using func_t = std::function<void(int sockfd, const std::string&, InetAddr&)>;// 默认无效的文件描述符值
const int defaultfd = -1;// UDP服务器类 - 负责创建UDP服务器并处理客户端消息
class UdpServer
{
public:// 构造函数 - 初始化UDP服务器对象UdpServer(uint16_t port, func_t func): _sockfd(defaultfd), // 初始化套接字描述符为无效值_port(port), // 设置服务器端口_isrunning(false), // 初始化运行状态为停止_func(func) // 设置消息处理回调函数{}// 初始化UDP服务器void Init(){// 1. 创建UDP套接字_sockfd = socket(AF_INET, SOCK_DGRAM, 0);if (_sockfd < 0){// 如果创建套接字失败,记录致命错误日志并退出程序LOG(LogLevel::FATAL) << "socket error!";exit(1);}// 记录套接字创建成功的日志信息LOG(LogLevel::INFO) << "socket success, sockfd : " << _sockfd;// 2. 准备并绑定套接字信息(IP和端口)// 2.1 填充sockaddr_in结构体,用于指定本地地址信息struct sockaddr_in local;bzero(&local, sizeof(local)); // 清零地址结构体local.sin_family = AF_INET; // 设置地址族为IPv4// 将主机字节序的端口号转换为网络字节序local.sin_port = htons(_port);// 设置本地IP地址为通配符,表示监听所有网络接口local.sin_addr.s_addr = INADDR_ANY;// 2.2 调用bind函数将套接字绑定到指定的本地地址和端口int n = bind(_sockfd, (struct sockaddr *)&local, sizeof(local));if (n < 0){// 如果绑定失败,记录致命错误日志并退出程序LOG(LogLevel::FATAL) << "bind error";exit(2);}// 记录绑定成功的日志信息LOG(LogLevel::INFO) << "bind success, sockfd : " << _sockfd;}// 启动UDP服务器并开始接收和处理客户端消息void Start(){_isrunning = true; // 设置服务器为运行状态while (_isrunning){char buffer[1024]; // 用于存储接收到的消息内容struct sockaddr_in peer; // 存储发送方的网络地址信息socklen_t len = sizeof(peer); // 发送方地址结构体的长度// 1. 从套接字接收消息ssize_t s = recvfrom(_sockfd, buffer, sizeof(buffer) - 1, 0,(struct sockaddr *)&peer, &len);if (s > 0){// 将接收到的网络地址信息封装为InetAddr对象InetAddr client(peer);// 确保接收到的消息以null结尾,形成有效的C风格字符串buffer[s] = 0;// 调用回调函数处理消息_func(_sockfd, buffer, client);// 下面是原始的回显逻辑(被回调函数替代)// LOG(LogLevel::DEBUG) << "[" << peer_ip << ":" << peer_port<< "]# " << buffer;// 2. 发送回显消息给客户端(已被回调函数替代)// std::string echo_string = "server echo@ ";// echo_string += buffer;// sendto(_sockfd, result.c_str(), result.size(), 0, (struct sockaddr*)&peer, len);}}}// 析构函数 - 释放UDP服务器资源~UdpServer(){}private:int _sockfd; // UDP套接字文件描述符uint16_t _port; // 服务器监听的端口号// std::string _ip; // 服务器监听的IP地址(本例中使用INADDR_ANY代替)bool _isrunning; // 服务器运行状态标志func_t _func; // 消息处理回调函数(由用户提供一个处理逻辑)
};
4.4 UdpServer.cc UDP服务端实现
#include <iostream>
#include <memory>
#include "Route.hpp" // 消息路由功能
#include "UdpServer.hpp" // UDP网络通信功能
#include "ThreadPool.hpp" // 线程池功能using namespace ThreadPoolModule; // 引入线程池模块命名空间
using task_t = std::function<void()>; // 线程池任务类型定义// 测试用的默认消息处理函数
std::string defaulthandler(const std::string &message)
{std::string hello = "hello, ";hello += message; // 简单的字符串拼接作为示例处理return hello;
}// 主函数 - 程序入口点
// 编译指令:./udpserver port
int main(int argc, char *argv[])
{// 检查命令行参数数量是否正确if (argc != 2){// 如果参数数量不正确,输出使用说明并退出程序std::cerr << "Usage: " << argv[0] << " port" << std::endl;return 1;}// 从命令行参数获取服务器监听的端口号uint16_t port = std::stoi(argv[1]);// 启用控制台日志策略,以便在终端显示日志信息Enable_Console_Log_Strategy();// 1. 创建消息路由服务对象Route r;// 2. 获取线程池实例(单例模式)auto tp = ThreadPool<task_t>::GetInstance();// 3. 创建UDP服务器对象,并设置消息处理逻辑std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(port, [&r, &tp](int sockfd, const std::string &message, InetAddr &peer) {// 创建一个任务,将消息路由逻辑包装为可在线程池中执行的任务task_t t = std::bind(&Route::MessageRoute, &r, sockfd, message, peer);// 将任务提交到线程池中执行tp->Enqueue(t);});// 初始化UDP服务器,包括创建套接字和绑定端口等操作usvr->Init();// 启动UDP服务器,开始监听并接收客户端消息usvr->Start();return 0; // 程序正常结束
}
4.5 UdpClient.cc 客户端实现
#include <iostream>
#include <string>
#include <cstring>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/types.h>
#include <sys/socket.h>
#include "Thread.hpp"// 全局变量 - 套接字描述符
int sockfd = 0;
// 全局变量 - 服务器IP地址
std::string server_ip;
// 全局变量 - 服务器端口号
uint16_t server_port = 0;
// 全局变量 - 接收线程的线程ID
pthread_t id;using namespace ThreadModlue; // 引入线程模块命名空间// 接收消息线程函数
void Recv()
{while (true){char buffer[1024]; // 用于存储接收到的消息内容struct sockaddr_in peer; // 存储发送方的网络地址信息socklen_t len = sizeof(peer); // 发送方地址结构体的长度// 从套接字接收消息int m = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0,(struct sockaddr *)&peer, &len);if (m > 0){// 确保接收到的消息以null结尾,形成有效的C风格字符串buffer[m] = 0;// 在控制台输出接收到的消息std::cerr << buffer << std::endl; // 2}}
}// 发送消息线程函数
void Send()
{// 填充服务器端的网络地址结构体struct sockaddr_in server;memset(&server, 0, sizeof(server)); // 清零地址结构体server.sin_family = AF_INET; // 设置地址族为IPv4server.sin_port = htons(server_port); // 将主机字节序的端口号转换为网络字节序server.sin_addr.s_addr = inet_addr(server_ip.c_str()); // 设置服务器IP地址// 发送"online"消息,表示客户端上线const std::string online = "inline";sendto(sockfd, online.c_str(), online.size(), 0,(struct sockaddr *)&server, sizeof(server));while (true){std::string input;std::cout << "Please Enter# "; // 1 - 提示用户输入消息std::getline(std::cin, input); // 0 - 从标准输入读取用户输入的消息// 向服务器发送用户输入的消息int n = sendto(sockfd, input.c_str(), input.size(), 0,(struct sockaddr *)&server, sizeof(server));(void)n; // 忽略发送操作的返回结果// 如果用户输入的是"QUIT",则退出发送循环并终止接收线程if (input == "QUIT"){pthread_cancel(id); // 终止接收线程break;}}
}// UDP客户端主函数
int main(int argc, char *argv[])
{// 检查命令行参数数量是否正确if (argc != 3){// 如果参数数量不正确,输出使用说明并退出程序std::cerr << "Usage: " << argv[0] << " server_ip server_port" << std::endl;return 1;}// 从命令行参数获取服务器IP地址和端口号server_ip = argv[1];server_port = std::stoi(argv[2]);// 1. 创建UDP套接字sockfd = socket(AF_INET, SOCK_DGRAM, 0);if (sockfd < 0){// 如果创建套接字失败,输出错误信息并退出程序std::cerr << "socket error" << std::endl;return 2;}// 2. 创建并启动接收和发送线程Thread recver(Recv); // 创建接收消息的线程对象Thread sender(Send); // 创建发送消息的线程对象recver.Start(); // 启动接收线程sender.Start(); // 启动发送线程// 获取接收线程的线程ID,用于后续可能的线程操作id = recver.Id();// 等待接收线程和发送线程完成(通常不会到达这里,除非客户端主动退出)recver.Join();sender.Join();// 关于客户端是否需要显式绑定本地地址的说明:// 客户端不需要显式调用bind函数。首次调用sendto发送消息时,// 操作系统会自动为客户端套接字分配一个随机的可用端口号,// 并将客户端的IP地址和端口号绑定到套接字上。这样可以避免客户端端口冲突,// 因为每个客户端进程都会被分配一个唯一的随机端口号。return 0; // 程序正常结束
}
4.6 效果展示
操作 | 客户端A(输入) | 客户端B(显示) |
---|---|---|
发送"Hello" | 输入> Hello | [新消息] 127.0.0.1:5567# Hello |
发送"QUIT" | 输入> QUIT | [通知] 127.0.0.1:5567 已退出 |
服务端日志 | 无 | 记录用户加入/退出事件 |
关键设计说明
线程分工明确》》》发送线程:专注处理阻塞式用户输入;接收线程:非阻塞轮询+实时消息显示;服务线程池:并行处理消息路由
终端输出分离:std::cout
用于输入提示(保留在原始终端);std::cerr
用于消息显示(可重定向到其他终端)
并发安全保证:Route类使用Mutex
保护在线用户列表;原子变量running
控制线程生命周期;发送接收线程完全解耦
网络字节序处理:InetAddr自动处理htons/ntohs
转换;统一使用sockaddr_in
网络原始结构
补充说明
IP地址表示方式
在IPv4网络编程中,IP地址有两种核心表示方式:
-
点分十进制字符串:如"192.168.1.1",便于人类阅读。
-
二进制结构体in_addr:32位无符号整数,存储于sockaddr_in中,用于网络传输。
字符串与in_addr的转换函数
1. 传统转换函数
#include <arpa/inet.h>// 字符串转in_addr(已废弃,不推荐使用)
in_addr_t inet_addr(const char *cp); // 字符串转in_addr(推荐替代inet_addr)
int inet_aton(const char *cp, struct in_addr *inp);// in_addr转字符串(线程不安全)
char *inet_ntoa(struct in_addr in);
函数对比:
函数 | 输入 | 输出 | 线程安全 | 支持IPv6 |
---|---|---|---|---|
inet_addr | 字符串(如"192.168.1.1") | 32位网络字节序整数 | 是 | ❌ |
inet_aton | 字符串 | 填充in_addr结构体 | 是 | ❌ |
inet_ntoa | in_addr结构体 | 静态存储区字符串指针 | ❌ | ❌ |
示例代码:
struct sockaddr_in addr;
inet_aton("192.168.1.1", &addr.sin_addr); // 字符串转二进制
printf("IP: %s\n", inet_ntoa(addr.sin_addr)); // 二进制转字符串
2. 现代转换函数(推荐使用)
#include <arpa/inet.h>// 字符串转二进制(支持IPv4/IPv6)
int inet_pton(int af, const char *src, void *dst);// 二进制转字符串(支持IPv4/IPv6)
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
-
af
:地址族(AF_INET或AF_INET6) -
src
:输入数据(字符串或二进制地址) -
dst
:输出缓冲区 -
size
:缓冲区大小(推荐INET_ADDRSTRLEN或INET6_ADDRSTRLEN)
示例代码:
// IPv4转换示例
struct in_addr ipv4_addr;
char str[INET_ADDRSTRLEN];inet_pton(AF_INET, "192.168.1.1", &ipv4_addr); // 字符串转二进制
inet_ntop(AF_INET, &ipv4_addr, str, INET_ADDRSTRLEN); // 二进制转字符串
inet_ntoa的线程安全问题
1. 问题根源
-
静态存储区:inet_ntoa内部使用静态缓冲区存储结果,多次调用会覆盖之前的值。
-
非线程安全:多线程并发调用时,可能产生数据竞争。
验证代码:
#include <stdio.h>
#include <pthread.h>
#include <arpa/inet.h>void* thread_func1(void* arg) {struct in_addr addr = { .s_addr = 0x0100007F }; // 127.0.0.1while (1) {char* ip = inet_ntoa(addr);printf("Thread1: %s\n", ip);}return NULL;
}void* thread_func2(void* arg) {struct in_addr addr = { .s_addr = 0x0101A8C0 }; // 192.168.1.1while (1) {char* ip = inet_ntoa(addr);printf("Thread2: %s\n", ip);}return NULL;
}int main() {pthread_t t1, t2;pthread_create(&t1, NULL, thread_func1, NULL);pthread_create(&t2, NULL, thread_func2, NULL);pthread_join(t1, NULL);pthread_join(t2, NULL);return 0;
}
运行结果示例:
Thread1: 192.168.1.1 // 预期127.0.0.1
Thread2: 192.168.1.1
Thread1: 192.168.1.1 // 数据被覆盖
┌───────────────────────────inet_ntoa 线程安全问题────────────────────────────┐
│ │
│ ╭──────────────❌ 线程不安全场景─────────────╮ ╭──────────────✅ 线程安全解决方案─────────────╮ │
│ │ │ │ │ │
│ │ [Thread 1] [Thread 2] │ │ [Thread 1] [Thread 2] │ │
│ │ ┌────────────┐ ┌────────────┐ │ │ ┌────────────┐ ┌────────────┐ │ │
│ │ │ 调用 │ │ 调用 │ │ │ │ 调用 │ │ 调用 │ │ │
│ │ │ inet_ntoa │ │ inet_ntoa │ │ │ │ inet_ntop │ │ inet_ntop │ │ │
│ │ │ (ip1) │ │ (ip2) │ │ │ │ (ip1, buf1)│ │ (ip2, buf2)│ │ │
│ │ └─────┬──────┘ └─────┬──────┘ │ │ └─────┬──────┘ └─────┬──────┘ │ │
│ │ ▼ ▼ │ │ ▼ ▼ │ │
│ │ ┌──────────────────────────────────┐ │ │ ┌────────────┐ ┌────────────┐ │ │
│ │ │ 静态缓冲区(共享) │ │ │ │ 私有缓冲区 │ │ 私有缓冲区 │ │ │
│ │ │ │ │ │ │ buf1[16] │ │ buf2[16] │ │ │
│ │ │ 初始值: 192.168.1.1 │ │ │ │ "192.168.1.1" "10.0.0.1" │ │ │
│ │ │ 被Thread 2覆盖为: 10.0.0.1 ────╮ │ │ │ └────────────┘ └────────────┘ │ │
│ │ └──────────────────────────────────┘ │ │ │ │
│ │ ▲ ▲ │ │ 无冲突,结果独立 │ │
│ │ ╰─────────┬──────────╯ │ │ │ │
│ │ ╰── 冲突!两个线程均输出 "10.0.0.1" │ │
│ ╰───────────────────────────────────────────╯ ╰───────────────────────────────────────────╯ │
└───────────────────────────────────────────────────────────────────────────────────────┘
展示多线程环境下inet_ntoa函数由于使用静态缓冲区导致的数据覆盖问题。可以使用流程图或对比图形式,直观展示线程1和线程2同时调用inet_ntoa时,如何互相覆盖结果。
2. 系统差异
-
CentOS 7:某些版本通过加锁实现伪线程安全,但不可依赖。
-
其他系统:通常存在线程安全问题。
四、inet_ntop的线程安全解决方案
1. 核心优势
-
栈缓冲区:避免使用静态存储区。
-
支持IPv4/IPv6:统一接口更灵活。
正确用法:
struct in_addr ipv4_addr;
char buffer[INET_ADDRSTRLEN]; // 专用缓冲区// 转换操作
inet_ntop(AF_INET, &ipv4_addr, buffer, INET_ADDRSTRLEN);
printf("IP: %s\n", buffer);
+---------------------- inet_ntoa 线程安全问题 vs inet_ntop 解决方案 ----------------------+
| |
| ╭───────────────────────────┬───────────────────────────╮ |
| | ❌ 线程不安全 (inet_ntoa) | ✅ 线程安全 (inet_ntop) | |
| | | | |
| | [Thread 1] [Thread 2] | [Thread 1] [Thread 2] | |
| | +---------+ +---------+ | +---------+ +---------+ | |
| | |调用 | |调用 | | |调用 | |调用 | | |
| | |inet_ntoa| |inet_ntoa| | |inet_ntop| |inet_ntop| | |
| | |(ip1) | |(ip2) | | |(ip1,buf1)|(ip2,buf2)| | |
| | +----┬----+ +----┬----+ | +----┬----+ +----┬----+ | |
| | ▼ ▼ | ▼ ▼ | |
| | +---------------------+ | +---------+ +---------+ | |
| | | 静态缓冲区(共享) | | | buf1 | | buf2 | | |
| | | 初始值: 192.168.1.1 | | | 192.168 | | 10.0.0.1| | |
| | | 被覆盖为: 10.0.0.1 ←┼───┼─┤ (独立) | | (独立) | | |
| | +---------------------+ | +---------+ +---------+ | |
| | ▲ ▲ | | | | |
| | └─────┬─────┘ | ▼ ▼ | |
| | ╰── 冲突! | +---------+ +---------+ | |
| | 输出均为 10.0.0.1 | | 正确结果 | | 正确结果 | | |
| | | | 192.168 | | 10.0.0.1| | |
| ╰───────────────────────────┴───────────────────────────╯ |
+-----------------------------------------------------------------------------------------+
展示inet_ntop函数如何通过使用独立的输出缓冲区避免线程安全问题。可以使用对比图形式,与inet_ntoa的静态缓冲区方式进行对比,突出inet_ntop的优势。
函数对比与选型建议
函数对比
特性 | inet_ntoa | inet_ntop |
---|---|---|
线程安全性 | ❌ 非线程安全 | ✔️ 线程安全 |
内存管理 | 静态缓冲区 | 输出缓冲区 |
IPv6支持 | ❌ | ✔️ |
返回值稳定性 | 多次调用结果被覆盖 | 每次调用结果独立 |
推荐使用场景 | 单线程简单测试 | 生产环境、多线程程序 |
最佳实践总结
弃用inet_ntoa:尤其在多线程环境中。
优先使用inet_pton/ntop:
显式指定地址族(AF_INET或AF_INET6);为字符串转换预留足够缓冲区
缓冲区大小常量:
-
IPv4:INET_ADDRSTRLEN(16字节)
-
IPv6:INET6_ADDRSTRLEN(46字节)
安全转换示例代码
struct sockaddr_in addr;
char ip_str[INET_ADDRSTRLEN];inet_pton(AF_INET, "192.168.1.1", &addr.sin_addr);
inet_ntop(AF_INET, &addr.sin_addr, ip_str, INET_ADDRSTRLEN);
┌─────────────────────────── IP地址转换流程示意图 ───────────────────────────┐
│ │
│ [IPv4结构体] [IPv6结构体] │
│ struct in_addr struct in6_addr │
│ (二进制格式) (二进制格式) │
│ │ │ │
│ │ inet_ntoa │ inet_ntop │
│ ▼ ▼ │
│ ┌───────────┐ ┌───────────┐ │
│ │ 点分十进制 │ │ 字符串格式 │ │
│ │ 字符串 │ │ (IPv4/IPv6)│ │
│ │ e.g. │ │ e.g. │ │
│ │ "192.168.1.1" │ "2001:db8::1" │
│ └───────────┘ └───────────┘ │
│ ▲ ▲ │
│ │ inet_aton │ inet_pton │
│ │ │ │
│ ┌───────────┐ ┌───────────┐ │
│ │ 旧函数 │ │ 新函数 │ │
│ │ 线程不安全 │ │ 线程安全 │ │
│ │ (静态缓冲区) │ │ (需手动分配缓冲区)│ │
│ └───────────┘ └───────────┘ │
│ │
│ ╭─────────────────────────────┬───────────────────────────────╮ │
│ │ ❌ 不安全的旧函数 │ ✅ 安全的新函数 │ │
│ │ - `inet_ntoa` │ - `inet_ntop` │ │
│ │ - 依赖静态缓冲区 │ - 需手动分配缓冲区 │ │
│ │ - 仅支持IPv4 │ - 支持IPv4/IPv6 │ │
│ ╰─────────────────────────────┴───────────────────────────────╯ │
└──────────────────────────────────────────────────────────────────────────┘
展示从点分十进制字符串到二进制结构体,再通过inet_ntop转换回字符串的完整流程。可以使用流程图形式,标注每个步骤的关键操作和数据流向。
通过合理选择IP地址转换函数,可以显著提升程序的健壮性和跨平台兼容性,尤其在多线程网络服务中,inet_ntop是确保线程安全的不二之选。