C++11异步多线程跨平台网络库:游戏服务端自研实践
发布时间:2026/9/29 1:22:06 锦皓数字建站

简介这是一份基于 C11 标准实现的异步多线程跨平台网络库源码面向网络游戏服务端开发场景适合希望深入理解网络编程、事件驱动模型与多线程调度的进阶学习者也可作为课程设计、毕业设计或工程实训的参考项目。资源包共 135 个文件以 58 个 .h 头文件与 47 个 .cc 源文件为核心辅以 makefile、mak、sh 等构建脚本以及 json 配置、proto 协议定义和少量日志文件压缩包约 46.51MB。库在 Windows 平台采用 select 模式单机最高支持 2048 个 socket 事件使用 VS2017 编译Linux 平台则基于 epoll提供 make 一键编译。架构上采用一个 accept 线程搭配多个 reactor 线程acceptor 负责均衡各 reactor 的 socket 数量reactor 线程承担收包、发包、解包与封包工作。内容预览可见 reactor、tcp_service、dynamic_buffer、socket_address 等模块便于读者梳理分层设计与线程模型。目前已有 155 人学习下载。1. 从零写一个 C11 异步多线程跨平台网络库游戏服务端为什么值得自己造轮子做游戏服务端的人迟早会撞上一个选择网络层到底用现成的还是自己写。libevent、libuv、asio 这些库都很成熟但真到项目里你会发现游戏场景和通用 HTTP 服务差别很大——长连接为主、消息包小而频繁、需要精确控制每个连接的收发节奏、还要在 Linux 和 Windows 上跑同一套逻辑。这时候一个基于 C11 的异步多线程跨平台网络库就有了存在价值它不追求大而全只解决「多线程 Reactor 跨平台 socket 封装 异步回调」这三件事。这篇文章面向的是想自己动手写网络库、或者正在评估要不要自研的游戏服务端开发者。我会把整个库的架构、关键代码、参数设置和踩过的坑讲清楚让你能照着搭出一个能跑的最小可用版本。C11 是底线因为 std::thread、std::function、std::atomic、智能指针这些设施刚好够用又不会像 C17 那样在老旧编译环境里翻车。跨平台部分主要处理 Linux epoll 和 Windows IOCP/select 的差异这是整个库最费劲也最值得投入的地方。2. 网络库的骨架Reactor 模型怎么在 C11 里落地2.1 为什么选多线程 Reactor 而不是 proactor游戏服务端的连接数通常在几千到几万之间不是 C10K 那种极端场景但对延迟敏感。Reactor 模型的核心是「事件循环 回调」一个线程跑一个 EventLoop每个 EventLoop 绑定一个 epoll 实例Linux或 IOCP 完成端口Windows。多线程的做法是起 N 个 EventLoop 线程主线程负责 accept然后把新连接按轮询或最少连接数分发给某个 EventLoop。选 Reactor 而不是纯 Proactor 的原因很实际Linux 上真正的异步 IOio_uring在 C11 时代还不成熟Windows 的 IOCP 虽然是 Proactor但接口风格和 epoll 差异太大统一抽象成本高。折中方案是在 Linux 用 epoll 的 ET 模式模拟异步在 Windows 用 IOCP 或者 select 兜底。我一般会定义一个Poller抽象基类Linux 下实现EpollPollerWindows 下实现IocpPoller上层 EventLoop 只调poll()、addFd()、removeFd()这几个接口。线程模型上推荐「主从 Reactor 线程池」主 Reactor 只做 accept从 Reactor 负责读写事件业务逻辑丢到线程池。这样 accept 不会被业务阻塞读写也不会因为某个慢业务卡住整个 EventLoop。C11 的std::thread和std::mutex足够支撑这个模型但要注意 EventLoop 的线程安全性——跨线程调用必须用runInLoop()把任务投递到目标 EventLoop 线程执行。2.2 最小可跑的 EventLoop 与 Poller 抽象先看 Poller 基类的定义这是跨平台的关键抽象层// Poller.h #pragma once #include vector #include cstdint class Poller { public: virtual ~Poller() default; // 等待事件timeoutMs 为 -1 表示阻塞等待 virtual int poll(int timeoutMs, std::vectorChannel* activeChannels) 0; virtual void addFd(int fd, uint32_t events) 0; virtual void modFd(int fd, uint32_t events) 0; virtual void removeFd(int fd) 0; }; // Linux 下用 epoll 实现 class EpollPoller : public Poller { public: EpollPoller(); ~EpollPoller() override; int poll(int timeoutMs, std::vectorChannel* activeChannels) override; void addFd(int fd, uint32_t events) override; void modFd(int fd, uint32_t events) override; void removeFd(int fd) override; private: int epollFd_; std::vectorstruct epoll_event events_; // 预分配避免每次 poll 都扩容 };events_预分配是血泪经验早期版本每次 poll 都临时构造 vectorQPS 一高就频繁 malloc延迟抖动明显。预分配 1024 个 event 槽位poll 返回后只 resize 到实际数量性能稳定很多。EventLoop 的核心循环// EventLoop.cpp void EventLoop::loop() { while (!quit_) { std::vectorChannel* activeChannels; int numEvents poller_-poll(-1, activeChannels); for (Channel* ch : activeChannels) { ch-handleEvent(); // 分发到具体回调 } doPendingFunctors(); // 执行跨线程投递的任务 } } void EventLoop::runInLoop(Functor cb) { if (isInLoopThread()) { cb(); } else { queueInLoop(std::move(cb)); } } void EventLoop::queueInLoop(Functor cb) { { std::lock_guardstd::mutex lock(mutex_); pendingFunctors_.push_back(std::move(cb)); } // 唤醒 epoll_wait否则新任务要等下一次事件才执行 wakeup(); }wakeup()是必须的如果 EventLoop 正阻塞在epoll_wait其他线程投递的任务不会立即执行。常见做法是用eventfdLinux或socketpair跨平台注册一个可读事件写一个字节就能唤醒。Windows 下可以用PostQueuedCompletionStatus或者一个自连接的 UDP socket 来唤醒。参数上poll的 timeout 设 -1 表示无限阻塞适合纯事件驱动如果要做定时器可以设最近定时器的剩余时间。我一般会单独用一个 timerfdLinux或SetWaitableTimerWindows来驱动定时任务不混在 poll timeout 里逻辑更清晰。3. 跨平台 socket 封装Linux 和 Windows 的差异怎么抹平3.1 头文件、类型和错误码的三处硬差异跨平台最烦的不是 API 名字不同而是那些「看起来一样但行为不同」的地方。第一处是头文件Linux 用sys/socket.h、netinet/in.h、unistd.hWindows 用winsock2.h、ws2tcpip.h而且 Windows 必须先WSAStartup。第二处是类型Linux 的 socket 是intWindows 是SOCKET本质是UINT_PTR关闭用closevsclosesocket。第三处是错误码Linux 看errnoWindows 看WSAGetLastError()而且EAGAIN/EWOULDBLOCK在 Windows 上对应WSAEWOULDBLOCK。统一做法是定义一个SocketType别名和一组宏// SocketCompat.h #ifdef _WIN32 #include winsock2.h #include ws2tcpip.h #pragma comment(lib, ws2_32.lib) using SocketType SOCKET; #define INVALID_SOCKET_FD INVALID_SOCKET #define CLOSE_SOCKET(fd) closesocket(fd) #define GET_SOCKET_ERROR() WSAGetLastError() #define SOCKET_WOULDBLOCK WSAEWOULDBLOCK #else #include sys/socket.h #include netinet/in.h #include unistd.h #include fcntl.h #include errno.h using SocketType int; #define INVALID_SOCKET_FD (-1) #define CLOSE_SOCKET(fd) ::close(fd) #define GET_SOCKET_ERROR() errno #define SOCKET_WOULDBLOCK EAGAIN #endif非阻塞设置也要分开处理Linux 用fcntl(fd, F_SETFL, O_NONBLOCK)Windows 用ioctlsocket(fd, FIONBIO, mode)。SO_REUSEADDR两边都有但 Windows 上还要考虑SO_EXCLUSIVEADDRUSE的坑默认行为不同。3.2 非阻塞 connect 和 accept 的正确姿势游戏服务端经常要主动连其他服务比如网关连逻辑服非阻塞 connect 是必须的。Linux 上connect返回 -1 且 errno 为EINPROGRESS表示连接进行中之后用 epoll 监听可写事件判断成功。Windows 上返回SOCKET_ERROR且WSAGetLastError()为WSAEWOULDBLOCK逻辑类似但错误码不同。// Connector.cpp 非阻塞 connect 核心逻辑 int Connector::connect() { SocketType fd ::socket(AF_INET, SOCK_STREAM, 0); setNonBlocking(fd); struct sockaddr_in addr; // ... 填充 addr int ret ::connect(fd, (struct sockaddr*)addr, sizeof(addr)); if (ret 0) { return fd; // 立即成功本地回环常见 } int err GET_SOCKET_ERROR(); if (err SOCKET_WOULDBLOCK #ifdef _WIN32 || err WSAEINPROGRESS #else || err EINPROGRESS #endif ) { return fd; // 连接进行中交给 EventLoop 监听可写 } CLOSE_SOCKET(fd); return INVALID_SOCKET_FD; }连接成功后要检查SO_ERROR这一步很多人漏掉可写事件触发不代表连接成功必须用getsockopt(fd, SOL_SOCKET, SO_ERROR, ...)取错误码为 0 才是真成功。我见过有人直接认为可写就是连上了结果对端拒绝连接时回调里还在发数据直接翻车。accept 在 ET 模式下必须循环调用直到返回EAGAIN否则会丢连接。Windows 的 IOCP 模型下 accept 是投递AcceptEx逻辑完全不同如果不想写两套Windows 下可以用 select 兜底性能差一些但代码统一。3.3 Buffer 设计与粘包处理TCP 是字节流游戏消息必须自己定边界。常见方案是「长度字段 消息体」长度字段 4 字节大端。Buffer 类要支持高效读写// Buffer.h 关键接口 class Buffer { public: size_t readableBytes() const { return writerIndex_ - readerIndex_; } size_t writableBytes() const { return buffer_.size() - writerIndex_; } const char* peek() const { return begin() readerIndex_; } void retrieve(size_t len) { if (len readableBytes()) readerIndex_ len; else retrieveAll(); } void append(const char* data, size_t len) { ensureWritableBytes(len); std::copy(data, data len, begin() writerIndex_); writerIndex_ len; } private: std::vectorchar buffer_; size_t readerIndex_ 0; size_t writerIndex_ 0; void ensureWritableBytes(size_t len); // 空间不够时扩容或搬移 };ensureWritableBytes的策略是先看尾部空间够不够不够再看头部已读空间能不能复用把未读数据搬到前面最后才扩容。这个细节决定了高并发下的内存分配频率。初始 buffer 给 1024 字节扩容按 2 倍增长但设一个上限比如 64KB防止恶意大包打爆内存。粘包处理在onMessage回调里做先检查readableBytes() 4读出长度再检查readableBytes() 4 len够了才取出一条完整消息循环直到不够。长度字段本身要做上限校验比如单条消息不超过 1MB超过直接断开连接这是防攻击的基本操作。4. 多线程模型与连接管理线程安全怎么保证4.1 主从 Reactor 的线程分配与负载均衡主 Reactor 跑在主线程只监听 listen fd 的可读事件accept 到新连接后分发给从 Reactor。分发策略有三种轮询、随机、最少连接数。轮询最简单也最常用但如果某个从 Reactor 上挂了几个特别活跃的连接比如战斗房间负载会不均。我一般用「轮询 连接数权重」维护每个 EventLoop 的当前连接数新连接优先给连接数最少的相同则轮询。从 Reactor 的数量建议设为 CPU 核心数但不要超过 8 个。线程太多上下文切换开销大而且游戏服务端通常还有逻辑线程、DB 线程网络线程占满 CPU 反而不好。实测 4 核机器上 3 个网络线程 1 个逻辑线程是比较舒服的配置。跨线程投递任务用runInLoop但要注意生命周期如果 EventLoop 正在销毁投递的任务可能访问已释放的对象。解决办法是在 EventLoop 析构时设置quit_标志queueInLoop检查标志已退出就丢弃任务。另外pendingFunctors_的 swap 要在锁外执行减少锁持有时间void EventLoop::doPendingFunctors() { std::vectorFunctor functors; { std::lock_guardstd::mutex lock(mutex_); functors.swap(pendingFunctors_); } for (auto f : functors) f(); }4.2 连接生命周期管理与 shared_ptr 陷阱TcpConnection 用std::shared_ptr管理但循环引用是经典坑Connection 持有 ChannelChannel 持有回调回调又捕获 Connection 的 shared_ptr引用计数永远不归零。解决办法是回调里捕获weak_ptr需要时lock()提升。// TcpServer 中处理新连接 void TcpServer::newConnection(SocketType fd, const InetAddress peerAddr) { EventLoop* ioLoop threadPool_-getNextLoop(); auto conn std::make_sharedTcpConnection(ioLoop, fd, peerAddr); connections_[fd] conn; // 用 weak_ptr 避免循环引用 std::weak_ptrTcpConnection weakConn conn; conn-setCloseCallback([this, weakConn](const std::shared_ptrTcpConnection c) { if (auto conn weakConn.lock()) { removeConnection(conn); } }); ioLoop-runInLoop([conn]() { conn-connectEstablished(); }); }连接关闭要分「主动关闭」和「被动关闭」。主动关闭调shutdownWrite()发完剩余数据再关被动关闭是读到 0 字节或出错。关闭时先从 EventLoop 移除 Channel再从 connections_ map 删除最后等 shared_ptr 自然析构。顺序错了会 crash这是调试时最常见的段错误来源。4.3 定时器与心跳机制游戏长连接必须有心跳否则中间设备会悄悄断开。定时器用timerfdLinux或SetWaitableTimerWindows驱动维护一个按到期时间排序的std::setTimer。每个连接注册一个心跳定时器比如 30 秒没收到数据就发 ping60 秒没响应就断开。// 心跳检查在 EventLoop 线程执行 void HeartbeatMgr::checkTimeout() { auto now std::chrono::steady_clock::now(); for (auto it conns_.begin(); it ! conns_.end(); ) { auto conn it-second.lock(); if (!conn) { it conns_.erase(it); continue; } auto idle std::chrono::duration_caststd::chrono::seconds( now - conn-lastActiveTime()).count(); if (idle 60) { conn-forceClose(); it conns_.erase(it); } else if (idle 30) { conn-sendPing(); it; } else { it; } } }lastActiveTime用std::atomicint64_t存读写不加锁。心跳间隔和超时时间要可配置不同游戏类型差别很大MMO 可以 30/90 秒FPS 可能 5/15 秒。定时器精度不用太高100ms 级别的 tick 足够太密反而增加 CPU 占用。5. 避坑与排查那些让我加班到凌晨的问题5.1 epoll ET 模式下漏读导致连接假死现象客户端发了数据服务端偶尔收不到连接还在但消息丢了。原因ET 模式只在状态变化时通知一次如果一次read没把缓冲区读空剩余数据不会触发新事件。解决read必须循环直到返回EAGAIN每次读固定大小比如 64KB拼到 Buffer 里。我一般会在 Channel 的读回调里写while (true)循环读到EAGAIN才 break。5.2 Windows 下 closesocket 后事件仍触发现象Windows 上关闭连接后IOCP 或 select 还会返回该 socket 的事件导致访问已释放内存。原因Windows 的 socket 关闭是异步的closesocket返回不代表内核已清理完。解决关闭前先shutdown(fd, SD_BOTH)然后从 Poller 移除最后才closesocket。另外用shared_ptr延迟释放确保事件处理完再析构。5.3 跨线程调用 runInLoop 时对象已析构现象逻辑线程投递任务到网络线程任务执行时 TcpConnection 已经关闭访问空指针 crash。原因投递时对象还活着执行时已经死了。解决任务里捕获weak_ptr执行时lock()检查或者用std::enable_shared_from_this配合shared_from_this()延长生命周期。更彻底的做法是给每个连接加一个「关闭标志」任务执行前先检查。5.4 大端小端和结构体对齐导致协议解析错乱现象Linux 和 Windows 之间传二进制结构体字段值对不上。原因x86 是小端网络字节序是大端而且不同编译器结构体对齐可能不同。解决协议里所有多字节整数用htonl/ntohl转换不要直接传结构体手动序列化每个字段。如果非要用结构体加#pragma pack(1)并确认两端一致但这不是长久之计。5.5 线程数过多导致上下文切换开销爆炸现象CPU 使用率不高但 QPS 上不去延迟抖动大。原因网络线程、逻辑线程、DB 线程加起来几十个频繁切换。解决网络线程数控制在 CPU 核心数以内逻辑线程用线程池而不是每连接一线程DB 操作异步化。用perf top或 Windows Performance Monitor 看上下文切换次数超过 10 万/秒就要警惕。6. 进阶技巧用压测和日志把库打磨到生产可用库能跑通只是第一步能不能上生产要看压测和可观测性。压测我一般用自己写的 client 工具模拟 1 万连接、每连接每秒 10 条消息跑 10 分钟看内存增长、延迟分布和 CPU 占用。重点看三个指标P99 延迟游戏体验、内存是否稳定有没有泄漏、连接建立速率accept 性能。日志方面网络库不要用printf或std::cout多线程下会交错甚至 crash。用异步日志业务线程写std::string到无锁队列单独一个日志线程落盘。日志级别分 DEBUG/INFO/WARN/ERROR生产环境只开 INFO 以上。关键路径accept、connect、close、错误必须打日志但读写数据不要打否则日志量爆炸。一个具体技巧是给每个连接分配唯一 ID日志里带上 ID排查问题时能串起一个连接的所有事件。ID 用std::atomicuint64_t自增生成简单高效。另外建议加一个「慢操作告警」如果某个回调执行超过 100ms打 WARN 日志这能帮你发现业务代码里的隐藏阻塞。最后说个我自己的习惯每次改完网络库先跑一遍「连接-发消息-关闭」的单元测试再跑 24 小时稳定性测试确认没有内存泄漏和 fd 泄漏才合并。fd 泄漏用lsof -p或 Windows 的资源监视器看内存泄漏用 Valgrind 或 ASan。这套流程帮我挡掉过好几次线上事故希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。