当前位置: 首页 > news >正文

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 

关键问题分析

  1. 输入阻塞接收:用户输入时(std::cin),无法处理接收的消息。

  2. 接收阻塞输入:等待消息时(recvfrom),用户无法发送新消息。

  3. 实时性丧失:在输入长消息或网络延迟时,其他用户的消息会严重延迟显示。

实际场景模拟:

# 用户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";}
}

这个时候我们客户端进程为什么使用多线程是因为: 

  1. 输入阻塞不可接受:用户需要随时中断输入查看新消息

  2. 网络延迟不可控recvfrom可能因网络抖动长时间阻塞

  3. 实时性是群聊的生命线:消息必须立即显示,不能等待任何操作

为了能够实现消息的分开打印和发送,我们可以利用文件操作,一个通过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机制,在其生命周期结束时自动释放锁,避免死锁。

线程安全的用户管理:

在添加和删除用户时,同样需要在 AddUserDeleteUser 方法中使用锁来保护对 _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_ntoain_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_ntoainet_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是确保线程安全的不二之选。

相关文章:

  • Windows权限与icacls命令详解
  • Latex全面汇总
  • Express 文件上传不迷路:req.files 一次性讲明白
  • 如何在本地部署小智服务器:从源码到全模块运行的详细步骤
  • 桂链:使用Fabric的测试网络
  • 单链表详解
  • css3伸缩盒模型第一章(主轴以及伸缩盒模型)
  • 单片机-89C51部分:9、串行口通讯
  • Gitea windows服务注册,服务启动、停止、重启脚本
  • MySQL慢查询日志分析方法
  • BLE技术,如何高效赋能IoT短距无线通信?
  • 应用安全系列之四十七:NoSQL注入
  • 14.外观模式:思考与解读
  • IoTDB数据库建模与资源优化指南
  • 从拒绝采样到强化学习,大语言模型推理极简新路径!
  • Tailwind CSS 实战:基于 Kooboo 构建企业官网页面(三)
  • Webshell管理工具的流量特征
  • Selenium 与 Playwright:浏览器自动化工具的深度对比
  • python jupyter notebook
  • 麒麟OS系统的Python程序和应用部署
  • 海南机场拟超23亿元收购美兰空港控股权,进一步聚焦机场主业
  • 人民日报:在大有可为的时代大有作为
  • 一位排球青训教练的20年时光:努力提高女排球员成才率
  • 病人有头发,照护者不发疯:《黑镜》中的身体缺席与虚伪关怀
  • 王毅出席金砖国家外长会晤
  • “杭州六小龙”的招聘迷局