ControlRookie
返回文章

第07篇_Server 03|多客户端接入,连接槽位和生命周期怎么设计

一个监听端口背后可能同时存在多个 TCP 连接。PLC 需要固定槽位、独立状态和可回收句柄,不能用一个全局接收缓冲区处理所有客户端。

一个监听端口背后可能同时存在多个 TCP 连接。PLC 需要固定槽位、独立状态和可回收句柄,不能用一个全局接收缓冲区处理所有客户端。

适合谁收藏

  • 正在实现 PLC 端 HTTP Server 的工程师。
  • 正在排查监听、多连接、请求边界或响应发送问题的人。
  • 需要建立 Server 真机验收清单的读者。

服务器篇,第 3/7 篇;主系列第 07/28 篇。

现场问题

单客户端测试很容易掩盖连接管理问题。第二个工具一接入,常见现象是请求串到一起、旧句柄没有释放、某个错误让所有客户端一起掉线。

当前实现使用 4 个固定槽位,每个槽位拥有自己的 TCP_Read、TCP_Write、Parser、Builder、缓冲区和连接快照。固定上限不是保守,而是让 PLC 的内存和扫描时间可计算。

先给结论

多客户端 Server 的最小可靠模型,是一个监听器加固定数量的独立连接槽。每个槽位只服务一个句柄,完成、错误和清理都在槽位边界内收口。

Server 03|多客户端接入,连接槽位和生命周期怎么设计
Server 03|多客户端接入,连接槽位和生命周期怎么设计

读图重点

这张图只压缩本篇的判断路径。读图时先找“接入”对应的输入边界,再沿着“关闭或错误后释放句柄”检查状态怎样推进,最后用“槽位回到可复用状态”确认输出是否已经形成验收证据。

把对象和边界分开

对象或阶段工程职责现场观察点
接入新句柄绑定空闲槽位记录 accepted 计数
服务每周期调用所有活跃槽位不因某槽位忙而跳过其他槽位
聚合汇总最近请求、最近错误和快照保留槽位编号
清理关闭或错误后释放句柄槽位回到可复用状态

从协议约束到代码职责

协议约束

多客户端 Server 的最小可靠模型,是一个监听器加固定数量的独立连接槽。每个槽位只服务一个句柄,完成、错误和清理都在槽位边界内收口。 这条结论先限定消息什么时候成立,再限定哪个角色可以消费结果。若绕过协议边界直接驱动业务,半包、超时、重复执行和连接残留就会进入应用层。

工程抽象

M_AcceptNewConnection 只负责把 NBS 句柄交给槽位,真正的 Read/Write 留到统一服务阶段。源码中特别避免在同一扫描周期重复读取新连接,否则大 Body 的半包场景可能被瞬时读错误误杀。

连接快照记录槽位、状态、句柄、目标路径和计数。它把“偶发第二个客户端失败”从难以复现的问题,变成可以按槽位复盘的证据。

  • 接入:工程职责是“新句柄绑定空闲槽位”。它不能只停留在命名层面,运行时必须能通过“记录 accepted 计数”观察到输入、状态或结果;否则这一层即使有代码,也没有形成可验证边界。
  • 服务:工程职责是“每周期调用所有活跃槽位”。它不能只停留在命名层面,运行时必须能通过“不因某槽位忙而跳过其他槽位”观察到输入、状态或结果;否则这一层即使有代码,也没有形成可验证边界。
  • 聚合:工程职责是“汇总最近请求、最近错误和快照”。它不能只停留在命名层面,运行时必须能通过“保留槽位编号”观察到输入、状态或结果;否则这一层即使有代码,也没有形成可验证边界。
  • 清理:工程职责是“关闭或错误后释放句柄”。它不能只停留在命名层面,运行时必须能通过“槽位回到可复用状态”观察到输入、状态或结果;否则这一层即使有代码,也没有形成可验证边界。

程序单元

本篇主证据来自 FB_HttpServer.st 中以 METHOD PRIVATE M_AcceptNewConnection 为定位点的连续源码。这里不是为了展示语法,而是把协议约束落到确定程序单元:输入先进入结构体或缓冲区,状态机只在本周期处理可确认的部分,长度和结束条件决定能否前进,错误码与指标负责把失败原因带出对象边界。这样一来,“一个端口不等于一个连接。”可以在代码、在线变量和外部报文之间逐项对照,而不是依赖经验猜测。

本篇核心源码片段

下面两段代码来自同一个真实文件 FB_HttpServer.st,以 METHOD PRIVATE M_AcceptNewConnection 为中心连续截取,没有改写变量、删除分支或用伪代码替代。第一段用于确认入口与前置条件,第二段用于确认状态、边界和输出。核对时重点看“新句柄绑定空闲槽位”怎样进入对象,以及“槽位回到可复用状态”怎样证明本次处理已经结束。若两段之间的连续关系无法解释“连接错误必须限制在单槽位内。”,就不能把局部代码截图当成实现证据。

片段一:入口、声明与前置条件

iecst
METHOD PRIVATE M_AcceptNewConnection
// === IMPLEMENTATION ===
// 工程说明:本段集中处理状态、边界或诊断,避免跨周期残留。
// 边界说明:执行前后保持输出和错误码可被在线诊断追踪。
bAcceptError := FALSE;
FOR uiSlotIndex := 1 TO GVL_Http.cnMaxClientSlots DO
    aTcpAccept[uiSlotIndex](
        xEnable := hServer <> 0,
        hServer := hServer
        );
    IF aTcpAccept[uiSlotIndex].xActive THEN
        IF (NOT aConnections[uiSlotIndex].bActive)
            OR (aConnections[uiSlotIndex].stConnection.hConnection <> aTcpAccept[uiSlotIndex].hConnection) THEN
            // 原因:接入阶段只绑定 NBS 句柄,读写统一留给 M_ServiceConnections;
            // 避免新连接在同一扫描周期被 TCP_Read 调用两次,导致大 body 半包场景被误判为接收错误。
            aConnections[uiSlotIndex].M_Attach(
                hConnection := aTcpAccept[uiSlotIndex].hConnection
                );
            stMetrics.udiAcceptedConnections := stMetrics.udiAcceptedConnections + 1;
        END_IF
    END_IF
    IF aTcpAccept[uiSlotIndex].xError THEN
        bAcceptError := TRUE;
        eTcpError := aTcpAccept[uiSlotIndex].eError;
        uiLastErrorSlot := uiSlotIndex;
    END_IF
END_FOR

IF bAcceptError THEN
    eLastNbsError := eTcpError;
    M_SetServerError(
        eError   := E_HttpError.iTcpServerFailed,
        sMessage := 'TCP accept failed'
        );
END_IF

// === METHOD M_ServiceConnections ===
/// =======================================================================
/// 名称      : M_ServiceConnections
/// 功能      : 周期服务所有 HTTP 连接槽位。

这一段先回答对象在什么输入和状态下开始工作。阅读时要核对变量的初值、长度上限和启动条件,不能只看某个布尔量是否变成 TRUE。

片段二:状态推进、边界与输出

iecst
/// 说明      : 请求、响应、错误和清理均由连接 FB 输出聚合。
/// =======================================================================
{attribute 'hide_all_locals'}
METHOD PRIVATE M_ServiceConnections
// === IMPLEMENTATION ===
// 工程说明:本段集中处理状态、边界或诊断,避免跨周期残留。
// 边界说明:执行前后保持输出和错误码可被在线诊断追踪。
// 原因:每个连接槽都必须每周期服务,NBS 句柄状态变化可能发生在任意扫描周期。
// 诊断:最近请求槽、最近错误槽和连接快照用于外部探针复盘多连接真实通信路径。
uiActiveCount := 0;
FOR uiSlotIndex := 1 TO GVL_Http.cnMaxClientSlots DO
    aConnections[uiSlotIndex](
        bEnable             := aConnections[uiSlotIndex].bActive,
        xCloseConnection    := xCloseConnection,
        uiSlot              := uiSlotIndex,
        hConnectionIn       := aConnections[uiSlotIndex].stConnection.hConnection,
        bConnectionActiveIn := aConnections[uiSlotIndex].bActive,
        uiResponseStatusCode:= uiResponseStatusCode,
        sResponseBody       := sResponseBody,
        sResponseContentType:= sResponseContentType,
        sAdditionalHeader   := sAdditionalHeader,
        udiNowMs            := udiNowMs
        );

    IF aConnections[uiSlotIndex].bActive THEN
        uiActiveCount := uiActiveCount + 1;
    END_IF

    IF aConnections[uiSlotIndex].bRequestReady THEN
        uiLastRequestSlot := uiSlotIndex;
        sLastTarget := aConnections[uiSlotIndex].stRequest.sTarget;
        sLastBody := aConnections[uiSlotIndex].stRequest.sBody;
        sRequestTarget := aConnections[uiSlotIndex].stRequest.sTarget;
        sRequestBody := aConnections[uiSlotIndex].stRequest.sBody;
        sRxMessage := aConnections[uiSlotIndex].sRxMessage;
        stMetrics.udiRequestCount := stMetrics.udiRequestCount + 1;
    END_IF

    IF aConnections[uiSlotIndex].bResponseSent THEN
        sTxMessage := aConnections[uiSlotIndex].sTxMessage;

第二段继续展示同一连续源码范围。把它与第一段合起来,才能判断输入怎样被锁存、状态何时推进、边界何时满足,以及错误出口是否保留了足够诊断信息。

验证路径

场景操作通过口径
并发接入两个以上客户端分别获得槽位请求不串线
独立错误一个客户端发送坏报文其他槽位继续服务
主动断开客户端关闭连接槽位被清理并可再次接入
资源上限超过槽位数量行为可解释且无内存漂移

场景 1:并发接入

同时打开两个以上测试客户端,让每个客户端发送带有不同路径或 Body 标识的请求。在线变量里要能看到不同槽位分别锁存自己的句柄、目标路径和收发计数;外部响应也必须回到对应客户端。只要出现 A 客户端读到 B 的路径、某个槽位计数被另一个槽位覆盖,或者所有连接共用同一份接收状态,就说明多连接模型还没有成立。

场景 2:独立错误

保留一个正常客户端持续访问 /api/ping,再让另一个客户端发送缺失 Header 结束符或非法请求行。坏连接所在槽位应进入明确错误和关闭流程,正常槽位的请求计数、响应内容和状态推进不能被带偏。这个场景看的不是“有没有报错”,而是错误有没有被限制在单个槽位内。

场景 3:主动断开

让客户端完成一次请求后主动断开,再观察对应槽位的句柄、Rx 长度、Tx 长度、最后错误和快照是否回到可复用状态。随后用新的客户端占用同一槽位并再次请求,确认旧路径、旧 Body 和旧错误不会残留。这个验证能直接暴露“看似断开、实际槽位不可复用”的问题。

场景 4:资源上限

用超过 cnMaxClientSlots 的客户端同时连接,重点看第 N+1 条连接如何被拒绝、等待或关闭。通过口径不是所有连接都成功,而是已占用槽位保持稳定,超限连接有可解释诊断,计数器和快照不出现越界或漂移。PLC 里固定上限必须可观测,不能靠偶然跑过去。

常见误判

  • 多个客户端共用一份 Rx 缓冲区,第二条连接到来后请求发生串线。
  • 只调用当前活跃槽位,漏掉句柄刚发生变化的其他槽位。
  • 连接断开后不清理快照和句柄,槽位看似空闲却无法再次接入。

这些误判的共同点,是拿一个局部现象替代完整事务。定位时必须回到本篇的输入、状态、边界和输出四个坐标,并用相同输入完成回归。

这一篇你最该记住

  • 一个端口不等于一个连接。
  • 固定槽位让资源和诊断都可控。
  • 连接错误必须限制在单槽位内。

系列导航

  • 系列:CodeSys HTTP 系列教程,第 07/28 篇。
  • 阶段:服务器篇,职责线位置 3/7。
  • 上一篇:第06篇
  • 下一篇:第08篇
  • 发布顺序:基础认知 -> Server -> Client -> 完整源码加更 -> 综合收束。
评论和回复区

评论区预留

这里先保留评论和回复结构,不接入第三方服务。后续统一决定登录、匿名、审核、反垃圾和静态站兼容策略。

↑ ↓