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() 把线程分离出去。但我在项目中吃过亏——线程分离后,如果主线程退出了,子线程可能还在访问已经销毁的资源。嗯,这里要小心,分离线程的生命周期管理是个坑。

警告: 在嵌入式设备上,线程数量不是越多越好。每个线程都有栈空间(默认可能 1MB 左右),开多了内存直接爆掉。我建议根据 CPU 核心数来定,一般不超过 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。

避坑指南: 我曾经在嵌入式 Linux 上遇到过一个问题——程序崩溃后,日志文件里最后几条记录丢了。原因是我没有主动调用 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 的烦恼。

注意: 别用「双重检查锁定」那种老掉牙的写法。在 C++11 之前,那玩意儿有内存序的问题,容易出 bug。现在有了 Meyer's Singleton,就别折腾了。

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>(); });

这个模式的好处是,新增一个推理后端时,你只需要写一个新的引擎类,然后在某个地方注册一下。主流程代码完全不用改。这就是「开闭原则」——对扩展开放,对修改关闭。

我的经验: 在嵌入式设备上,工厂模式配合配置文件使用效果极佳。比如在 JSON 配置文件里写 "backend": "NPU",程序启动时读配置,然后通过工厂创建对应的引擎。这样换硬件平台时,只需要改配置文件,代码一行都不用动。

好了,这一章的内容就到这里。多线程、文件操作、设计模式,这三样东西在边缘 AI 开发里就像地基一样。地基打牢了,后面搭推理框架的流水线、做性能优化,才能得心应手。下一章,我们开始真正动手搭建推理框架的骨架。