4. C++基础回顾(下):多线程编程、线程同步、文件操作与设计模式
好,咱们接着聊。上一章我们把C++的现代特性过了一遍,这一章要啃的,是边缘AI推理里真正绕不开的硬骨头——多线程、文件读写,还有那几个经典的设计模式。说实话,这些内容在嵌入式开发里,尤其是做实时推理时,几乎每天都在打交道。
4.1 多线程编程:std::thread
先说说多线程。边缘设备上跑AI推理,最头疼的就是延迟。你想想看,摄像头一帧数据来了,你得做预处理、推理、后处理,如果全串行跑,帧率肯定上不去。所以,我习惯把流水线拆成几个线程:一个线程抓图,一个线程做推理,一个线程做结果输出。
C++11 标准库给了我们 std::thread,用起来非常直接。看个最简单的例子:
#include <iostream>
#include <thread>
void inference_worker(int camera_id) {
std::cout << "开始处理摄像头 " << camera_id << " 的数据" << std::endl;
// 模拟推理耗时
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::cout << "摄像头 " << camera_id << " 推理完成" << std::endl;
}
int main() {
std::thread t1(inference_worker, 1);
std::thread t2(inference_worker, 2);
t1.join(); // 等待线程结束
t2.join();
return 0;
}
这里有个细节我要提醒你:join() 会阻塞主线程,直到子线程跑完。如果你不想等,可以用 detach() 把线程分离出去。但我在项目中吃过亏——线程分离后,如果主线程退出了,子线程可能还在访问已经销毁的资源。嗯,这里要小心,分离线程的生命周期管理是个坑。
std::thread::hardware_concurrency() 的返回值。
4.2 线程同步:mutex 与 condition_variable
多线程一多,资源竞争就来了。比如两个线程都想往同一个队列里写数据,不加锁的话,数据就乱套了。C++ 提供了 std::mutex 来解决这个问题。
我个人习惯用 std::lock_guard 或者 std::unique_lock 来管理锁,而不是手动 lock() 和 unlock()。为什么?因为 lock_guard 在构造时自动加锁,析构时自动解锁,就算中间抛出异常,锁也能正确释放。这叫 RAII(资源获取即初始化),C++ 里非常优雅的惯用法。
#include <iostream>
#include <thread>
#include <mutex>
#include <queue>
std::queue<int> frame_queue;
std::mutex mtx;
void producer() {
for (int i = 0; i < 10; ++i) {
std::lock_guard<std::mutex> lock(mtx);
frame_queue.push(i);
std::cout << "生产帧: " << i << std::endl;
}
}
void consumer() {
for (int i = 0; i < 10; ++i) {
std::lock_guard<std::mutex> lock(mtx);
if (!frame_queue.empty()) {
int frame = frame_queue.front();
frame_queue.pop();
std::cout << "消费帧: " << frame << std::endl;
}
}
}
但光有 mutex 还不够。你想想看,消费者线程如果发现队列是空的,难道要一直轮询吗?那 CPU 就白烧了。这时候就需要 std::condition_variable 登场了。它能让线程在条件不满足时休眠,等条件满足了再被唤醒。
#include <condition_variable>
std::condition_variable cv;
bool ready = false;
void consumer() {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, []{ return ready; }); // 等待 ready 变为 true
// 处理数据...
}
void producer() {
{
std::lock_guard<std::mutex> lock(mtx);
ready = true;
}
cv.notify_one(); // 唤醒一个等待的线程
}
cv.wait() 时,一定要传一个 lambda 作为谓词条件。我曾经见过有人只写 cv.wait(lock),结果发生了虚假唤醒(spurious wakeup),程序莫名其妙就崩了。加上谓词条件,就算被误唤醒,也会重新检查条件是否真的满足。
4.3 文件操作:fstream
边缘 AI 推理里,文件操作主要干两件事:读模型权重、写日志。C++ 的 <fstream> 库提供了 ifstream(读)、ofstream(写)、fstream(读写)三种流。
读一个二进制模型文件,我通常这么写:
#include <fstream>
#include <vector>
std::vector<char> load_model(const std::string& path) {
std::ifstream file(path, std::ios::binary | std::ios::ate);
if (!file.is_open()) {
throw std::runtime_error("无法打开模型文件: " + path);
}
std::streamsize size = file.tellg(); // 获取文件大小
file.seekg(0, std::ios::beg);
std::vector<char> buffer(size);
if (file.read(buffer.data(), size)) {
return buffer;
} else {
throw std::runtime_error("读取模型文件失败");
}
}
这里有个小细节:std::ios::ate 让文件指针一开始就定位到末尾,这样 tellg() 就能直接拿到文件大小。然后 seekg 回到开头,再一次性读入。效率比逐字节读高得多。
写日志的话,我习惯用 ofstream 配合 std::endl 来换行并刷新缓冲区。但注意,频繁刷新会影响性能。如果只是记录推理时间,可以攒一批再 flush。
file.close() 或者 file.flush()。虽然 ofstream 析构时会自动 flush,但崩溃时析构函数可能没执行。所以,关键日志写完后,手动 flush 一下更保险。
4.4 设计模式:单例与工厂
最后聊聊设计模式。在边缘 AI 框架里,有两个模式我几乎每个项目都会用:单例模式和工厂模式。
4.4.1 单例模式
单例模式保证一个类只有一个实例。比如全局的日志系统、模型管理器,这些对象整个进程只需要一份。C++ 里实现单例,最推荐的方式是 Meyer's Singleton(局部静态变量):
class Logger {
public:
static Logger& getInstance() {
static Logger instance; // C++11 保证线程安全
return instance;
}
void log(const std::string& msg) {
std::lock_guard<std::mutex> lock(m_mutex);
// 写日志...
}
private:
Logger() = default;
~Logger() = default;
Logger(const Logger&) = delete;
Logger& operator=(const Logger&) = delete;
std::mutex m_mutex;
};
为什么推荐这个写法?因为 C++11 标准保证了局部静态变量的初始化是线程安全的。你不需要额外加锁,编译器帮你搞定了。而且这个写法非常简洁,没有 new/delete 的烦恼。
4.4.2 工厂模式
工厂模式用来创建对象,但把创建逻辑封装起来。在 AI 推理框架里,我经常用它来注册不同的推理后端。比如,有的设备用 CPU 推理,有的用 GPU,有的用 NPU。工厂模式可以让我们根据设备类型,动态创建对应的推理引擎。
#include <memory>
#include <unordered_map>
#include <functional>
class InferenceEngine {
public:
virtual void run() = 0;
virtual ~InferenceEngine() = default;
};
class CPUEngine : public InferenceEngine {
public:
void run() override { /* CPU 推理 */ }
};
class GPUEngine : public InferenceEngine {
public:
void run() override { /* GPU 推理 */ }
};
class EngineFactory {
public:
using Creator = std::function<std::unique_ptr<InferenceEngine>()>;
static void registerEngine(const std::string& type, Creator creator) {
getRegistry()[type] = creator;
}
static std::unique_ptr<InferenceEngine> create(const std::string& type) {
auto it = getRegistry().find(type);
if (it != getRegistry().end()) {
return it->second();
}
return nullptr;
}
private:
static std::unordered_map<std::string, Creator>& getRegistry() {
static std::unordered_map<std::string, Creator> registry;
return registry;
}
};
// 注册引擎
// EngineFactory::registerEngine("CPU", []{ return std::make_unique<CPUEngine>(); });
// EngineFactory::registerEngine("GPU", []{ return std::make_unique<GPUEngine>(); });
这个模式的好处是,新增一个推理后端时,你只需要写一个新的引擎类,然后在某个地方注册一下。主流程代码完全不用改。这就是「开闭原则」——对扩展开放,对修改关闭。
"backend": "NPU",程序启动时读配置,然后通过工厂创建对应的引擎。这样换硬件平台时,只需要改配置文件,代码一行都不用动。
好了,这一章的内容就到这里。多线程、文件操作、设计模式,这三样东西在边缘 AI 开发里就像地基一样。地基打牢了,后面搭推理框架的流水线、做性能优化,才能得心应手。下一章,我们开始真正动手搭建推理框架的骨架。