C++实现聊天小程序
发布时间:2026/11/2 2:10:38 锦皓数字建站

前言「用 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中等规模用数组代替位图没有固定上限epollLinux大量连接事件驱动适合高并发每连接一线程少量连接代码直观线程开销和同步成本高select的可移植性最好适合教学和本机小工具。它有两个现实限制文件描述符编号必须小于FD_SETSIZE常见实现是 1024以及每次都要把整个集合从用户态拷进内核。连接数上去后应换poll或epoll。三、完整服务器实现下面的服务器做了这些事创建监听 socket、绑定回环地址、接受连接、用select等待可读事件、按行切分消息、广播给其他客户端、在连接断开时清理。发送部分用循环处理「只写出去一部分」的情况。// 适用于 C17POSIX 平台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 vectornamespace {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_caststd::size_t(n);}return true;}void removeClient(std::vectorint clients,std::mapint, std::string pending,std::size_t index) {const int fd clients[index];::close(fd);pending.erase(fd);clients.erase(clients.begin() static_caststd::ptrdiff_t(index));}} // namespaceint 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_castsockaddr*(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::vectorint clients;std::mapint, 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_caststd::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::mapint, std::string而不是vectorstring是为了在clients删除元素后仍能按 fd 找到缓冲区。广播是同步send如果某个客户端一直不读服务器的这次发送可能阻塞进而拖住所有人小规模本机聊天可以接受正式服务应改成非阻塞 socket 加输出缓冲区或使用epoll的EPOLLOUT。kMaxPending限制单个连接未成行的数据量防止恶意客户端只发半行把内存撑爆。四、客户端与工程注意事项客户端需要同时做两件事读键盘、收服务器消息。用std::thread起一个读线程主线程负责发送是最短的写法。// 适用于 C17POSIX 平台链接时加 -pthread#include arpa/inet.h#include sys/socket.h#include unistd.h#include cstdio#include iostream#include string#include threadint 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_castsockaddr*(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_caststd::size_t(n);}if (sent line.size()) {break;}}::shutdown(fd, SHUT_RDWR);reader.join();::close(fd);return 0;}编译服务器时用g -stdc17 -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_NOSIGNALLinux按平台选择。❌ 关闭 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才不会在细节上反复返工。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。