AFSIM 的时间到底是怎么"走"的?——从 2.9.0 源码拆解离散事件时钟

文 / 沙盘客 · 公众号「仿真推演框架」

如果有人问你:"AFSIM 里的时间是怎么流动的?"

大多数人的第一反应是:和现实一样啊,一秒一秒地走,仿真跑了 60 秒,墙上的钟也走了 60 秒。

但这个直觉是错的。

AFSIM 里"没有"一条匀速流动的河。它既没有操作系统里那个滴答作响的时钟中断,也不是在每一帧里给一个全局计数器 +1。在它的内核里,时间是一种被"事件"反复重塑的坐标 ------引擎从一个事件跳到下一个事件,跳的时候把"当前时间"直接改写成下一个事件的时刻。你看到的"连续推进",其实是无数次离散的跳跃

这篇我们钻进 AFSIM 2.9.0 的源码(swdev/src/core/wsf),把这套时间管理机制一层一层拆开:时钟抽象、离散事件队列、三种时间形态、实时同步与"落后"补偿,以及墙钟事件那套独立于仿真暂停的心跳。

读完你会明白:为什么 AFSIM 既能 10000 倍快进回放,又能严格 1:1 实时,还能被外部 AI 一个事件一个事件地"推"着走。


一、时间的唯一真相:一个 double,从 0 开始

先说最朴素的事实。AFSIM 的"当前仿真时间"就是 WsfSimulation 里的一个成员变量:

cpp 复制代码
// WsfSimulation.hpp
double mSimTime{0.0};          // 当前仿真时间
double mEndTime;               // 定义的结束时间
double mRealTime;              // 最近一次采样的实时钟
double mTimeBehind;            // 仿真钟落后实时钟多少
double mTimestep;              // 帧步进时的步长(离散事件模式下为 0)
double mSyncTimestep;          // DIS-NRT 模式下的同步步长

对外只暴露一个取值口:

cpp 复制代码
double GetSimTime() const { return mSimTime; }

注意类型是 double(双精度浮点,单位:秒),不是整型 tick,也不是某个从纪元起算的纳秒计数。这是个很有讲究的设计选择------

  • double 而不是 int64 的纳秒,意味着 AFSIM 把"仿真时间"建模为连续实数轴上的时刻 ,而不是离散的整型编号。模型里写 move at 300 m/s for 12.37 s,时间直接参与浮点运算,物理积分自然、直观。
  • double 也有代价:极大时间跨度下尾数精度会下降。AFSIM 默认把 "0.0" 当作仿真起点(mSimTime{0.0}),end_time 默认 60.0 秒,就是把仿真时间轴锚定在一个可控的小范围内,避开大数吃精度的坑。

那么场景里那个 end_timeclock_rate 是怎么进到这些变量里的?答案在 WsfSimulationInput 的输入解析:

cpp 复制代码
// WsfSimulationInput.cpp
if (command == "end_time")   { aInput.ReadValueOfType(mEndTime, UtInput::cTIME); }
else if (command == "clock_rate") { aInput.ReadValue(mClockRate); }
else if (command == "realtime")  { mIsRealTime = true; }
else if (command == "non-realtime") { mIsRealTime = false; }

也就是说,你在 .txt 想定里写的:

yaml 复制代码
end_time 3600
clock_rate 1
realtime

最终都落到了上面那组 double 上。

二、时钟抽象:WsfClockSource 与"被请求的时间"

AFSIM 把"时钟"做成了可替换的抽象WsfSimulation 只持有一个 WsfClockSource 指针:

cpp 复制代码
std::unique_ptr<WsfClockSource> mClockSourcePtr{nullptr};
WsfRealTimeClockSource*         mRealTimeClockSourcePtr{nullptr};

基类的 GetClock 简单到让人意外:

cpp 复制代码
// WsfClockSource.cpp
double WsfClockSource::GetClock(double aClock) const
{
   double clockNow = aClock;
   if (clockNow > mMaximumClock) clockNow = mMaximumClock;
   return clockNow;
}

它几乎什么都不做 ------把调用者"想要的时间"原样返回(最多被 mMaximumClock 截断)。这正是离散事件模式的本质:在默认(非实时)仿真里,时钟就是"被请求的时间"。引擎说"现在跳到 12.37 秒",时钟就报 12.37 秒,没有"流逝"这回事。

真正让时钟"活"起来的是派生类 WsfRealTimeClockSource。它的 GetClock 是这样定义的:

cpp 复制代码
// WsfRealTimeClockSource.cpp
double WsfRealTimeClockSource::GetClock(double aClock) const
{
   double simulationClock = mTimeAccumulated;          // 上次暂停时累计的仿真时间
   if (!mClockPaused)
   {
      simulationClock += mWallClock.GetClock() * mClockRate;  // 墙钟流逝 × 倍率
      if (simulationClock > mMaximumClock) simulationClock = mMaximumClock;
   }
   if (aClock < simulationClock) simulationClock = aClock;  // 时钟不能超前于"请求时间"
   return simulationClock;
}

这一行 mWallClock.GetClock() * mClockRate 就是 AFSIM 实时能力的全部秘密:

  • mWallClock 是真正的墙钟(基于 UtWallClock,可切换 performance_counter/system_time/tick_count 等计时源);
  • mClockRate 就是你在想定里写的 clock_rateclock_rate 1 是 1:1 实时;clock_rate 10 表示墙钟走 1 秒、仿真走 10 秒------快进 10 倍clock_rate 0.5 则是半速慢放。
  • mTimeAccumulated 记录了"上次暂停时刻已经累计了多少仿真时间",恢复时从此接续,不会清零重来。

而且 SetClockRate 支持运行中动态改速率 ------它会先把当前仿真钟"结算"进 mTimeAccumulated,再重置墙钟起点。这意味着你可以让仿真先慢放定位问题,再一键加速跑完长航迹,全程时间连续不跳变。


三、离散事件引擎:时间是在"跳跃",不是"流逝"

现在到了核心。AFSIM 的主循环藏在 WsfStandardApplication 里,骨架极简:

cpp 复制代码
// WsfStandardApplication.cpp
while (aSimPtr->IsActive())
{
   aSimPtr->WaitForAdvanceTime();
   simTime = aSimPtr->AdvanceTime();
   // ... 日志、消息分发 ...
}
aSimPtr->Complete(aSimPtr->GetEndTime());

每一轮循环,AdvanceTime() 干的事,是整篇的精华(WsfSimulation.cpp:186):

cpp 复制代码
double WsfSimulation::AdvanceTime()
{
   double timeStart = 0.0;
   if (mIsRealTime && (mRealTimeClockSourcePtr != nullptr))
      timeStart = mRealTimeClockSourcePtr->GetElapsedWallTime();

   WsfEvent* eventPtr = mEventManager.PeekEvent();   // ① 偷看队列里"最早"的事件
   if (eventPtr != nullptr)
      mSimTime = eventPtr->GetTime();                // ② 时间直接跳到那个事件的时刻!
   else
      mSimTime = GetEndTime() + 0.1;                 // 没有事件了 → 越过结束时间

   mSimTime = mClockSourcePtr->GetClock(mSimTime);   // ③ 让时钟抽象再校准一次
   WsfObserver::AdvanceTime(this)(mSimTime);

   if (mSimTime > GetEndTime())
      mState = cPENDING_COMPLETE;                    // ④ 越过终点 → 准备收尾

   DispatchEvents(mSimTime);                         // ⑤ 把所有"到点"的事件都执行掉
   return mSimTime;
}

注意第 ② 步:mSimTime 不是 += dt,而是被直接赋值为下一个事件的时刻。这就是"离散事件"四字的分量------仿真不会在 12.36、12.37、12.38......里一步步爬,而是从 12.37 直接跳到下一个事件发生的 14.02。两个事件之间的"空白"里,什么都没发生,所以 AFSIM 根本不浪费算力去"走"那 1.65 秒。

事件队列:一个最小堆

驱动这一切的"下一个事件"来自哪?来自 mEventManager,它的底层是 C++ 标准库的最小堆:

cpp 复制代码
// WsfEventManager.hpp
using EventQueue = std::priority_queue<Event, std::vector<Event>,
                                       std::greater<Event>>; // 最小元素在堆顶

事件的排序键是一个三元组,保证先按时间、再按稳定次序,避免同刻事件乱序:

cpp 复制代码
using Key = std::tuple<double, int, unsigned int>; // (时刻, 类型优先级, 入队序号)

队列还加了 std::recursive_mutex,所以是线程安全的------在多核并行推演里,不同线程往里塞事件也不会崩。

事件是怎么"跑"起来的

AdvanceTime 最后调用的 DispatchEvents,才是真正执行逻辑的地方:

cpp 复制代码
// WsfSimulation.cpp
void DispatchEventsHelper(WsfEventManager& aEventManager, double aSimTime)
{
   WsfEvent* peekEventPtr = aEventManager.PeekEvent();
   while (peekEventPtr && (peekEventPtr->GetTime() <= aSimTime))
   {
      auto eventPtr = aEventManager.PopEvent();
      if (eventPtr->ShouldExecute())
      {
         WsfEvent::EventDisposition disposition = eventPtr->Execute();
         if (disposition == WsfEvent::cRESCHEDULE)   // 关键:定时器就是这么实现的
            aEventManager.AddEvent(std::move(eventPtr)); // 改个时间,重新入队
      }
      peekEventPtr = aEventManager.PeekEvent();
   }
}

这里有一个常被忽略的妙处:WsfEvent::Execute() 的返回值如果是 cRESCHEDULE ,事件会被改个时间、重新塞回队列。AFSIM 里那些"每 5 秒扫描一次雷达""每 1 秒广播一次位置"的周期性行为,本质上就是靠这个自我重排的事件实现的------并没有一个全局 ticker 在后台滴答。

四、三种时间形态:同一套内核,三种"走法"

光有离散事件还不够。AFSIM 通过三个 WsfSimulation 的派生类,把同一套时钟与事件机制,演绎成三种时间形态:

形态 时间怎么走 典型用途
离散事件(默认) WsfSimulation 跳到下一个事件 绝大多数作战推演
帧步进 WsfFrameStepSimulation 固定 mNextFrameTime 步进 需要等间隔采样的场景
事件步进 WsfEventStepSimulation 一次只推进一个事件 调试、单步、外部 lock-step 驱动

帧步进:把"跳跃"变成"等间隔台阶"

WsfFrameStepSimulation::AdvanceTime() 不再看队列里下一个事件,而是看 mNextFrameTime

cpp 复制代码
double WsfFrameStepSimulation::AdvanceTime()
{
   mSimTime = mClockSourcePtr->GetClock(mNextFrameTime + 0.000001);
   if (mSimTime > mNextFrameTime)
   {
      mSimTime = AdvanceFrame();   // 推进一帧,处理这一帧内所有对象
      // ...
   }
   return mSimTime;
}

你在想定里写的 time_step 1.0,对应的就是这种"每隔 1 秒算一帧"的等间隔模式。它牺牲了一点效率(空帧也要走),换来的是输出节奏规整------适合需要固定采样率做后处理的任务。

事件步进:一次一个事件,且能对齐墙钟

WsfEventStepSimulation 是"离散事件"的精细化版:它一次只弹出一个事件并处理,因此可以被外部单步驱动 。更关键的是它的 WaitForAdvanceTime() 里有一段"对齐实时"的逻辑:

cpp 复制代码
// WsfEventStepSimulation.cpp
double nextEventTime = 1.0E+30;
WsfEvent* eventPtr = mEventManager.PeekEvent();
if (eventPtr) nextEventTime = eventPtr->GetTime();

mRealTime  = mClockSourcePtr->GetClock(1.0E+37);
double sleepTime = nextEventTime - mRealTime;
if (sleepTime >= 0.0)
{
   sleepTime /= GetClockRate();   // 换算成墙钟该睡多久
   // ... 若睡得久,先让出 CPU 一下,提升计时精度 ...
}
else
{
   mTimeBehind = -sleepTime;      // 仿真落后了!通知观察者
   WsfObserver::SimulationTimeBehind(this)(mTimeBehind);
}

这就是 AFSIM 在实时模式下**"紧跟墙钟"的算法:算出"下一个事件时刻"和"当前实时钟"的差距,把差距按 clock_rate 换算成该 sleep 的墙钟时长,然后让线程睡过去;睡醒了再推进。如果算出来是负数(事件时刻已经早于实时钟),说明仿真 落后于实时**,于是通过 SimulationTimeBehind 观察者广播"我落后了",让其它组件(比如渲染、外部接口)决定要不要降负载追赶。

五、受控步进与"墙钟心跳":给外部 AI 留的接口

前面讲的都是 AFSIM "自己跑"。但如果你想用 Python 的强化学习智能体、或者用 wsf_external_control 插件从外部 TCP 接管平台,时间就必须能被外部"一步一步"地驱动 ------这正是 SetTimeParametersPauseAndRequestAdvance 的用武之地。

cpp 复制代码
// WsfSimulation.cpp
void WsfSimulation::SetTimeParameters(int aTimeScheme, double aSimTime,
                                      double aClockRate, double aTimeStep, bool aTimeAdvance)
{
   SetClockRate(aClockRate);
   mSyncTimestep = aTimeStep;
   // ... 设定时钟 ...
   AddEvent(ut::make_unique<WsfOneShotEvent>(aSimTime,
            [=]() { PauseAndRequestAdvance(aSimTime); })); // 到点就暂停,等外部发话
   if (aTimeAdvance && GetClockSource()->IsStopped())
   {
      mSyncAccumulatedTime = 0.0;
      Resume();   // 需要的话恢复
   }
}

PauseAndRequestAdvance 会在指定时刻塞一个"暂停事件",于是仿真推进到那一点就停下,通过 WaitForAdvanceTime() 阻塞住,直到外部调用 Resume() 才继续。这就是 AFSIM 与外部控制器之间的 lock-step(锁步)时间协议 ------也是我们在《AFSIM 外部控制与 AI 作战》里讲的 PyCMO、RL 智能体能够"每步观察---决策---动作"的底层支点:时间不是 AFSIM 独占的,而是可以被协商、被暂停、被外部推进的

还有一个容易被忽视的细节:墙钟事件队列 mWallEventManager 与仿真事件队列是分开的

cpp 复制代码
void WsfSimulation::DispatchEvents(double aSimTime)
{
   DispatchSimEvents(aSimTime);   // 基于仿真时间
   DispatchWallEvents();          // 基于墙钟时间
}

墙钟事件即便仿真被暂停,只要墙钟走到点,也会触发------它是仿真管理、外部心跳、连接保活这类"不能因为暂停就停摆"的逻辑的载体。暂停的是"作战世界",不停的是"控制面",这个分离非常工程化。


六、为什么这套设计"聪明"

把源码读到这里,几个工程上的巧思值得点出来:

1. double 秒而非整型 tick。 模型代码里时间直接参与物理运算,积分器、运动学、传播延迟都能用真实物理量表达;同时通过把起点锚在 0 来规避大数精度问题。

2. 时钟与模型解耦。 WsfClockSource 是一个可替换的策略对象。同一套平台/传感器/武器模型,既能在离散事件下 10000 倍快进做蒙特卡洛,又能在实时模式下 1:1 接入 HLA/DIS 演练,模型代码一行都不用改

3. 最小堆 + 稳定排序键。 取"下一个事件"是 O(log n),同刻事件靠 (时刻, 类型优先级, 入队序号) 三元组稳定定序,既快又确定。

4. 可暂停、可协商的时间。 Pause/Resume + WaitForAdvanceTime 的锁步机制,让 AFSIM 天然适合做人在回路、AI 在回路的推演------时间不再是一个黑盒计数器,而是一个可以被外部握在手里的总线。

5. 确定性回放有根基。 因为时间完全由"事件序列 + 时钟策略"决定,给定相同种子与事件队列,仿真可以复现;real-time 模式下的"落后"也只是一个可观测、可补偿的偏差,而非不可控的混沌。


七、小结与延伸

把全篇串起来,AFSIM 的时间管理可以浓缩成三句话:

  • 时间里没有"流逝",只有"被事件改写的坐标" ------AdvanceTime()mSimTime 直接跳到下一事件时刻;
  • 时钟是可替换的策略 ------WsfClockSource 基类原样返回请求时间(离散事件),WsfRealTimeClockSource墙钟 × clock_rate 造出实时(或快进/慢放);
  • 时间是可以被外部握住的 ------PauseAndRequestAdvance + 墙钟事件队列,让作战世界能暂停、控制面却始终心跳,从而支撑人在回路与 AI 在回路。

理解它,你就握住了 AFSIM 的"节拍器":所有平台机动、传感器探测、武器发射,本质上都是往那条最小堆事件队列里塞一个个带时刻的球,然后等着引擎把它们一个个弹出来执行。


系列导航:本文是「AFSIM 工程精读」系列之一。前置可参看《初识 AFSIM:一个让作战仿真"跑起来"的框架》《AFSIM 外部控制与 AI 作战》;平台全景见《一张架构图,看懂现代作战仿真推演平台》。

如果这篇对你理清 AFSIM 的"时间观"有帮助,点赞、在看、转发让更多做仿真推演的同行看到。关注「仿真推演框架」,持续拆解作战仿真的工程内核。

相关推荐
jump_jump7 天前
Kysely 源码解剖:类型安全 SQL 如何走到 AST
node.js·orm·源码阅读
mCell8 天前
从 VS Code 学系统架构:回到当时,理解它为什么这样演进
架构·源码阅读·visual studio code
mCell9 天前
别给烂代码写注释——重写它
agent·代码规范·源码阅读
我是大卫12 天前
【图】React源码解析-第五部分:底层数据结构与内存模型
react.js·源码阅读
抹茶冰淇淋25 天前
为何努力那么难?结合脑科学与心理学的分析
源码阅读
yinchnag1 个月前
Go 语言 map 底层实现
后端·源码阅读
夕夕木各1 个月前
探究 TypeScript 类型体操里的 Equal 和 IsAny
typescript·源码阅读
vim怎么退出1 个月前
Dive into React——高级特性
前端·react.js·源码阅读
vim怎么退出1 个月前
Dive into React——事件系统
前端·react.js·源码阅读