简介:压缩包围绕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 为什么选 TCP:Flash 端 Socket 接口与服务器推送
Flash 端有两条网络通道。老一点的是 XMLSocket,只能走文本,收发都以\0结尾,效率很低,做实时对战基本不推荐。新一点的是 AS3 自带的 Socket 类,支持 ByteArray 二进制读写,底层走 TCP,连接稳定,适合承载帧同步指令或者状态同步协议。服务端同时服务几十个小型客户端,体量不大,但对延迟和服务器主动下发数据的能力有硬要求。HTTP 轮询每秒钟产生大量无意义请求,拉高服务器负载,交互延迟还高,所以我会直接选 TCP 长连接。
长连接的好处是连接建立一次,之后双向通信都基于同一连接,服务器可以随时往下推数据。比如其他玩家移动了、攻击命中了、房间状态变化了,都能立刻推送,不需要客户端反复来问。代价是服务器要自己管理连接生命周期,包括超时、断线重连、半包处理和并发模型。这套资源里的服务器正是围绕这些点展开的,而 select 就是管理几十个 TCP 连接最直接的那个轮子。
2.2 二进制协议设计:两字节长度头加消息 ID
协议是我每次强调的第一优先级。不要把业务逻辑直接写在 netty 或者 socket 回调里,先把报文格式定死,后面所有模块都省心。这套资源用的协议很简单:两字节长度头 + 两字节消息 ID + 包体。
| 字节偏移 | 长度 | 内容 | 说明 |
|---|---|---|---|
| 0-1 | 2 字节 | 包体长度 len | 不含长度头自身 |
| 2-3 | 2 字节 | 消息 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 初始化与监听 socket:WSAStartup、bind、listen
C++ 服务端入口没什么花哨的,先初始化 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_set
select 的核心操作就是三件事:把关心的 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 秒而不是 NULL,NULL 代表无限期阻塞,那心跳和超时清理就没机会执行了。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]置为 true,select 返回后只对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 默认容量是 64,select 内部按 FD_SETSIZE 处理位。连接超过 64 后,FD_SET 写入越界,内存被踩,select 行为完全不可预期。
解决:在#include <winsock2.h>之前先#define FD_SETSIZE 1024,同时把所有依赖 FD_SETSIZE 的数组同步放大到 1024。注意 1024 也不是无限大,select 本身是线性扫描,连接再多就该换模型了。
4.2 socket 断开后没清 fd_set:CPU 莫名飙到 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 每次都会返回大量可写 socket,send 不停地空转,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, f"player-{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 个并发客户端,最后看一眼心跳清理有没有跑。这套动作看似简单,但把它固化成流程后,我在服务器这块真的少踩了很多坑。希望这些经验帮到你。
本文还有配套的精品资源,点击获取