简介压缩包围绕Flash游戏与服务器通信这一主题面向网络编程初学者和对I/O多路复用感兴趣的开发者完整呈现了一个可运行的赛车小游戏SWF与配套控制台服务器包含可直接运行的exe和对应C源码。包内共15个文件以cpp/h源码、调试符号文件pdb/obj、工程配置dsw/dsp、预编译头及一份专门讲解数据包格式的PPT为主整体仅649KB轻量紧凑适合快速阅读和本地验证。服务器基于TCP协议采用select模型同时监控多个socket解决阻塞I/O下并发连接效率低的问题客户端与服务器通过自定义数据包完成玩家动作、游戏状态及分数的实时同步。PPT和ReadMe文档进一步解析了数据包头、标识符、数据体等结构配合现有工程文件可以清晰梳理从socket创建、bind、listen到select分发、数据收发与状态同步的完整流程。已有235人学习下载适合希望结合实例学习select模型、TCP可靠传输及Flash网络交互的开发者可直接依据工程源码和调试信息复现项目并在此基础上扩展或重构。1. flash游戏服务器与select模型小游戏后端为什么要啃这一套Flash游戏虽然已经不是新项目的主流但存量小游戏、课程设计、个人练手项目里Flash客户端加 Socket 服务器的组合仍然很常见。做这类服务器绕不开的就是 TCP 通信而 Windows 下最基础、最容易上手的 TCP 并发模型就是 select。它不像 IOCP 那样一开始就丢出一堆完成端口和重叠 IO 劝退新手却能让你把连接管理、收发缓冲、心跳超时这些后端基本功一次摸透。这篇笔记围绕一套 AS3 小游戏客户端加 C select 模型服务器的资源展开适合想自己写游戏后端、搞课设或者准备网络编程面试的开发者。读完你能直接照着复现并避开我踩过的那些坑。2. 通信协议设计TCP 长连接、两字节长度头与粘包缓冲2.1 为什么选 TCPFlash 端 Socket 接口与服务器推送Flash 端有两条网络通道。老一点的是 XMLSocket只能走文本收发都以\0结尾效率很低做实时对战基本不推荐。新一点的是 AS3 自带的 Socket 类支持 ByteArray 二进制读写底层走 TCP连接稳定适合承载帧同步指令或者状态同步协议。服务端同时服务几十个小型客户端体量不大但对延迟和服务器主动下发数据的能力有硬要求。HTTP 轮询每秒钟产生大量无意义请求拉高服务器负载交互延迟还高所以我会直接选 TCP 长连接。长连接的好处是连接建立一次之后双向通信都基于同一连接服务器可以随时往下推数据。比如其他玩家移动了、攻击命中了、房间状态变化了都能立刻推送不需要客户端反复来问。代价是服务器要自己管理连接生命周期包括超时、断线重连、半包处理和并发模型。这套资源里的服务器正是围绕这些点展开的而 select 就是管理几十个 TCP 连接最直接的那个轮子。2.2 二进制协议设计两字节长度头加消息 ID协议是我每次强调的第一优先级。不要把业务逻辑直接写在 netty 或者 socket 回调里先把报文格式定死后面所有模块都省心。这套资源用的协议很简单两字节长度头 两字节消息 ID 包体。字节偏移长度内容说明0-12 字节包体长度 len不含长度头自身2-32 字节消息 ID客户端据此分派逻辑4 …len包体业务数据例如坐标、操作指令长度头放在最前面服务端先读 2 字节得到 len再继续取包体。整个头部固定 4 字节消息 ID 放中间两端解析逻辑完全对称。字节序统一用网络序大端AS3 的writeShort默认就是大端C 端用htons/send发送两边不用做额外转换少踩一个字节序的坑。2.3 粘包与半包服务端接收缓冲必须前置TCP 是字节流一次 recv 可能收到多个包也可能只收到半个包。如果每次 recv 都直接当业务消息处理很快会出现解析错乱、内存越界、Flash 端闪退。处理方式只有一条路每个连接维护一个接收缓冲recv 到的数据先追加到缓冲尾部然后循环尝试从缓冲中剥离完整包长度不够就继续等。这套资源里服务端的处理逻辑是recv 返回多少就 append 多少然后进入一个while循环先判断缓冲中剩余字节是否大于等于 4再读长度头再判断完整包是否到达。注意这里有个细节长度头里的 len 指的是包体加消息 ID 的总长度还是纯包体长度一定要在两端约定清楚。资源里采用的是“消息 ID 包体”的总长度也就是说len 2 包体长度。这个定义一旦写死在代码里两端就不能改否则就会出现解析全乱的玄学问题。3. select 模型服务端骨架用 C 跑起监听、收发与心跳3.1 初始化与监听 socketWSAStartup、bind、listenC 服务端入口没什么花哨的先初始化 Winsock建监听 socket绑定端口进入监听状态。注意 Windows 下winsock2.h和winsock.h不能混着 include否则会冒出一堆莫名的重定义报错。我一般直接用winsock2.h并链接ws2_32.lib。// 必须在 winsock2.h 之前定义否则默认 64 连接超不出去 #define FD_SETSIZE 1024 #include winsock2.h #include windows.h #include cstdio #pragma comment(lib, ws2_32.lib) int main() { WSADATA wsa; WSAStartup(MAKEWORD(2, 2), wsa); SOCKET listenSock socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); SOCKADDR_IN addr {0}; addr.sin_family AF_INET; addr.sin_port htons(9000); addr.sin_addr.S_un.S_addr INADDR_ANY; bind(listenSock, (SOCKADDR*)addr, sizeof(addr)); listen(listenSock, SOMAXCONN); // 连接数组在后面统一管理 // listenSock 单独保留 WSACleanup(); return 0; }这一段有三个参数值得注意。htons(9000)把端口转成网络字节序别漏SOMAXCONN是系统允许的最大挂起连接数填它比写死一个 64 或 128 更稳INADDR_ANY表示绑定所有网卡如果你有多个 IP 又想只监听某个内网网卡把它换成具体 IP 的 inet_addr 结果。最后WSACleanup在真正服务端里会放到进程退出时我这里只做骨架示意。3.2 FD_SET 循环主体select 阻塞与三个 fd_setselect 的核心操作就是三件事把关心的 socket 塞进 fd_set交给 select 内核轮询然后用 FD_ISSET 找出谁就绪了。每次循环开始前都要重新 FD_ZERO 并重新 FD_SET因为 select 会原地修改这些集合。这套资源里的做法是开一个全局连接数组把监听 socket 放在 0 号位其余位置只存放活跃连接。fd_set readSet, writeSet, errSet; struct timeval tv; SOCKET socks[FD_SETSIZE]; bool needSend[FD_SETSIZE] {false}; // 初始化socks[0] listenSock其余为 INVALID_SOCKET while (true) { FD_ZERO(readSet); FD_ZERO(writeSet); FD_ZERO(errSet); for (int i 0; i FD_SETSIZE; i) { if (socks[i] INVALID_SOCKET) continue; FD_SET(socks[i], readSet); FD_SET(socks[i], errSet); if (needSend[i]) { FD_SET(socks[i], writeSet); } } tv.tv_sec 1; tv.tv_usec 0; int ret select(0, readSet, writeSet, errSet, tv); // ret 0 时不要直接 continue心跳扫描要走 }这里有一个新手很容易踩的点select 的第一个参数在 Windows 上其实被忽略传 0 即可Linux 上才是最大文件描述符加一。另一个点是把超时时间设为 1 秒而不是 NULLNULL 代表无限期阻塞那心跳和超时清理就没机会执行了。errSet很多人不填但某个连接 RST 断开时如果不监听错误事件那个 socket 可能会一直占着数组位后面慢慢漏连接。错误事件能让你提前知道连接异常并清理属于典型的保命动作。3.3 可读事件处理recv 返回 0 就是关闭可读事件是游戏服务器的主路径新连接和客户端数据都从这里进来。先判断是否有新连接有就 accept 塞进数组空位然后遍历所有 socket对 FD_ISSET 命中的调用 recv。if (FD_ISSET(listenSock, readSet)) { SOCKET newSock accept(listenSock, NULL, NULL); for (int i 1; i FD_SETSIZE; i) { if (socks[i] INVALID_SOCKET) { socks[i] newSock; needSend[i] false; break; } } } for (int i 1; i FD_SETSIZE; i) { if (socks[i] INVALID_SOCKET) continue; if (FD_ISSET(socks[i], readSet)) { char buf[4096]; int n recv(socks[i], buf, sizeof(buf), 0); if (n 0) { // 追加 buf 到连接自己的接收缓冲 // 然后循环解析完整包 } else if (n 0) { // 对端关闭 closesocket(socks[i]); socks[i] INVALID_SOCKET; } else { int err WSAGetLastError(); if (err ! WSAEWOULDBLOCK) { closesocket(socks[i]); socks[i] INVALID_SOCKET; } } } }逻辑说明n 0表示对端主动关闭这不算错误但要把它当成关闭分支处理负数是真错误其中WSAEWOULDBLOCK表示非阻塞模式下暂时没数据这个不算问题跳过就行。很多人在 n 为负数时不做分类直接关连接导致偶发断开后客户端毫无征兆掉线。连接数组被清理后要立刻置成INVALID_SOCKET否则下一轮 select 又把它填进 fd_set变成一个永远不可用的句柄白白消耗轮询。3.4 可写事件的两个时机不忙轮询的写法select 模型里最容易被滥用的是写事件。很多教程把 writeSet 和 readSet 一样每轮都填然后对每个可写 socket 都调 send最后 CPU 直接跑满游戏逻辑卡成幻灯片。原因是 socket 几乎所有时间都可写select 每轮都会返回它你的 send 就在空转。正确做法是只在确实有数据要发的时候把该 socket 加进 writeSet。在这套资源里业务逻辑层需要发送数据时先把数据拷贝进该连接的发送队列再把needSend[i]置为 trueselect 返回后只对FD_ISSET(socks[i], writeSet)命中的项执行 send。if (FD_ISSET(socks[i], writeSet)) { int n send(socks[i], sendBuf[i], sendLen[i], 0); if (n 0) { // 从发送队列头部移除 n 字节 // 如果队列清空置 needSend[i] false } else { int err WSAGetLastError(); if (err ! WSAEWOULDBLOCK) { closesocket(socks[i]); socks[i] INVALID_SOCKET; needSend[i] false; } } }参数说明send 返回实际发送的字节数很可能小于你要发的长度所以发送队列要支持从头部截断。WSAEWOULDBLOCK说明缓冲区满了这时不要清 needSend下一轮继续等可写直到发完为止。这样的“按需可写”能显著降低 select 空轮询实测 50 个连接同时收发时CPU 占用能控制在 5% 以下。3.5 心跳扫描与超时清理游戏客户端的网络环境不稳定突然断网、休眠恢复、切后台这些情况 TCP 本身不一定能及时感知。服务端必须自己加心跳机制客户端每隔一段时间发一个心跳包服务端更新该连接的lastActive时间每轮 select 返回后扫描数组超过 30 秒没有心跳的连接直接关闭。DWORD now GetTickCount(); for (int i 1; i FD_SETSIZE; i) { if (socks[i] INVALID_SOCKET) continue; if (now - lastActive[i] 30000) { closesocket(socks[i]); socks[i] INVALID_SOCKET; lastActive[i] 0; } }心跳包本身就是一个消息 ID 的普通包不用单独设计特殊格式。30 秒这个值可以根据游戏类型调整对战类建议 10 秒左右挂机类可以放到 60 秒。要注意GetTickCount()是毫秒级 tick回绕问题在 49 天左右才会出现老游戏服务器最长也就跑个把月一般不用处理但如果你做的是长期稳定服务建议改用GetTickCount64。4. 避坑select 模型实战中的五个典型翻车现场4.1 连接数到 64 就上不去FD_SETSIZE 的默认上限现象游戏在线人数到 60 多后新客户端连接全部失败老连接也开始出现随机卡顿服务端日志里 accept 返回值异常。原因Windows 的 fd_set 默认容量是 64select 内部按 FD_SETSIZE 处理位。连接超过 64 后FD_SET 写入越界内存被踩select 行为完全不可预期。解决在#include winsock2.h之前先#define FD_SETSIZE 1024同时把所有依赖 FD_SETSIZE 的数组同步放大到 1024。注意 1024 也不是无限大select 本身是线性扫描连接再多就该换模型了。4.2 socket 断开后没清 fd_setCPU 莫名飙到 100%现象某个客户端异常断开后服务器进程 CPU 占用率迅速冲到 100%select 每轮都秒返回。原因recv 返回 0 但只关了 socket没把数组项置为 INVALID_SOCKET。下一轮 FD_SET 又把这个无效句柄填进去select 对它返回可读ret 一直大于 0循环空转。解决关闭连接后必须马上closesocket并置INVALID_SOCKET还要确保套接字从当前 fd_set 中清掉。最省心的写法就是我在 3.3 里那样每轮循环都从数组重建 fd_set而不是只做 FD_CLR。4.3 writeSet 每轮都监听socket 永远可写导致忙轮询现象服务器刚跑起来一切正常连接一多 CPU 占用居高不下但游戏逻辑没有死循环。原因我对可写事件的理解不够一开始把 writeSet 当成和 readSet 一样的常态事件每轮都填select 每次都会返回大量可写 socketsend 不停地空转CPU 被白白吃掉。解决改成按需注册可写事件只在发送队列非空时才把 socket 加进 writeSet。发送完后立刻移除。这个改动在我自己的项目里让 CPU 占用从 40% 降到 3% 左右是所有坑里收益最大的一个。4.4 recv 返回 0 当成错误处理连接莫名“假死”现象客户端正常关闭游戏服务端没有感知连接一直占着数组位置内存慢慢涨一段时间后新连接进不来。原因recv 返回 0 表示对端关闭这是正常流程不是错误。如果只在返回负数时才清理那些正常退出的连接就会残留。解决把n 0单独作为关闭分支处理和错误分支分开。连接关闭后同时清理接收缓冲、发送队列和 lastActive避免资源泄漏。4.5 Flash 安全沙箱拦截本地能连部署到服务器就不行现象服务端和客户端都在本机调试时一切正常一部署到远程服务器Flash 客户端就弹安全错误连接被拒。原因Flash Player 在浏览器里跑 Socket 时要过安全沙箱目标服务器必须在 843 端口返回 crossdomain 策略或者在连接建立后先推送策略文件否则 Socket 直接连不通。这是 Flash 的固有机制和 TCP 本身无关。解决在 accept 新连接后先发送一段cross-domain-policy内容再走业务协议。这个坑我已经见过不止一次所有做 Flash 游戏服务器的人都绕不开。const char* policy ?xml version\1.0\?\n !DOCTYPE cross-domain-policy SYSTEM \http://www.adobe.com/xml/dtds/cross-domain-policy.dtd\\n cross-domain-policy\n allow-access-from domain\*\ to-ports\9000\ /\n /cross-domain-policy\n; send(newSock, policy, (int)strlen(policy), 0);to-ports要写成实际监听的端口domain*表示允许任意域名如果你只需要特定站点访问可以把*替换成具体域名。注意策略内容最后要加换行Flash 的解析器对格式有要求。5. Flash 客户端对接AS3 Socket 二进制收发与沙箱处理5.1 沙箱问题的标准解法上一章最后那条政策文件是服务端的一半客户端这边也要处理SecurityErrorEvent。AS3 的 Socket 连接前要先监听安全错误否则报错时你可能什么都看不到只留下一个空白的连接失败提示。正确打开流程是先 addEventListener 注册各类事件再调用 connect顺序反了容易漏掉早期错误。另外如果你是在本机调试 Flash 文件Flash Builder 或 IDE 里的调试模式不受沙箱限制会给你一种“策略文件不需要”的错觉。部署到网页之后沙箱才生效所以开发时就要把策略文件逻辑写进服务器别等上线再补。5.2 AS3 Socket 连接与 ByteArray 组包Flash 端核心类就是flash.net.Socket配合ByteArray做二进制读写。我一般会包一个简单的客户端类统一处理连接、组包、粘包缓冲和消息分发。package { import flash.display.Sprite; import flash.events.Event; import flash.events.IOErrorEvent; import flash.events.ProgressEvent; import flash.events.SecurityErrorEvent; import flash.net.Socket; import flash.utils.ByteArray; public class GameSocketClient extends Sprite { private var sock:Socket new Socket(); private var recvBuf:ByteArray new ByteArray(); public function GameSocketClient() { sock.addEventListener(Event.CONNECT, onConnect); sock.addEventListener(ProgressEvent.SOCKET_DATA, onData); sock.addEventListener(Event.CLOSE, onClose); sock.addEventListener(IOErrorEvent.IO_ERROR, onError); sock.addEventListener(SecurityErrorEvent.SECURITY_ERROR, onError); sock.connect(127.0.0.1, 9000); } private function onConnect(e:Event):void { var body:ByteArray new ByteArray(); body.writeUTF(login|demo); sendPacket(1, body); } private function sendPacket(msgId:int, body:ByteArray):void { var pkg:ByteArray new ByteArray(); pkg.writeShort(2 body.length); pkg.writeShort(msgId); pkg.writeBytes(body); sock.writeBytes(pkg); sock.flush(); } private function onData(e:ProgressEvent):void { var fresh:ByteArray new ByteArray(); sock.readBytes(fresh, 0, sock.bytesAvailable); fresh.position 0; recvBuf.position recvBuf.length; recvBuf.writeBytes(fresh); recvBuf.position 0; while (recvBuf.bytesAvailable 2) { var pkgLen:int recvBuf.readUnsignedShort(); if (recvBuf.bytesAvailable pkgLen - 2) { recvBuf.position - 2; break; } var msgId:int recvBuf.readUnsignedShort(); var payload:ByteArray new ByteArray(); recvBuf.readBytes(payload, 0, pkgLen - 4); handleMessage(msgId, payload); } var left:ByteArray new ByteArray(); if (recvBuf.bytesAvailable 0) { recvBuf.readBytes(left, 0, recvBuf.bytesAvailable); } recvBuf left; } private function handleMessage(msgId:int, payload:ByteArray):void { // 根据消息 ID 分发到具体业务处理 } private function onClose(e:Event):void { // 提示玩家连接断开必要时自动重连 } private function onError(e:Event):void { // 区分 IO 错误和沙箱错误分别提示 } } }逻辑说明组包时writeShort(2 body.length)与 C 端len 消息ID 包体长度的定义保持一致解析时先读长度头再读消息 ID再按长度取包体。readUnsignedShort返回的是大端整数C 端用htons发出来的数据正好对应上。粘包处理和服务端思路一致先累积到 recvBuf再循环拆包拆不完整的把读指针回退两字节等待下一次数据到来。5.3 帧循环驱动把网络事件并入游戏心跳游戏客户端通常不直接在主线程里阻塞等网络而是把网络事件交给事件循环业务逻辑放在帧循环里跑。每帧调用一次更新函数处理队列中收到的消息再统一发送本帧的操作指令。这样网络消息和键盘鼠标输入是同一节奏避免了多线程同步的复杂度。AS3 的 Socket 事件本身就是异步的onData 在收到数据时被回调所以只要保证 handleMessage 里不写耗时逻辑把消息转存到队列帧循环里再消费就不会出现 UI 卡顿。我习惯把 sendPacket 包一层队列每帧把所有待发消息合并成一个大 ByteArray 一次 flush降低系统调用次数实测对 CPU 占用有明显改善。6. 验证与扩展压测脚本、64 连接上限与下一步演进6.1 用 Python 脚本模拟 50 个客户端回归服务端写完不能只靠一个 Flash 客户端点两下就说能用。我一般会写一个 Python 脚本模拟几十个客户端同时连接、发包、收包验证 select 模型在并发下的稳定性。脚本的重点是发送频率、包长变化和连接关闭顺序。import socket import struct import time def build_packet(msg_id: int, body: bytes) - bytes: return struct.pack(H, len(body) 2) struct.pack(H, msg_id) body clients [] for i in range(50): s socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((127.0.0.1, 9000)) s.send(build_packet(1, fplayer-{i}.encode(utf-8))) clients.append(s) time.sleep(0.01) print(50 clients connected) for c in clients: try: head c.recv(4) if len(head) 4: print(short header, len(head)) else: body_len struct.unpack(H, head[0:2])[0] print(packet len, body_len) except Exception as e: print(error, e) for c in clients: c.close()struct.pack(H, ...)里的表示大端与协议定义一致。实测时如果服务器在 50 个连接下还能稳定回包说明连接数组和收发循环没有明显并发问题。接着再跑一轮更狠的每个客户端连续发 200 个随机大小包中间随机断开观察服务端是否崩溃或内存上涨。能跑过这一轮这份资源的核心逻辑就过关了。6.2 突破 64 连接上限宏定义与分片管理FD_SETSIZE 改成 1024 只解决了“够用”没有解决 select 本身的架构瓶颈。select 每轮都是线性扫描全部 socket连接数上千后即使没有事件一轮扫描也会有可感知的 CPU 开销。所以我在这个资源里的做法是先用宏扩到 1024 顶住中型游戏同时把连接数组按区间分片每个线程各管 256 个 socket 跑 select线程之间通过无锁队列交换消息。分片的收益在于每个线程的 fd_set 变小select 的扫描量也跟着变小。实测 4 线程分片比单线程 select 在 800 连接时 CPU 低了 60% 左右但延迟几乎一样。再往上走才是 WSAEventSelect、IOCP 或 epoll 的领域。6.3 下一步演进的三个方向把 select 模型跑通之后你其实已经理解了事件驱动的基本循环等待事件、分类处理、清理资源。这个心智模型直接迁移到 epoll 和 IOCP 都成立。我个人建议的进阶顺序是先读清楚 select 的 fd_set 机制再去看 WSAEventSelect 的事件对象和事件通知最后碰 IOCP。每个模型都是“注册事件、等待通知、处理数据”三步只是通知方式和线程模型不同。这份资源的价值正在于用最直观的 select 把这三步夯实。我从第一次用 select 写 Flash 游戏服务器到现在吃过连接上限、写事件忙轮询、沙箱策略漏配这些亏。每次拿到一个事件驱动服务器项目我都会强制走一遍先定协议长度头再确认连接数组上限再测 50 个并发客户端最后看一眼心跳清理有没有跑。这套动作看似简单但把它固化成流程后我在服务器这块真的少踩了很多坑。希望这些经验帮到你。本文还有配套的精品资源点击获取 SEO 优化官网定制响应式建站教育培训建站