【C++并发入门】摄像头帧率计算和多线程相机读取(上):并发基础概念和代码实现

时间:2024-09-30 07:29:25

前言

  • 高帧率摄像头往往应用在很多opencv项目中,今天就来通过简单计算摄像头帧率,抛出一个单线程读取摄像头会遇到的问题,同时提出一种解决方案,使用多线程对摄像头进行读取。
  • 同时本文介绍了线程入门的基础知识,讲解了线程进程的概念,std::thread的使用,std::mutex锁的概念。
  • 本教程使用的环境:
    • opencv C++ 4.5
    • C++11
    • KS1A293黑白240fps摄像头

1 摄像头帧率计算

1-1 概念
  • 摄像头帧率通常指的是视频摄像头每秒钟能够捕捉到的图像数量,单位是帧每秒(fps)。经常打游戏的朋友应该不陌生FPS吧(乐)请添加图片描述
1-2 代码实现
  • 那以我手上的这个240fps的摄像头为例子请添加图片描述

  • 我们简单使用opencv-C++根据摄像机帧率进行简单的FPS计算,并画在图上

#include <opencv2/opencv.hpp>
#include <iostream>
#include <opencv2/core/utils/logger.hpp>
#include <chrono>
#include <thread>


int main() {
    cv::utils::logging::setLogLevel(cv::utils::logging::LOG_LEVEL_VERBOSE);
    cv::VideoCapture cap(0);

    if (!cap.isOpened()) {
        std::cerr << "open camera failed!" << std::endl;
        return -1;
    }

    cap.set(cv::CAP_PROP_FRAME_WIDTH, 640);
    cap.set(cv::CAP_PROP_FRAME_HEIGHT, 400);
    cap.set(cv::CAP_PROP_FPS, 240);
    cap.set(cv::CAP_PROP_FOURCC, cv::VideoWriter::fourcc('M', 'J', 'P', 'G'));

    std::chrono::time_point<std::chrono::steady_clock> startTime=std::chrono::steady_clock::now();
    std::chrono::time_point<std::chrono::steady_clock> endTime;
    double fps = 0.0;
    int frame_count = 0;
    cv::Mat frame;
    while (true) {
        bool ret = cap.read(frame);
        if (!ret) {
            break;
        }
        frame_count++;


        endTime = std::chrono::steady_clock::now();
        double timeTaken = std::chrono::duration<double, std::milli>(endTime - startTime).count();
        if (timeTaken >= 1000)
        {
            fps = frame_count;
            startTime = std::chrono::steady_clock::now();
            frame_count = 0;
        }

        cv::putText(frame, std::to_string(int(fps)) + " FPS", cv::Point(frame.cols / 4, frame.rows / 3), cv::FONT_HERSHEY_SIMPLEX, 0.7, cv::Scalar(255, 0, 0), 2);

        
        cv::imshow("Frame", frame);

        if (cv::waitKey(1) == 'q') 
             break;
       

    }

    cap.release();

    return 0;
}

  • 代码很简单,根据FPS的定义使用C++通用时间库chrono可以计算出每1000ms(1s)读到的帧数,得到如下的窗口显示,可以看到有正常使用由于部分硬件限制是可以达到180fps左右的实时帧率的。请添加图片描述
1-3 问题抛出
  • 那我们的cv代码对捕获的图像进行处理就理所应当顺其自然的写在获取到图像的循环中了,那么我拿下述代码模拟我在主循环执行的耗时操作
std::this_thread::sleep_for(std::chrono::milliseconds(10)); // 10ms
  • 我们把上述模拟耗时的代码放入主循环的任意位置,可以看到,摄像头的FPS明显下降请添加图片描述

  • 那我们再做一个实验,模拟平常没有使用高帧率摄像头的情况,我把摄像头的帧率降低到30帧

 cap.set(cv::CAP_PROP_FRAME_WIDTH, 640);
 cap.set(cv::CAP_PROP_FRAME_HEIGHT, 480);
 cap.set(cv::CAP_PROP_FOURCC, cv::VideoWriter::fourcc('M', 'J', 'P', 'G'));
 cap.set(cv::CAP_PROP_FPS, 30);
  • 然后我们重复和上述一样的操作增减模拟耗时的代码,发现帧率基本是还是在30fps左右,相信这样一对比大家肯定就明白原因了。如果把相机的读取和处理放在同一个线程下,且摄像头帧率又过快时,图像处理的代码会影响摄像头的读取,那么我们就引入了今天的主题—多线程读取相机。

2 基础概念

  • 在踏入正式的多线程多进程的道路之前,你需要知道点概念。
2-1 线程和进程的概念
  • 线程(Thread)和进程(Process)是操作系统中用于执行程序的基本单位,它们之间有着密切的关系,但也有明显的区别。

    • *进程(Process):进程是操作系统进行资源分配和调度的基本单位。它是程序的一次执行过程,包含了程序运行所需的全部资源,如内存空间、文件描述符、环境变量等。每个进程都有自己的地址空间,一个进程中的数据对其他进程是不可见的。进程之间是相互独立的,一个进程的崩溃不会影响到其他进程。
    • *线程(Thread):线程是进程中的一个实体,被系统独立调度和分派的基本单位。它是进程的执行流,一个进程可以有多个线程,而同一个进程中的所有线程共享进程的资源。线程的切换通常比进程的切换要快,因为线程之间的切换不需要重新加载进程的上下文。
  • 说人话版本就是

    • 进程就像是电脑上运行的独立程序。比如,当你打开一个浏览器、一个文本编辑器或者一个游戏,每个这样的程序在操作系统中都是一个独立的进程。每个进程都有自己的内存空间、数据和其他资源,它们彼此之间是隔离的,一个进程的崩溃通常不会影响到其他进程。最简单的例子就是打开你电脑的任务管理器,每一栏就是一个独立的进程,他们彼此之间不会干架,进程统一由操作系统进行维护。操作系统负责管理和协调这些进程,确保它们可以有效地使用计算机的资源,比如CPU、内存和硬盘空间。请添加图片描述

    • 线程,举个餐厅的例子,我们假定你开了一家餐厅,这个餐厅是一个进程。你的餐厅需要同时处理多个任务,比如迎接顾客、点菜、烹饪和清洁。每个任务可以看作是一个线程。

      • 迎接顾客的员工是一个线程,他们负责接待客人、带位和解释菜单。
      • 点菜的员工是另一个线程,他们记录顾客的点菜信息并传递给厨房。
      • 厨房里的厨师是第三个线程,他们根据点菜信息准备食物。
      • 清洁工是第四个线程,他们负责保持餐厅的清洁。
    • 每个线程都在执行不同的任务,但它们都共享餐厅的资源,比如厨房、餐具和收银台。如果餐厅的生意很好,这些线程可以同时工作,提高效率。如果某个线程(比如厨师)因为某些原因无法工作(比如生病),其他线程(比如迎接顾客和点菜)的工作可能会受到影响,因为顾客点的菜无法及时准备好。请添加图片描述

  • *关系与区别

    • 资源占用:进程是资源分配的单位,每个进程都有自己的资源,如内存空间。而线程共享进程的资源,线程之间共享进程的内存空间、文件描述符等。
    • 执行控制:线程是独立调度的基本单位,一个进程中的线程可以并发执行。而进程是操作系统进行保护和资源分配的基本单位。
    • 上下文切换:线程的上下文切换通常比进程的上下文切换要快,因为线程共享进程的上下文,而进程的上下文切换需要保存和恢复整个进程的状态。
    • 通信方式:进程间的通信通常需要通过操作系统提供的机制,如管道、消息队列、共享内存等。而线程间的通信通常更加直接,因为它们共享进程的内存空间。

2-2 并行和并发
  • 说完线程和进程,不得不提到 并行和并发,并行(Parallelism)并发(Concurrency)是计算机科学中经常讨论的两个概念,它们描述了程序或任务在多处理器或多核系统中的执行方式。请添加图片描述

    • 并发(Concurrency)是指在同一时间间隔内,多个任务可以“同时”开始或结束,但不一定是真正的同时。并发通常发生在单核处理器上,通过操作系统的任务调度器(scheduler)在多个任务之间快速切换,使得每个任务都能得到执行时间。对于用户来说,似乎这些任务是在同时进行的,但实际上是轮流执行。并发是一种逻辑上的同时执行,它允许多个任务交替使用CPU资源。
    • 并行(Parallelism)是指多个任务在同一时刻真正地同时执行。这通常发生在多核处理器或多个处理器上,每个核或处理器可以同时处理不同的任务。并行是一种物理上的同时执行,它能够显著提高计算速度和效率。
  • 还是举个说人话的例子

    • 并发:你可以在一个炉灶上煮汤,同时在另一个炉灶上炒菜,同时电饭锅里头还在煮米饭。虽然你一次只能操作一个炉灶,但你可以快速地在两个炉灶之间切换,使得几个菜看起来像是同时烹饪的。这就像是单核处理器上的并发执行,处理器快速地在多个任务之间切换。
    • 并行:但是假如你有女朋友(不是),那她可以帮助你做饭,每个人操作一个炉灶,那么几个菜肴就可以真正地同时烹饪。这就像是多核处理器上的并行执行,每个核可以独立地处理一个任务。

3 C++线程入门std::thread

  • std::thread 是 C++11 引入的标准库功能,用于创建和管理线程。使用 std::thread,我们可以创建新的执行线程,并在线程中执行函数或 lambda 表达式。
3-1 创建线程
  • std::thread支持你传递一个函数指针、函数对象或 lambda 表达式来创建一个新线程。
#include <iostream>
#include <thread>

void printHello() {
    std::cout << "Hello from thread!\n";
}

int main() {
    std::thread t(printHello); 
    t.join(); // 等待线程完成
    return 0;
}
  • 你也可以直接在 std::thread 构造函数中使用 lambda 表达式:
std::thread t([]() {
    std::cout << "Hello from lambda thread!\n";
});
  • 你可以向线程函数传递参数,这些参数可以是普通变量、引用或 std::move 的对象。
void printMessage(const std::string& msg) {
    std::cout << msg << std::endl;
}

int main() {
    std::string msg = "Hello from thread!";
    std::thread t(printMessage, std::move(msg)); // 传递 msg 到线程函数
    t.join();
    return 0;
}

3-2 join 和 detach
  • joindetachstd::thread 类的两个成员函数,用于管理线程的生命周期。它们的主要作用是控制线程何时结束以及如何与主线程(创建线程的线程)交互。
3-2-1 join
  • 当线程开始执行后,主线程默认会继续执行而不会等待线程完成。如果你希望主线程等待一个线程完成,可以使用 join 方法。这会阻塞当前线程(通常是主线程)直到另一个线程完成执行。
std::thread t(printHello);
t.join(); // 主线程会等待直到 t 线程完成
  • 还是说人话,假设你是一名厨师,你正在准备一顿晚餐。你有一个煎锅,需要同时煎牛排和炒蔬菜。你可以将煎牛排的任务交给一个你滴助手,然后开始炒蔬菜。在这个过程中,你希望确保牛排煎好后,再继续炒蔬菜。这时,你可以告诉你滴助手,在他煎好牛排后,通知你。这个通知的过程就像是 join,你(主线程)会等待你滴助手(线程)完成煎牛排的任务后,再继续炒蔬菜。
std::thread t(cookSteak); // 你滴助手开始煎牛排
t.join(); // 你等待你滴助手煎好牛排
cookVegetables(); // 你开始炒蔬菜
3-2-2 detach
  • 如果你想创建一个独立于主线程的线程,你可以使用 detach 方法。这会告诉操作系统,当线程函数返回时,回收线程资源,而不是等待主线程来回收。这意味着线程和主线程将独立运行,主线程不会等待线程完成。
std::thread t(printHello);
t.detach(); // t 线程将独立运行
  • 同样说人话,假设你是一名厨师,但这次你需要同时处理多个任务。除了煎牛排,你还需要煮米饭和准备沙拉。这些任务可以同时进行,不需要互相等待。在这种情况下,你可以让你滴助手开始煎牛排,然后让他独立完成,不需要通知你。这时候你滴助手会自己处理煎牛排的所有事情,包括清洗煎锅和调味。一旦你滴助手开始煎牛排,你就让他自己处理,你(主线程)可以继续做其他事情,比如煮米饭和准备沙拉。
std::thread t(cookSteak); // 你滴助手开始煎牛排
t.detach(); // 你滴助手独立完成煎牛排,不需要通知你
cookRice(); // 你开始煮米饭
prepareSalad(); // 你准备沙拉
  • 这下懂了吧?

4 竞态条件(race condition)和数据不一致

  • 竞态条件(Race Condition)数据不一致问题多线程编程中常见的问题,它们通常发生在多个线程访问和修改共享数据时。
4-1 竞态条件(Race Condition)
  • 竞态条件是指程序执行的结果依赖于线程调度的顺序,即多个线程以不同的顺序执行时,程序可能会产生不同的结果。竞态条件通常发生在以下几种情况:
    1. 共享数据访问:当多个线程访问和修改同一块共享数据时,如果没有适当的同步机制,就可能导致数据的不一致。
    2. 条件检查与操作:在多线程环境中,一个线程可能会在另一个线程修改共享数据之前检查某个条件,这可能导致条件判断错误。
    3. 信号量或锁的错误使用:不当使用信号量或锁,比如死锁、忘记释放锁等,也可能导致竞态条件。
  • 我们来举个例子
#include <iostream>
#include <thread>

int counter = 0;

void incrementCounter() {
    for (int i = 0; i < 1000; ++i) {
        ++counter;
    }
}

int main() {
    std::thread t2(incrementCounter);
    std::thread t1(incrementCounter);
   

    t1.join();
    t2.join();

    std::cout << "Counter: " << counter << std::endl;
}

  • 在这个例子中,我们创建了两个线程,每个线程都尝试将 counter 的值增加 1000 次。理想情况下,我们期望 counter 的最终值为 2000。然而,由于线程的调度是不确定的,两个线程可能会同时读取和修改 counter 的值,导致最终的 counter 值小于 2000。

  • 我们在VS2022进行测试,前几次都是理想的2000请添加图片描述

  • 然而在我坚持不懈的尝试下(迫真),得到的如下的情况请添加图片描述

  • 值得一提的是为了复现这个效果,你需要在 Visual Studio 中禁用编译器优化。

    1. 打开项目属性。
    2. 导航到“C/C++” > “代码生成”。
    3. 在“优化”下拉菜单中,选择“无 (/Od)”。
    4. 点击“确定”保存更改。请添加图片描述

4-2 数据不一致问题
  • 数据不一致问题是指由于多个线程同时读写共享数据,导致数据的状态变得不可预测或不符合预期。数据不一致问题通常是由竞态条件引起的,因为多个线程没有正确地同步对共享数据的访问。
  • 简单说这两差不多一个意思,但是区别就是:竞态条件是一种可能导致数据不一致的情况,但数据不一致问题可能有其他原因。竞态条件是导致数据不一致的一个常见原因,但不是唯一原因
  • 为了解决竞态条件和数据不一致问题,通常需要使用互斥锁(如 std::mutex)、原子操作(如 std::atomic)或其他同步机制来确保对共享数据的访问是安全的。

5 std::mutex

  • std::mutex 是 C++11 中引入的一个线程同步机制,用于保护共享数据,防止多个线程同时访问同一资源。当多个线程尝试同时访问共享资源时,std::mutex 可以确保一次只有一个线程能够访问该资源,从而避免了竞态条件(race condition)和数据不一致的问题。
  • std::mutex 提供了基本的锁功能,它有两个主要成员函数:
    1. lock(): 当一个线程调用这个函数时,它会尝试获取锁。如果锁当前没有被其他线程持有,这个调用会立即返回,并且锁会被当前线程持有。如果锁已经被其他线程持有,当前线程会被阻塞,直到锁被释放。
    2. unlock(): 当一个线程完成了对共享资源的访问后,它需要调用这个函数来释放锁。一旦锁被释放,其他等待锁的线程中的一个可以获取锁并继续执行。
  • 还是刚刚那个例子
#include <iostream>
#include <thread>
#include <mutex>

int counter = 0;
std::mutex mtx; // 创建一个互斥锁

void incrementCounter() {
    for (int i = 0; i < 1000; ++i) {
        mtx.lock(); // 加锁
        ++counter;
        mtx.unlock(); // 解锁
    }
}

int main() {
    std::thread t1(incrementCounter);
    std::thread t2(incrementCounter);

    t1.join();
    t2.join();

    std::cout << "Counter: " << counter << std::endl;
    return 0;
}

  • 在这个修改后的代码中,我们创建了一个std::mutex对象mtx。在incrementCounter函数中,我们使用mtx.lock()来加锁,然后递增counter,最后使用mtx.unlock()来解锁。这样,即使两个线程同时尝试递增counter,也只有一个线程可以进入临界区(即加锁后的代码块)。请添加图片描述

4-3死锁(deadlock)
  • 死锁(deadlock)是指两个或多个线程无限期地等待对方释放锁,导致所有线程都无法继续执行。在多线程程序中,死锁通常是由于不正确的锁管理造成的。
  • 死锁的发生通常需要满足以下四个条件,这被称为死锁的四个必要条件:
    1. 互斥条件:至少有一个资源必须处于非共享模式,即一次只能由一个进程(或线程)使用。
    2. 占有和等待条件:一个进程(或线程)至少持有一个资源,并且正在等待获取一个由其他进程(或线程)持有的资源。
    3. 非抢占条件:资源不能被强制从一个进程(或线程)转移到另一个进程(或线程),进程(或线程)只能释放资源。
    4. 循环等待条件:存在一个由两个或多个进程(或线程)组成的循环链,每个进程(或线程)都在等待下一个进程(或线程)持有的资源。
  • 还是说人话环境,我们还是说厨师 (乐),假设有两个厨师,我们称他们为厨师A厨师B。他们共享两个工具:一个锅和一个炉子。厨师A需要先使用锅,然后使用炉子来烹饪食物;而厨师B则需要先使用炉子,然后使用锅来烹饪食物。
    • 现在,让我们来看看如果他们同时开始工作,会发生什么:
      1. 厨师A首先拿起锅开始烹饪。
      2. 厨师B同时拿起炉子开始烹饪。
    • 到这里为止,一切都很正常。但是,当厨师A烹饪完锅里的食物后,他需要使用炉子来加热,但这时炉子已经被厨师B占用。同样,厨师B在烹饪完炉子上的食物后,他需要使用锅来烹饪,但锅已经被厨师A占用。
    • 由于厨师A和厨师B都在等待对方释放他们需要的工具,他们都无法继续工作。这就是一个死锁的情景。除非有外部干预,否则厨师A和厨师B将永远无法完成他们的烹饪任务。
  • 代码实现起来就是这个样子:
#include <iostream>
#include <thread>
#include <mutex>

std::mutex mtx_pot, mtx_stove;

void cookAfun() {
    // 线程A首先尝试锁定mtx_pot
    mtx_pot.lock();
    std::cout << "厨师A拿起了锅\n" << std::endl;

    // 线程A尝试锁定mtx_stove,但mtx_stove已被线程B锁定
    mtx_stove.lock();
    std::cout << "厨师A拿起了炉子\n" << std::endl;

    // 释放锁
    mtx_stove.unlock();
    mtx_pot.unlock();
}

void cookBfun() {
    // 线程B首先尝试锁定mtx_stove
    mtx_stove.lock();
    std::cout << "厨师B拿起了锅\n" << std::endl;

    // 线程B尝试锁定mtx_pot,但mtx_pot已被线程A锁定
    mtx_pot.lock();
    std::cout << "厨师B拿起了炉子\n" << std::endl;

    // 释放锁
    mtx_pot.unlock();
    mtx_stove.unlock();
}

int main() {
    std::thread t1(cookAfun);
    std::thread t2(cookBfun);

    t1.join();
    t2.join();

    return 0;
}

  • 如下程序陷入了死锁,卡死了,两个厨师在那干瞪眼请添加图片描述

  • 那为了避免死锁,我们可以让厨师A和厨师B都先尝试锁定锅(mtx_pot),然后再锁定炉子(mtx_stove)。这样,无论哪个线程先开始执行,都不会发生死锁,因为它们都会以相同的顺序获取锁。

#include <iostream>
#include <thread>
#include <mutex>

std::mutex mtx_pot, mtx_stove;

void cookAfun() {
    // 线程A首先尝试锁定mtx_pot
    mtx_pot.lock();
    std::cout << "厨师A拿起了锅\n" << std::endl;

    // 线程A尝试锁定mtx_stove
    mtx_stove.lock();
    std::cout << "厨师A拿起了炉子\n" << std::endl;

    // 释放锁
    mtx_stove.unlock();
    mtx_pot.unlock();
}

void cookBfun() {
    // 线程B首先尝试锁定mtx_pot
    mtx_pot.lock();
    std::cout << "厨师B拿起了锅\n" << std::endl;

    // 线程B尝试锁定mtx_stove
    mtx_stove.lock();
    std::cout << "厨师B拿起了炉子\n" << std::endl;

    // 释放锁
    mtx_stove.unlock();
    mtx_pot.unlock();
}

int main() {
    std::thread t1(cookAfun);
    std::thread t2(cookBfun);

    t1.join();
    t2.join();

    return 0;
}

  • 这样,大家就可以各干各的了请添加图片描述

  • 然而,直接使用 lock()unlock() 可能会导致死锁(deadlock),如果忘记调用 unlock() 或者在持有锁的时候发生异常。


6 std::lock_guard

  • 为了解决忘记调用unlock或者上述问题,C++11 引入了 std::lock_guardstd::unique_lock,它们是 RAII(Resource Acquisition Is Initialization)风格的互斥锁封装器,可以自动管理锁的获取和释放。
  • 咱们直接上例子,还是那俩厨师
#include <iostream>
#include <thread>
#include <mutex>

std::mutex mtx_pot, mtx_stove;
void cookAfun() {
    // 使用std::lock_guard自动锁定mtx_pot
    std::lock_guard<std::mutex> guard_pot(mtx_pot);
    std::cout << "厨师A拿起了锅\n" << std::endl;

    // 使用std::lock_guard自动锁定mtx_stove
    std::lock_guard<std::mutex> guard_stove(mtx_stove);
    std::cout << "厨师A拿起了炉子\n" << std::endl;

    // 释放锁
    // std::lock_guard在作用域结束时自动释放锁
}

void cookBfun() {
    // 使用std::lock_guard自动锁定mtx_pot
    std::lock_guard<std::mutex> guard_pot(mtx_pot);
    std::cout << "厨师B拿起了锅\n" << std::endl;

    // 使用std::lock_guard自动锁定mtx_stove
    std::lock_guard<std::mutex> guard_stove(mtx_stove);
    std::cout <<