C++ C/S考试系统:TCP粘包、epoll高并发与断线重连

C++ C/S考试系统:TCP粘包、epoll高并发与断线重连 简介这份实训指导文档面向具备初步程序设计经验的大专院校信息类专业学生及开发者围绕 C/C 环境下考试系统的构建与 C/S 模式开发展开解决从数据库连接到网络通信的实操落地问题。文档以两周任务计划为主线第一天到第五天依次覆盖 Visual Studio 连接 MySQL 的环境搭建、库表创建与数据库函数使用、账号注册与登录身份识别、用户表与试题表的增删改查、随机组卷与自动评分第二周引入 socket 通信讲解客户端与服务器之间收发函数的改写完成登录信息传递与教师、学生角色的分支应答并给出多工程协同的演示思路。资源包内为 1 个 docx 文件约 12KB集中呈现每日学习任务、时间分配、成果展示规范与评估方式。目前已有 62 人学习。读者可据此获得可落地的项目拆解、分阶段验收标准与排错方向。1. 从一场90人机房联考说起C/C 与 C/S 模式为什么还值得选开考铃响的瞬间90 台机器同时点下登录监考端要在一秒内确认所有考生就位交卷倒计时归零90 份答卷要同时上传、自动判分并锁定成绩。这种瞬时高并发 强一致 断网不能丢卷的场景正是考试系统最真实的压力点。用 C/C 配合 C/S 模式来做优势不在时髦而在于客户端是装有本地缓存的原生进程服务端能把内存、线程、网络收发控制到字节级遇上网线被拔、机器重启这类考场高频事故答卷仍然握在本地。这篇讲的是怎么把一个能真正进机房的考试系统搭起来协议怎么定、服务端并发怎么选、客户端怎么防止断线丢分以及工程怎么用 CMake 和 VS Code 跑通调试。适合写过一点 SOCKET、想把零散知识拼成一个完整系统的开发者。2. 考试系统 C/S 通信协议设计TCP 粘包、报文头与指令状态机协议定得好不好直接决定后面服务端和客户端写起来是舒服还是痛苦。考试系统的通信特征很明确连接数量固定就是考生数、消息小而频繁心跳、答题进度、倒计时同步、对可靠性的要求远高于对吞吐的要求。基于这几点选型和格式都有比较稳的答案。2.1 为什么考试系统优先选 TCP 长连接而不是 HTTP 短轮询HTTP 短轮询在这个场景里有两个硬伤。一是延迟抖动轮询间隔决定了服务端下发指令比如还剩 5 分钟、强制收卷的最坏到达时间间隔设小了请求量翻倍设大了监考指令就滞后。二是连接成本90 个客户端每秒各发一次请求服务端要反复做握手、解析、释放考场这种局域网环境本可以维持长连接省掉这些开销。长连接配合自定义二进制协议服务端可以在任意时刻主动推送客户端也能用很小的包维持在线状态。代价是要自己处理粘包、心跳和断线这些正是下一节要解决的事。2.2 报文头定义与粘包、半包处理网络层交给我们的是一条字节流不是一个个消息。考试系统里答题包可能只有几十字节交卷包带上整份答卷可能几十 KB两种长度混在一起粘包一定会发生。标准解法是固定长度报文头 头里写明包体长度。#pragma pack(push, 1) // 关掉对齐填充保证两边结构体字节一致 typedef struct { uint32_t magic; // 魔数固定 0x4D414558用来快速识别脏数据 uint16_t cmd; // 指令号见下方指令表 uint16_t flags; // 预留标志位如压缩、加密 uint32_t body_len; // 包体字节数收满这么多才算一个完整包 uint32_t seq; // 序号用于请求应答配对与去重 uint32_t crc32; // 对 body 做校验防止考场网线老化导致位翻转 } exam_header_t; #pragma pack(pop)magic是第一个过滤器缓冲区里出现对不齐的字节时靠它滑动窗口重新同步。body_len决定还要再收多少字节是拆包的唯一依据。seq让客户端能判断某个应答是不是自己刚才那条请求的结果弱网重发时避免重复判分。crc32的收益在机房里特别明显劣质网线和接触不良的网口会零星出错有校验就能让客户端重传而不是提交一份坏答卷。对应的收包循环核心是一个可增长的缓冲区// 从 fd 读数据追加到 conn-buf然后尽量切出完整包 int on_readable(conn_t *c) { ssize_t n recv(c-fd, c-buf c-len, c-cap - c-len, 0); if (n 0) return n; // 0 表示对端关闭0 需要看 errno c-len n; while (c-len sizeof(exam_header_t)) { exam_header_t h; memcpy(h, c-buf, sizeof(h)); if (h.magic ! 0x4D414558) { // 头部错位丢弃一字节重新找边界 memmove(c-buf, c-buf 1, --c-len); continue; } size_t total sizeof(h) h.body_len; if (c-len total) break; // 半包等下次可读事件再拼 dispatch(c, h, c-buf sizeof(h)); memmove(c-buf, c-buf total, c-len - total); c-len - total; } return n; }参数上要注意两点缓冲区按需扩容但设上限防止恶意或异常的超大body_len直接吃光内存recv单次读取的大小不要小于最大包体否则大批量交卷时会频繁触发系统调用。2.3 指令表与登录—答题—交卷状态机客户端和服务端各维护一份指令表是两边能对上的前提。cmd名称方向body 内容备注0x0001LOGIN_REQC→S考号、机器码、试卷号触发身份校验0x0002LOGIN_ACKS→C结果码、剩余时长、试卷摘要失败带原因码0x0010HEARTBEAT双向客户端本地时间戳默认 5 秒一次0x0020ANSWER_SYNCC→S题号、选项/文本每次作答都增量上报0x0021ANSWER_ACKS→C已落库的题号位图客户端据此清缓存0x0030FORCE_SUBMITS→C截止时间监考端下发0x0040SUBMIT_REQC→S完整答卷、校验和交卷0x0041SUBMIT_ACKS→C成绩或已收卷幂等重复提交返回同结果状态机在客户端侧很重要未登录 → 已登录 → 答题中 → 已交卷只有处于答题中才允许发送ANSWER_SYNC收到FORCE_SUBMIT后立刻停止接受输入。服务端按seq做幂等同一条SUBMIT_REQ重复到达只判一次分返回同一份结果。这一层做扎实考场上学生疯狂点交卷就不会变成事故。3. 服务端构建用 C 实现 epoll 并发、题库存取与自动判分服务端是整个系统的中枢它要同时面对连接管理、业务状态和判分三件事。908 台机器规模不大但设计上不要写成只有 90 人能跑的代码留出余量对后续扩容和复用都有好处。3.1 并发模型为什么是 epoll 而不是一连接一线程一连接一线程写起来最直观每个考生一个线程阻塞在recv上。但考试系统的流量是尖峰式的平时几乎没数据交卷瞬间集中爆发。线程模型在这种模式下会浪费大量调度和栈内存且线程间共享题库需要加锁锁竞争反而会拖慢判分。Linux 上更合适的是 epoll 少量工作线程或线程池。主线程只做事件分发收到完整包后投递到任务队列由固定数量的工作线程处理业务业务里对题库只读、对成绩单按考生分片写锁的粒度就能做得很小。int ep epoll_create1(0); struct epoll_event ev, events[MAX_EV]; ev.events EPOLLIN | EPOLLET; // 边沿触发减少重复通知 ev.data.fd listen_fd; epoll_ctl(ep, EPOLL_CTL_ADD, listen_fd, ev); while (running) { int n epoll_wait(ep, events, MAX_EV, 1000); // 1 秒超时方便做心跳超时检查 for (int i 0; i n; i) { if (events[i].data.fd listen_fd) accept_new(ep, listen_fd); else on_readable(conn_of(events[i].data.fd)); } sweep_timeout_conns(now_ms()); // 超时未心跳的连接在这里回收 }边沿触发要求每次可读事件必须把缓冲区读到EAGAIN否则会漏事件这是用 epoll 最容易踩的坑。epoll_wait的超时不要设 0忙等吃满 CPU也不要设 -1无法做超时清理留个几百毫秒到一秒把心跳检测嵌在循环里最省事。3.2 题库与试卷的内存布局题库是典型启动加载、运行期只读的数据最适合在进程启动时一次性读进内存之后全局共享只读完全不需要锁。结构关键字段说明Questionid、type、stem、options、answer单选/多选/判断/填空Paperpaper_id、duration、items[]有序题目列表StudentState考号、连接指针、已答位图、开始时间每个连接一份ScoreRecord考号、得分、提交时间、答卷快照交卷后写入试卷不要每来一个考生就拷贝一份完整题目对象。做法是试卷只存题目 id 列表考生状态里存题号→作答的稀疏映射。90 份答卷共用同一份题库内存占用几乎等于题库本身大小。真正需要单独存的只有考生的答案和状态位图。3.3 收卷、判分与成绩落库判分逻辑本身简单难点在不能阻塞在交卷高峰上。一条SUBMIT_REQ到达后正确顺序是先完整落盘原始答卷写文件或写库再判分最后回SUBMIT_ACK。哪怕判分线程崩了原始答卷还在可以离线重判。int handle_submit(StudentState *s, const exam_header_t *h, const char *body) { // 1. 幂等该考生已交卷则直接返回同结果 if (s-submitted) return send_submit_ack(s, s-final_score); // 2. 先持久化原始答卷保证不丢 if (save_raw_answer(s-exam_no, body, h-body_len) ! 0) return send_err(s, E_PERSIST_FAIL); // 3. 判分按题号逐题比对答案客观题直接算分 int score 0; for (each item in paper(s-paper_id)) { const char *ans lookup_answer(s, item.id); if (ans cmp_answer(item, ans)) score item.point; } s-final_score score; s-submitted 1; update_score_record(s-exam_no, score, now_ms()); return send_submit_ack(s, score); }save_raw_answer失败时返回错误而不是继续判分是为了避免成绩有了但原始卷没了考务对账时无法追溯。判分中的cmp_answer要按题型分支单选多选比集合判断题比布尔填空做去空格和可选多答案匹配。多选建议按少选得部分分、多选不得分的规则处理规则写死在配置里别散落在代码各处。4. 考生客户端实现SOCKET 连接管理、断线重连与本地答卷缓存客户端是考生唯一能看到的部分它的第一指标不是性能而是无论发生什么都别丢答卷。围绕这一点客户端要做三件事稳定的连接、可靠的本地缓存、以及清晰的重连策略。4.1 SOCKET 封装与心跳保活把 socket 的创建、连接、收发、关闭封成一个类业务层只调send_packet(cmd, body)。连接建立时要设TCP_NODELAY考试消息都很小禁用 Nagle 可以避免几十毫秒的合并延迟让监考指令下发得更及时。心跳的作用不只是保活还承担两个职责一是让服务端知道客户端还在超时即剔除二是探测链路是否已经悄悄断开。考场网线被踢掉时recv不会立刻返回错误只有靠心跳超时才能发现。void heartbeat_loop(Client *c) { while (c-running) { uint64_t now now_ms(); if (now - c-last_send 5000) { // 每 5 秒发一次 c-send_packet(CMD_HEARTBEAT, nullptr, 0); } if (now - c-last_recv 15000) { // 15 秒没收到任何数据判定断线 c-mark_disconnected(); break; } sleep_ms(200); } }发送间隔 5 秒、判定超时 15 秒是三次心跳没回应才认定断开的常见配法能容忍考场里短暂的广播风暴或瞬时丢包又不会让学生断线后干等太久。4.2 断线重连与本地答卷缓存重连不能无脑死循环会把服务端打出一片连接风暴。合理的做法是带抖动的指数退避首次 1 秒之后翻倍封顶 10 秒并叠加一个随机抖动避免 90 台机器同时到点、同时冲击服务端。重试次数基础间隔实际区间含抖动11s1.0 ~ 1.5s22s2.0 ~ 3.0s34s4.0 ~ 6.0s48s8.0 ~ 10.0s510s10.0 ~ 12.0s本地缓存是防丢卷的关键。每次作答除了上报服务端还要立刻写本地文件写入用先写临时文件再原子重命名的方式防止写到一半掉电产生半截文件。int save_local_answer(const char *exam_no, int qid, const char *ans) { // 每条答案追加写入行格式: qid\t内容\n附带时间戳便于合并 FILE *f fopen(local_file(exam_no), a); if (!f) return -1; fprintf(f, %d\t%s\t%llu\n, qid, ans, now_ms()); fflush(f); // 立即刷盘掉电也不丢已答内容 fclose(f); return 0; }重连成功后客户端把本地缓存里服务端ANSWER_ACK位图未覆盖的题号补传上去服务端按seq/题号去重。整个链条闭合后即使中途断网半小时已答内容也不会丢。4.3 监考端的可采集范围与边界监考功能通常需要知道考生进程是否异常、是否切出答题窗口。常见的可采集项包括答题窗口的焦点变化、进程列表中的可疑程序、以及按需的屏幕截图。这里有个实际边界采集要在考生知情、考务授权的前提下进行客户端启动时就应明确告知采集范围截图频率也要可控比如仅在切窗时触发一次避免把带宽和 CPU 全吃掉。把这些做成可配置开关不同考点的要求能适配而不是写死。5. 工程落地与排错CMake 构建加上 VS Code 的 C/C 环境配置代码写完之后真正消耗时间的往往是能不能干净地编译出来和崩了怎么定位。把构建和调试环境理顺后面改功能才快。5.1 用 CMake 组织服务端与客户端一个仓库里同时放服务端、客户端和公共协议代码用 CMake 的多个 target 分开公共部分做成静态库。cmake_minimum_required(VERSION 3.16) project(exam_system C CXX) set(CMAKE_CXX_STANDARD 17) add_compile_options(-Wall -Wextra -g) # 打开警告和调试符号排查阶段必备 add_library(exam_proto STATIC # 协议编解码、报文头两端共用 src/proto/codec.c src/proto/crc32.c) add_executable(server src/server/main.cpp src/server/epoll_loop.cpp) target_link_libraries(server exam_proto pthread) add_executable(client src/client/main.cpp src/client/socket_client.cpp) target_link_libraries(client exam_proto pthread)把协议抽成静态库的价值在于报文头一旦改动两端会同时重新编译避免出现客户端按旧结构发、服务端按新结构收这类只在联调时才暴露的错位。-Wall -Wextra在写 SOCKET 代码时能提前抓出不少类型截断和未初始化问题。5.2 VS Code 的 C/C 智能提示路径优先级联调时经常遇到 IntelliSense 找不到头文件、结构体成员补全错乱。原因是插件按c_cpp_properties.json里includePath的顺序解析谁在前谁优先。工程里引入第三方头文件时顺序要尽量自己项目在前、系统路径在后避免同名的旧头文件抢答。{ configurations: [ { name: Linux, includePath: [ ${workspaceFolder}/src/**, // 自己的头文件优先 ${workspaceFolder}/third_party/**, /usr/include, /usr/include/x86_64-linux-gnu ], defines: [_GNU_SOURCE], compileCommands: ${workspaceFolder}/build/compile_commands.json, cppStandard: c17, intelliSenseMode: linux-gcc-x64 } ] }最省心的做法是让 CMake 生成compile_commands.json配置时加-DCMAKE_EXPORT_COMPILE_COMMANDSON再用compileCommands指过去。这样 IntelliSense 直接复用真实编译参数路径和宏定义都不会跑偏补全和跳转基本和实际编译行为一致。5.3 用退出代码和 core dump 定位问题程序跑飞时先看退出代码它比日志更早给出方向。常见几种139是12811对应 SIGSEGV几乎都是空指针或越界134是1286SIGABRT多为assert失败或 glibc 检测到堆破坏136是浮点异常。考试系统的崩溃高发点是报文解析——body_len没校验就按它去 memcpy或者按对齐结构体直接强转指针读字段。定位手段是开 core dump 后用 gdb 看栈ulimit -c unlimited打开核心转储崩溃后gdb ./server corebt看调用栈frame N切到出事的帧p *conn看连接对象。如果崩溃点在收包路径优先检查body_len的上限校验有没有做、memmove的偏移有没有算错。养成每次recv之后先验证头部再谈其他的习惯能挡掉大部分线上崩溃。本文还有配套的精品资源点击获取