C++实现全双工网络通信:多线程阻塞socket源码详解
发布时间:2026/10/20 12:29:41 锦皓数字建站

双工通信说穿了就是通信双方同时能发又能收。电话是典型的全双工对讲机是半双工你按着通话键的时候是听不到对方的。用C写网络程序很多人最初接触的都是“请求-响应”模式客户端发一条消息服务端回一条然后close。这个模式在聊天室、远程控制、实时指令下发、服务端主动推送这些场景里根本不够用因为两边都需要随时“开口”谁也不能一直等对方把话说完。这篇文章就是把一套能编译、能运行的C双工通信源码端上来服务端和客户端都有完整代码跑起来之后你在本机开两个终端就能看到两边互相发消息、互不阻塞。代码之外我会把为什么这么设计讲清楚包括线程模型、消息格式、条件变量、粘包拆包这些核心问题最后再把我踩过的坑一并交代清楚。1. 双工通信的实现思路与方案选型1.1 先分清楚单工、半双工、全双工在写代码之前必须把概念对齐。单工通信就是数据只能朝一个方向流比如老式的广播电台发射塔只发收音机只听两者角色固定。半双工通信双方都能发但同一时刻只能有一个方向在发对讲机就是这个模型你要么按住按键说话要么松开按键听别人说不能同时进行。全双工通信就是收发同时进行电话是最典型的例子你说的时候对方也能说两边互不干扰。TCP天然支持全双工。一条TCP连接建立之后操作系统会为两端各维护一套独立的发送缓冲区和接收缓冲区从内核层面来讲一边拿着socket调用send、另一边同时调用recv完全不会互相阻塞。很多初学者以为“发消息”和“收消息”是同一个操作的两个阶段其实它们是两条独立的流水线只是被你在业务层面下意识地串起来了。所谓实现双工通信本质上就是把这两条流水线拆开各自用独立的执行流去驱动。理解了这一点后面所有的代码结构就都顺理成章了。1.2 C落到代码上的几个方案有了概念接着要选实现路线。我见过比较常见的做法有三种各有各的适用场景阻塞socket 多线程。每个客户端连接进来之后单独开一个接收线程负责recv再开一个发送线程负责send。逻辑最直观收发完全独立哪边阻塞了都不影响另一边。缺点是连接多了之后线程数量会爆炸几千个连接的话线程调度的开销很吓人。select / poll 多路复用。单线程里同时监测一堆socket哪个有数据读哪个哪个能写了就往哪个写。线程少但代码结构会变成事件驱动收发逻辑被拆成回调式的碎片初学者很容易绕晕。同时select有FD_SETSIZE限制默认1024个fd量再大就无能为力了。epoll / IOCP 异步事件驱动。高并发服务器的标准答案性能最强但状态机、事件循环、回调地狱一样不少适合做网关、消息中间件这类重负载系统不适合拿来学习“双工通信”这个概念本身。这里给一个简单的对比表方案代码难度并发能力调试难度适用场景阻塞socket 多线程低中等低中小连接数、业务逻辑直观select/poll中中等中几十到几百连接epoll/IOCP高高高网关、推送服务、高并发接入1.3 我为什么选“多线程 阻塞socket”理由很简单这篇的重点是让你把“双工”这个本质看清楚而不是让你在被事件驱动模型绕晕之后放弃治疗。多线程阻塞模型下收发代码就是顺序写的recv读到一个完整的消息就处理一个send发不出去就等着没有任何异步回调。每个线程只负责自己那一件事心智负担最小。实际工程里这个方案也并没有过时。很多内部系统、工具型程序、设备对接程序连接数撑死几十个根本没必要上epoll。反而是上了epoll之后代码从“人读起来舒服”变成“机器跑起来舒服”维护成本立刻上来。我个人的习惯是连接数在几百以内、消息频率不高的时候优先用阻塞socket加多线程到了需要支撑几千连接再做事件驱动改造通信协议那一层是可以原样保留的。2. 消息协议与线程模型设计2.1 TCP是字节流消息边界必须自己定TCP不是一个一个“消息包”传给你的它只保证字节的顺序不保证字节的边界。你在客户端连续调用两次send第一次发送hello第二次发送world服务端那边未必会收到两次“hello”和“world”很可能一次recv就把helloworld全读上来了这叫粘包。反过来如果一次发送的数据量很大TCP可能会拆成好几个TCP报文分段慢慢传recv第一次只读到一部分这叫半包。这是一个绕不开的前提双工通信必须先定义消息格式否则收发双方对“一句完整的话到哪里结束”完全没有共识。最简单的消息格式就是“长度字段 消息体”。客户端发送前把payload长度算出来转成网络字节序放在最前面后面跟上原始数据。接收端先读满4字节长度再按这个长度读消息体读满一个完整的消息就处理一个。不管是粘包还是半包只要严格按照这个流程去读最终都能得到正确的结果。2.2 这个Demo的消息格式我用的格式非常朴素| 4字节长度 | 消息体 |长度字段用htonl转成网络字节序接收端用ntohl转回主机字节序。消息体在Demo里就是一段UTF-8文本不做加密也不做压缩。这里有一个很容易踩的坑如果直接发送std::string的裸指针和长度接收端读出来的字节数可能包含中间的空字符所以接收端在构造字符串的时候一定要显式指定长度std::string body(len, \0)然后用readN函数把数据直接填进去。千万不要依赖strlen去推算长度二进制数据里出现0的概率是必然的。2.3 线程安全发送队列锁和条件变量全双工框架里最关键的共享数据结构是每个连接自己的发送队列。为什么需要它因为“业务侧想发送消息”和“发送线程正在send”发生在不同的线程里。如果不加队列业务侧直接去调用send万一发送线程也在调用send两个线程同时写同一个socket数据就会乱掉。我设计了一个极其简单的发送队列结构std::queuestd::string outbox_; std::mutex mtx_; std::condition_variable cv_;业务侧需要发消息时加锁把消息push进队列然后调用cv_.notify_one()唤醒发送线程。发送线程则在cv_.wait()上等着队列里面有数据就去取取完再调用send。如果队列空发送线程就继续睡觉不占CPU。这是典型的“生产者-消费者”模型。从网卡角度再看一层发送线程把数据从应用层队列挪到内核发送缓冲区这个过程本身也可能被阻塞。比如对端接收很慢内核发送缓冲区满了send就会卡住。有了独立发送队列之后业务侧push消息永远不会因为对端慢而阻塞最多是队列里堆积消息。慢客户端只会拖住它自己那条发送线程不会影响其他连接这是这个模型最有价值的地方。2.4 全局结构谁在收发谁在广播整个Demo跑起来之后会有这些角色服务端有一个主线程一直在accept等待新连接。每个新连接进来创建两个线程一个接收线程一个发送线程。服务端还有一个控制台线程专门读标准输入管理员敲一行字就广播给所有客户端。客户端有一个接收线程同时主线程负责读键盘输入并发送给服务端。从这张结构能直观看到服务端控制台线程和客户端主线程都是“主动发送方”收发互不依赖。这才是真正意义上的双工通信而不是服务端只做回显。需要额外说明的是这个Demo里客户端A发来的消息只会打印在服务端窗口不会被自动转发给客户端B。想要做成聊天室那样A说话大家都能看到需要再写一层消息路由给每条消息加上来源标识和目标列表双工通信只负责“两端能同时收发”转发是业务层的事别把两个概念混在一起。3. 服务端源码实现3.1 环境说明与核心公共代码下面代码基于Linux环境使用C11标准编写编译命令是g -stdc11 server.cpp -o server -pthread整个服务端我拆成了三个部分公共的读写工具函数、单个连接的封装类、主流程。先看公共工具#include sys/socket.h #include sys/types.h #include netinet/in.h #include arpa/inet.h #include unistd.h #include csignal #include cstdint #include cstring #include cstdio #include atomic #include condition_variable #include iostream #include memory #include mutex #include queue #include string #include thread #include vector static const int kMaxMsgLen 64 * 1024; bool readN(int fd, char* buf, size_t n) { size_t got 0; while (got n) { ssize_t r recv(fd, buf got, n - got, 0); if (r 0) return false; got r; } return true; } bool writeN(int fd, const char* buf, size_t n) { size_t sent 0; while (sent n) { ssize_t r send(fd, buf sent, n - sent, 0); if (r 0) return false; sent r; } return true; } std::string wrapMessage(const std::string text) { uint32_t len htonl((uint32_t)text.size()); std::string out; out.reserve(4 text.size()); out.append((const char*)len, 4); out.append(text); return out; }readN和writeN之所以要做成循环是因为一次recv或者send不一定能把n个字节全部处理完。尤其在高负载下能读多少、能写多少完全看内核缓冲区的心情。循环到读满为止才算是处理完一个完整的“消息帧”。3.2 TcpConnection一条连接就是一个小系统接下来是每个连接的核心封装。一个连接对象内部管理三个关键资源socket、收发两个线程、发送队列。class TcpConnection { public: TcpConnection(int fd, const std::string name) : sockfd_(fd), name_(name) {} ~TcpConnection() { if (sockfd_ 0) close(sockfd_); } void start() { sendThread_ std::thread([this] { sendLoop(); }); recvThread_ std::thread([this] { recvLoop(); }); } void sendMessage(const std::string text) { if (!alive_.load()) return; std::string frame wrapMessage(text); std::unique_lockstd::mutex lk(mtx_); outbox_.push(std::move(frame)); cv_.notify_one(); } bool alive() const { return alive_.load(); } void joinThreads() { if (sendThread_.joinable()) sendThread_.join(); if (recvThread_.joinable()) recvThread_.join(); } private: void sendLoop() { while (true) { std::string frame; { std::unique_lockstd::mutex lk(mtx_); cv_.wait(lk, [this] { return !outbox_.empty() || !alive_.load(); }); if (!alive_.load() outbox_.empty()) break; frame std::move(outbox_.front()); outbox_.pop(); } if (!writeN(sockfd_, frame.data(), frame.size())) { break; } } stopFromLocal(); } void recvLoop() { while (alive_.load()) { uint32_t netLen 0; if (!readN(sockfd_, (char*)netLen, 4)) break; uint32_t len ntohl(netLen); if (len 0 || len kMaxMsgLen) break; std::string body(len, \0); if (!readN(sockfd_, body[0], len)) break; std::cout [ name_ ] body std::endl; } stopFromLocal(); } void stopFromLocal() { bool expected true; if (alive_.compare_exchange_strong(expected, false)) { cv_.notify_all(); shutdown(sockfd_, SHUT_RDWR); } } int sockfd_; std::string name_; std::atomicbool alive_{true}; std::mutex mtx_; std::condition_variable cv_; std::queuestd::string outbox_; std::thread sendThread_; std::thread recvThread_; };这段代码有几个地方需要特别留意。接收线程的执行逻辑就是标准的“先读4字节长度再读消息体”。一旦recv返回0说明对端已经关闭readN会返回false接收线程退出循环并调用stopFromLocal把连接标记为不存活、唤醒发送线程、shutdown掉socket。发送线程如果正阻塞在cv_.wait()上被条件变量唤醒后看到alive变成false而且队列空了就会退出自己的循环。这种“双线程互相对齐生命周期”的设计是通信代码里最容易出错的地方。很多人只写了“接收线程退出后close”却忘了发送线程还傻乎乎地等队列结果线程泄漏。我自己早期就犯过这种错。3.3 主流程accept、广播与连接清理主流程部分我维护一个全局客户端列表再开一个控制台线程读标准输入std::vectorstd::shared_ptrTcpConnection g_clients; std::mutex g_clientsMtx; void broadcast(const std::string text) { std::lock_guardstd::mutex lk(g_clientsMtx); for (auto c : g_clients) { c-sendMessage(text); } } void cleanupConnections() { std::lock_guardstd::mutex lk(g_clientsMtx); for (auto it g_clients.begin(); it ! g_clients.end(); ) { if (!(*it)-alive()) { (*it)-joinThreads(); it g_clients.erase(it); } else { it; } } } void consoleLoop() { std::string line; while (std::getline(std::cin, line)) { if (line exit || line quit) { broadcast([system] server is shutting down); std::lock_guardstd::mutex lk(g_clientsMtx); for (auto c : g_clients) c-sendMessage([system] bye); std::exit(0); } broadcast([server] line); } } int main() { signal(SIGPIPE, SIG_IGN); int listenFd socket(AF_INET, SOCK_STREAM, 0); if (listenFd 0) { perror(socket); return 1; } int opt 1; setsockopt(listenFd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(9000); if (bind(listenFd, (sockaddr*)addr, sizeof(addr)) 0) { perror(bind); return 1; } if (listen(listenFd, 8) 0) { perror(listen); return 1; } std::cout server listen on 0.0.0.0:9000 std::endl; std::thread consoleThread(consoleLoop); int index 0; while (true) { sockaddr_in peer; socklen_t peerLen sizeof(peer); int cfd accept(listenFd, (sockaddr*)peer, peerLen); if (cfd 0) { continue; } char ip[INET_ADDRSTRLEN]; inet_ntop(AF_INET, peer.sin_addr, ip, sizeof(ip)); int port ntohs(peer.sin_port); std::string name std::string(ip) : std::to_string(port); auto conn std::make_sharedTcpConnection(cfd, name); { std::lock_guardstd::mutex lk(g_clientsMtx); g_clients.push_back(conn); } conn-start(); broadcast([system] name joined); cleanupConnections(); } close(listenFd); return 0; }服务端要演示“主动发送”靠的就是consoleLoop线程。你在服务端窗口敲一行字它会把这段文字广播给所有在线客户端。这一步很直观地验证了全双工服务端不是只能被动回消息它也能在任意时刻主动推消息。连接清理我放在了新连接accept之后再执行这是个懒清理方案。好处是主循环逻辑简单坏处是如果长时间没有新连接进来死连接会堆积在列表里。真实项目里一般会用定时器定期清理或者让连接对象在断开时自己从全局列表里摘除但那种方式要小心死锁——如果recv线程在持锁状态下调用join会把自己join进去。Demo里我选择懒清理是为了把最核心的逻辑暴露出来。socket编程里一个很影响体验的坑是SIGPIPE。如果对端已经关闭连接你还在往这个socket上sendLinux系统默认会给进程直接发SIGPIPE信号把整个程序干掉你甚至看不到任何错误输出。所以我在main里先执行了signal(SIGPIPE, SIG_IGN)简单粗暴但管用。4. 客户端源码实现4.1 客户端完整代码客户端代码和服务端共用同一套readN、writeN、wrapMessage工具函数编译时单独编译即可g -stdc11 client.cpp -o client -pthread#include arpa/inet.h #include netinet/in.h #include sys/socket.h #include unistd.h #include cstdint #include cstring #include cstdio #include atomic #include iostream #include string #include thread static const int kMaxMsgLen 64 * 1024; bool readN(int fd, char* buf, size_t n) { size_t got 0; while (got n) { ssize_t r recv(fd, buf got, n - got, 0); if (r 0) return false; got r; } return true; } bool writeN(int fd, const char* buf, size_t n) { size_t sent 0; while (sent n) { ssize_t r send(fd, buf sent, n - sent, 0); if (r 0) return false; sent r; } return true; } std::string wrapMessage(const std::string text) { uint32_t len htonl((uint32_t)text.size()); std::string out; out.reserve(4 text.size()); out.append((const char*)len, 4); out.append(text); return out; } int main(int argc, char* argv[]) { const char* serverIp argc 1 ? argv[1] : 127.0.0.1; int port argc 2 ? std::atoi(argv[2]) : 9000; int fd socket(AF_INET, SOCK_STREAM, 0); if (fd 0) { perror(socket); return 1; } sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(port); if (inet_pton(AF_INET, serverIp, addr.sin_addr) 0) { perror(inet_pton); return 1; } if (connect(fd, (sockaddr*)addr, sizeof(addr)) 0) { perror(connect); return 1; } std::atomicbool running{true}; std::thread recvThread([] { while (running.load()) { uint32_t netLen 0; if (!readN(fd, (char*)netLen, 4)) break; uint32_t len ntohl(netLen); if (len 0 || len kMaxMsgLen) break; std::string body(len, \0); if (!readN(fd, body[0], len)) break; std::cout \r body std::endl std::flush; } running false; }); std::cout connected to serverIp : port std::endl; std::cout type exit to quit std::endl; std::string line; while (std::getline(std::cin, line)) { if (line exit || line quit) break; std::string frame wrapMessage(line); if (!writeN(fd, frame.data(), frame.size())) { std::cout send failed std::endl; break; } } shutdown(fd, SHUT_RDWR); close(fd); running false; if (recvThread.joinable()) recvThread.join(); return 0; }4.2 输入、发送、接收三条线怎么配合客户端这边接收线程负责从socket上取数据并打印到屏幕主线程负责从键盘读输入并发送。你可能会问为什么客户端没有再单独开一个发送线程原因是主线程的工作循环本身就是“读一行、发一条”发送频率由人的输入速度决定不需要独立线程。三条线的配合方式是这样的收和发互不干扰你可以在等待服务端消息的同时继续在键盘上敲内容。打印的时候我用了一个小技巧先输出\r回车符回到行首再打印消息最后重新输出命令提示符这样服务端消息不会把输入中的那行文字搅得乱七八糟。这个细节不算复杂但体验差距很明显。还有一种常见情况需要特别说明程序在std::getline上阻塞等待用户输入的时候如果服务端断开连接接收线程会检测到recv失败并退出但主线程很可能还在等输入用户不明所以地继续敲字结果发现发送失败。Demo里我把这种情况做了简单处理发送失败就退出循环但主线程依然被getline卡住的话程序不会自动消失。想要彻底解决“断开即退出”就需要用select同时监听键盘和socket或者用更复杂的UI事件循环这种优化留给有需要的人自己改。4.3 收尾关闭close还是shutdown客户端输入exit退出时我特意先调用shutdown(fd, SHUT_RDWR)再调用close(fd)。这说明一个很重要的细节close只是把fd从进程里注销对端可能感知不到shutdown才是真正地发送FIN或者RST信号去通知对端连接要断开了。在服务端那边recv读到0readN返回false接收线程才会正确地退出。同样的道理也用在TcpConnection的stopFromLocal里shutdown(sockfd_, SHUT_RDWR)是为了让对端和本端的收发线程都醒过来。如果你只close不shutdown阻塞中的recv未必会立刻返回这在多线程收尾时特别容易造成线程join挂起。5. 常见问题与排查经验5.1 编译报错与平台差异Linux下编译最常踩的坑是忘记加-pthread链接参数报错通常是undefined reference to pthread_create。g的std::thread底层依赖pthread库编译和链接都要显式指定。还有的朋友会忘记包含cstdint用uint32_t时候报错“not declared”。如果你在Windows上编译这段代码不能直接跑因为Windows的socket API是另一套东西启动前要调WSAStartup关闭要调closesocket头文件是winsock2.h链接时要加ws2_32。更省事的办法是项目里统一封装一个跨平台socket辅助层或者干脆在Linux环境、WSL下面跑这份代码。双工通信的核心思路两边完全一致差别主要是操作系统API。5.2 send不完整和recv只收一半这是新手百分之百会遇到的问题。你调用一次send想让内核把1000字节全发出去但内核缓冲区可能只接收了500字节就返回了。如果你忽略这个返回值直接把剩余的500字节丢掉对端永远只能看到半条消息。我封装writeN和readN就是为了应对这种“一次收不完、一次发不完”的情况。凡是涉及网络传输永远不要相信一次函数调用就能搞定全部数据。正确的思路是循环处理直到处理完要求的字节数或者出现错误。这个习惯养成之后很多疑难杂症都能从根源上避免。半包和粘包本质上是同一个问题的两种表象TCP只保证字节流顺序不保证消息边界。所以我才坚持在消息前面加4字节长度字段。如果哪一天你在排查“消息多了字”或者“消息被截断”的问题第一反应应该是去查消息边界而不是去查业务逻辑。5.3 连接断开的判断判断对端断开的标准是recv返回0。这个看起来很简单实际代码里有两种危险写法第一是直接在recv返回值小于等于0时立刻close但没考虑EINTR被信号中断的情况可能把正常中断当成错误处理稳妥做法是r0时视为正常关闭r0时再根据errno区分第二是在处理“对端发来FIN之后还有数据没读完”时草率地关闭socket把未处理完的数据丢掉。在TcpConnection的收尾流程里我把alive状态和shutdown做了联动。接收线程一旦发现readN返回false就调用stopFromLocal通过条件变量通知发送线程把整个连接的线程生命周期对齐。这个流程值得你在自己的项目里反复推敲每一线程都必须有一个明确的退出路径。5.4 端口占用与SIGPIPE启动服务端时如果报bind: Address already in use大概率是上次程序退出之后端口进入了TIME_WAIT状态。我一般会在声明socket之后马上设置SO_REUSEADDR这样能允许端口重用。调试时频繁kill进程很常见这个配置基本成了我的固定套路。SIGPIPE是另一个杀人于无形的问题。对端断开之后你继续sendLinux默认直接杀掉进程连报错的机会都不给。排查办法是在启动代码里加signal(SIGPIPE, SIG_IGN)或者send时加上MSG_NOSIGNAL标志。一旦你加了信号屏蔽send返回值就能正常变成-1错误也能暴露出来。5.5 双工通信测试与观察建议代码全部跑起来之后我建议按这个顺序做测试服务端启动客户端A连接客户端B连接。服务端窗口输入“hello”两个客户端都应该收到。客户端A输入“I am A”服务端窗口应该打印出A的消息。客户端A直接CtrlC杀掉进程服务端窗口不会崩溃等有新连接进入时会清理掉A的记录。服务端窗口输入“exit”所有客户端都能收到服务端退出广播然后服务端进程结束。用netstat -tlnp | grep 9000检查端口状态看服务端监听是否正常。上面这几步做完你对双工通信的完整生命周期就有手感了。之后再做高并发压测压到几百连接时观察线程数量和内存占用你就能亲身体会到“每连接两个线程”模型的边界在哪里。最后说点个人体会。很多人拿到网络通信代码第一反应是研究收发函数怎么调但真正决定工程质量的往往是线程生命周期和连接状态的清理。我见过不少项目功能都完整了就是断开重连后线程越开越多、内存悄悄泄漏问题几乎全出在收尾上谁负责退出、谁负责唤醒、谁负责通知对端、队列里残留消息怎么处理。这套Demo代码里我没有把收尾流程藏起来。你只要想清楚“每一条线程什么时候退出、socket什么时候shutdown、队列里的消息怎么办”双工通信本身其实一点都不神秘。先跑通再抽离协议层最后根据真实负载选择事件驱动还是继续用多线程这是我验证过很多次的路径。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。