多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

生产者消费者模式:设备软件里日志、报警、图像怎么不卡住业务线程

生产者消费者模式:设备软件里日志、报警、图像怎么不卡住业务线程 这篇解决一个问题设备软件里业务线程在跑同时要写日志、存数据库、存图像、上报 SECS。这些 IO 操作慢直接在业务线程里做会卡住状态机。怎么写才能让业务线程「丢下就走」后台慢慢处理。一、先从一个生活例子说起你去银行办业务柜员办完你的事后给你一张回执单。柜员不会站在窗口等你把回执单收好再叫下一个号而是把回执单往旁边一放立刻叫下一个人。旁边那堆回执单就是「队列」。柜员是「生产者」不断产生回执单。你客户是「消费者」自己拿走。柜员不等消费者消费者也不影响柜员。如果柜员非要等你收好回执单再叫下一个号那每个客户多花 10 秒一天少办一半业务。这就是生产者消费者模式的核心生产者只管往队列里丢消费者只管从队列里取两边各跑各的互不等待。二、回到代码不用队列会怎样设备软件里最常见的场景报警。手臂运动线程发现真空失败要报警。报警要做这些事写日志快微秒级。响蜂鸣快。弹 UI 报警窗慢可能等用户点确认。写数据库慢几十毫秒。上报 SECS慢网络通信几百毫秒。最直觉的写法业务线程里直接做void Arm::OnVacuumFail() { Log(真空失败); // 快 Beeper::Beep(); // 快 pDlg-ShowAlarm(真空失败); // 慢等用户点确认 db-WriteAlarm(code, msg); // 慢几十毫秒 secs-Report(msg); // 慢几百毫秒 }问题一堆问题一业务线程卡住。ShowAlarm等用户点确认可能等几十秒。这几十秒里手臂线程不动状态机不推进其它料堆积。问题二数据库卡住运动。WriteAlarm几十毫秒里手臂线程阻塞可能正好该停机却停不了。问题三SECS 网络超时拖死业务。 网络不好时secs-Report可能卡几秒手臂线程一直等。问题四多条报警同时来互相阻塞。 第一条报警还在弹窗第二条又来了叠在一起。问题五业务线程和 UI 线程混在一起。ShowAlarm在运动线程里调 UI 控件跨线程操作 MFC 控件可能崩。根本问题快节奏的业务线程和慢节奏的 IO 操作搅在一起。三、生产者消费者模式怎么解决思路业务线程只管「把报警丢进队列」一个专门的消费者线程从队列里取出来慢慢处理。第一步定义队列和事件struct AlarmEvent { string time; string code; string message; }; class AlarmQueue { queueAlarmEvent m_queue; mutex m_mtx; condition_variable m_cv; bool m_running true; public: // 生产者调用丢一个事件进去立刻返回 void Push(AlarmEvent ev) { { lock_guardmutex lk(m_mtx); m_queue.push(ev); } m_cv.notify_one(); // 叫醒消费者 } // 消费者调用阻塞等事件 bool Pop(AlarmEvent out) { unique_lockmutex lk(m_mtx); m_cv.wait(lk, [] { return !m_queue.empty() || !m_running; }); if (!m_running m_queue.empty()) return false; out m_queue.front(); m_queue.pop(); return true; } void Stop() { { lock_guardmutex lk(m_mtx); m_running false; } m_cv.notify_all(); } };第二步消费者线程AlarmQueue g_alarmQueue; void AlarmConsumerThread() { while (true) { AlarmEvent ev; if (!g_alarmQueue.Pop(ev)) return; // 队列停了 // 慢操作在这里做不影响业务线程 db-WriteAlarm(ev.code, ev.time, ev.message); secs-Report(ev.message); // UI 操作发到 UI 线程做 PostMessage(pDlg-m_hWnd, WM_SHOW_ALARM, 0, (LPARAM)new string(ev.message)); } }第三步业务线程只 Pushvoid Arm::OnVacuumFail() { Log(真空失败); // 快操作直接做 Beeper::Beep(); // 快操作直接做 g_alarmQueue.Push({ // 慢操作丢队列立刻返回 CurrentTime(), 601_205, 手臂真空失败 }); // 业务线程立刻继续跑状态机不等数据库和 SECS }第四步初始化时启动消费者// 程序启动 std::thread(AlarmConsumerThread).detach(); // 程序退出 g_alarmQueue.Stop();关键变化业务线程Push后立刻返回不等数据库、不等 SECS、不等 UI。消费者线程慢慢处理处理完一个取下一个。业务线程和 IO 操作完全解耦互不阻塞。四、生产者消费者模式的标准结构角色例子Producer生产者业务线程Arm、Channel 等产生事件Consumer消费者专门的处理线程消费事件Queue缓冲队列AlarmQueue线程安全带条件变量Event事件数据AlarmEvent携带需要处理的数据Producer业务线程 Consumer处理线程 │ │ │ Push(event) │ Pop(event) ▼ ▲ Queue ──────────────────────────── [event1] [event2] [event3] ...核心生产者和消费者通过队列间接通信互不直接依赖互不等待。五、设备软件里的真实场景场景一报警处理——最典型// 全局报警队列 AlarmQueue g_alarmQueue; // 任意模块报警 void Actor::Alarm(string msg) { Log(msg); Beeper::Beep(); g_alarmQueue.Push({time, code, msg}); // 丢队列就走 }场景二日志写入日志如果直接写文件磁盘 IO 慢时也会卡业务线程。用生产者消费者struct LogEvent { string message; spdlog::level::level_enum level; }; class LogQueue { queueLogEvent m_queue; mutex m_mtx; condition_variable m_cv; public: void Push(LogEvent ev) { /* 入队 notify */ } bool Pop(LogEvent out) { /* 阻塞等 */ } }; LogQueue g_logQueue; // 业务线程 void Actor::Log(string msg) { g_logQueue.Push({msg, spdlog::level::info}); // 立刻返回不等磁盘写完 } // 日志消费者线程 void LogConsumerThread() { while (true) { LogEvent ev; if (!g_logQueue.Pop(ev)) return; SpdLogger::Instance().Write(ev.message, ev.level); // 慢写磁盘 } }场景三图像存储视觉检测拍了一张图要存到硬盘。存图慢几十毫秒不能卡检测流程。struct ImageEvent { string name; cv::Mat image; }; ImageQueue g_imageQueue; // 视觉线程拍完 void OnCaptureDone(cv::Mat img) { g_imageQueue.Push({timestamp, img}); // 立刻继续拍下一张 } // 图像消费者线程 void ImageConsumerThread() { while (true) { ImageEvent ev; if (!g_imageQueue.Pop(ev)) return; cv::imwrite(D:\\images\\ ev.name .bmp, ev.image); } }场景四SECS 上报SECS 通信慢且可能超时。业务事件上报用队列SecsQueue g_secsQueue; // 任意模块要上报 void ReportEvent(string eventCode, string data) { g_secsQueue.Push({eventCode, data}); // 立刻返回不等网络 } // SECS 消费者线程 void SecsConsumerThread() { while (true) { SecsEvent ev; if (!g_secsQueue.Pop(ev)) return; secs-SendEvent(ev.code, ev.data); // 慢网络通信 } }场景五料号流转更宏观地看整台设备的料流转也是生产者消费者上料站生产者──→ Buffer队列──→ 测试台消费者上料站不断往 Buffer 里放料测试台从 Buffer 取料测。上料站不等测试台测完才放下一个测试台不等上料站放好才取。Buffer 就是中间队列。六、关键实现细节细节一队列必须线程安全void Push(T item) { { lock_guardmutex lk(m_mtx); // 加锁 m_queue.push(item); } m_cv.notify_one(); // 通知 } bool Pop(T out) { unique_lockmutex lk(m_mtx); m_cv.wait(lk, [] { return !m_queue.empty() || !m_running; }); // 等待 if (!m_running m_queue.empty()) return false; out m_queue.front(); m_queue.pop(); return true; }要点Push加锁入队后notify_onePop用条件变量阻塞等待不空轮询。细节二队列要能停void Stop() { { lock_guardmutex lk(m_mtx); m_running false; } m_cv.notify_all(); // 叫醒所有等待的消费者 }程序退出时要能优雅停掉消费者线程不能让它永远阻塞在Pop上。细节三队列满了怎么办如果生产速度远超消费速度队列会无限增长。两种策略// 策略一丢弃最旧的保留最新 void Push(T item) { lock_guardmutex lk(m_mtx); if (m_queue.size() m_maxSize) m_queue.pop(); // 丢最旧的 m_queue.push(item); } // 策略二丢弃最新的保留正在处理的 void Push(T item) { lock_guardmutex lk(m_mtx); if (m_queue.size() m_maxSize) return; // 满了就不收 m_queue.push(item); }设备软件里日志/报警通常用策略一丢最旧的保最新的图像通常用策略二满了就不收新的避免内存爆。细节四UI 操作必须发到 UI 线程消费者线程不能直接操作 MFC 控件要用PostMessage发到 UI 线程// 消费者线程里 PostMessage(pDlg-m_hWnd, WM_SHOW_ALARM, 0, (LPARAM)new string(ev.message)); // UI 线程里处理 BEGIN_MESSAGE_MAP(CMyDlg, CDialogEx) ON_MESSAGE(WM_SHOW_ALARM, OnShowAlarm) END_MESSAGE_MAP() LRESULT CMyDlg::OnShowAlarm(WPARAM wParam, LPARAM lParam) { string* msg (string*)lParam; m_lblAlarm.SetWindowText(msg-c_str()); delete msg; return 0; }七、生产者消费者 vs 观察者什么区别这两个都涉及「事件通知」容易混。生产者消费者观察者通信方式通过队列间接直接调回调是否等待生产者不等消费者事件源不等观察者同步调但不等返回缓冲有队列缓冲无缓冲直接调适合慢 IO、异步处理事件广播、多订阅例子日志写盘、报警存库报警通知 UI 刷新关系观察者可以和生产者消费者组合——事件源用观察者通知观察者收到后 Push 到队列消费者从队列取。这样既解耦了事件源和响应者又解耦了响应者和慢操作。八、生产者消费者的坑坑一队列无限增长导致内存爆生产速度远超消费速度时队列越来越大最终内存耗尽。对策设最大长度满了按策略丢弃。坑二消费者线程崩溃后队列堆积消费者线程挂了生产者还在 Push队列堆积。对策消费者线程加异常保护崩了自动重启或者监控队列长度超限报警。坑三事件数据里有指针消费者用时已失效struct Event { Station* station; // 指针 }; // 生产者 Push 时 station 还在 // 消费者 Pop 时 station 已被 delete → 野指针对策事件里存 ID 或值拷贝不存指针或用shared_ptr保活。坑四多个消费者时重复处理如果起了两个消费者线程从同一个队列 Pop一个事件只会被一个消费者处理。如果需要广播给所有消费者要用多个队列每个消费者一个。九、可复用结论生产者消费者模式的本质生产者往队列丢消费者从队列取两边各跑各的互不等待。四个角色Producer、Consumer、Queue线程安全条件变量、Event。设备软件最典型应用报警处理、日志写入、图像存储、SECS 上报、料号流转。核心价值快节奏业务线程不被慢 IO 卡住生产消费解耦队列缓冲削峰填谷。和观察者的区别观察者直接调回调无缓冲生产者消费者通过队列有缓冲。坑队列限长防内存爆、消费者崩了要重启、事件不存裸指针、多消费者要多队列。生产者消费者模式在设备软件里不是「课本上的 Producer-Consumer」而是「日志怎么不卡运动、报警怎么不卡状态机、图像怎么不卡检测」的实打实解法。用对了业务线程永远不被 IO 拖住用错了要么业务线程被磁盘卡死要么队列无限增长内存爆掉。关键是分清「快操作直接做」和「慢操作丢队列」在两者之间放一个线程安全的缓冲队列。
返回列表