OPEN_CV多线程线程池管理

OPENCV 多线程机制调研

OPENCV 中的两种线程体

opencv 使用两种不同类型的线程体, 线程池自身ThreadPool作为主线程, 线程池内部管理工作线程 std::vector< Ptr<WorkerThread> > threads.. 主线程的任务是从任务池中选择任务, 将任务分配给工作线程, 工作线程负责执行任务, 并将任务执行的结果反馈给主线程. 如果工作线程没有获取到任务, 工作线程可能会阻塞, 主线程分配完任务后, 需要等待工作线程返回任务的执行结果, 如果等待时间过长, 主线程可能会阻塞.

OPENCV中线程体的两种等待形式

我们在上述主线程与工作线程等待临界资源的时候, 都是使用线程可能阻塞. 这是因为 opencv在线程的调度与同步时, 线程会处于主动等待被动等待两种不同的状态. 主动等待是一种灵活使用CPU的方式, 在线程未获取资源时, 线程会陷入短暂的忙等, 此时, 线程不会释放CPU, 如果满足条件, 线程会继续执行, 而不会被阻塞. 这种方式在多核CPU中, 可以减少线程的切换频率, 提高效率. 被动等待是主动等待结束后, 线程仍未获取资源, 线程进入阻塞状态, 等待资源. 被动等待中 opencv使用条件变量与互斥锁来实现多线程的调度与临界资源的同步.

下图是 OPENCV 中多线程调度的基本流程示意图.

OPENCV 中不同等待的实现方式

主动等待

两种自旋锁的设置

主动等待根据不同操作系统与CPU型号, 使用CPU级别的自旋锁, 使线程陷入忙等, 但是这个忙等时间是可配置的.

  1. 操作系统级别自旋锁的设置, 例如在C++11及以后的版本中, 自带线程管理库, 可以定义为: define CV_YIELD() std::this_thread::yield(), 此时进入CV_YIELD(), 线程释放CPU, 线程被阻塞, 等待被唤醒.
  2. CPU级别的自旋锁的设置, 与使用的CPU架构有关, 以X86为例, X86对应的汇编代码如下: static inline void cv_non_sse_mm_pause() { __asm__ __volatile__ ("rep; nop"); }, 使用一个内联函数 cv_non_sse_mm_pause, 然后重复不断的执行nop, 即进行CPU占用的忙等待.
  3. 假设opencv中的某个线程在忙等一段时间后想释放CPU, 获取操作系统级别的自旋锁可以使线程在跳出忙等, 释放CPU, 进入正常的阻塞状态, 等待被唤醒.

主动等待的目的

当该线程被创建, 线程获取CPU之后, 还未获取临界资源, 此时, 线程并不会直接释放CPU, 而是会忙等一定的时间, 在工作线程中这个时间与参数OPENCV_THREAD_POOL_ACTIVE_WAIT_WORKER成正比. 这样做, 用户可以选择配置忙等的时间, 可以更加灵活的使用CPU. 在多核CPU机器中, 可以减少线程切换的频率.

主动等待的过程

在工作线程中, 主动等待循环的次数为 CV_WORKER_ACTIVE_WAIT .就是环境变量OPENCV_THREAD_POOL_ACTIVE_WAIT_WORKER, 在未达到循环次数时, 调用 CPU 的自旋锁, 使线程陷入忙等, 在达到循环次数之后, 调用操作系统的自旋锁, 线程释放CPU, 等待资源.

// 主动等待
CV_LOG_VERBOSE(NULL, 5, "Thread: ... loop iteration: allow_active_wait=" << allow_active_wait << "   has_wake_signal=" << has_wake_signal);
// 如果允许忙等, 并且设置的环境变量 OPENCV_THREAD_POOL_ACTIVE_WAIT_WORKER > 0
if (allow_active_wait && CV_WORKER_ACTIVE_WAIT > 0)
{
    allow_active_wait = false;
    // 在 OPENCV_THREAD_POOL_ACTIVE_WAIT_WORKER 时间内忙等资源, 实际上是等待收到信号 has_wake_signal
    for (int i = 0; i < CV_WORKER_ACTIVE_WAIT; i++)
    {
        // 如果已经收到唤醒信号, 表示可以开始执行任务, 退出循环
        if (has_wake_signal)
            break;
        if (CV_ACTIVE_WAIT_PAUSE_LIMIT > 0 && (i < CV_ACTIVE_WAIT_PAUSE_LIMIT || (i & 1)))
            CV_PAUSE(16);
        else
            CV_YIELD();
    }
}

被动等待

被动等待是在主动等待之后, 线程如果没有获取到资源, 会进入阻塞状态, 直到其他线程提供资源, 唤醒该线程.

被动等待的实现方式是C++多线程中的 pthread_cond_waitpthread_cond_signal方法. 这种机制使用条件变量与互斥锁, 这种方式的好处是:

  1. 避免直接使用互斥锁的忙等待.
  2. 简化同步逻辑, 以及高效的线程唤醒.

多线程条件变量等待与互斥锁的配合使用

条件变量是利用线程间共享的全局变量进行同步的一种机制, 主要包括两个动作:

  1. 一个线程等待"条件变量的条件成立"而挂起, 进入阻塞状态;
  2. 另一个线程使"条件成立"(给出条件成立信号).
    为了防止竞争, 条件变量的使用总是和一个互斥锁结合在一起, 这样既可以避免了直接使用互斥锁导致的忙等, 也可以实现资源的同步, 防止资源使用冲突.

使用实例

使用2个线程对count每次分别加1, 第三个线程等count大于10后一次加100.

互斥锁与条件变量的声明
int count = 0;
int thread_ids[3] = { 0,1,2 };
// 线程互斥锁
pthread_mutex_t count_mutex;
// 线程资源等待条件变量
pthread_cond_t  count_threshold_cv;
// 初始化互斥锁与条件变量
pthread_mutex_init(&count_mutex, NULL);
pthread_cond_init(&count_threshold_cv, NULL);

/* 创建线程 */
pthread_attr_init(&attr);
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_JOINABLE);
pthread_create(&threads[0], &attr, inc_count, (void*)&thread_ids[0]);
pthread_create(&threads[1], &attr, inc_count, (void*)&thread_ids[1]);
pthread_create(&threads[2], &attr, watch_count, (void*)&thread_ids[2]);

/* Wait for all threads to complete */
for (i = 0; i < NUM_THREADS; i++) {
    pthread_join(threads[i], NULL);
}
called_thread

该线程等待使用资源count, 被阻塞在条件变量 count_threshold_cv 上.
pthread_cond_wait() 不仅会作用在条件变量上, 也会使用锁, 在下面的例子中:

  1. 线程首先会在 pthread_mutex_lock(&count_mutex); 获取互斥锁
  2. pthread_cond_wait()函数首先会释放互斥锁, 然后使当前线程进入阻塞状态, 等待当前线程被pthread_cond_signal 或者 pthread_cond_broadcast函数唤醒, 也可能在被信号中断后被唤醒. 唤醒后线程进入就绪态.
  3. 线程会被唤醒之后, pthread_cond_wait() 会等待 call_thread释放互斥锁, 然后会再次获取互斥锁, 互斥锁被当前线程锁定.
  4. 如果没有线程被阻塞在条件变量上,那么调用pthread_cond_signal()将没有作用
void* watch_count(void* idp)
{   
    // 线程ID
    int* my_id = (int*)idp;
    printf("Starting watch_count(): thread %d\n", *my_id);
    // 获取互斥锁
    pthread_mutex_lock(&count_mutex);
    while (count < COUNT_LIMIT) {
        sleep(3);
        // These functions atomically release mutex and cause the calling thread to block on the condition variable cond;
        // "官网原话 atomically with respect to access by another thread to the mutex and then the condition variable".
        pthread_cond_wait(&count_threshold_cv, &count_mutex);
        printf("watch_count(): thread %d Condition signal received.\n", *my_id);
    }

    count += 100;
    pthread_mutex_unlock(&count_mutex);
    pthread_exit(NULL);
}
call_thread

该线程通知其他线程, 条件变量成立

  1. pthread_cond_signal() 函数与 pthread_cond_broadcast() 均可以唤醒被阻塞的线程
  2. 唤醒操作没有获取以及释放互斥锁, 只有线程主动释放互斥锁, 被唤醒的线程中的 pthread_cond_wait() 函数才可以获取互斥锁.
void* inc_count(void* idp)
{
    int i = 0;
    // 任务次数记录
    int taskid = 0;
    // 线程ID
    int* my_id = (int*)idp;
    for (i = 0; i < TCOUNT; i++) {
        pthread_mutex_lock(&count_mutex);
        taskid = count;
        count++;
        /*
          唤醒一个阻塞在该条件变量到线程
          如果没有线程被阻塞在条件变量上,那么调用pthread_cond_signal()将没有作用
        */
        // pthread_cond_signal() 执行之后
        pthread_cond_signal(&count_threshold_cv);

        printf("inc_count(): thread %d, count = %d, unlocking mutex\n", *my_id, count);
        pthread_mutex_unlock(&count_mutex);
        sleep(1);
    }
    printf("inc_count(): thread %d, Threshold reached.\n", *my_id);
    pthread_exit(NULL);
}

OPENCV中被动等待的实现

OPENCV通过主线程向工作线程分配任务的方式来实现多线程的调度. 下图中展示了线程在运行态, 阻塞态以及就绪态之间的切换:

  1. 主线程负责唤醒工作线程, 分配任务, 工作线程执行任务, 然后通知主线程. 主线程与工作线程均会被阻塞, 主线程被任务完成标识(job->is_completed)阻塞, 工作线程被收到工作信号(has_wake_signal) 阻塞.
  2. 主线程与工作线程均使用条件变量等待pthread_cond_wait与互斥锁的来实现线程同步, job->is_completedhas_wake_signal 是主线程与工作线程之间的临界资源.
  3. 临界资源 job->is_completed 表示任务是否被完成, 如果job->is_completed==False, 那么主线程会阻塞在对应的条件变量 cond_thread_task_complete 处. 在代码中就是阻塞在: pthread_cond_wait(&cond_thread_task_complete, &mutex_notify); 这一行, mutex_notify 则是用来访问临界资源 job->is_completed 的互斥锁.
  4. 临界资源has_wake_signal表示工作线程是否收到主线程分配的工作任务, has_wake_signal==False, 那么工作线程会阻塞在对应的条件变量 cond_thread_wake上, 在代码中就是: pthread_cond_wait(&cond_thread_wake, &mutex);. 这一行中的 mutex 就是临界资源has_wake_signal的互斥锁.
  5. 图中的实例中可以看到主动等待的好处, 如果有多个CPU, 工作线程在3.21处在忙等时, 主线程已经完成4.2步骤, 那么工作线程就避免了一次阻塞与唤醒, 直接运行.
OPENCV 中工作线程作为called_thread
  1. opencv 工作线程类为: WorkerThread, 线程方法为thread_body.
  2. opencv中工作线程会被阻塞在has_wake_signal信号处, 在下面的代码while()循环判断中, 工作线程调用函数pthread_cond_wait() 等待的条件变量为 cond_thread_wake.
  3. 使用 while (!has_wake_signal) 判断 has_wake_signal 的原因是, 如果主线程给一个工作线程分配任务后, 使用的是pthread_cond_broadcast() 函数来通知所有线程, 那么有可能该线程的has_wake_signal仍然为False, 实际通知的是其他线程, while (!has_wake_signal)可以保证确定收到工作任务.
// 被动等待开始, 获取互斥锁
pthread_mutex_lock(&mutex);
#ifdef CV_PROFILE_THREADS
stat.threadWait = getTickCount();
#endif       
// 进入循环等待, 等待收到唤醒信号 has_wake_signal
while (!has_wake_signal) // to handle spurious wakeups
{
    //CV_LOG_VERBOSE(NULL, 5, "Thread: wait (sleep) ...");
// 如果使用全局的唤醒信号
#if defined(CV_USE_GLOBAL_WORKERS_COND_VAR)
    pthread_cond_wait(&thread_pool.cond_thread_wake, &mutex);
#else
    isActive = false;
    // 这里会释放CPU资源, 然后等待条件变量, 同时释放互斥锁, 
    pthread_cond_wait(&cond_thread_wake, &mutex);
    isActive = true;
#endif
    CV_LOG_VERBOSE(NULL, 5, "Thread: wake ... (has_wake_signal=" << has_wake_signal << " stop_thread=" << stop_thread << ")")
}
#ifdef CV_PROFILE_THREADS
stat.threadWake = getTickCount();
#endif

CV_LOG_VERBOSE(NULL, 5, "Thread: checking for new job");
// CV_WORKER_ACTIVE_WAIT_THREADS_LIMIT 是否运行忙等的开关
if (CV_WORKER_ACTIVE_WAIT_THREADS_LIMIT == 0)
    allow_active_wait = true;
// 当前线程接管主线程分配的任务
Ptr<ParallelJob> j_ptr; swap(j_ptr, job);
has_wake_signal = false;    
pthread_mutex_unlock(&mutex);
OPENCV 中工作线程的call_thread
  1. opencv中工作线程的 call_thread 就是主线程, 主线程给工作线程分配任务, 并通知它条件变量thread.cond_thread_wake成立, 线程可以继续运行:
 CV_LOG_VERBOSE(NULL, 1, "MainThread: initialize parallel job: " << range.size());
//  初始化任务
job = Ptr<ParallelJob>(new ParallelJob(*this, range, body, nstripes));
pthread_mutex_unlock(&mutex);
// 主线程开始唤醒工作线程
CV_LOG_VERBOSE(NULL, 5, "MainThread: wake worker threads...");
for (size_t i = 0; i < threads.size(); ++i)
{
    // 初始化工作线程, 工作线程初始化后会进入阻塞状态, 等待 has_wake_signal 信号, 如果线程在忙等, 那么thread.isActive=True, 如果线程被阻塞, 那么thread.isActive=False
    WorkerThread& thread = *(threads[i].get());
    if (thread.isActive || thread.has_wake_signal || !thread.job.empty())
    {
        // 获取工作线程的 has_wake_signal 的互斥锁
        pthread_mutex_lock(&thread.mutex);
        // 分配任务
        thread.job = job;
        bool isActive = thread.isActive;
        thread.has_wake_signal = true;
        // 释放工作线程的 has_wake_signal 变量的互斥锁
        pthread_mutex_unlock(&thread.mutex);
#if !defined(CV_USE_GLOBAL_WORKERS_COND_VAR)
        if (!isActive)
        {  
            // 通知工作线程任务分配完成, 也就是 has_wake_signal 被设置为True, 使用的是pthread_cond_broadcast通知条件变量thread.cond_thread_wake
            pthread_cond_broadcast/*pthread_cond_signal*/(&thread.cond_thread_wake); // wake thread
        }
#endif
    }
    else
    {
        // 如果线程已经完成了某个任务, 等待新的任务, 处于阻塞状态, 那么分配新的任务
        CV_Assert(thread.job.empty());
        thread.job = job;
        thread.has_wake_signal = true;
#ifdef CV_PROFILE_THREADS
        threads_stat[i + 1].reset();
#endif
#if !defined(CV_USE_GLOBAL_WORKERS_COND_VAR)
        // 唤醒线程, 使用全局条件变量 thread.cond_thread_wake
        pthread_cond_broadcast/*pthread_cond_signal*/(&thread.cond_thread_wake); // wake thread
#endif
    }
}
CV_LOG_VERBOSE(NULL, 5, "MainThread: wake worker threads... (done)");
OPENCV 中主线程作为called_thread
  1. 主线程会被阻塞在 job->is_completed 变量资源上, 如果任务没有完成, 主线程会先忙等一下, 然后调用pthread_cond_wait 进入阻塞, 等待条件变量为 cond_thread_task_complete.
// 主线程的主动等待
if (CV_MAIN_THREAD_ACTIVE_WAIT > 0)
{
    // 这里不可以忙等太久, 因为主线程给工作线程分配完成任务后, 仍然会继续执行
    // 如果此处忙等时间太长, 主线程无法释放CPU, 而工作进程完成任务需要时间往往比较长, 这里忙等会陷入白白等待
    for (int i = 0; i < CV_MAIN_THREAD_ACTIVE_WAIT; i++)  // don't spin too much in any case (inaccurate getTickCount())
    {
        if (job->is_completed)
        {
            CV_LOG_VERBOSE(NULL, 5, "MainThread: job finalize (active wait) " << j.active_thread_count << " " << j.completed_thread_count);
            break;
        }
        if (CV_ACTIVE_WAIT_PAUSE_LIMIT > 0 && (i < CV_ACTIVE_WAIT_PAUSE_LIMIT || (i & 1)))
            CV_PAUSE(16);
        else
            CV_YIELD();
    }
}
// 主线程的被动等待
if (!job->is_completed)
{
    CV_LOG_VERBOSE(NULL, 5, "MainThread: prepare wait " << j.active_thread_count << " " << j.completed_thread_count);
    // 获取 job->is_completed 的互斥锁
    pthread_mutex_lock(&mutex_notify);
    for (;;)
    {
        if (job->is_completed)
        {
            CV_LOG_VERBOSE(NULL, 5, "MainThread: job finalize (wait) " << j.active_thread_count << " " << j.completed_thread_count);
            break;
        }
        CV_LOG_VERBOSE(NULL, 5, "MainThread: wait completion (sleep) ...");
        // 主线程被 job->is_completed 阻塞, 等待工作线程完成任务, 然后通知主线程, 使用的条件变量为 cond_thread_task_complete
        pthread_cond_wait(&cond_thread_task_complete, &mutex_notify);
        CV_LOG_VERBOSE(NULL, 5, "MainThread: wake");
    }
    pthread_mutex_unlock(&mutex_notify);
}
OPENCV 中主线程的call_thread
  1. opencv中主线程的 call_thread 就是工作线程, 工作线程在完成主线程分配的任务之后使用 pthread_cond_broadcast 唤醒主线程.
CV_LOG_VERBOSE(NULL, 5, "Thread: completed job processing: " << active << " " << completed);
// active 与 completed是读取自工作池中的任务, 如果所有的线程都完成任务
if (active == completed)
{
    // 此时, j->is_completed 还是 false
    bool need_signal = !j->is_completed;
    j->is_completed = true;
    // 释放工作池中的任务
    j = NULL; j_ptr.release();
    // 如果需要发送信号, 也就是说要通知主线程, 工作线程已经完成任务
    if (need_signal)
    {
        CV_LOG_VERBOSE(NULL, 5, "Thread: job finished => notifying the main thread");
        pthread_mutex_lock(&thread_pool.mutex_notify);  // to avoid signal miss due pre-check condition
        // empty
        pthread_mutex_unlock(&thread_pool.mutex_notify);
        // 唤醒主线程, 使用全局条件变量 thread_pool.cond_thread_task_complete, 告诉主线程, 工作线程已经完成任务
        pthread_cond_broadcast/*pthread_cond_signal*/(&thread_pool.cond_thread_task_complete);
    }
}

总结

  1. OPENCV在多线程调度时, 将线程分为主线程与工作线程, 主线程负责分配任务, 工作线程执行任务.
  2. 主线程与工作线程均使用主动等待与被动等待两种线程状态切换机制.
  3. 主动等待过程中, 线程会占用CPU, 忙等一段时间, 这个时间是可以通过环境变量参数配置的, 在多核CPU中可以降低线程的切换频率, 提高效率.
  4. 被动等待过程中, 主线程与工作线程分别阻塞在不同的资源信号中, opencv使用的是 pthread_cond_wait()与条件变量以及互斥锁的方式实现资源的同步以及线程的调度.
posted @ 2024-06-03 20:07  虾野百鹤  阅读(17)  评论(0编辑  收藏  举报