前言
「用 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 或成熟的网络库),以及日志脱敏。不要把「语法上成立」等同于「可以上线」。
常见坑点
- ❌ 把一次
recv的返回值当成一条完整消息。
✅ TCP 是字节流,必须用换行或长度前缀分帧,并保留未成行的剩余数据。
- ❌
send返回的字节数少于请求长度时,认为发送失败并丢弃剩余部分。
✅ 用循环发送,直到全部写完或遇到真正的错误。
- ❌ 不处理
EINTR,信号一到select或recv就退出循环。
✅ 对errno == EINTR重试。
- ❌ 向已关闭的连接
send,进程被SIGPIPE直接终止。
✅ 忽略SIGPIPE,或使用MSG_NOSIGNAL(Linux);按平台选择。
- ❌ 关闭 fd 后忘记从
clients中删除,后续select使用已关闭甚至被复用的 fd。
✅ 关闭的同时 erase,并同步清理该连接的接收缓冲区。
- ❌ 客户端数超过
FD_SETSIZE仍继续FD_SET,造成越界。
✅ 限制最大连接数,或改用poll/epoll。
- ❌ 服务器绑定
INADDR_ANY,本意只是本机测试却暴露到所有网卡。
✅ 明确绑定INADDR_LOOPBACK,需要对外时再显式配置并加防护。
- ❌ 认为聊天室不需要认证,直接把明文端口开到公网。
✅ 生产环境加身份验证、TLS、限流和审计,示例只用于本机学习。
总结
| 要点 | 做法 |
|---|
| 连接 | socket、bind、listen、accept,地址限定127.0.0.1 |
| 多路复用 | select读集合,每次重新填充,参数是最大 fd 加一 |
| 消息边界 | 换行分帧,每连接维护接收缓冲区 |
| 发送 | 循环send直到写完,处理EINTR与SIGPIPE |
| 清理 | close后立即从容器删除,避免使用已关闭的 fd |
| 客户端 | 读线程收消息,主线程读键盘并发送,退出时shutdown再join |
聊天程序的价值在于把网络编程的几个基本问题集中暴露出来:字节流没有边界、发送可能只完成一部分、连接随时会断、慢客户端会拖累广播。把分帧、循环发送和连接清理这三件事写扎实,再往上加昵称、私聊、房间和 TLS,才不会在细节上反复返工。