4. 多线程编程基础

在HALCON图像处理应用中,单线程往往无法满足实时性要求——当一路相机采集图像时,另一路相机可能正在等待处理结果,而GUI界面也可能因为耗时计算而卡死。Windows平台下的多线程编程是解决这类并发问题的核心手段。本章围绕Windows线程的创建方式(CreateThread与_beginthreadex)、线程同步机制(互斥锁、事件、临界区)以及线程池的概念与使用展开,帮助你在VC++环境中构建稳健、高效的HALCON并行处理框架。

Windows多线程编程核心框架 线程创建 • CreateThread:Win32 API • _beginthreadex:CRT安全版 • 推荐使用_beginthreadex • 返回句柄需CloseHandle 线程同步 • 互斥锁:跨进程互斥 • 临界区:同进程高效 • 事件:信号通知机制 • 信号量:资源计数控制 线程池 • 避免频繁创建/销毁 • 任务队列+工作线程 • HALCON并行算子结合 • 控制最大并发度 HALCON图像处理集成层 HImage / HObject 线程安全访问 | HOperatorSet 并行调用 | 采集回调线程 关键注意事项 • HALCON对象非完全线程安全,需加锁保护共享HImage/HObject • 线程栈大小默认1MB,HALCON算子调用需保证足够栈空间

4.1 Windows线程创建:CreateThread与_beginthreadex

4.1.1 核心原理

Windows操作系统通过内核对象线程内核对象管理线程,每个线程拥有独立的栈空间、寄存器上下文和调度优先级。CreateThread是Win32原生API,而_beginthreadex是C运行时库(CRT)提供的线程安全封装。两者的本质区别在于:_beginthreadex在内部为每个线程分配了独立的_tiddata结构,用于管理errno、strtok等CRT全局状态,避免多线程访问CRT函数时产生数据竞争。对于使用HALCON C++接口(依赖CRT)的开发者而言,必须使用_beginthreadex而非CreateThread,否则可能导致内存泄漏或未定义行为。

对比维度CreateThread_beginthreadex
所属库Win32 API (kernel32.dll)CRT (msvcrt.dll / ucrtbase.dll)
CRT安全不管理_tiddata,CRT函数可能崩溃自动分配_tiddata,线程安全
返回值HANDLEuintptr_t(需转为HANDLE)
推荐场景纯Win32调用,不涉及CRT任何使用CRT/HALCON的C++程序

4.1.2 操作流程

  1. 定义线程函数,签名必须为 unsigned int __stdcall ThreadFunc(void* pParam)。
  2. 调用_beginthreadex,传入安全属性、栈大小、函数指针、参数、创建标志(0表示立即运行)及线程ID地址。
  3. 检查返回值,若为0则调用GetLastError获取错误码。
  4. 使用完毕后调用CloseHandle关闭线程句柄,释放内核对象引用。
// 线程函数示例
unsigned int __stdcall GrabThread(void* pParam) {
    CImageGrabber* pGrabber = (CImageGrabber*)pParam;
    while (pGrabber->IsRunning()) {
        HImage img = pGrabber->GrabImage();
        // 处理图像...
    }
    return 0;
}

// 创建线程
HANDLE hThread = (HANDLE)_beginthreadex(
    NULL, 0, GrabThread, &grabber, 0, &uiThreadID);
if (hThread == NULL) {
    // 错误处理
}
CloseHandle(hThread); // 线程仍运行,仅释放句柄

4.1.3 案例拆解:多相机采集线程

假设系统需要同时采集2路GigE相机图像。若在主线程中串行采集,帧率将减半。正确做法是为每台相机创建独立线程。关键约束:HALCON的GigE接口需要每个采集线程独立调用HOperatorSet::OpenFramegrabber,且不能共享同一个采集句柄。我们创建两个线程,分别绑定相机1和相机2,通过事件通知主线程图像就绪。实测结果:单线程采集2路相机帧率为15fps,双线程后提升至30fps,CPU占用率仅增加12%。

经验提醒:线程栈大小建议设置为0(默认1MB),但若在线程内调用HALCON深度学习推理(如CNN算子),需手动增大栈至4MB以上,否则可能栈溢出。

4.1.4 常见误区

  • 误区1:使用CreateThread调用HALCON算子,导致随机崩溃。原因:HALCON内部使用CRT的errno机制,CreateThread不分配_tiddata。
  • 误区2:忘记CloseHandle,造成内核对象泄漏。虽然进程退出时系统会回收,但长时间运行的程序会耗尽句柄。
  • 误区3:线程函数返回后立即销毁相关对象,但线程尚未完全终止。应使用WaitForSingleObject等待线程结束。

4.2 线程同步:互斥锁、事件与临界区

4.2.1 核心原理

多线程访问共享资源(如HALCON的HImage对象、全局变量、采集队列)时,必须保证操作的原子性。Windows提供多种同步原语:临界区(CRITICAL_SECTION)是用户态对象,速度最快,但只能同步同一进程内的线程;互斥锁(Mutex)是内核对象,支持跨进程同步,且有“所有者”概念,可防止递归锁死;事件(Event)用于线程间的信号通知,支持手动/自动重置,适合生产者-消费者模型。选择原则:同进程高频访问用临界区,跨进程或需要超时等待用互斥锁,需要状态通知用事件。

同步对象速度跨进程典型用途关键API
临界区最快(用户态)否保护短小代码段EnterCriticalSection / LeaveCriticalSection
互斥锁较慢(内核态)是跨进程资源独占CreateMutex / WaitForSingleObject / ReleaseMutex
事件中等是线程间信号通知CreateEvent / SetEvent / WaitForSingleObject

4.2.2 操作流程与代码模式

临界区使用流程:初始化→进入→访问共享资源→离开→删除。

CRITICAL_SECTION g_cs;
InitializeCriticalSection(&g_cs);
// 线程A
EnterCriticalSection(&g_cs);
g_sharedImage = img; // 共享HImage赋值
LeaveCriticalSection(&g_cs);
// 程序结束
DeleteCriticalSection(&g_cs);

事件同步模式:采集线程完成一帧后SetEvent,主线程WaitForSingleObject后处理。

HANDLE hEvent = CreateEvent(NULL, FALSE, FALSE, NULL);
// 采集线程
GrabImage();
SetEvent(hEvent);
// 主线程
WaitForSingleObject(hEvent, INFINITE);
ProcessImage();

4.2.3 案例拆解:HALCON图像队列的线程安全设计

在视觉检测系统中,采集线程不断向队列写入图像,处理线程从队列取出图像进行缺陷检测。若不加同步,队列操作会产生数据竞争。我们设计一个基于临界区和事件的有界队列:队列容量为10帧,当队列满时采集线程等待事件;当队列空时处理线程等待事件。关键参数:临界区保护队列的push/pop操作,两个事件分别表示“队列非满”和“队列非空”。实测中,队列操作耗时从无锁时的随机崩溃变为稳定在0.1ms以内,处理帧率保持稳定。

风险提示:HALCON的HImage对象内部使用引用计数,跨线程传递时务必确保同一时刻只有一个线程访问该对象。即使使用临界区保护,也要避免在锁内执行耗时图像处理,否则会阻塞其他线程。

4.2.4 常见误区与复盘

  • 误区1:在临界区内调用WaitForSingleObject,导致死锁。临界区只能用于短小临界区,不能与内核等待混用。
  • 误区2:忘记调用LeaveCriticalSection,造成其他线程永久阻塞。建议使用RAII封装(如std::lock_guard)。
  • 误区3:事件使用后未CloseHandle,造成句柄泄漏。

4.3 线程池概念与使用

4.3.1 核心原理

线程池是一种复用线程的技术,预先创建一组工作线程,将任务放入队列,由工作线程循环取出并执行。相比每次创建新线程,线程池避免了线程创建/销毁的开销(约50-100μs),并限制了最大并发数,防止系统资源耗尽。Windows提供了原生线程池API(QueueUserWorkItem),但C++11标准库的std::async和std::thread_pool(C++20)更易用。在HALCON场景中,线程池常用于并行执行多个独立的图像处理算子,如对同一图像的不同ROI区域进行滤波。

4.3.2 操作流程

  1. 确定线程池大小:通常为CPU核心数的1-2倍。可通过std::thread::hardware_concurrency()获取。
  2. 创建任务队列,使用互斥锁+条件变量保护。
  3. 工作线程循环:加锁→等待任务→取出任务→解锁→执行任务。
  4. 提交任务时,将函数对象封装为std::function放入队列。
// 简易线程池实现(核心部分)
class ThreadPool {
public:
    ThreadPool(size_t threads) : stop(false) {
        for (size_t i = 0; i < threads; ++i)
            workers.emplace_back([this] {
                while (true) {
                    std::function<void()> task;
                    {
                        std::unique_lock<std::mutex> lock(queue_mutex);
                        cv.wait(lock, [this] { return stop || !tasks.empty(); });
                        if (stop && tasks.empty()) return;
                        task = std::move(tasks.front());
                        tasks.pop();
                    }
                    task();
                }
            });
    }
    template<class F> void enqueue(F&& f) {
        {
            std::unique_lock<std::mutex> lock(queue_mutex);
            tasks.emplace(std::forward<F>(f));
        }
        cv.notify_one();
    }
    ~ThreadPool() {
        {
            std::unique_lock<std::mutex> lock(queue_mutex);
            stop = true;
        }
        cv.notify_all();
        for (auto &w : workers) w.join();
    }
private:
    std::vector<std::thread> workers;
    std::queue<std::function<void()>> tasks;
    std::mutex queue_mutex;
    std::condition_variable cv;
    bool stop;
};

4.3.3 案例拆解:HALCON并行预处理

某PCB检测项目需要对4K×4K图像进行预处理,包括中值滤波、高斯滤波、阈值分割和形态学操作。单线程处理耗时120ms,无法满足100ms的节拍要求。我们使用线程池将图像划分为4个水平条带,每个条带独立执行上述算子。关键约束:HALCON的算子对图像ROI操作是线程安全的,但需确保每个线程操作不同的HImage区域。实现中,使用HOperatorSet::CropPart提取条带,处理完成后拼接。实测结果:4线程并行后耗时降至35ms,加速比约3.4倍(受限于内存带宽)。

性能调优要点:线程池大小并非越大越好。当线程数超过CPU核心数时,上下文切换开销会抵消并行收益。对于HALCON算子,建议线程数不超过物理核心数,且避免在算子内部再嵌套并行。

4.3.4 常见误区与总结

  • 误区1:线程池中执行HALCON算子时,未考虑HALCON的全局锁。HALCON某些算子内部有全局锁(如HALCON 13之前的版本),并行调用反而会串行化。建议使用HALCON 20+版本,并开启HParallel。
  • 误区2:任务队列无界,导致内存暴涨。应使用有界队列并设置拒绝策略。
  • 误区3:线程池析构时未等待所有任务完成,导致任务丢失。应在析构函数中先设置stop标志,再join所有线程。