18 KiB
Machine Actor: 设备状态机 goroutine 化
目标: 每台设备一个 goroutine,自治管理槽位状态和 PLC 信号监听,替换当前散落在 EventLoop/Station/SignalWatcher 中的设备逻辑。
架构: Actor 模型 — goroutine + channel,私有状态,外部只能发消息/读快照。全局调度器聚合快照做跨设备决策,EventLoop 保持 DB 单线程写。
实现方式: 重写。不迁移旧 Station 代码,新 Actor 替换旧 Station 后删除旧代码。
1. 动机
当前问题:
- 设备状态更新散落两处 —
handleMachineDone先写 DB equipment_slot,再调updateStationDone写 Station 内存,两个操作不在同一事务 - 调度器读两个源 —
buildSystemState从 Station 内存读MachineBusy,从 DB 读MachineHasJob,语义重叠但不同步 - handleMachineDone 职责过重 — 批量/单信号设备分支、slot 查找、DB 更新、Station 更新、Job 推进混在一个方法
- SignalWatcher 集中轮询 — 所有设备走一个 ticker,设备数量增加时轮询粒度不可控
核心矛盾:设备状态(槽位 Empty/Occupied/Done + 工站 Idle/Processing/Full/Waiting)天然属于设备自身,但当前被 EventLoop 和 Station 分而治之。
2. 架构概览
PLC 硬件信号
↓ SignalRouter 集中轮询,路由到各 Actor channel
┌──────────────────────────────────────────────────────────────┐
│ MachineActor-1 MachineActor-2 TempStoreActor │
│ (CNC #1, goroutine) (清洗机, goroutine) (暂存台, goroutine) │
│ │
│ 私有: slots[],status 私有: slots[],status 私有: slots[] │
│ 外部只能: Send/Snapshot │
│ │
│ 自写 DB: equipment_slot 自写 DB: job │
│ 发布事件: EvtMachineDone 无 PLC 信号 │
└──────┬───────────────────────────────────────────────────────┘
│ 发布事件到 EventBus
▼
GlobalScheduler ──── 聚合所有 Actor 快照
│ 换料配对、优先级排序
│ 输出 CandidateTask
▼
EventLoop ────────── Job 步骤推进 (DB)
│ AdvanceStep / FinishJob
│ 不碰 equipment_slot
▼
PostgreSQL
职责边界:
| 组件 | 职责 | 不负责 |
|---|---|---|
| MachineActor | 槽位状态机、PLC 信号监听、完成判定、DB equipment_slot 写入 | Job 步骤推进 |
| TempStoreActor | 暂存台槽位分配/释放、DB job.temp_slot_no 写入 | Job 步骤推进 |
| GlobalScheduler | 跨设备决策(换料配对、优先级) | 设备内部状态 |
| EventLoop | Job 步骤推进、DB job 表写入 | 设备/暂存台状态管理 |
3. MachineActor 设计
3.1 接口
// MachineActor 设备状态机
type MachineActor interface {
ID() int // 设备 ID
Type() string // 设备类型编码
Run(ctx context.Context) // 启动 goroutine,阻塞
Send(msg MachineMsg) // 外部发消息(非阻塞,chan 容量 8)
Snapshot() MachineSnapshot // 读快照(atomic.Value,无锁)
}
// MachineMsg 外部发给 Actor 的命令
type MachineMsg struct {
Type string // LOAD_COMPLETE / UNLOAD_COMPLETE / RELEASE / RESTORE
JobID int
SlotNo int
Params map[string]any
}
// MachineSnapshot 只读快照,供调度器聚合
type MachineSnapshot struct {
ID int
Type string
Status ActorStatus // Idle/Processing/Full/Waiting/Fault
Slots []SlotSnapshot
UpdatedAt time.Time
}
type SlotSnapshot struct {
SlotNo int
Status SlotStatus // Empty/Occupied/Done
JobID int
OccupiedAt time.Time
}
3.2 工站状态机
┌──────────────────────────┐
│ FAULT │←── PLC 通信异常 / 硬件故障
│ (需人工介入恢复) │
└──────────────────────────┘
↑ 异常
│
┌──────────┐ Load完成 ┌──────────────┐ 满 ┌──────────┐
│ IDLE │──────────→ │ PROCESSING │───→ │ FULL │
│ (全Empty)│←────────── │ (有空+有占) │←─── │ (全占满) │
└──────────┘ Unload/全部 └──────────────┘ └──────────┘
槽位释放 │ ↑ ↑ │
│ │ │ │
│ │ └─ Load(有空位) │
│ │ │ PLC 完成信号
│ └── PLC Done ────────┘
│
│ PLC 完成信号(非批量设备)
▼
┌──────────────┐
│ WAITING │ ← 有 Done 槽位,等待卸料/换料
│ (有Done槽位) │
└──────────────┘
状态规则:
- IDLE: 所有槽位 Empty
- PROCESSING: 有空 + 有占。批量设备单信号完成时短暂停留
- FULL: 全部 Occupied 或 Occupied+Done 之和=capacity,不接受上料
- WAITING: 有至少一个 Done 槽位,调度器优先处理
- FAULT: PLC 通信异常,需人工恢复
3.3 槽位状态机
Empty ──Load完成──→ Occupied ──PLC Done──→ Done ──Unload完成──→ Empty
3.4 批量设备 vs 单信号设备
两类设备完成行为不同,在 Actor 内部通过 batch 配置区分:
type machineActor struct {
id int
batch bool // true: 批量完成(所有 Occupied→Done)
// ...
}
批量设备(batch=true,如高压清洗机):收到 PLC 完成信号 → 所有 Occupied 槽位标记 Done
单信号设备(batch=false,如 CNC):收到 PLC 完成信号 → 按 OccupiedAt FIFO 找最早占用槽位标记 Done
3.5 检测设备
检测设备(内窥镜检测线、抽检台)在 Actor 中维护 hasNG bool:
handleMachineWaitCandidate的跳过行为保留在 EventLoop,不迁移到 Actor- 检测结果判定逻辑在 Actor 中:
- Done 信号(pass)→ 发布
EvtInspectionResult(machineID, true) - NG 信号 → 发布
EvtInspectionResult(machineID, false)
- Done 信号(pass)→ 发布
- EventLoop 消费
EvtInspectionResult:pass→CompleteStep→SetWaitingUnload;fail→FinishJob(Scrapped)+Alarm
3.6 Run 主循环
func (m *machineActor) Run(ctx context.Context) {
ticker := time.NewTicker(m.pollInterval)
defer ticker.Stop()
for {
select {
case msg := <-m.msgCh:
m.handleMessage(msg)
case evt := <-m.signalCh:
m.handleSignal(evt) // 非阻塞接收 SignalRouter 推送
case <-ctx.Done():
return
}
}
}
3.7 消息处理
func (m *machineActor) handleMessage(msg MachineMsg) {
switch msg.Type {
case "LOAD_COMPLETE":
m.slots[msg.SlotNo-1] = SlotStatus_Occupied
m.slotJobs[msg.SlotNo-1] = msg.JobID
m.slotOccupiedAt[msg.SlotNo-1] = time.Now()
m.updateStatus()
m.db.SetEquipmentSlot(ctx, m.id, msg.SlotNo, SlotStatus_Empty, SlotStatus_Occupied, msg.JobID)
case "UNLOAD_COMPLETE":
m.db.SetEquipmentSlot(ctx, m.id, msg.SlotNo, SlotStatus_Done, SlotStatus_Empty, 0)
m.slots[msg.SlotNo-1] = SlotStatus_Empty
m.slotJobs[msg.SlotNo-1] = 0
m.slotOccupiedAt[msg.SlotNo-1] = time.Time{}
m.updateStatus()
case "RESTORE":
m.slots[msg.SlotNo-1] = parseSlotStatus(msg.Params["status"])
m.slotJobs[msg.SlotNo-1] = msg.JobID
m.updateStatus()
}
}
3.8 信号处理 + DB 持久化
Actor 收到完成信号后:更新私有状态 → 写 DB → 发布事件通知 EventLoop 推进 Job。内存与 DB 在同一 goroutine 内顺序执行,无一致性问题。
检测设备的 Done 信号表示检测合格(pass),走 EvtInspectionResult 而非 EvtMachineDone。
func (m *machineActor) handleSignal(evt SignalEvent) {
// 检测设备 NG 信号
if evt.Type == SignalNG && evt.Value {
m.bus.Publish(EventInspectionResult, m.id, false)
return
}
if evt.Type == SignalDone && evt.Value {
// 检测设备:Done 信号 = 检测合格
if m.inspection {
// 标记所有 Occupied 槽位为 Done
for i := range m.slots {
if m.slots[i] == SlotStatus_Occupied {
m.slots[i] = SlotStatus_Done
m.db.SetEquipmentSlot(ctx, m.id, i+1, SlotStatus_Occupied, SlotStatus_Done, m.slotJobs[i])
}
}
m.updateStatus()
// 发布 EvtInspectionResult(pass=true),EventLoop 处理
m.bus.Publish(EventInspectionResult, m.id, true)
return
}
// 非检测设备:正常加工完成
var doneSlots []int
if m.batch {
for i := range m.slots {
if m.slots[i] == SlotStatus_Occupied {
m.slots[i] = SlotStatus_Done
doneSlots = append(doneSlots, i+1)
}
}
} else {
slotNo := m.findFirstOccupied()
if slotNo > 0 {
m.slots[slotNo-1] = SlotStatus_Done
doneSlots = append(doneSlots, slotNo)
}
}
m.updateStatus()
for _, slotNo := range doneSlots {
m.db.SetEquipmentSlot(ctx, m.id, slotNo, SlotStatus_Occupied, SlotStatus_Done, m.slotJobs[slotNo-1])
}
for _, slotNo := range doneSlots {
m.bus.Publish(EventMachineDone, m.id, slotNo, m.slotJobs[slotNo-1])
}
}
}
4. SignalRouter
PLC 连接集中管理(S7 单连接),一个 goroutine 轮询,路由到各 Actor channel:
type SignalRouter struct {
plc goplc.Client
mu sync.RWMutex
watches map[int]*SignalWatch
}
type SignalWatch struct {
DoneAddr string // 完成信号 PLC 地址
NGAddr string // NG 信号地址(检测设备才有)
Ch chan SignalEvent
}
type SignalEvent struct {
Type SignalType // Done / NG
Value bool
}
Ch容量为 1,SignalRouter 写不阻塞- Actor 从
Ch非阻塞读取(select default),积压时丢弃旧值
5. TempStoreActor(暂存台)
暂存台没有 PLC 信号,但需要管理槽位占用。与 MachineActor 的关键区别:槽位从分配到释放贯穿 Job 整个生命周期。
补料(分配slot3) → 上料到CNC(slot3仍属此job) → 下料回暂存台(slot3) → ... → 完成(释放slot3)
中间 job 在机器上加工时,暂存台槽位是"虚占"状态 — 物理上工件不在,但槽位仍属此 job,不能被其他 job 使用。
type TempStoreActor struct {
capacity int
slots []int // slotNo → jobID (0 = 空)
msgCh chan MachineMsg
db *DBState
}
func (t *TempStoreActor) Run(ctx context.Context) {
for {
select {
case msg := <-t.msgCh:
switch msg.Type {
case "ALLOCATE":
t.allocate(ctx, msg.JobID)
case "RELEASE":
t.release(ctx, msg.JobID)
case "RESTORE":
t.restore(ctx, msg.SlotNo, msg.JobID)
}
case <-ctx.Done():
return
}
}
}
func (t *TempStoreActor) allocate(ctx context.Context, jobID int) {
for i := range t.slots {
if t.slots[i] == 0 {
t.slots[i] = jobID
t.db.SetJobTempSlot(ctx, jobID, i+1) // 写 DB job.temp_slot_no
return
}
}
}
func (t *TempStoreActor) release(ctx context.Context, jobID int) {
for i := range t.slots {
if t.slots[i] == jobID {
t.slots[i] = 0
t.db.ClearJobTempSlot(ctx, jobID) // 清 DB job.temp_slot_no
return
}
}
}
func (t *TempStoreActor) Snapshot() TempStoreSnapshot {
return TempStoreSnapshot{
Capacity: t.capacity,
Slots: append([]int(nil), t.slots...),
}
}
调度器通过 Snapshot 读暂存台占用,替代 buildSystemState 中的 TempSlotJobs 计算和 TempSlotAllocator。
6. 调度器集成
buildSystemState 从 Actor 单一源读(设备从 MachineActor,暂存台从 TempStoreActor):
func (l *ProductionEventLoop) buildSystemState(ctx context.Context) scheduler.SystemState {
state := scheduler.SystemState{
MachineBusy: make(map[int]bool),
MachineHasJob: make(map[int]int),
TempSlotJobs: make(map[int]int),
}
for _, actor := range l.machineActors {
snap := actor.Snapshot()
state.MachineBusy[snap.ID] = snap.Status == ActorStatus_Full
for _, slot := range snap.Slots {
if slot.JobID > 0 && slot.Status != SlotStatus_Empty {
state.MachineHasJob[snap.ID] = slot.JobID
}
}
}
// 暂存台
tempSnap := l.tempStoreActor.Snapshot()
occupied := 0
for slotNo, jobID := range tempSnap.Slots {
if jobID > 0 {
state.TempSlotJobs[slotNo] = jobID
occupied++
}
}
state.TempSlotFree = tempSnap.Capacity - occupied
return state
}
换料配对逻辑不变,仍在 trySchedule 阶段。
7. EventLoop 变更
Job 状态持久化仍由 EventLoop 负责 — Job 是跨设备实体,不属于任何单一 Actor。
持久化职责分割:
| 表 | Owner | 时机 |
|---|---|---|
equipment_slot |
MachineActor | 上料/下料/完成信号时 |
job(状态/步骤/位置/上下文) |
EventLoop | AdvanceStep/FinishJob/SetJobProcessing |
work_order |
EventLoop | FinishJob 时累加 |
alarm |
EventLoop | Actor 发布故障事件时 |
job.temp_slot_no |
TempStoreActor | ALLOCATE/RELEASE 时 |
新增:消费 Actor 事件,推进 Job
case EventMachineDone:
l.handleMachineDone(ctx, msg) // 精简后:只 AdvanceStep/SetWaitingUnload + trySchedule
case EventInspectionResult:
l.handleInspectionDone(ctx, machineID, pass)
handleMachineDone 精简:Actor 已完成 DB equipment_slot 写入,EventLoop 仅做 Job 步骤推进。不再调 updateStationDone,不再查 slot 状态。仅从事件中拿 machineID/slotNo/jobID 参数。
Worker 完成后通知 Actor:上料/下料成功后发送 LOAD_COMPLETE/UNLOAD_COMPLETE 消息,Actor 更新内存槽位状态并写 DB。
删除:
updateStationDone方法batchMachines/inspectionMachinesmap — Actor 内部自管理- equipment_slot 的 DB 写入逻辑 — 移至 Actor
8. 启动与恢复
8.1 创建
func BuildMachineActors(ctx context.Context, entClient *ent.Client, signalRouter *SignalRouter, bus eventbus.Bus) map[int]MachineActor {
equipments, _ := entClient.Equipment.Query().WithEquipmentType().All(ctx)
actors := make(map[int]MachineActor)
for _, eq := range equipments {
cfg := MachineActorConfig{
ID: eq.ID,
Type: eq.Edges.EquipmentType.Code,
Capacity: eq.SlotCount,
Batch: eq.Batch,
Inspection: eq.Edges.EquipmentType.Code == InspectionType, // 检测设备
PollInterval: 1 * time.Second,
SignalCh: signalRouter.Watch(eq.ID, doneAddr, ngAddr),
Bus: bus,
}
actors[eq.ID] = NewMachineActor(cfg)
}
return actors
}
8.2 恢复
RecoverOnStartup 从 DB equipment_slot 表恢复初始状态:
func (d *DBState) RecoverMachineActors(ctx context.Context, actors map[int]MachineActor) {
slots, _ := d.client.EquipmentSlot.Query().All(ctx)
for _, s := range slots {
actor, ok := actors[*s.EquipmentId]
if !ok {
continue
}
actor.Send(MachineMsg{
Type: "RESTORE",
JobID: slotJobID(s),
SlotNo: s.SlotNo,
Params: map[string]any{"status": string(s.Status)},
})
}
}
RESTORE 直接设置槽位状态(跳过 Empty→Occupied 转换校验)。
9. 删除清单
新 Actor 上线后删除:
internal/station/— 整个 Station 包(接口、BaseStation、CNC/Cleaning/Washer/Inspection/Deburr/Sampling/Scanner/LaserMarker/Registry),保留HandheldTool接口internal/processor/temp_slot_allocator.go— TempSlotAllocator(被 TempStoreActor 替代)internal/processor/signal_watcher.go— SignalWatcher(被 SignalRouter 替代)internal/processor/step_timeout.go— 工序超时internal/eventloop/loop.go—updateStationDone、batchMachines、inspectionMachines
10. 测试策略
- MachineActor 单元测试: 注入模拟 signalCh,验证 Idle→Processing→Full→Waiting 全路径
- TempStoreActor 单元测试: 验证 ALLOCATE/RELEASE 分配释放
- 批量设备测试: 验证 PLC Done → 所有 Occupied → Done
- 检测设备测试: 验证 pass/NG 信号 → 正确事件发布
- 恢复测试: RESTORE 消息恢复各状态槽位
- 集成测试: Actor + SignalRouter + EventLoop 联调完整链路