ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++实现聊天小程序

C++实现聊天小程序

前言


「用 C++ 写一个聊天程序」听起来像一个大工程,其实核心只有三件事:建立 TCP 连接、在连接上收发字节、把一条客户端发来的消息转发给其他客户端。真正需要设计的是消息边界、连接管理和错误处理,而不是语言语法。


本文实现一个只在本机运行的群聊小工具:服务器监听127.0.0.1:9000,多个客户端连上后,任何一个人发送的一行文字会转发给其余所有人。代码使用 POSIX socket API,适用于 Linux、macOS 以及带 POSIX 层的环境;Windows 原生 socket 需要<winsock2.h>、WSAStartup和closesocket,接口形状类似但细节不同。文中不涉及任何公网扫描或攻击行为,所有地址都限定在本机回环。


先说明一个关键概念:TCP 是字节流(byte stream),不是消息流。一次send的内容可能被拆成多次recv,多次send也可能被合并成一次recv。如果直接「收到什么就打印什么」,短消息通常没事,网络一慢或消息一大就会出现半句话。因此必须自己定义消息边界,这就是后文的「分帧」。


一、协议与消息分帧


分帧(framing)要回答一个问题:接收方如何知道一条消息到哪里结束。常见方案有两种。




方案做法优点缺点



换行分隔每条消息以\n结尾简单、可读、方便手工测试正文不能含换行;需处理\r\n

长度前缀先发 4 字节长度,再发正文二进制安全、边界明确需要处理字节序和最大长度



本文选换行分隔,因为聊天文本天然按行输入。服务器为每个连接维护一个接收缓冲区:收到字节后追加进去,循环查找\n,每找到一条就切出来转发,剩下的半条留在缓冲区等下一次数据。这样无论 TCP 怎么拆分,消息边界都由应用层恢复。


协议还要约定编码。示例按 UTF-8 字节原样转发,服务器不解析字符,因此中文不会被破坏;但如果客户端做「按字符截断」之类的处理,就要注意 UTF-8 是多字节编码,不能按单个char切。


二、单线程 select 事件循环


最直接的并发模型是「一个客户端一个线程」,但线程数量随连接数增长,同步和退出都更麻烦。聊天室这种 I/O 密集型场景,用单线程多路复用更简单:把所有 socket 放进select的读集合,哪个可读就处理哪个。


select的调用形式是select(maxFd + 1, &readfds, nullptr, nullptr, nullptr)。第一个参数是「最大文件描述符加一」,不是连接数;fd_set是位图,FD_SET把描述符加入集合,FD_ISSET检查某个描述符是否就绪。每次调用前都要重新填充集合,因为select会修改它。




模型适用规模特点



select少量连接接口古老、跨平台性好,受FD_SETSIZE限制

poll中等规模用数组代替位图,没有固定上限

epoll(Linux)大量连接事件驱动,适合高并发

每连接一线程少量连接代码直观,线程开销和同步成本高



select的可移植性最好,适合教学和本机小工具。它有两个现实限制:文件描述符编号必须小于FD_SETSIZE(常见实现是 1024),以及每次都要把整个集合从用户态拷进内核。连接数上去后,应换poll或epoll。


三、完整服务器实现


下面的服务器做了这些事:创建监听 socket、绑定回环地址、接受连接、用select等待可读事件、按行切分消息、广播给其他客户端、在连接断开时清理。发送部分用循环处理「只写出去一部分」的情况。


// 适用于 C++17,POSIX 平台(Linux/macOS)
#include <arpa/inet.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <unistd.h>

#include <cerrno>
#include <csignal>
#include <cstddef>
#include <cstdio>
#include <cstring>
#include <map>
#include <string>
#include <vector>

namespace {

constexpr int kPort = 9000;
constexpr std::size_t kBufferSize = 1024;
constexpr std::size_t kMaxPending = 64 * 1024;

bool sendAll(int fd, const char* data, std::size_t size) {
std::size_t sent = 0;
while (sent < size) {
const ssize_t n = ::send(fd, data + sent, size - sent, 0);
if (n < 0 && errno == EINTR) {
continue;
}
if (n <= 0) {
return false;
}
sent += static_cast<std::size_t>(n);
}
return true;
}

void removeClient(std::vector<int>& clients,
std::map<int, std::string>& pending,
std::size_t index) {
const int fd = clients[index];
::close(fd);
pending.erase(fd);
clients.erase(clients.begin() + static_cast<std::ptrdiff_t>(index));
}

} // namespace

int main() {
// 向已关闭的连接写数据会触发 SIGPIPE,默认动作是终止进程
std::signal(SIGPIPE, SIG_IGN);

const int listenFd = ::socket(AF_INET, SOCK_STREAM, 0);
if (listenFd < 0) {
std::perror("socket");
return 1;
}

int yes = 1;
if (::setsockopt(listenFd, SOL_SOCKET, SO_REUSEADDR,
&yes, sizeof yes) < 0) {
std::perror("setsockopt");
::close(listenFd);
return 1;
}

sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); // 仅本机
addr.sin_port = htons(kPort);

if (::bind(listenFd, reinterpret_cast<sockaddr*>(&addr), sizeof addr) < 0) {
std::perror("bind");
::close(listenFd);
return 1;
}
if (::listen(listenFd, 16) < 0) {
std::perror("listen");
::close(listenFd);
return 1;
}
std::printf("listening on 127.0.0.1:%d\n", kPort);

std::vector<int> clients;
std::map<int, std::string> pending;

for (;;) {
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(listenFd, &readfds);
int maxFd = listenFd;
for (int fd : clients) {
FD_SET(fd, &readfds);
if (fd > maxFd) {
maxFd = fd;
}
}

const int ready = ::select(maxFd + 1, &readfds, nullptr, nullptr, nullptr);
if (ready < 0) {
if (errno == EINTR) {
continue;
}
std::perror("select");
break;
}

if (FD_ISSET(listenFd, &readfds)) {
const int fd = ::accept(listenFd, nullptr, nullptr);
if (fd >= 0) {
if (fd >= FD_SETSIZE) { // 超出 select 能表示的范围
::close(fd);
} else {
clients.push_back(fd);
pending.emplace(fd, std::string{});
}
}
}

for (std::size_t i = 0; i < clients.size();) {
const int fd = clients[i];
if (!FD_ISSET(fd, &readfds)) {
++i;
continue;
}

char buffer[kBufferSize];
const ssize_t n = ::recv(fd, buffer, sizeof buffer, 0);
if (n <= 0) {
removeClient(clients, pending, i);
continue;
}

std::string& inbox = pending[fd];
inbox.append(buffer, static_cast<std::size_t>(n));

std::size_t pos = 0;
while ((pos = inbox.find('\n')) != std::string::npos) {
std::string line = inbox.substr(0, pos);
inbox.erase(0, pos + 1);
if (!line.empty() && line.back() == '\r') {
line.pop_back();
}
if (line.empty()) {
continue;
}
line.push_back('\n');
for (int out : clients) {
if (out != fd) {
// 简化处理:发送失败留给后续 recv 清理
(void)sendAll(out, line.data(), line.size());
}
}
}

if (inbox.size() > kMaxPending) {
removeClient(clients, pending, i);
continue;
}
++i;
}
}

for (int fd : clients) {
::close(fd);
}
::close(listenFd);
return 0;
}

代码里有几个设计取舍。pending用std::map<int, std::string>,而不是vector<string>,是为了在clients删除元素后仍能按 fd 找到缓冲区。广播是同步send,如果某个客户端一直不读,服务器的这次发送可能阻塞,进而拖住所有人;小规模本机聊天可以接受,正式服务应改成非阻塞 socket 加输出缓冲区,或使用epoll的EPOLLOUT。kMaxPending限制单个连接未成行的数据量,防止恶意客户端只发半行把内存撑爆。


四、客户端与工程注意事项


客户端需要同时做两件事:读键盘、收服务器消息。用std::thread起一个读线程,主线程负责发送,是最短的写法。


// 适用于 C++17,POSIX 平台;链接时加 -pthread
#include <arpa/inet.h>
#include <sys/socket.h>
#include <unistd.h>

#include <cstdio>
#include <iostream>
#include <string>
#include <thread>

int main() {
const int fd = ::socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
std::perror("socket");
return 1;
}

sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(9000);
if (::inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr) != 1) {
std::perror("inet_pton");
::close(fd);
return 1;
}
if (::connect(fd, reinterpret_cast<sockaddr*>(&addr), sizeof addr) < 0) {
std::perror("connect");
::close(fd);
return 1;
}

std::thread reader([fd] {
char buffer[1024];
for (;;) {
const ssize_t n = ::recv(fd, buffer, sizeof buffer, 0);
if (n <= 0) {
break;
}
std::cout.write(buffer, n);
std::cout.flush();
}
});

std::string line;
while (std::getline(std::cin, line)) {
line.push_back('\n');
std::size_t sent = 0;
while (sent < line.size()) {
const ssize_t n = ::send(fd, line.data() + sent,
line.size() - sent, 0);
if (n <= 0) {
break;
}
sent += static_cast<std::size_t>(n);
}
if (sent < line.size()) {
break;
}
}

::shutdown(fd, SHUT_RDWR);
reader.join();
::close(fd);
return 0;
}

编译服务器时用g++ -std=c++17 -Wall -Wextra server.cpp -o server,客户端再加-pthread。运行顺序是:先启动服务器,再开两个终端分别运行客户端。服务器绑定的是127.0.0.1,外部主机无法连接;如果确实需要局域网访问,要显式绑定网卡地址,并同时考虑认证与加密。


安全方面,这个示例没有任何身份验证,任何人连上就能发言,消息也是明文。用于学习足够,放到共享网络前至少应加上:连接数上限、消息长度上限、空闲超时、TLS(例如用 OpenSSL 或成熟的网络库),以及日志脱敏。不要把「语法上成立」等同于「可以上线」。


常见坑点



  1. ❌ 把一次recv的返回值当成一条完整消息。


✅ TCP 是字节流,必须用换行或长度前缀分帧,并保留未成行的剩余数据。



  1. ❌send返回的字节数少于请求长度时,认为发送失败并丢弃剩余部分。


✅ 用循环发送,直到全部写完或遇到真正的错误。



  1. ❌ 不处理EINTR,信号一到select或recv就退出循环。


✅ 对errno == EINTR重试。



  1. ❌ 向已关闭的连接send,进程被SIGPIPE直接终止。


✅ 忽略SIGPIPE,或使用MSG_NOSIGNAL(Linux);按平台选择。



  1. ❌ 关闭 fd 后忘记从clients中删除,后续select使用已关闭甚至被复用的 fd。


✅ 关闭的同时 erase,并同步清理该连接的接收缓冲区。



  1. ❌ 客户端数超过FD_SETSIZE仍继续FD_SET,造成越界。


✅ 限制最大连接数,或改用poll/epoll。



  1. ❌ 服务器绑定INADDR_ANY,本意只是本机测试却暴露到所有网卡。


✅ 明确绑定INADDR_LOOPBACK,需要对外时再显式配置并加防护。



  1. ❌ 认为聊天室不需要认证,直接把明文端口开到公网。


✅ 生产环境加身份验证、TLS、限流和审计,示例只用于本机学习。


总结




要点做法



连接socket、bind、listen、accept,地址限定127.0.0.1

多路复用select读集合,每次重新填充,参数是最大 fd 加一

消息边界换行分帧,每连接维护接收缓冲区

发送循环send直到写完,处理EINTR与SIGPIPE

清理close后立即从容器删除,避免使用已关闭的 fd

客户端读线程收消息,主线程读键盘并发送,退出时shutdown再join



聊天程序的价值在于把网络编程的几个基本问题集中暴露出来:字节流没有边界、发送可能只完成一部分、连接随时会断、慢客户端会拖累广播。把分帧、循环发送和连接清理这三件事写扎实,再往上加昵称、私聊、房间和 TLS,才不会在细节上反复返工。




RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进