本章导读
卷七的主线是"让数据流起来":地址(62)、端口(63)、缓冲(64)、DNS(65)、TCP(66–67)、UDP(68)、代理(69)、接口(70)。收官这一章补两件拼装级工具,它们不引入新的连接对象,而是把已有部件黏合成完整方案:帧解析器回答"TCP 这条没有边界的字节流怎么还原成一条条消息"——第 65 章的 xnetbuf 是它的输入,第 67 章的接收回调是它的驱动;原生异步文件 I/O 回答"文件服务器怎么把磁盘读写挂进与网络同一个事件循环"——第 64 章的 Worker 归属规则在这里再次兑现。两者都是零引擎依赖的小模块,却分别支撑着文本协议(Redis/SMTP 风格)、私有二进制协议(RPC/游戏服)与代理缓存、静态文件服务这几大类真实负载。
72.1引入
两个场景,一个共性。场景一:你在 TCP 上实现一个文本协议,客户端发来 PING\r\n,服务端回 PONG\r\n。听起来简单,但 TCP 不保证"一次 send 对应一次 recv"——三次发送可能粘成一次到达(粘包),一次发送也可能拆成两半到达(半包)。PING\r 先到怎么办?裸写就是手忙脚乱的下标算术。场景二:你在写一个代理缓存或文件服务,网络的读写都挂在 Engine 上异步推进,但磁盘读文件是阻塞调用——Worker 一读盘,同 Worker 上的几百条连接全部卡住。
两个场景的共性是:边界问题。前者缺消息边界,帧解析器补上;后者缺"磁盘与网络统一的事件边界",原生异步文件补上——文件操作直接提交到 Worker 的完成端口,Windows 走 IOCP 的 ReadFile/WriteFile,Linux 走 io_uring 的 READV/WRITEV,与 socket 事件同池分发,Worker 永不因磁盘而阻塞。
72.2概念
三态解析:增量帧器的统一节奏
两种帧器(行分隔、长度前缀)共享同一个三态返回模型:
XNET_FRAME_MORE 是正常增量控制结果,不设置错误——它就是"粘包半包被状态机吸收"的体现。XNET_FRAME_READY 时输出的 xnetframe 描述当前输入头部的一条完整帧:PayloadOffset/PayloadSize 定位载荷、FrameSize 是整帧字节数、Declared 保存协议长度字段原值。XNET_FRAME_ERROR 携带结构化错误(FRAME_CONFIG/FRAME_STATE/FRAME_LIMIT/FRAME_LENGTH 四类),通常意味着协议流已经不可信,应关闭连接或丢弃输入后重置帧器。
行分帧:任意分隔符的增量搜索
xnetlineconfig 三要素:Delimiter 分隔符(借用视图,必须存活到帧器不再使用;没有长度上限,\n、\r\n、甚至多字节哨兵都可以)、MaxPayload 载荷上限(默认 8192;SIZE_MAX 是显式无界)、IncludeDelimiter 是否保留分隔符(默认去除)。默认配置就是 LF 分隔。
帧器内部保存块指针与块内偏移(xnetlineframer),因此即使输入分散在大量单字节引用块里,定位旧偏移也不必反复遍历链头——这是它与"每次从头扫描"的分水岭,也是第 65 章引用块追加的正确消费方式。分隔符跨缓冲块、自重叠(如分隔符 \r\n 遇到输入 \r\r\n)、恰好落在载荷上限上,全部受支持。
一条关键契约:Next 返回 MORE 之后,调用方只能保留原输入前缀并在同一个 xnetbuf 尾部追加;消费、替换、换缓冲、重排输入之前必须 xrtNetLineReset(保留配置、丢弃增量进度)。Reset 的另一用途是同一条连接"重新开始对话"时复用帧器,省一次初始化。
长度前缀分帧:一条公式走天下
私有二进制协议最常用的分帧:头部 1–8 字节长度字段 + 载荷。帧总长由一条公式决定:
FrameSize = LengthOffset + LengthSize + Declared + Adjustment
LengthOffset/LengthSize:长度字段在帧内的位置与宽度(1..8 字节);Order明确大小端,独立于主机字节序。Adjustment:有符号调整。协议长度字段若已包含头部,用负值扣回。Strip:载荷起点——保留完整头部、只去掉长度字段、或去掉整个应用头,一个字段三种选择。MaxFrame:总帧硬上限(默认 1 MiB)——恶意或损坏的声明长度在这里被拦住。
默认配置是"4 字节大端载荷长度、去除长度字段、1 MiB 上限",正是多数 RPC 的格式。所有边界——字段末端、无符号声明值、有符号调整、size_t 转换、strip 与上限——都在访问载荷前验证,不存在"先信长度再越界读"的路径。
帧的两种取用:复制与零拷贝
Next 产出帧描述后,载荷有两种取法。便利路径 xrtNetFrameCopy:把不超过输出容量的载荷字节复制进调用方缓冲,小消息一行搞定。零拷贝路径:直接用第 65 章的 xrtNetBufSpans/xrtNetBufPeek 拿载荷视图——大帧不必复制。取用完成后 xrtNetFrameConsume 从输入头部精确移除整帧(它会先验证帧范围仍完整位于头部,输入被改动过就拒绝并报 FRAME_STATE)。注意帧描述借用当前输入:输入前缀被消费、替换或重排后,旧帧描述不得继续使用。
原生异步文件:完成端口上的磁盘
net_file 模块把普通文件的定位读写提交到 Network Engine 的完成端口,用于文件服务、代理缓存与自定义存储管线——不创建 Future、不占用 TaskPool、不复制调用方载荷。五个要点:
- 打开:
xrtNetFileOpen自动附加XFILE_ASYNC标志,选项与第 45 章的xrtFileOpen同一套(默认只读,新建文件要显式XFILE_CREATE|XFILE_WRITE)。 - Worker 归属:
Read/Write只能在所属 Worker 线程提交——同一文件首次提交后固定由同一个 Worker 使用;跨线程提交收到XERR_STATE。主线程想发起,就用xrtNetEnginePost把提交动作投递到 Worker 上(本章示例的标准姿势)。 - 绝对偏移:读写都带
iOffset,不依赖文件游标——同一文件多个并发读互不干扰。 - 唯一终态:每个操作返回非零标识,
xnetcompletion回调在 Worker 上收到恰好一次终态事件(xnetportevent的Result/Bytes/Id描述结果)。文件、缓冲与 completion 必须保持到终态到达。xrtNetFileCancel只请求取消,原操作仍通过 completion 收尾——终态可能是XNET_RESULT_CANCELLED,也可能操作已完成而是OK。 - 能力位:Worker 端口后端无原生文件 I/O 能力时(如 SELECT 兜底后端),提交收到
XERR_UNSUPPORTED——明确报告,不退化为阻塞 Worker。
全部操作终结后用 xrtClose 关闭文件——句柄类型就是第 45 章的 xfile,整个文件家族共享这一个关闭入口,没有为异步文件单设的关闭函数。
72.3示例
第一个完整程序:CRLF 行分帧与半包吸收
下面的程序来自 examples/network/frame_line/main.c:输入被刻意切成 first\r 与 \nsecond\r\n 两块到达(半包横跨分隔符),帧器逐条还原并演示 Reset 复用:
#include <stdio.h> #include <xrt.h> */ /* 从分块输入中逐条取出 CRLF 文本帧。 */ int main(void) { xnetlineconfig Config; xnetlineframer Framer; xnetframe Frame; xnetbuf Input; char sLine[32]; xrtNetLineConfigInit(&Config); Config.Delimiter.Data = (cbytes)"\r\n"; Config.Delimiter.Size = 2; if ( !xrtNetLineInit(&Framer, &Config) || !xrtNetBufInit(&Input, NULL) || !xrtNetBufAppend(&Input, "first\r", 6) ) { return 1; } if ( xrtNetLineNext(&Framer, &Input, &Frame) != XNET_FRAME_MORE || !xrtNetBufAppend(&Input, "\nsecond\r\n", 10) ) { xrtNetBufClear(&Input); return 1; } while ( xrtNetLineNext(&Framer, &Input, &Frame) == XNET_FRAME_READY ) { size_t iSize = xrtNetFrameCopy( &Input, &Frame, sLine, sizeof(sLine) - 1u ); sLine[iSize] = 0; printf("%s\n", sLine); if ( !xrtNetFrameConsume(&Input, &Frame) ) { xrtNetBufClear(&Input); return 1; } } xrtNetBufClear(&Input); * 时复用帧器(省一次初始化);Reset 后可继续喂新流。 */ (void)xrtNetLineReset(&Framer); printf("reset=ok\n"); return 0; }
刚才发生了什么。 ① 分隔符设为 \r\n(借用视图直接指向字面量);第一块 first\r 喂进去得到 MORE——分隔符只到了一半,帧器把未决前缀记在增量游标里。② 第二块 \nsecond\r\n 追加到同一个 xnetbuf 尾部(MORE 契约的正用),循环 Next 依次解出 first 与 second 两条完整帧。③ 每条帧走"Copy 到栈缓冲 → 打印 → Consume 精确消费"三步,sizeof(sLine) - 1u 给结尾零留位。④ 末尾 xrtNetLineReset 保留配置、丢弃进度——新会话复用帧器。这就是 Redis/SMTP 类文本协议的分帧底座:粘包半包由解析器状态机吸收,业务代码只看到一条条完整消息。
第二个完整程序:四字节长度前缀解帧
第二个程序来自 examples/network/frame_length/main.c,私有二进制协议的标准样本——00 00 00 05 + hello:
#include <stdio.h> #include <xrt.h> */ /* 解析四字节网络序长度前缀,并直接读取 payload。 */ int main(void) { static const uint8 Packet[] = { 0, 0, 0, 5, 'h', 'e', 'l', 'l', 'o' }; xnetlengthconfig Config; xnetlengthframer Framer; xnetframe Frame; xnetbuf Input; char sPayload[6] = { 0 }; xrtNetLengthConfigInit(&Config); if ( !xrtNetLengthInit(&Framer, &Config) || !xrtNetBufInit(&Input, NULL) || !xrtNetBufAppend(&Input, Packet, sizeof(Packet)) || (xrtNetLengthNext(&Framer, &Input, &Frame) != XNET_FRAME_READY) || (xrtNetFrameCopy( &Input, &Frame, sPayload, sizeof(sPayload) - 1u ) != 5) ) { xrtNetBufClear(&Input); return 1; } printf("%s\n", sPayload); xrtNetBufClear(&Input); return 0; }
刚才发生了什么。 默认配置(xrtNetLengthConfigInit)就是四字节大端长度、去除长度字段、1 MiB 上限,直接命中本协议格式。LengthNext 一步解出帧描述,FrameCopy 把载荷复制进 5 字节缓冲(返回 5 恰好等于载荷长度,多一位给结尾零)。对照行分帧示例看返回模型:LengthNext 遇到"长度字段不完整"或"声明长度未到齐"同样返回 MORE,调用方的追加-重试循环与行分帧完全同构——换一种帧格式,不改驱动节奏。
第三个完整程序:完成端口上的文件读写与取消
第三个程序来自 examples/network/file_tour/main.c,把"提交-终态-取消"完整走一遍:绝对偏移写、读回验证、在途读取消后仍收唯一终态:
*/ #include <stdio.h> #include <string.h> #include <xrt.h> #include <math.h> static inline double exampleTimerLimit(int64 Timeout) { return Timeout == XRT_WAIT_FOREVER ? INFINITY : xrtTimer() + (double)Timeout / 1000.0; } static inline bool exampleTimerExpired(double Limit) { return xrtTimer() >= Limit; } static inline int64 exampleTimerRemaining(double Limit) { double Ms; if (Limit == INFINITY) return XRT_WAIT_FOREVER; Ms = ceil((Limit - xrtTimer()) * 1000.0); return Ms <= 0 ? 0 : Ms >= 0x1p63 ? INT64_MAX : (int64)Ms; } static const char* sFile = "net_file_tour_tmp.bin"; /* Completion 终态记录。 */ typedef struct exampleio { xnetcompletion Completion; volatile bool bDone; xnetresult Result; size_t iBytes; uint64 iId; } exampleio; static void exampleDone(xnetworker* pWorker, const xnetportevent* pEvent, ptr pData) { exampleio* pIo = (exampleio*)pData; (void)pWorker; pIo->Result = pEvent->Result; pIo->iBytes = pEvent->Bytes; pIo->iId = pEvent->Id; pIo->bDone = true; } static bool exampleSpin(exampleio* pIo, double iEnd) { while ( !pIo->bDone ) { if ( exampleTimerExpired(iEnd) ) { return false; } xrtThreadYield(); } return true; } /* Worker 任务:提交写并登记 Completion。 */ typedef struct exampletask { xnetworker* pWorker; xfile File; exampleio* pIo; const void* pData; size_t iSize; uint64 iOffset; volatile bool bDone; bool bOk; uint64 iId; } exampletask; static bool exampleSpinUntilTask(exampletask* pTask); static void exampleWriteTask(xnetworker* pWorker, ptr pUserData) { exampletask* pTask = (exampletask*)pUserData; (void)pWorker; pTask->iId = xrtNetFileWrite(pTask->pWorker, pTask->File, pTask->iOffset, pTask->pData, pTask->iSize, &pTask->pIo->Completion); pTask->bOk = (pTask->iId != 0u); pTask->bDone = true; } static void exampleReadTask(xnetworker* pWorker, ptr pUserData) { exampletask* pTask = (exampletask*)pUserData; (void)pWorker; pTask->iId = xrtNetFileRead(pTask->pWorker, pTask->File, pTask->iOffset, (void*)pTask->pData, pTask->iSize, &pTask->pIo->Completion); pTask->bOk = (pTask->iId != 0u); pTask->bDone = true; } static bool examplePost(xnetengine* pEngine, xnetpost* pPost, void (*pProc)(xnetworker*, ptr), exampletask* pTask) { (void)pPost; return xrtNetEnginePost(pEngine, 0u, pProc, (ptr)pTask) && exampleSpin((exampleio*)pTask->pIo, exampleTimerLimit(3000)) && ((exampleio*)pTask->pIo)->bDone; } /* 等待任务提交本身完成(不含 IO 终态)。 */ static bool exampleSpinUntilTask(exampletask* pTask) { double iEnd = exampleTimerLimit(3000); while ( !pTask->bDone ) { if ( exampleTimerExpired(iEnd) ) { return false; } xrtThreadYield(); } return true; } int main(void) { xnetengine* pEngine = NULL; xnetpost Post; xfile File = 0; exampleio Io; exampletask Task; uint8 arrRead[16]; int iResult = 1; (void)remove(sFile); pEngine = xrtNetEngineCreate(NULL); if ( (pEngine == NULL) || !xrtNetEngineStart(pEngine) || !xrtNetPostInit(&Post) ) { goto Cleanup; } /* Native file completion requires IOCP or an available io_uring port. */ { xnetport* pPort = xrtNetWorkerPort(xrtNetEngineWorker(pEngine, 0u)); if (pPort == NULL) goto Cleanup; if ((xrtNetPortCapabilities(pPort) & XNET_PORT_CAP_FILE_IO) == 0u) { printf("file: native file completion unavailable on this backend\n"); iResult = 0; goto Cleanup; } } /* ---- 打开 + 写:绝对偏移 0 写 8 字节。 ---- */ { xfileoptions Options; /* 默认选项是只读——新文件须显式 CREATE|WRITE。 */ xrtFileOptionsInit(&Options); Options.Flags = XFILE_READ | XFILE_WRITE | XFILE_CREATE | XFILE_TRUNCATE; File = xrtNetFileOpen(sFile, &Options); } memset(&Io, 0, sizeof(Io)); xrtNetCompletionInit(&Io.Completion, exampleDone, (ptr)&Io); memset(&Task, 0, sizeof(Task)); Task.pWorker = xrtNetEngineWorker(pEngine, 0u); Task.File = File; Task.pIo = &Io; Task.pData = "filedata"; Task.iSize = 8u; Task.iOffset = 0u; if ( (File == 0) || !examplePost(pEngine, &Post, exampleWriteTask, &Task) || (Io.Result != XNET_RESULT_OK) || (Io.iBytes != 8u) || (Io.iId != Task.iId) ) { goto Cleanup; } printf("file: open(async) + write completed at offset 0 ok\n"); /* ---- 读回验证 + 追加偏移写。 ---- */ memset(&Io, 0, sizeof(Io)); xrtNetCompletionInit(&Io.Completion, exampleDone, (ptr)&Io); memset(arrRead, 0, sizeof(arrRead)); memset(&Task, 0, sizeof(Task)); Task.pWorker = xrtNetEngineWorker(pEngine, 0u); Task.File = File; Task.pIo = &Io; Task.pData = arrRead; Task.iSize = 8u; Task.iOffset = 0u; if ( !examplePost(pEngine, &Post, exampleReadTask, &Task) || (Io.Result != XNET_RESULT_OK) || (Io.iBytes != 8u) || (memcmp(arrRead, "filedata", 8u) != 0) ) { goto Cleanup; } printf("file: read-back verified + reopen append ok\n"); /* ---- 取消:提交一个大读后立即取消,仍收唯一终态。 ---- */ { static uint8 arrBig[4096]; exampleio CancelIo; uint64 iCancelId; memset(&CancelIo, 0, sizeof(CancelIo)); xrtNetCompletionInit(&CancelIo.Completion, exampleDone, (ptr)&CancelIo); memset(&Task, 0, sizeof(Task)); Task.pWorker = xrtNetEngineWorker(pEngine, 0u); Task.File = File; Task.pIo = &CancelIo; Task.pData = arrBig; Task.iSize = sizeof(arrBig); Task.iOffset = 0u; if ( !xrtNetEnginePost(pEngine, 0u, exampleReadTask, (ptr)&Task) || !exampleSpinUntilTask(&Task) || (Task.iId == 0u) ) { goto Cleanup; } iCancelId = Task.iId; * 到 EOF)——先等一小段,未完成才取消。 */ { int iSpin; for ( iSpin = 0; (iSpin < 50) && !CancelIo.bDone; iSpin++ ) { xrtSleep(1u); } if ( !CancelIo.bDone ) { if ( !xrtNetFileCancel(Task.pWorker, iCancelId) || !exampleSpin(&CancelIo, exampleTimerLimit( 3000)) ) { goto Cleanup; } } } /* 终态必达:结果是 OK(读已完成)或 CANCELLED。 */ if ( (CancelIo.Result != XNET_RESULT_OK) && (CancelIo.Result != XNET_RESULT_CANCELLED) ) { goto Cleanup; } } printf("file: cancel in-flight read -> terminal event ok\n"); iResult = 0; Cleanup: if ( File != 0 ) { xrtClose(File); } (void)xrtNetEngineStop(pEngine); (void)xrtNetEngineDestroy(pEngine); (void)remove(sFile); return iResult; }
刚才发生了什么。 ① 文件选项显式 XFILE_READ|XFILE_WRITE|XFILE_CREATE|XFILE_TRUNCATE——默认只读,新建文件必须声明,xrtNetFileOpen 在此之上自动附加异步标志。② 提交走标准两跳:主线程 xrtNetEnginePost 把 exampleWriteTask 投递到 0 号 Worker,任务函数在 Worker 上调用 xrtNetFileWrite 并登记 completion——这是"只能在所属 Worker 提交"契约的落地形态。终态回调 exampleDone 记录 Result/Bytes/Id,主线程自旋等 bDone 并核对三件事:结果 OK、字节数吻合、事件 Id 与提交返回值一致。③ 读回用同一姿势,memcmp 验证内容。④ 取消段先提交一个 4096 字节大读,自旋一小段确认未完成后 xrtNetFileCancel——终态必达且结果只可能是 OK(读恰好已完成)或 CANCELLED;注意 8 字节文件读 4096 字节会立即 EOF 完成,所以示例先等待再决定是否取消,这个"取消可能输给完成"的竞态处理正是真实代码的样子。⑤ 收尾 xrtClose(File) → 停 Engine → 删临时文件,顺序不可颠倒。
72.4契约
- 帧器生命周期:帧器是调用方栈上的值对象,无共享状态;不同实例任意线程并发、同一实例顺序调用均安全;配置(分隔符视图)必须存活到帧器弃用。
- MORE 契约:
MORE后只允许保留原输入前缀并在同一xnetbuf追加;消费/替换/换缓冲/重排前必须Reset;MORE不设错误。 - 帧借用输入:
xnetframe的偏移相对当前输入头部;输入前缀变动后旧帧失效;Consume验证范围后精确消费。 - 帧上限:行载荷超
MaxPayload报FRAME_LIMIT;长度声明溢出/超限/调整非法报FRAME_LENGTH;配置本身不可能合法报FRAME_CONFIG。 - 文件归属:
Read/Write/Cancel只在所属 Worker 线程有效(XERR_STATE);同文件首次提交后固定同一 Worker;主线程经EnginePost转交。 - 文件终态:操作返回非零
Id;completion 恰好一次终态(Result/Bytes/Id);文件、缓冲、completion 保持到终态;Cancel只请求,终态为OK或CANCELLED。 - 文件能力:后端无原生文件 I/O(SELECT 兜底)报
XERR_UNSUPPORTED,不退化阻塞;Windows IOCP、Linux io_uring。 - 关闭:全部操作终结后
xrtClose;偏移 + 长度超出可表达范围报XNET_ERROR_PORT_SUBMIT。
常见错误
症状:偶发 XNET_FRAME_ERROR + FRAME_STATE,或解出的帧内容错乱——往往在"解到一半去处理别的字节"的代码里。
原因:MORE 后帧器的增量游标锚定在原输入前缀上。消费头部、替换缓冲、换一个 xnetbuf、甚至把前缀搬到别处,都会让锚点失效——契约规定此路之前必须 Reset。
if ( xrtNetLineNext(&Framer, &Input, &Frame) == XNET_FRAME_MORE ) { xrtNetBufConsume(&Input, 8); /* 动了前缀:增量游标悬空 */ xrtNetLineNext(&Framer, &Input, &Frame); /* FRAME_STATE */ }
if ( xrtNetLineNext(&Framer, &Input, &Frame) == XNET_FRAME_MORE ) { /* 唯一合法动作:原前缀保留,尾部追加新数据 */ xrtNetBufAppend(&Input, pChunk, iSize); } /* 确要丢弃当前输入(如协议重置):先 Reset 再喂新流 */ (void)xrtNetLineReset(&Framer);
症状:提交返回 0,线程错误槽里是 XERR_STATE——文件打开得好好的,怎么提交都失败。
原因:xrtNetFileRead/Write/Cancel 是 Worker 专属接口,只能在所属 Worker 线程调用;主线程不是 Worker。这不是缺陷而是设计——完成端口操作必须在泵线程上提交,模块用错误码把边界讲清楚。
/* 主线程:不在所属 Worker,XERR_STATE,返回 0 */ iId = xrtNetFileRead(pWorker, File, 0, Buffer, sizeof(Buffer), &Completion);
/* 标准两跳:Post 把任务投到 Worker,任务函数里提交 */ static void submitRead(xnetworker* pWorker, ptr pUserData) { exampletask* pTask = (exampletask*)pUserData; pTask->iId = xrtNetFileRead(pWorker, pTask->File, pTask->iOffset, (void*)pTask->pData, pTask->iSize, &pTask->pIo->Completion); } xrtNetEnginePost(pEngine, 0u, submitRead, (ptr)&Task);
症状:同一份代码在 Windows(IOCP)正常,迁移到只有 SELECT 可用的嵌入式目标后,文件提交全部返回 0。
原因:SELECT 后端没有原生异步文件能力,模块按能力位明确拒绝(XERR_UNSUPPORTED + XNET_ERROR_PORT_SUBMIT)而不是悄悄退化成阻塞 Worker——否则一次磁盘读会卡住整个事件循环,这正是设计要消灭的故障模式。
iId = xrtNetFileRead(pWorker, File, 0, Buffer, sizeof(Buffer), &Completion); if ( iId == 0u ) { /* 假设是临时错误,死循环重试:永远 UNSUPPORTED */ }
iId = xrtNetFileRead(pWorker, File, 0, Buffer, sizeof(Buffer), &Completion); if ( iId == 0u && xrtErrorIs(xrtGetError(), XERR_UNSUPPORTED) ) { 模块或专用磁盘线程,而不是重试 */ }
编程练习
把行分帧示例改成 IncludeDelimiter = true 且分隔符仍为 \r\n:输出的两行应该带什么结尾?再换成单字节 \n 并把第二块输入拆成 \nsec 与 ond\n 两段,验证照样解出 second。验收标准:两种改动的输出与手推结果一致,程序返回 0。
设计协议"1 字节类型 + 2 字节小端长度 + 载荷":配置 LengthOffset = 1、LengthSize = 2、Order = XNET_FRAME_LITTLE_ENDIAN、Strip = 3,构造两条粘在一起的报文一次性喂入,循环解出并按类型字节分别打印。提示:FrameSize 公式此时是 1 + 2 + Declared;两条帧连续 Next/Consume 即可。
用第 67 章的事件面 TCP 服务 + 本章长度前缀帧器 + 异步文件,实现"客户端发 8 字节文件偏移请求 → 服务端读 4 KiB 块回传"的骨架:请求分帧用 xrtNetLengthNext(在 Read 回调里对 Recv 缓冲增量解析),磁盘读用 xrtNetFileRead(Worker 内提交),响应按"4 字节长度 + 数据"组帧经 xrtNetStreamSend 发送。验收标准:并发两客户端交替请求不串流;SELECT 后端启动时优雅报告能力缺失并退出;Engine 销毁无泄漏(第 6 章统计复核)。
小结
| 知识点 | 速查 |
|---|---|
| 三态 | READY(完整帧)/ MORE(等追加,不设错)/ ERROR(四类帧错,流不可信) |
| 行分帧 | 任意长度分隔符(借用);MaxPayload 默认 8192、SIZE_MAX 无界;IncludeDelimiter 控制去留 |
| MORE 契约 | 只许保留前缀 + 同一 xnetbuf 尾部追加;动前缀前先 Reset(留配置弃进度) |
| 长度帧公式 | FrameSize = LengthOffset + LengthSize + Declared + Adjustment;Adjustment 可负 |
| 长度帧默认 | 4 字节大端、去长度字段、1 MiB 上限;宽度 1..8、大小端自选、Strip 定载荷起点 |
| 取载荷 | 小消息 FrameCopy(受容量截断);大帧 BufSpans/BufPeek 零拷贝;FrameConsume 精确消费 |
| 异步文件 | NetFileOpen 自动 XFILE_ASYNC;绝对偏移读写;不建 Future、不占 TaskPool、零载荷复制 |
| Worker 归属 | Read/Write/Cancel 仅所属 Worker(跨线 XERR_STATE);主线程经 EnginePost 两跳提交 |
| 终态 | 非零 Id ↔ 事件 Id 对应;completion 恰一次;Cancel 只请求,终态 OK 或 CANCELLED |
| 后端能力 | IOCP(Win)/io_uring(Linux)原生;SELECT 明确 UNSUPPORTED,不阻塞退化 |
| 关闭 | 全部终态后 xrtClose(File);帧器/缓冲为栈值或 BufClear,无独立销毁 |
📖 本章涉及的 API 参考手册:net_frame(16 个条目)net_file(4 个条目)net(247 个条目)
点击查看完整签名、参数约束、返回值与错误说明