Linux 网络编程:UDP Socket 群聊模型实现与细节分析
本文基于 Linux 网络编程,使用 C/C++ 的 socket API,实现一个简单的 UDP 群聊程序,以加深对 UDP 通信模型的理解。
Linux 网络编程中 UDP Socket 群聊模型通过服务端接收消息并转发给在线用户,客户端发送消息并接收广播实现。服务端需绑定端口维护用户表,利用 recvfrom 获取客户端地址;客户端无需绑定端口由系统分配。代码采用 C++ 类封装服务端逻辑,多线程处理收发。关键技术点包括 SOCK_DGRAM 选择、inet_addr 监听所有网卡、端口号避免冲突及无连接特性理解。该模型适用于实时性高允许丢包的场景如聊天室或游戏同步。

本文基于 Linux 网络编程,使用 C/C++ 的 socket API,实现一个简单的 UDP 群聊程序,以加深对 UDP 通信模型的理解。
Server 负责收消息、记住客户端并转发消息;Client 负责发消息和收广播。
socket(AF_INET, SOCK_DGRAM):创建 UDP 套接字bind:占住一个众所周知的端口,等客户端来找recvfrom:接收数据,顺带感知客户端的 IP 和端口ip + port 作为 key,维护一个在线用户表sendto 给所有用户 → 群聊效果bind,交给 OS 随机分配端口sendto 发给 serverrecvfrom 等 server 的广播消息class Server {
public:
// 构造函数:保存 server IP 和端口号
Server(std::string server_ip, uint16_t server_port)
: server_ip_(server_ip), server_port_(server_port) {}
// 初始化 UDP socket 并完成 bind
void init() {
// 1. 创建 UDP socket
// AF_INET : IPv4
// SOCK_DGRAM : UDP(无连接、面向报文)
sockfd_ = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd_ < 0) {
printf("socket fail!\n");
exit(1);
}
printf("socket successful! socket:%d\n", sockfd_);
// 2. 填充本地地址结构
struct sockaddr_in local;
bzero(&local, sizeof(local)); // 清零,防止脏数据
local.sin_family = AF_INET;
local.sin_port = htons(server_port_); // 主机字节序 → 网络字节序
// 0.0.0.0 表示监听本机所有网卡
local.sin_addr.s_addr = inet_addr(server_ip_.c_str());
socklen_t address_len = sizeof(local);
// 3. 绑定端口
// UDP server 必须 bind,客户端才能找到
if (bind(sockfd_, (struct sockaddr *)&local, address_len) < 0) {
printf("bind fail!!!\n");
exit(2);
}
printf("bind successful!!!\n");
}
// 广播函数:将某个客户端的消息发送给所有在线用户
void broadcast(std::string info, std::string client_ip, uint16_t client_port) {
// 拼接广播消息格式:[ ip : port ] message
std::string message = "[ ";
message += client_ip;
message += " : ";
message += std::to_string(client_port);
message += " ] ";
message += info;
// 遍历用户表,向每一个客户端发送消息
for (auto user : users) {
sendto(sockfd_, message.c_str(), message.size(), 0, (struct sockaddr *)&user.second, sizeof(user.second));
}
}
// server 主循环
void run() {
while (1) {
char buffer[1024];
char key[256]; // 用于标识客户端的 key(ip-port)
memset(buffer, 0, sizeof(buffer));
memset(key, 0, sizeof(key));
struct sockaddr_in client;
bzero(&client, sizeof client);
socklen_t client_len = sizeof(client);
// recvfrom:UDP 核心接口
// 接收数据的同时获取客户端的 IP 和端口
int n = recvfrom(sockfd_, buffer, sizeof(buffer) - 1, 0, (sockaddr *)&client, &client_len);
// 提取客户端 IP 和端口
// inet_ntoa 将网络字节序 IP 转成字符串(非线程安全,但这里是单线程)
std::string client_ip = inet_ntoa(client.sin_addr);
uint16_t client_port = ntohs(client.sin_port);
// 使用 ip + port 作为客户端的唯一标识
snprintf(key, sizeof key, "%s-%d", client_ip.c_str(), client_port);
// 判断该客户端是否是第一次出现
auto is_exist = users.find(key);
if (is_exist == users.end()) {
// UDP 是无连接的,通过 recvfrom 动态'感知'客户端
users.insert(std::make_pair(key, client));
printf("new user add\n");
}
// recvfrom 返回的是接收到的字节数
if (n > 0) {
// UDP 不会自动补 '\0',必须手动处理
buffer[n] = 0;
std::string info = buffer;
// 将该客户端的消息广播给所有在线用户
broadcast(info, client_ip, client_port);
}
}
}
// 析构函数:关闭 socket
~Server() {
if (sockfd_ > 0) {
close(sockfd_);
}
}
private:
std::string server_ip_; // server 监听 IP
uint16_t server_port_; // server 监听端口
int sockfd_; // UDP socket 描述符
// 在线用户表
// key : "ip-port"
// value : 对应客户端的 sockaddr_in
std::unordered_map<std::string, struct sockaddr_in> users;
};
int main() {
// 创建 UDP Server,监听 0.0.0.0:8080
Server* scr = new Server("0.0.0.0", 8080);
scr->init();
scr->run();
}
struct ThreadData {
int sockfd; // UDP socket 描述符
struct sockaddr_in server; // server 地址信息
};
// 发送线程:从标准输入读取数据并发送给 server
void *send_msg(void *arg) {
ThreadData *td = (ThreadData *)arg;
std::string message;
while (1) {
// 从终端读取一行输入
std::getline(std::cin, message);
// 使用 sendto 向 server 发送数据
// UDP 是无连接的,每次发送都要指定对端地址
sendto(td->sockfd, message.c_str(), message.size(), 0, (sockaddr *)&(td->server), sizeof(td->server));
}
return nullptr;
}
// 接收线程:负责接收 server 广播回来的消息
void *recv_msg(void *arg) {
ThreadData *td = (ThreadData *)arg;
char buffer[1024];
while (1) {
struct sockaddr_in temp;
socklen_t len = sizeof(temp);
// recvfrom:接收数据的同时获取发送方地址
ssize_t s = recvfrom(td->sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&temp, &len);
if (s > 0) {
// UDP 接收到的是原始字节流,需要手动补 '\0'
buffer[s] = 0;
// 打印接收到的消息
std::cerr << buffer << std::endl;
}
}
return nullptr;
}
int main(int argc, char *argv[]) {
// 参数校验:需要指定 server IP 和端口
if (argc != 3) {
printf("\n\t usage : %s server_ip server_port\n", argv[0]);
exit(1);
}
ThreadData td;
// 从命令行参数解析 server 信息
std::string server_ip = argv[1];
uint16_t server_port = atoi(argv[2]);
struct sockaddr_in server;
server.sin_family = AF_INET;
server.sin_port = htons(server_port); // 主机序 → 网络序
server.sin_addr.s_addr = inet_addr(server_ip.c_str());
td.server = server;
// 创建 UDP socket
// 客户端不需要 bind,由 OS 自动分配端口
td.sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (td.sockfd < 0) {
printf("socket fail!!!\n");
exit(1);
}
printf("socket successful, sockfd : %d\n", td.sockfd);
// 创建发送和接收两个线程
// 一个线程负责输入并发送
// 一个线程负责接收 server 广播消息
pthread_t send, recv;
pthread_create(&send, nullptr, send_msg, &td);
pthread_create(&recv, nullptr, recv_msg, &td);
// 等待线程结束
pthread_join(send, nullptr);
pthread_join(recv, nullptr);
// 关闭 socket
close(td.sockfd);
return 0;
}

这是因为 UDP 是面向数据报的传输层协议,所以选用 SOCK_DGRAM。当我们需要进行 TCP 网络编程时,就需要使用 SOCK_STREAM。
首先客户端是需要 bind 端口号的,只不过不需要我们显式地进行 bind,可以让操作系统随机选择即可。并且一个端口号只能被一个进程 bind,不能被多个进程同时 bind,这是因为端口号具有唯一标识主机进程的作用。如果一个端口被多个进程 bind,对端发过来的消息,我们就不知道给哪一个进程发送了。
现在假如我们将客户端也 bind 一个具体的端口号的话,那么就会很容易造成冲突。比如我们现在正在悠闲的躺在床上刷着抖音,而我们使用的抖音客户端假如 bind 的是 1234 这个端口号,现在到中午了,我们要开始觅食了,这个时候我们要打开美团进行点外卖了,但是美团的客户端也 bind 了 1234 这个端口号,这个时候一旦你要打开美团这个软件,不好意思,这个时候你已经打不开了,因为你正在刷抖音的客户端已经占用了 1234 这个端口号,所以美团想要打开是不可以的。所以对于客户端直接 bind 一个具体的端口号是不现实的,我们手机上面这么多软件,总不能让所有的这些软件的公司都协商一下,一人用一个吧,所以客户端不能直接 bind 一个具体的端口号,只能交给我们的操作系统,让我们操作系统随机为我们分配一个端口号就可以了,只要这个端口号唯一,我们就可以与远程的服务器进行通信,从而让我们再快乐刷抖音的同时,可以打开美团点外卖了。
这就是为什么 server 要 bind 具体的端口号,而 client 却不需要。
inet_addr("0.0.0.0") 是什么意思?这个其实就是表示监听我这个主机中所有的网卡,只要是发给我这个主机的,统统接收。因为我们的主机不止一个网卡,会有许多的网卡,其它的不用多说,起码有线网卡和无线网卡就有两个,现在我们大多使用的都是无线网卡,还有像虚拟机中的虚拟网卡等等都会进行监听,所有的数据只要发给我这台主机,统统照单全收。还可以使用以下的方式:
local.sin_addr.s_addr = htonl(INADDR_ANY);
0.0.0.0 = INADDR_ANY,都是表示监听所有网卡,异曲同工之妙。
recvfrom 为什么要传 sockaddr_in*?这个就很简单了,sockaddr_in 保存了发给我们数据的对端的 IP 和端口号,这样我们就知道这个数据是谁给我们发过来的,这样我们在处理完数据之后就可以通过这个 IP 和端口号再将我们想要回复的消息通过 sendto 系统调用接口给对方返回回去。
这是因为 UDP 是无连接,不可靠的传输层协议,是不存在连接状态的维护,并且 sendto / recvfrom 每次都携带目标地址,同时 UDP 也不需要 listen 和 accept,这些都是 TCP 专用接口,我们再接下来的 TCP 网络编程中会见到这些接口的详细使用的,现在不需要着急。
这是因为在操作系统中,0~1023 这区间内的端口号通常已经被系统服务或标准网络协议占用。
| 端口号 | 服务 |
|---|---|
| 22 | SSH |
| 21 | FTP |
| 23 | Telnet |
| 25 | SMTP |
| 53 | DNS |
| 80 | HTTP |
| 443 | HTTPS |
所以为了避免端口冲突,选择 1024 以上的端口,可以尽可能的减少冲突。
UDP 网络编程的核心思想并不在于'接口有多复杂',而在于对无连接模型的正确理解:
正是这种特性,使得 UDP 非常适合实时性要求高、允许少量丢包的场景,例如聊天室、直播、游戏同步等。

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
暂无推荐文章,稍后可再来查看。
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online