后台写盘:线程安全队列不等于可关闭管线
自动关联目录:后台写盘:线程安全队列不等于可关闭管线
核心判断:互斥锁只能保护一次入队或出队;可靠后台写盘还必须回答何时拒绝新数据、停止时写完还是丢弃、怎样唤醒等待者、何时可以释放线程对象。
一、业务冲突:操作记录不能卡主线程,也不能在退出时悄悄丢失
创作操作和脚本诊断会持续追加记录。同步打开、编码和写盘会放大 Game Thread 抖动,因此工程实现了后台写线程,并出现过两种形态:一种按记录入线程安全队列,逐条追加;另一种按文件聚合字符串,在一个临界区内批量编码、一次写入。后者减少了文件调用次数,这是有效的吞吐优化。
二、实际实现:数据竞争被部分解决,关闭协议没有建立
两种实现都把生产者与文件 IO 分开,也都用锁保护共享容器。但完整控制流仍有共同问题:
- 运行和暂停标记是跨线程读写的普通
bool,没有原子或同一把锁保护。 - 消费者通过固定时长
Sleep轮询;新数据无法立即唤醒,空闲时又持续增加关闭延迟。 - 暂停同时使用业务标记和 OS 线程
Suspend;一种 Run 路径甚至把“暂停”解释为线程退出,之后 Wake 无法让已结束的 Run 重新开始。 - 停止依赖
Kill(true)。一种变体随后删除线程对象,另一种只把工作对象指针置空;两者都没有清晰证明正常退出和资源唯一释放。 - 停止时没有统一的 Drain 语义。有的队列直接清空,有的在
bRunning=false后不再消费;已经返回“入队成功”的记录可能未落盘。 - 批量版本持锁完成编码和文件写入,慢盘会让所有生产者在同一临界区排队。
这说明“批量写”优化了平均 IO,却没有解决生命周期正确性。
三、目标设计:Close、Drain/Abort、Join 是一个状态机
enum class WriterState { Running, ClosingDrain, ClosingAbort, Stopped };
bool Enqueue(Record r) {
Lock guard(mutex);
if (state != Running) return false;
if (queuedBytes + r.Bytes() > maxQueuedBytes) return false;
queue.Push(MoveTemp(r));
queuedBytes += r.Bytes();
event->Trigger();
return true;
}
uint32 Run() {
for (;;) {
event->Wait();
Batch local;
{
Lock guard(mutex);
queue.SwapInto(local); // IO 不持生产者锁
if (state == ClosingAbort) local.Reset();
if (state != Running && local.IsEmpty() && queue.IsEmpty()) break;
}
EncodeAndAppendGroupedByFile(local);
}
state = Stopped;
return 0;
}
void Shutdown(ShutdownPolicy p) {
TransitionRunningToClosing(p); // 此后 Enqueue 必须失败
event->Trigger(); // 唤醒空闲消费者
thread->WaitForCompletion(); // 只 Join,不 Suspend/Kill
}正常退出选择 Drain:停止接收,写完已接受记录,再 Join。崩溃止损或磁盘不可用可选择 Abort,但必须返回丢弃条数与字节,不能伪装成成功。队列按字节设上限;达到高水位时由业务选择拒绝低价值记录、同步关键记录或短暂背压。
四、批量策略的正确边界
按文件聚合能减少打开和追加次数,但批次必须在锁内快速交换、锁外编码写入。同一路径需要保持 FIFO;不同文件可并行,但要限制同时打开的文件数。写入失败不能无限重试占满内存,应按错误类型执行有限重试、降级路径或明确失败。
若记录用于恢复或审计,还需定义 Flush Fence:调用方拿到 fence 后,可以等待“此前所有记录已持久化”。仅仅观察队列为空不够,因为消费者可能已经取走但尚未写完。
五、决策与验证
压测生产速度高于磁盘速度、暂停时持续入队、空队列关闭、满队列关闭、写入中断电、文件权限失败和重复 Start/Stop。指标包括排队字节、最老记录年龄、批量大小、每文件写入次数、生产者阻塞、拒绝/丢弃量、Flush 延迟和 Shutdown 延迟。
判断后台写盘成熟度,不看“有没有线程安全队列”,而看:一个已被接受的记录,最终只有三种可观测结局:已持久化、明确失败、按声明策略被丢弃。