Linux 网络编程:UDP Socket 群聊模型的实现与细节分析
本文介绍了一个基于UDP协议的简易群聊程序实现。系统采用C++开发,服务端负责接收消息并转发给所有客户端,客户端通过双线程实现消息发送与接收。关键点包括:1) 服务端使用socket/bind/recvfrom/sendto等系统调用;2) 客户端不绑定固定端口避免冲突;3) 通过IP+端口唯一标识客户端。文章还深入分析了UDP特性,如无连接、报文传输、端口分配机制等,并对比了TCP与UDP的差异
在上一篇文章中,我们对计算机网络的基本概念进行了简单梳理,包括 TCP/IP 协议栈、IP 与端口号的作用等内容。现在我们基于 Linux 网络编程,使用 C/C++ 的 socket API,实现一个简单的 UDP 群聊程序,以加深对 UDP 通信模型的理解。
整体设计思路
一句话概括:
Server 负责“收消息 + 记住客户端 + 转发消息”,Client 负责“发消息 + 收广播”。
Server 的核心职责
-
socket(AF_INET, SOCK_DGRAM):创建 UDP 套接字 -
bind:占住一个众所周知的端口,等客户端来找 -
recvfrom:-
接收数据
-
顺带“感知”客户端的 IP 和端口
-
-
用
ip + port作为 key,维护一个在线用户表 -
每收到一条消息,就
sendto给所有用户 → 群聊效果
Client 的核心职责
-
不
bind,交给 OS 随机分配端口 -
一个线程:
-
从标准输入读数据
-
sendto发给 server
-
-
一个线程:
-
recvfrom等 server 的广播消息
-
UDP服务端
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();
}
UDP客户端
struct ThreadDate
{
int sockfd; // UDP socket 描述符
struct sockaddr_in server; // server 地址信息
};
// 发送线程:从标准输入读取数据并发送给 server
void *send_msg(void *arg)
{
ThreadDate *td = (ThreadDate *)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)
{
ThreadDate *td = (ThreadDate *)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);
}
ThreadDate 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的群聊功能就实现了。
但是其中还有许多细节需要我们注意,现在我们就来仔细深挖一下。
为什么调用socket系统调用时使用的是SOCK_DGRAM,而不是SOCK_STREAM?
-
TCP:面向连接、可靠传输、面向字节流
-
UDP:无连接、不可靠传输、面向报文
这是因为UDP是面向数据报的传输层协议,所以选用SOCK_DGRAM,当我们需要进行TCP网络编程时,就需要使用SOCK_STREAM。
为什么 server 要 bind具体的端口号,而client 却不用?
首先客户端是需要bind端口号的,只不过不需要我们显示的进行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 为什么不需要 connect?
这是因为UDP是无连接,不可靠的传输层协议,是不存在连接状态的维护,并且sendto / recvfrom 每次都携带目标地址,同时UDP也不需要listen 和 accept,这些都是TCP 专用接口,我们再接下来的TCP网络编程中会见到这些接口的详细使用的,现在不需要着急。
server绑定的端口号不要选择[0,1024],尽量选择1024以上
这是因为在操作系统中,0~1023这区间内的端口号通常已经被系统服务或标准网络协议占用。
| 端口号 | 服务 |
|---|---|
| 22 | SSH |
| 21 | FTP |
| 23 | Telnet |
| 25 | SMTP |
| 53 | DNS |
| 80 | HTTP |
| 443 | HTTPS |
所以为了避免端口冲突,选择1024以上的端口,可以尽可能的减少冲突。
到这里,我们已经完成了一个基于 UDP 的简易群聊程序,并且从系统调用、协议语义以及操作系统角度,对其关键实现细节进行了深入分析。
UDP 网络编程的核心思想并不在于“接口有多复杂”,而在于对无连接模型的正确理解:
-
没有连接状态
-
每个数据包自带源地址
-
通信关系由数据“自然形成”
正是这种特性,使得 UDP 非常适合实时性要求高、允许少量丢包的场景,例如聊天室、直播、游戏同步等。这就是UDP Socket 群聊模型的简单实现。
更多推荐

所有评论(0)