资讯动态

老Flash联机Demo拆解:TCP长连接与select模型实战

发布时间:2026/10/21 8:15:43 来源:尧图企业网站定制
简介这一压缩包汇集了Flash游戏客户端与配套服务器源码并以PPT讲解双方交互的数据包格式适合正在学习网络编程、TCP通信及select I/O多路复用模型的开发者参考尤其对想要理解传统浏览器端游戏如何与后端服务交互的读者十分实用。包内共15个文件以C源码cpp/h、编译中间文件obj/pdb及可执行程序exe为主另有工程配置与说明文档其中服务器控制台程序负责处理客户端请求、同步游戏状态并演示了如何用select同时管理多个套接字而Flash游戏如flash_car2则作为客户端展示实时交互过程。整体649KB结构紧凑便于快速查看关键代码与运行效果。已有235人学习对于想理解在线游戏服务器架构、数据包设计或select模型实战应用的人这是一份可运行的参考案例能帮助你将课堂理论与具体实现对应起来。1. 一个老Flash联机DemoTCP长连接与select模型的完整组合Flash游戏在今天的浏览器里已经不多见但联机玩法的通讯逻辑并没有过时客户端用ActionScript 3的Socket建立TCP长连接服务器用C的select模型同时管理几十上百个连接消息按自定义二进制协议收发。这份「flash游戏以及服务器」就是这样一个最小可跑的联机Demo——包含登录、房间列表、移动同步三条业务链路服务端代码量不大却把socket阻塞、粘包、fd_set重设、客户端掉线清理这些底层问题都实际跑了一遍。适合两类人一类是接手老Flash项目、需要把服务器逻辑抽出来改造成新通讯层的人另一类是刚接触TCP服务端编程、想用最直接的方式理解多连接管理的新手。翻车与踩坑我在拆的过程中都遇到了照着下面章节一步步走能少走很多弯路。2. 整体链路与技术选型从AS3客户端到C select服务器2.1 项目定位登录、房间、移动同步三条链路这份Demo的角色不是仿真大作而是Flash联机游戏里最常见的通讯原型。客户端启动后先发登录请求服务器验证账号后返回角色信息和房间列表玩家加入某个房间后客户端每移动一次就发一条同步消息给服务器服务器再把这条移动广播给同房间其他人。整个服务器要处理的事情可以拆成三类连接管理、协议解析、业务分发。连接管理负责accept新连接、recv数据、关闭异常连接协议解析负责从TCP字节流里切出完整消息业务分发则根据消息ID调用登录、房间、移动对应的处理函数。这三层在代码里是分开的net目录管连接proto目录管解析biz目录管业务。这样的分层对后来做二次开发很友好——想改成epoll就改net层想加消息类型就改proto和biz层彼此不纠缠。技术栈上客户端是ActionScript 3.0用内置的Socket类做TCP通讯消息体用ByteArray序列化服务端是C用标准socket接口加select做并发。这套组合是早期Flash联机游戏的主流标配没有引入任何第三方框架依赖极少拿到源码后直接就能编译。2.2 服务端为什么选select模型而不是其他方案服务端并发方案常见的有阻塞多线程、select模型、epoll、IOCP。这个Demo选select有几个现实原因。第一老Flash联机项目大多部署在Windows服务器上select模型在Windows和Linux都能编译代码不需要平台分支IOCP在Windows上性能好但门槛高epoll则只能在Linux用。第二房间规模不大几十个连接属于select的舒适区它默认能管理的fd上限是1024对当前业务绰绰有余。第三单线程模型对调试非常友好数据竞争少新人看代码不会一头雾水。相比直接套libevent或Boost.Asio手写select的好处是每一步都透明。fd_set里填充了哪些文件描述符select返回后哪些socket可读数据读完缓冲区还剩多少全部清晰可见。等连接数真的大于500再往epoll迁移也不迟因为业务分发和连接管理是分离的迁移成本主要在net层。提示select模型的小并发定位要心里有数。它适合单个房间几十人、整个服几百人这类场景不适合万人同服。认清场景再看代码很多设计决策就顺理成章。连接管理上有个细节值得注意select是水平触发只要fd上有数据没读完下次select依然会返回可读。所以接收缓冲区一定要及时消费否则主循环会被某个不活跃的连接反复唤醒。这个Demo的做法是先用非阻塞socket加200毫秒超时单次recv最多读2048字节剩下的留在缓冲区下一轮再继续读。2.3 目录结构与编译顺序拿到源码后先看目录结构我拆的时候把核心文件单独拎了出来。服务端代码大致按下面这个方式组织server/ main.cpp # 入口初始化监听socket并进入select循环 net/ select_server.cpp # select主循环、accept新连接 conn.cpp # 单个客户端的fd、缓冲区、状态管理 proto/ message.h # 消息结构定义 dispatcher.cpp # 按消息ID分发到业务函数 biz/ login.cpp # 登录验证与角色信息返回 room.cpp # 房间列表、加入房间、移动广播 utils/ buffer.cpp # 收包缓冲区的粘包半包处理 flash_client/ GameSocket.as # 封装Socket连接、发送、接收解析 LoginView.as # 登录界面逻辑 RoomView.as # 房间内移动同步逻辑看代码的顺序建议是conn.cpp、select_server.cpp、buffer.cpp、dispatcher.cpp。先把单个客户端的数据结构看明白再理解主循环怎么管理一堆客户端最后看缓冲区如何处理TCP的流式数据。这套顺序比从头文件开始看高效得多。编译方面服务端用g直接编就能过命令行大致是这样g -o flash_server main.cpp net/select_server.cpp net/conn.cpp \ proto/dispatcher.cpp biz/login.cpp biz/room.cpp utils/buffer.cpp \ -lpthread -O2不需要额外链接第三方库-lpthread是为后面扩展心跳线程预留的。编译完成直接运行监听端口默认9000。客户端用Flash Builder或对应的AS3 IDE打开flash_client目录把GameSocket里的连接地址改成服务器IP就能联调。3. 协议层设计客户端和服务器之间怎么说话3.1 自定义二进制协议消息头加消息体TCP是流式协议数据像水管里的水一样没有边界。要区分“一条一条的消息”必须在协议层做封包。这份Demo用的是最常见的二进制协议每条消息由一个固定长度的头和可变长度的体组成。头里放消息ID和消息体长度服务器拿到数据后先读头再按长度读体。消息头定义在message.h里核心是一个紧凑结构体#pragma pack(push, 1) struct MessageHeader { uint16_t msg_id; // 消息ID比如1登录2加入房间3移动 uint32_t body_len; // 消息体字节数最大可支持4GB }; #pragma pack(pop)#pragma pack(push, 1)的作用是取消结构体的内存对齐让msg_id和body_len紧密排列。如果编译器默认对齐到4字节sizeof(MessageHeader)会从6变成8客户端和服务端计算的包头长度就对不上直接导致粘包解析全部错位。这个细节很坑很多人第一次联调失败就栽在这里。消息ID用uint16_t上限65535条消息类型对游戏项目足够用。body_len用uint32_t意味着单条消息理论上可以有4GB但实践里建议限制到64KB以内。原因是select模型的接收缓冲区通常只有几KB一条超大消息要分多次recv才能攒齐业务处理时间也会拉长容易被别的玩家感知到卡顿。3.2 Flash客户端Socket封装AS3里的连接与发送客户端这边ActionScript 3的Socket类封装了TCP连接的细节。常见做法是写一个GameSocket类把connect、send、数据事件监听都包一层业务界面就不需要直接碰Socket API。下面是一个最小可用的封装package { import flash.net.Socket; import flash.events.Event; import flash.events.ProgressEvent; import flash.utils.ByteArray; public class GameSocket { private var sock:Socket; public function connect(host:String, port:int):void { sock new Socket(); sock.addEventListener(Event.CONNECT, onConnect); sock.addEventListener(ProgressEvent.SOCKET_DATA, onData); sock.connect(host, port); } public function sendMessage(msgId:int, body:ByteArray):void { var out:ByteArray new ByteArray(); out.writeShort(msgId); out.writeInt(body.length); out.writeBytes(body); sock.writeBytes(out); sock.flush(); } private function onData(e:ProgressEvent):void { // 数据到达后由解析函数按包头切分 } } }sendMessage的写法与服务端包头严格对应writeShort写入2字节的消息IDwriteInt写入4字节的body长度最后写入请求体。这里要特别强调writeIntAS3的ByteArray.writeInt固定写4字节与C的uint32_t小端序一致不需要额外转换。Flash的Socket是异步模型connect之后不能立刻write必须等Event.CONNECT事件触发。很多初学AS3的人在这里犯过忘记监听CONNECT事件就直接发数据的错误服务器收不到任何东西还以为是网络问题。另外发送完数据要调用flush()否则数据停留在Flash运行时缓冲区里不会真正落到TCP连接上。3.3 粘包与半包处理缓冲区里的拼图游戏TCP没有消息边界100条消息发送后服务器可能一次recv收到好几条也可能只收到一条消息的前半截。前者叫粘包后者叫半包。处理方式是同一套服务器为每个连接维护一个接收缓冲区数据来了先追加到缓冲区然后循环尝试从缓冲区头部解析消息。buffer.cpp里的解析逻辑是这个Demo的核心之一简化后大致长这样int parse_buffer(Client* cli) { while (cli-recv_len (int)sizeof(MessageHeader)) { MessageHeader* h (MessageHeader*)cli-recv_buf; uint32_t need h-body_len; if (cli-recv_len (int)sizeof(MessageHeader) (int)need) { break; // 半包总数据还不够等下一轮recv } // 凑齐了一条完整消息交给分发器 dispatch(h-msg_id, cli-recv_buf sizeof(MessageHeader), need); // 把剩余数据搬到缓冲区头部继续循环 size_t left cli-recv_len - sizeof(MessageHeader) - need; memmove(cli-recv_buf, cli-recv_buf sizeof(MessageHeader) need, left); cli-recv_len left; } return 0; }这段代码的逻辑要点是第一次进入循环时先判断缓冲区是否够一个包头不够就直接break等select下一轮继续读够就解析出body_len再判断包头加消息体的总长度是否已经就绪如果消息体还没收全同样是break收全了就调用dispatch处理然后用memmove把剩余数据搬到缓冲区起点。整个过程像拼图一次循环处理一条完整消息一条不浪费。memmove在这里不能用memcpy代替。因为源地址和目标地址重叠memcpy的行为是未定义的只有memmove能正确处理重叠拷贝。这个位置如果写错数据错位会表现得极其诡异——可能某条消息正常下一条消息的msg_id变成上一段body的残留。还有一个容易被忽略的问题缓冲区长度限制。Demo里BUFFER_SIZE默认是8KB如果一条消息体超过了这个值或者客户端疯狂发包把缓冲区塞满解析代码会陷入死循环。常见的做法是在追加数据前做一次容量检查超过限制直接关闭连接防止被恶意客户端撑爆内存。4. 核心模块拆解select模型下一个消息从进入到返回的完整链路4.1 select主循环fd_set的填充、轮询、重设服务端的主循环位于select_server.cpp所有连接的可读事件都从这里分发。这个循环做的事情可以概括为三步把要监听的所有fd塞进fd_set调用select等待事件遍历fd_set找出可读的socket。代码核心结构如下void server_loop(int listen_fd) { fd_set readfds; while (!g_stop) { FD_ZERO(readfds); // 1. 清空集合 FD_SET(listen_fd, readfds); // 2. 加入监听fd int max_fd listen_fd; for (int i 0; i MAX_CLIENTS; i) { if (g_clients[i].fd 0) { FD_SET(g_clients[i].fd, readfds); if (g_clients[i].fd max_fd) { max_fd g_clients[i].fd; } } } struct timeval tv; tv.tv_sec 0; tv.tv_usec 200000; // 200ms超时 int ret select(max_fd 1, readfds, NULL, NULL, tv); if (ret 0) { continue; // 被信号打断重新填充 } if (FD_ISSET(listen_fd, readfds)) { accept_new_client(); // 有新连接 } for (int i 0; i MAX_CLIENTS; i) { if (g_clients[i].fd 0 FD_ISSET(g_clients[i].fd, readfds)) { handle_client_read(i); // 该客户端有数据到达 } } } }每次循环必须重新执行FD_ZERO和FD_SET这个顺序不能反过来。select返回后会改写fd_set只保留有事件的fd下一次循环如果直接用旧的fd_set没事件到达的fd就会全部消失表现为连接陆续断线。这是select模型最容易犯的错误几乎每个手写过select的人都在这上面翻过车。readfds之外的三个参数分别是写集合wr_fds、异常集合ex_fds和超时时间。这份Demo只监听了可读事件写操作通过发送缓冲区和send返回值来判断没有单独用写集合。对于小并发游戏场景把send做成阻塞调用通常不会出问题因为玩家操作频率低数据量小如果是实时战斗类游戏需要另开发送线程或用非阻塞send加上写事件监听。关于select的第一个参数Linux下要求传入“所有fd中最大值加1”。max_fd在每次循环里重新计算就是为了保证传入的值准确。Windows的select不校验这个参数但为了跨平台代码一致性Demo仍然取了max_fd 1。时间tv设为200毫秒一方面避免select永久阻塞导致无法处理定时任务另一方面不至于空转浪费CPU。4.2 新连接接入客户端对象是怎么被管理的监听fd可读时说明有一个新客户端发起了TCP握手需要调用accept把它接入。accept之后要做的事比想象中多一些不只是拿到一个fd就完事。下面这段是Demo里的接入逻辑void accept_new_client() { struct sockaddr_in addr; socklen_t addr_len sizeof(addr); int client_fd accept(listen_fd, (struct sockaddr*)addr, addr_len); if (client_fd 0) { return; } // 从全局客户端数组里找一个空位 int idx find_free_slot(); if (idx -1) { close(client_fd); // 连接数已满直接断开 return; } g_clients[idx].fd client_fd; g_clients[idx].recv_len 0; g_clients[idx].last_active_time time(NULL); g_clients[idx].state ST_CONNECTED; // 设置为非阻塞模式避免recv阻塞主循环 set_nonblocking(client_fd); }客户端管理用的是固定数组MAX_CLIENTS默认64。数组的好处是下标即ID查找快没有链表的内存碎片问题坏处是64是硬上限连接满了只能拒绝。select模型本来就限制在1024以内固定数组是合理的简化方案二次开发时如果连接数预期更大可以把数组改成vector加空闲链表。设置非阻塞模式很重要。如果保持阻塞模式某个客户端recv时数据没到线程会卡在recv里整个select循环停摆其他所有连接全部假死。非阻塞模式下recv没数据时立即返回-1并设置EWOULDBLOCK错误码主循环可以继续处理别的事件这是多连接服务端的基本素养。4.3 消息读取与业务分发从字节流到业务逻辑handle_client_read承担了读取数据、追加缓冲区、触发解析三个步骤。每次最多读2048字节避免单次读取阻塞太久void handle_client_read(int idx) { Client* cli g_clients[idx]; char tmp[2048]; int n recv(cli-fd, tmp, sizeof(tmp), 0); if (n 0) { // n0对端关闭n0非阻塞且无数据两者都要清理连接 close_client(idx); return; } if (cli-recv_len n BUFFER_SIZE) { close_client(idx); // 缓冲区溢出协议异常或客户端恶意发包 return; } memcpy(cli-recv_buf cli-recv_len, tmp, n); cli-recv_len n; cli-last_active_time time(NULL); parse_buffer(cli); // 尝试从缓冲区解析出完整消息 }recv的返回值需要区分三种情况大于0表示实际读取到n个字节等于0表示对端正常关闭连接此时要立刻close_client否则这个fd会一直被select标记为可读主循环空转小于0要检查错误码非阻塞模式下的EWOULDBLOCK不算异常但其他错误码同样要走close_client。初版代码经常只处理了小于0的情况结果对端关闭后fd反复可读CPU占用率被拉高到不正常水平。parse_buffer解析出完整消息后会调用dispatch函数。dispatch本质上是一张消息ID到处理函数的跳转表用switch实现void dispatch(uint16_t msg_id, const char* data, uint32_t len) { switch (msg_id) { case MSG_LOGIN: handle_login(g_current_client, data, len); break; case MSG_JOIN_ROOM: handle_join_room(g_current_client, data, len); break; case MSG_MOVE: handle_move(g_current_client, data, len); break; case MSG_PING: handle_ping(g_current_client, data, len); break; default: // 不认识的消息ID记日志即可别关闭连接 printf(unknown msg_id%u\n, msg_id); break; } }g_current_client是一个全局指针dispatch前由上层设置好当前正在处理哪个客户端。之所以没有把它作为参数直接传下来是为了让biz层的登录、房间函数签名保持简洁——它们只需要关心数据和长度不需要关心连接管理细节。这个取舍在Demo的场景里成立但如果同一份代码将来迁移到多线程这个全局变量要立刻改掉改成参数传递。4.4 心跳与超时清理避免僵尸连接占着名额TCP连接本身不会主动通知服务器“客户端已经死了”客户端程序直接关掉电脑或拔掉网线服务器看到的还是established状态。要检测这种僵尸连接游戏服务器普遍采用心跳机制客户端每隔5秒发一条PING服务器记录last_active_time如果10秒内没收到任何数据就判定超时。主循环在select超时返回后的空闲时机检查所有客户端的活跃时间void check_timeout() { time_t now time(NULL); for (int i 0; i MAX_CLIENTS; i) { if (g_clients[i].fd 0 g_clients[i].state ! ST_LOGINED) { continue; // 未登录的连接不参与心跳检查直接由登录超时管理 } if (g_clients[i].fd 0 now - g_clients[i].last_active_time KICK_TIMEOUT) { printf(kick timeout client fd%d\n, g_clients[i].fd); close_client(i); } } }心跳的好处不只是清理僵尸连接还能让服务器掌握玩家真实在线状态。玩家突然断网时服务器最多在10秒内就能释放这个连接占用的fd和缓冲区否则连接数会被慢慢耗尽新玩家无法登录。Flash客户端侧配合一个Timer每5秒调一次sendMessage发MSG_PING这是最简单又最有效的保活手段。5. 避坑指南Flash联调中踩过的五条血泪经验5.1 recv返回0没有立即清理CPU被打满现象几个客户端退出后服务器进程CPU占用率一直维持在30%以上日志里没有任何异常。排查时发现有个客户端的fd一直处于可读状态select每次返回都触发handle_client_read。原因初版代码在handle_client_read里只判断了n小于0才清理连接。对端正常关闭连接时recv返回的是0而不是-10被当成正常情况跳过fd留在fd_set里。因为TCP关闭后fd永久可读select每200毫秒返回一次一次空转循环反复唤醒CPU自然被拉高。解决把清理条件改成n 0无论是0还是负数都走close_client。这个修复是这份Demo里最值得记住的一行代码。5.2 select返回后没有重设fd_set新连接一接就断现象服务启动后前几个连接工作正常再过一会儿新连接的TCP握手都完成了但登录消息发过去一直没有回应过几秒连接被系统关闭。原因select会修改传入的fd_set只保留有事件发生的fd。如果循环体开始处没有FD_ZERO和FD_SET重建集合下一次select监听的fd数量会越来越少最终只剩listen_fd。新连接的fd虽然被accept了但它从来没有被加入后续的fd_set所以它的数据没人读。解决每次循环必须无条件重建fd_set顺序是FD_ZERO、加入listen_fd、遍历所有活跃客户端加入其fd。这个坑的排查方法也很直接在select返回后把当前可读的fd都打出来和预期对比。5.3 粘包只解析了一部分第二条消息永远报错现象客户端连续发送两条消息服务器收到的第一条正常处理第二条的msg_id看起来像乱码或者body_len大得离谱之后这个连接所有消息都解析失败。原因第一次recv可能同时收到两条消息的数据缓冲区里有消息A和消息B的一部分。解析代码在处理完消息A后只把游标向前移动了消息A的长度B的数据还残留在缓冲区。如果不做memmove或者只移动游标不移动数据下一次解析的起始位置就会错位。解决统一用“处理一条、搬移一次”的做法解析成功后把剩余数据memmove到缓冲区头部。搬移后recv_len要同步更新否则缓冲区头部会遗留脏数据干扰下一次解析。这个问题的玄学之处在于它不总是发生偶尔出现一次最容易被误判为网络问题。5.4 浏览器运行SWF连不上本机服务器安全沙箱拦截现象用Flash调试工具直接运行SWF连接服务器正常但把SWF放到网页里用浏览器打开服务器日志里完全没有TCP连接记录。一开始怀疑是端口被防火墙拦了换端口也一样。原因Flash Player的浏览器插件对socket连接有安全沙箱限制。客户端连接某个服务器端口之前会先请求该端口上的安全策略文件如果服务端没有返回合法的crossdomain.xml策略连接会在应用层被直接拒绝。本地调试工具没有沙箱所以表现不一致。解决在服务器监听逻辑里处理策略请求。常见做法是在客户端连接后先读一段数据判断是不是“ ”如果是就直接返回策略文件内容再关闭连接。这份Demo里的处理方式比较简略二次开发时建议把策略逻辑单独拆一个文件避免污染正常协议解析。5.5 业务线程直接send和select主循环抢发送缓冲现象为了提升房间广播效率把发送逻辑放到了一个独立线程里结果偶尔出现某条广播消息丢失、极少数情况崩溃。日志里没有明显报错崩溃位置还不固定。原因两个线程同时对一个socket调用send可能交错写入同一段数据导致协议内容错乱。更严重的是C标准库的send本身是线程安全的但发送缓冲区不是一个线程正在写包头另一个线程插进来写了一半的body对端解析就会失败。解决统一由select主循环所在的线程负责send业务线程只把待发送消息塞进一个互斥锁保护的队列。主循环每次select返回后检查队列统一发送。这个改动单独看不大但它让整个服务端从“多线程各干各的”变成“单一负责制”排查问题容易得多。6. 二次开发从“能跑”到“能上线”我建议这么改6.1 给协议加版本号和握手为后续兼容留后路原版协议的MessageHeader只有msg_id和body_len客户端连上来直接发登录消息。这种方式在Demo阶段没问题但到了真实项目里服务器要面对多个不同版本的客户端老版本没做过登录就升级了新版本。我会把协议头改成这样#pragma pack(push, 1) struct MessageHeader { uint16_t version; // 协议版本号收到不支持的版本直接拒绝 uint16_t msg_id; uint32_t body_len; }; #pragma pack(pop)改动之后客户端连接成功后先发一条握手消息带上自己声明的版本号。服务器校验版本号合法后才允许继续发登录消息。如果版本不匹配直接返回错误包并关闭连接。这段逻辑加在dispatch之前对业务代码完全无感。代价是协议头从6字节变成8字节粘包解析的sizeof(MessageHeader)会自动适配不需要改buffer.cpp的解析框架。6.2 用一个Python压测脚本验证改动的正确性开发服务器最怕“改完本地能跑一上线就翻车”。我给你一个自己用了很久的习惯每次改完协议先写一个轻量压测脚本模拟20个客户端同时连接、乱序发消息看服务器是否稳定。Python标准库的socket足够干这个事import socket import struct import threading import time def make_msg(msg_id: int, body: bytes) - bytes: # 前缀2字节msg_id 4字节body_len return struct.pack(HI, msg_id, len(body)) body def client_worker(idx: int): s socket.create_connection((127.0.0.1, 9000), timeout3) s.sendall(make_msg(1, buser_%d % idx)) # 登录 time.sleep(0.1) s.sendall(make_msg(3, bmove_x100)) # 移动 time.sleep(0.5) s.close() threads [threading.Thread(targetclient_worker, args(i,)) for i in range(20)] for t in threads: t.start() for t in threads: t.join() print(20 clients done)参数说明struct.pack的格式串HI表示小端序、2字节无符号短整型加4字节无符号整型正好对应C端的MessageHeader。登录消息的msg_id1移动消息的msg_id3需要和dispatch里的case保持一致。压测时观察服务器日志里是否出现异常断连、消息解析失败之类的输出如果连续跑三轮都干净再往代码库合。当然后续要真正上线还需要改很多日志系统、配置项、房间过期清理、断线重连这些都在这份Demo的基础上逐个加。但核心的通讯骨架不用动这就是当初把协议、连接、业务拆开的价值。那次改版本号的时候我写完压测脚本直接跑了发现连续几次都有几条消息解析失败排查到最后是客户端SendMessage里writeShort和writeInt的字节序没对齐。从那以后我每次改协议都强制自己先把抓包窗口打开跑一遍联调脚本确认完整来回再合代码。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑