ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++ 状态机实战:从 GoF 状态模式到 std::variant 与表驱动

C++ 状态机实战:从 GoF 状态模式到 std::variant 与表驱动 做设备侧客户端开发那几年我印象最深的一件事就是把一个空调控制模块里两百多行的switch (state)给拆了重写。表面原因是新需求要加“空气循环模式”实际上是因为那个模块的状态判断已经散落到事件响应、定时器回调和报文解析三个地方加一个功能就要牵一发动全身。后来我彻底换成 C 状态模式重写把自动空调控制状态、控制温度、连接握手这些行为全部装进独立的状态类里从那以后同样的改动只需要新增一个类、注册一条转移工作量小了一个数量级。这篇内容不是教科书式的状态模式讲解而是我实际用 C 做状态机的一点经验总结。里面包含经典 GoF 状态模式的落地写法、表驱动方案、现代 C 的std::variant玩法、异步事件队列、超时重试以及调试状态机时踩过的坑。适合正在写网络连接管理、嵌入式设备控制、协议栈解析或者准备 C 面试的朋友参考。1 状态模式到底在治什么病1.1 设备连接场景里的 if-else 灾难先看一个最常见的场景设备客户端要管理连接生命周期。未连接、连接中、握手、已连接、失败重试这是一条非常典型的状态链路。最简单粗暴的写法是定义一个枚举enum class ConnState { Disconnected, Connecting, Handshaking, Connected, Failed };然后在每个事件处理函数里写switchvoid onSocketEvent(ConnState st, Event ev) { switch (st) { case ConnState::Disconnected: if (ev Event::Connect) { startConnect(); } break; case ConnState::Connecting: if (ev Event::Connected) { startHandshake(); } else if (ev Event::Timeout) { retry(); } break; // ... 继续膨胀 } }这种代码的问题不是“不够优雅”而是它把状态和动作的对应关系摊平在每一个事件函数里。新增一个状态比如“固件升级中”你要去翻所有事件处理函数逐个检查这个状态该不该出现、该跳到哪。漏一个分支线上就会出“连接成功后回调却跑到超时逻辑”这种诡异现象。我当时遇到的另一个更隐蔽的问题是状态相关数据没有归属。重试次数、当前握手的序列号、心跳时间戳全部散落在模块成员变量里一个状态退出时忘了清零下一个状态就会拿到脏数据。我那次遇到的“空调控制温度一直停留在上次设定值”的 bug就是这么来的。1.2 GoF 状态模式在 C 里的经典落地状态模式的核心思路其实特别朴素把每个状态封装成类状态自己决定遇到事件后怎么处理、要不要切换到别的状态。C 实现的时候一般定义一个抽象基类每个具体状态继承它struct Event; class DeviceContext; class DeviceState { public: virtual ~DeviceState() default; virtual void onEnter(DeviceContext) {} virtual void handle(DeviceContext, const Event) 0; virtual void onExit(DeviceContext) {} }; class DeviceContext { public: explicit DeviceContext(std::unique_ptrDeviceState init) : state_(std::move(init)) { state_-onEnter(*this); } void dispatch(const Event ev) { auto* next state_-handle(*this, ev); if (next next ! state_.get()) { state_-onExit(*this); state_.reset(next); state_-onEnter(*this); } } private: std::unique_ptrDeviceState state_; };具体状态类长这样class Connecting : public DeviceState { public: void handle(DeviceContext ctx, const Event ev) override { switch (ev.type) { case Event::Connected: return new Handshaking{}; // 切换到握手状态 case Event::Timeout: return new RetryWait{}; // 超时进入重试等待 default: return nullptr; // 忽略无关事件 } } };handle返回新的状态对象DeviceContext::dispatch负责统一完成旧状态退出、新状态进入。这样每个状态的行为内聚在自己类里要加一个“握手失败”状态只需要新建类再改上游转移的返回值不用去动其他状态类。这个方案有一个工程细节要注意new Handshaking{}这种裸指针一旦在dispatch中被std::unique_ptr接管所有权就是 context 的了。如果你的状态类内部有数据需要跨切换保留比如重试次数那它在切换前先写到 context 上或者在状态构造函数里传入。1.3 状态对象怎么管生命周期与复用状态对象的管理是 C 状态模式比 Java 版更麻烦的地方。Java 里状态对象基本都是轻量实例切换时 new 一个GC 会帮你收掉。C 里没有 GC你就得自己决定每个状态都 new 新实例。简单但高频事件下会有分配开销。状态类的实例复用。如果状态内部是纯逻辑、无成员可以做成单例或用静态对象context 保存裸指针。但如果状态内部有计数、累计时间这种运行期数据复用同一个实例会互相污染。用shared_ptr管理状态对象。适合状态对象需要被多个模块引用的场景但代价是引用计数开销。我的习惯是默认用unique_ptr 每个状态 new 新实例。一个状态机的切换频率通常不会高到每秒上万次分配一个状态类对象的成本可以忽略。真正要警惕的是切换时忘了解除旧状态注册的回调导致状态对象释放后回调还在触发。常在河边走double free就是这么来的。保险做法是onExit里统一做“取消注册、清定时器、断连接”这类清理动作。2 表驱动状态机把转移关系从代码变成数据2.1 转移表与事件分派器状态多起来以后GoF 那种“每个状态类内部继续写 switch”的模式也会变得啰嗦。一个状态处理十来个事件里面一半是return nullptr;忽略。这时候我更推荐表驱动状态机把“状态 × 事件 → 动作 下一状态”这个关系做成一张数据表用一个统一的分派器去查表执行。enum class StateID { Disconnected, Connecting, Handshaking, Connected, Failed }; enum class EventID { Connect, Connected, HandshakeOk, Timeout, Disconnect, Error }; struct Transition { StateID from; EventID ev; StateID to; std::functionvoid(DeviceContext, const Event) action; }; std::vectorTransition kTransitionTable { { StateID::Disconnected, EventID::Connect, StateID::Connecting, [](DeviceContext ctx, const Event) { ctx.startConnect(); } }, { StateID::Connecting, EventID::Connected, StateID::Handshaking, [](DeviceContext ctx, const Event) { ctx.startHandshake(); } }, { StateID::Connecting, EventID::Timeout, StateID::Disconnected, [](DeviceContext ctx, const Event) { ctx.scheduleRetry(); } }, };分派器实现void dispatch(DeviceContext ctx, const Event ev) { for (auto t : kTransitionTable) { if (t.from ctx.currentState() t.ev ev.type) { if (t.action) t.action(ctx, ev); ctx.changeState(t.to); return; } } // 未匹配的转移记录日志忽略 }这张表的优势非常明显所有状态转移路径集中在一处评审代码时一眼就能看出有没有漏分支。以前排查“握手失败后没有重试”要在各个状态类里翻半天现在直接查表发现Connecting Timeout这一行被漏了补上就行。这招特别适合事件种类少、但状态数量多的场景比如网络连接管理、协议解析、UI 流程控制。2.2 状态机的可观测性怎么建状态机比 if-else 好调试的前提是你能随时看到当前状态和最近几次转移历史。我给状态机加日志时用了spdlog输出格式统一成这样spdlog::info([FSM] {} - {} (event{}, ctx_id{}), fromStateName, toStateName, eventName, ctx.id());别小看这句日志。状态机一旦出问题基本都能从日志里还原现场哪个事件、在哪个状态、被哪个转移处理了。如果日志显示事件到达但状态没变那就是转移表漏了如果状态连续快速切换那就是事件风暴或循环转移。更进一步的工程化做法是给状态机加dump()方法输出当前状态、事件队列长度、重试计数、最近 10 条历史记录。线上排查时把这个输出发回来比看一堆变量容易定位得多。调试状态机的效率很多时候取决于你日志打得够不够细。2.3 为什么 C 更适合表驱动C 做表驱动状态机有个天然优势转移表可以用静态数组直接初始化查表就是数组下标运算性能极好。而且std::function不够快的话还可以退回去用函数指针using ActionFn void(*)(DeviceContext, const Event); struct Transition { StateID from; EventID ev; StateID to; ActionFn action; };连std::function的堆分配开销都省了。相比之下脚本语言或 Java 里做表驱动往往要搞一堆 HashMap 嵌套代码反而更啰嗦。C 的表和结构体语法配合得很舒服所以表驱动在 C 状态机里是非常主流的高阶玩法。3 现代 C 的花活variant、function 与回调3.1 用 std::variant 表达状态如果你的状态机不追求 GoF 那种“状态类多态”可以考虑用std::variant来表达状态集。思路是把状态建模成类型集合值是各个状态需要的成员struct DisconnectedState { // 无特殊数据 }; struct ConnectingState { int retryCount 0; std::chrono::steady_clock::time_point startTime; }; struct ConnectedState { std::string sessionId; int temperature 0; int airMode 0; }; using DeviceState std::variantDisconnectedState, ConnectingState, ConnectedState;事件分发就是一次std::visitvoid dispatch(DeviceState st, const Event ev) { std::visit(overloaded{ [](DisconnectedState s) { if (ev.type Event::Connect) { st ConnectingState{}; } }, [](ConnectingState s) { if (ev.type Event::Connected) { st ConnectedState{}; } else if (ev.type Event::Timeout) { st DisconnectedState{}; } }, [](ConnectedState s) { /* ... */ } }, st); }overloaded是个经典工具函数用可变模板把多个 lambda 合并成一个std::visit可用的访问器很实用template typename... Ts struct overloaded : Ts... { using Ts::operator()...; };这种方式的好处是值语义。状态可以用std::atomicDeviceState或锁包起来直接跨线程访问不用 care 指针生命周期。缺点也明显状态内部逻辑多了以后所有转移都堆在 lambda 里状态多起来可读性会下降。还有一点std::variant的切换是“整体赋值”对体积大的状态对象会有拷贝或移动开销不过一般状态对象都很小实测没问题。3.2 转移表加 std::function 组织动作前面表驱动方案里已经用了std::function。我再展开讲讲它的边界。std::function非常灵活可以捕获任意上下文但它是有开销的——构造可能触发堆分配调用可能比裸函数指针慢。对于状态机这种低频事件这个开销完全可以忽略。我踩过的坑是在一个吞吐量很高的协议解析状态机里用了大量std::function每帧报文都触发好些 lambda性能剖出来一看状态分派占了 CPU 的好几个点。换成函数指针和静态数组后开销几乎归零。结论是状态机如果用在高频路径上能用函数指针就不用std::function低频管理逻辑随便用没毛病。3.3 回调函数与异步状态推进状态机跟异步回调是天生要配合的。比如连接操作是异步的网络层完成后通过回调通知结果。我不会让回调直接修改状态对象而是让它往事件队列里投递一个事件auto onConnectResult [this](ErrorCode ec) { eventQueue_.push(ec ErrorCode::OK ? Event{EventCode::Connected} : Event{EventCode::ConnectFailed}); };这么做有个非常实际的原因如果回调在某个工作线程里直接调用了state_.reset(...)而同一时刻主线程正在跑dispatch两个线程同时改状态对象轻则逻辑错乱重则直接崩溃。回调只负责投递事件状态机的状态变更永远发生在它自己的执行线程里这是最省心的线程安全模型。4 高级实战超时重试、事件队列与报文解析4.1 握手失败与重试状态机怎么写设备连接场景里最经典的需求就是“握手失败自动重试”。我当时的做法是在状态表里设计三个事件Connected、HandshakeOk、HandshakeTimeout。握手中超时根据重试次数决定回到Connecting还是进入Failed。class Handshaking : public DeviceState { public: void onEnter(DeviceContext ctx) override { retryCount_ ctx.retryCount(); startHandshakeTimer(ctx); } void handle(DeviceContext ctx, const Event ev) override { if (ev.type Event::HandshakeOk) { return new Connected{}; } if (ev.type Event::HandshakeTimeout) { if (retryCount_ ctx.maxRetry()) { ctx.setRetryCount(retryCount_ 1); return new Connecting{}; } return new Failed{}; } return nullptr; } private: int retryCount_ 0; };关键细节在于重试次数存哪里。存状态类里每次HandshakeTimeout就靠retryCount_递增但状态类释放后计数就丢了所以要拿到 context 上保存。存 context 上每个状态类都能访问。我一般两种数据分开放状态机共享的数据重试次数、会话 ID放 context单个状态运行期才需要的数据开始时间、临时缓存放状态类成员。这个边界分清楚状态机代码才不烂。4.2 事件队列防止递归状态切换状态机最隐蔽的问题之一是递归。假设状态 A 的handle里直接调用了另一个状态的事件分发那个状态又触发回到状态 A 的事件栈会越叠越深早晚栈溢出。我接手过的一个老模块就出过这种问题一次连接失败日志显示dispatch递归了上千层。解决方式很简单所有事件先入队状态机每次只从队列头取一个事件处理。class EventQueueStateMachine { public: void post(Event ev) { std::lock_guardstd::mutex lock(mutex_); queue_.push(std::move(ev)); } void pump() { while (true) { Event ev; { std::lock_guardstd::mutex lock(mutex_); if (queue_.empty()) break; ev std::move(queue_.front()); queue_.pop(); } dispatch(ev); } } private: std::mutex mutex_; std::queueEvent queue_; };事件队列带来的另一个好处是事件不会丢。如果状态机处理某个事件的耗时较长异步回调到达时状态机正忙事件先排队处理完再处理下一个逻辑依然是顺序的。我后来做“重置设备状态”需求时只调用了queue_.clear()和state_.reset(...)同时把事件队列清空避免重置后又处理旧事件问题就消失了。4.3 解析空调控制报文的实战姿势回到文章开头说的空调控制模块。报文长这样一帧数据里带自动空调控制状态、空调控制温度、空气循环模式等信息。我用状态机做接收解析状态分为WaitHeader、WaitLength、WaitPayload。报文到达后按当前状态处理WaitHeader里收到帧头字节切换到WaitLength把长度字段缓存起来。WaitLength里收到长度字段按长度切到WaitPayload。WaitPayload里收满指定字节数拼出完整报文交给业务层处理状态回到WaitHeader。这个状态机看起来简单但真的把我都写吐了的 if-else 全收编了。解析代码从“三处状态判断 两个只属于特定阶段的临时变量”收敛成“三个状态类各管各的阶段变量”。报文负载本身是裸字节数组我在解析时常遇到unsigned char recdata[512]转成字符串或数组的问题。常见的做法是std::size_t payloadLen static_caststd::size_t(recvLen); std::string payload(reinterpret_castconst char*(recdata), payloadLen);这里有个坑reinterpret_castconst char*得到的字符序列如果中间含\0用std::string构造时要显式传长度不能只用recdata的首地址然后靠strlen推断。我当时调了一个晚上才发现报文里的温度字段为 0 时后面的空气循环模式全被截断了。5 并发状态机与 ABA 问题5.1 状态机的线程边界怎么划状态机是单线程模型这是它的核心假设。一旦状态机的状态被多个线程同时访问、同时修改设计上的简单性就全没了。正确姿势是一个状态机配一个专属线程或搭配事件循环外部线程只能通过post投递事件不允许直接读改状态。如果非要暴露只读接口给其他线程可以用std::atomicStateID保存一个整数状态 ID供外部无锁读取真正的状态对象还是留在状态机线程里。注意外部只能拿到 ID不能拿到状态对象引用一旦拿到了引用生命周期问题就来了。5.2 无锁队列中的 ABA 问题“ABA 问题”在并发场景里是个经典坑。简单说线程 A 用 CAS 想把一个对象从值 X 改成 Y但在它执行 CAS 之前线程 B 已经把值从 X 改成 Z又改回 X。A 的 CAS 以为“还是原来的 X”结果成功但它操作的对象已经不是原来的对象了。这一点跟状态机有奇妙的联系。我记得有个同事在线程池调度模块里用无锁队列保存任务状态任务从Disconnected变成Connecting再变回Disconnected另一个线程 CAS 比较任务状态时发现“还是 Disconnected”就把一个本该跳过的不成熟任务重新入队了。真实世界里的状态变化不是“数值没变就万事大吉”状态值相同不代表语义相同——可能在中间已经发生了完整的转换周期。5.3 世代计数器防止过期事件解决 ABA 的思路在状态机里可以用“世代计数”落地。状态机上挂一个单调递增的generation_每次重置状态机比如连接断开、设备重置就generation_。所有投递的事件都带上当时generation_的快照状态机处理事件前先比对void post(Event ev) { ev.generation generation_.load(); queue_.push(std::move(ev)); } void pump() { Event ev queue_.front(); queue_.pop(); if (ev.generation ! generation_.load()) { // 这条事件是重置之前投递的直接丢弃 spdlog::warn([FSM] stale event dropped, gen{}, ev.generation); return; } dispatch(ev); }这招在“重置设备状态... 连接设备失败”这类需求里特别有用。重置动作发生瞬间可能已经有连接成功、握手超时等多个事件在队列里排队了如果不丢弃这些过期事件重置完设备后马上就会收到一个“握手超时”事件状态机直接跳到 Failed用户一脸懵。世代计数器是我用过的最简单可靠的过期事件过滤方案。6 工程化落地环境、调试与避坑6.1 VS Code 调试 C 状态机的配置要点状态机逻辑一旦复杂print 大法就不太够用需要用调试器看运行时的实际状态。VS Code 配 C 调试环境本身不难一个tasks.json负责编译一个launch.json负责启动 gdb 或 lldb。// tasks.json { version: 2.0.0, tasks: [ { type: cppbuild, label: build-fsm, command: g, args: [ -g, -stdc17, fsm.cpp, -o, fsm_demo ], group: build } ] }// launch.json { version: 0.2.0, configurations: [ { name: debug-fsm, type: cppdbg, request: launch, program: ${workspaceFolder}/fsm_demo, preLaunchTask: build-fsm, MIMode: gdb, stopAtEntry: false } ] }条件断点是调状态机的利器。比如我在DeviceContext::changeState函数打断点设置状态 ID 等于某个值时才停住stateID static_castint(StateID::Handshaking)配合在断点里调用printState()这种自定义函数可以快速确认“进入 Handshaking 时 context 里各项数据是否正常”。VS Code 调试std::variant时不太好直接看当前活跃类型我的经验是在访问器 lambda 里临时打一条日志或者用spdlog输出类型 ID比在调试器里翻variant的内部表示省事得多。6.2 常见问题速查表现象常见原因排查方向状态一直停留在初始状态转移表漏了该事件分支事件未入队查转移表是否覆盖from ev组合查事件投递路径状态机快速来回切事件风暴回调投递了重复/循环事件给事件队列打日志看单位时间事件量连接失败后不重试重试计数存到了状态类内部状态切换后丢数将重试计数移到 context 上重置状态后立刻又跳错误队列里残留旧事件引入世代计数重置时丢弃过期事件跨线程访问状态对象崩溃外部线程直接读写了 context 的状态统一事件投递模型禁止外部直接操作状态状态对象 double freeonExit 没清理异步回调在 onExit 中统一取消回调、关定时器报文解析到\0后截断字符串构造没显式传长度用std::string(data, len)而不是std::string(data)6.3 我私藏的一些设计习惯做状态机这几年我沉淀了几个非常顺手的习惯分享出来供参考。第一每个状态类实现一个name()方法。哪怕不用于业务日志和调试也会频繁用。没有名字的状态机出了 bug你只能靠枚举数字猜现场。第二状态切换的日志统一在changeState里打一次不要在具体状态类的handle里这里打一条、那里打一条。统一出口保证日志有序、不重复。第三对外暴露语义操作而不是暴露事件。业务代码调用deviceContext.connect()内部转换成Event::Connect投递到队列业务层不直接构造事件对象。这样状态机的事件类型怎么调整外部业务代码完全不受影响。我当时给空调模块加“空气循环模式切换”时就是新增一个语义操作setAirMode(int mode)内部转换成对应事件现有状态类完全不用改成事件接口。第四状态多到一定程度比如超过七八个并且还存在嵌套关系时别硬写在手写状态机里直接上boost::sml或层级状态机库。手写状态机适合三到六个状态的场景再多就不好维护了。最后说两句实在话。状态模式不是银弹它只是把“状态判断”这个维度的复杂度从散落的 if-else 收敛到统一的转移模型里。如果业务逻辑本身就没有清晰的状态边界强行套状态机只会造出一个更复杂的状态机。反过来只要你能画出一张稳定的状态转移图C 的状态模式绝对是性价比最高的实现方式之一。那次重构空调控制模块后我最大的体会是加功能不再心惊胆战了因为所有状态路径都明明白白躺在表里跑一遍单测就能验证有没有破坏既有行为。
返回列表