C++进阶(二)—— 多线程

文章目录

  • 概念
    • 多进程
    • 多线程
    • 多线程和多进程的区别
  • C++11 —— thread
  • 一、线程池
  • 二、锁
    • 共享锁和排他锁(C++17)
    • 互斥锁(C++11)基础
      • lock_guard与unique_lock的区别
    • 自旋锁基础
    • 条件变量(C++11)
    • 读写锁(C++14)
    • 信号量(C++11)
    • 原子操作
  • 三、生产者消费者
    • 单生产者单消费者
    • 多生产者多消费者
  • 四、交叉打印


概念

多进程

多进程是指在操作系统中同时运行多个独立的进程。每个进程有自己的地址空间、代码、数据和打开的文件等资源。下面是多进程的一些优点和缺点:

优点:
高稳定性:由于每个进程运行在独立的地址空间中,一个进程的崩溃或异常不会影响其他进程的运行,因此系统整体上更加稳定。
高并发性:每个进程都是独立的执行单位,可以同时运行多个进程,从而提高系统的并发处理能力。
简单的编程模型:多进程模型相对简单,不涉及共享内存和线程同步的复杂问题,适合并行计算和任务分发。
安全性:不同进程之间的资源隔离性较好,一个进程无法直接访问其他进程的内存,提供了一定的安全保障。

缺点:
资源开销:每个进程都需要独立的地址空间和系统资源,创建和切换进程需要耗费较多的系统开销,包括内存和CPU资源。
系统调度开销:多个进程之间的切换需要进行上下文切换,涉及保存和恢复进程的状态,这会带来一定的系统调度开销。
数据共享困难:不同进程之间的数据共享相对复杂,需要通过进程间通信(IPC)机制来进行数据传输,如管道、共享内存、消息队列等,增加了编程的复杂性。
同步与通信:多个进程之间的同步和通信需要额外的机制来确保数据的一致性和正确性,如互斥锁、信号量等,增加了编程的复杂性。

总结:
多进程适用于需要强隔离性、并行处理、高稳定性和安全性的场景。但是,由于进程间的切换和通信开销较大,对资源的需求较多,因此在资源有限、实时性要求高、需要高度内聚和协作的场景下,可能更适合使用多线程或者其他并发模型。

多线程

多线程是指在一个进程内同时执行多个线程,每个线程都共享进程的地址空间、文件和其他资源。下面是多线程的一些优点和缺点:

优点:
共享资源:多个线程可以直接访问和共享同一进程的内存和文件等资源,便于数据共享和通信,减少了数据传输和同步的开销。
快速切换:线程切换开销较小,线程的创建、销毁和切换比进程更快速,提高了系统的响应速度和并发性能。
资源节约:相对于多进程模型,多线程模型占用的内存和系统资源较少,可以更有效地利用系统资源。
共享代码:多个线程可以共享相同的代码段,减少了代码的冗余和维护的成本。

缺点:
线程安全问题:多个线程共享同一份数据时,需要进行合适的同步和互斥操作,否则可能引发数据竞争和并发错误。
调试困难:由于多线程的并发性和异步性,线程间的交互和依赖关系复杂,调试和定位问题比单线程更困难。
死锁和饥饿:在多线程编程中,如果线程之间存在资源的循环依赖,并且没有适当的同步机制,可能会导致死锁和饥饿等问题。
性能损失:线程之间的切换和同步会引入一定的开销,如果线程数量过多或同步机制设计不合理,可能会导致性能下降。

总结:
多线程适用于需要共享数据、并行计算和高度协作的场景。通过合理的设计和同步机制,可以提高系统的并发性和响应能力。然而,多线程编程也需要小心处理线程安全问题,确保数据的一致性和正确性。

多线程和多进程的区别

线程是进程的子集,一个进程可能由多个线程组成。

多进程的数据是分开的、共享复杂,需要用到IPC通信,但是同步简单

多线程共享进程的数据,共享简单,但是同步复杂

C++11 —— thread

阻塞分离
join:Join 线程,调用该函数会阻塞当前线程,直到由 *this 所标示的线程执行完毕 join 才返回。
detach: Detach 线程。 将当前线程对象所代表的执行实例与该线程对象分离,不会阻塞当前线程,使得线程的执行可以单独进行。一旦线程执行完毕,它所分配的资源将会被释放。

一、线程池

参考链接
对于web服务器、email服务器以及数据库服务器,都需要在单位时间内处理大量的请求。传统的方法是即时创建,即时销毁,虽然相比于创建进程已经快了很多,但是当线程执行时间较短时,会导致服务器不断忙于创建线程、销毁线程的状态。

线程池的引入减少了创建线程的个数,它采用了预创建的技术,在应用程序启动后,将立即创建一定数量的线程,放到空闲队列中,这些线程都属于阻塞状态,不消耗CPU,但是占用较小的内存空间。当任务到来时,将任务传入线程中执行。当所有线程都在处理任务时,会自动创建一定数量的新线程,用于处理任务,处理完毕后不会退出,在线程池中等待下一次任务。当系统比较空闲,大部分线程处于暂停状态,线程池会自动销毁一部分线程。

线程池,最简单的就是生产者消费者模型了。池里的每条线程,都是消费者,他们消费并处理一个个的任务,而任务队列就相当于生产者了。

线程池最简单的形式是含有一个固定数量的工作线程来处理任务,典型的数量是std::thread::hardware_concurrency()。

创建前提:线程本身的开销与线程执行任务相比不可忽略。

如果线程本身的开销相对于线程任务执行开销而言是可以忽略不计的,那么此时线程池所带来的好处是不明显的,比如对于FTP服务器以及Telnet服务器,通常传送文件的时间较长,开销较大,那么此时,我们采用线程池未必是理想的方法,我们可以选择“即时创建,即时销毁”的策略。

适用场景:
(1)单位时间内处理任务频繁而且任务处理时间短
(2)对实时性要求较高。如果接受到任务后再创建线程,可能满足不了实时要求,因此必须采用线程进行预创建。

二、锁

共享锁和排他锁(C++17)

排他锁:等于读写锁中的写锁,因为其他人什么都不能干,所以“排他”。

共享锁:等于读写锁中的读锁,因为其他人还可以读,所以“共享”。

共享锁(Shared Lock):
共享锁允许多个线程同时访问被保护的资源,这些线程之间不会相互阻塞。多个线程可以同时持有共享锁,并且可以同时读取共享资源,从而提高并发性能。当没有线程持有排他锁时,多个线程可以同时获取共享锁,但当有线程持有排他锁时,其他线程需要等待排他锁释放后才能获取共享锁。共享锁适用于读多写少的场景,可以提供更好的并发性能。

排他锁(Exclusive Lock):
排他锁提供了独占访问被保护的资源的能力。当一个线程持有排他锁时,其他线程无法同时持有排他锁或共享锁,它们需要等待排他锁的释放。只有一个线程能够持有排他锁,并且可以对资源进行修改或写入操作。排他锁适用于写多读少的场景,可以确保数据的一致性和安全性。

互斥锁(C++11)基础

互斥锁:就是经常见到的mutex,每个线程在对共享资源(比如一个作为缓冲区的全局数组)进行操作前先申请互斥锁,申请到的可以进行操作,没申请到的要阻塞阻塞阻塞

互斥锁的释放只能由加锁的那个线程来释放。
互斥锁只有加锁、解锁两种操作。

互斥锁是一种「独占锁」,比如当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,线程 B 加锁就会失败,于是就会释放 CPU 让给其他线程,既然线程 B 释放掉了 CPU,自然线程 B 加锁的代码就会被阻塞。也就是说线程B获取互斥锁失败后会休眠。
对于线程B互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的。当加锁失败时,内核会将线程置为「睡眠」状态,等到锁被释放后,内核会在合适的时机唤醒线程,当这个线程成功获取到锁后,于是就可以继续执行。
所以,互斥锁加锁失败时,会从用户态陷入到内核态,让内核帮我们切换线程,虽然简化了使用锁的难度,但是存在一定的性能开销成本。

开销成本:会有两次线程上下文切换的成本。

所以,如果你能确定被锁住的代码执行时间小于上下文切换的耗时(上下文切换的耗时大概在几十纳秒到几微秒之间),就不应该用互斥锁,而应该选用自旋锁。

std::mutex mtx;  // 创建互斥锁对象

// 加锁方式1
std::lock_guard<std::mutex> lock(mtx);  // 在函数内部创建 std::lock_guard 对象,它会自动管理互斥锁的上锁和解锁操作

// 加锁方式2
std::unique_lock<std::mutex> lock(mtx);

// 加锁方式3
mtx.lock();  // 手动上锁
mtx.unlock();  // 手动解锁

lock_guard与unique_lock的区别

lock_guard:

lock_guard 通常用来管理一个 std::mutex 类型的对象,通过定义一个 lock_guard 一个对象来管理 std::mutex 的上锁和解锁。在 lock_guard 初始化的时候进行上锁,然后在 lock_guard 析构(一般是退出作用域)的时候进行解锁。这样避免了人为的对 std::mutex 的上锁和解锁的管理。

定义如下:

std::mutex mtx;
std::lock_guard<std::mutex> lock(mtx);

它的特点如下:
(1) 创建即加锁,作用域结束自动析构并解锁,无需手工解锁
(2) 不能中途解锁,必须等作用域结束才解锁
(3) 不能复制

注意:
lock_guard 并不管理 std::mutex 对象的声明周期,也就是说在使用 lock_guard 的过程中,如果 std::mutex 的对象被释放了,那么在 lock_guard 析构的时候进行解锁就会出现空指针错误。

unique_lock:

简单地讲,unique_lock 是 lock_guard 的升级加强版,它具有 lock_guard 的所有功能,同时又具有其他很多方法,使用起来更强灵活方便,能够应对更复杂的锁定需要。

定义如下:

std::mutex mtx;
std::unique_lock<std::mutex> lock(mtx);

特点如下:
(1) 创建时可以不锁定(通过指定第二个参数为 std::defer_lock),而在需要时再锁定
(2) 可以随时加锁解锁
(3) 作用域规则同 lock_grard,析构时自动释放锁
(4) 不可复制,可移动
(5) 条件变量需要该类型的锁作为参数(此时必须使用 unique_lock

自旋锁基础

在C++中,自旋锁没有标准的库提供,可以使用一些第三方库或平台特定的API来实现自旋锁。

自旋锁是通过 CPU 提供的 CAS 函数(Compare And Swap),在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。也就是说线程B获取自旋锁失败后不会休眠

自旋锁与互斥锁的区别:当加锁失败时,互斥锁用「线程切换」来应对,自旋锁则用「忙等待」来应对。

#include 
#include 
#include 

class SpinLock {
    std::atomic_flag flag = ATOMIC_FLAG_INIT;  // 原子标志,用于表示锁的状态

public:
    void lock() {
        while (flag.test_and_set(std::memory_order_acquire)) {
            // 自旋等待直到获取锁
        }
    }

    void unlock() {
        flag.clear(std::memory_order_release);
    }
};

SpinLock spinLock;  // 创建自旋锁对象

void worker(int id) {
    spinLock.lock();  // 上锁
    std::cout << "Thread " << id << ": Hello, World!" << std::endl;
    spinLock.unlock();  // 解锁
}

int main() {
    std::thread t1(worker, 1);
    std::thread t2(worker, 2);

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

    return 0;
}

条件变量(C++11)

条件变量:能用于阻塞一个/多个线程,直至另一线程修改共享变量(条件)并通知。为了防止race-condition,条件变量总是和互斥锁变量mutex结合在一起使用。

在C++中,条件变量是通过 std::condition_variable 类实现的。它提供了以下主要操作:

  • wait(lock): 线程调用 wait() 函数时,会释放所持有的互斥锁 lock 并进入等待状态,直到收到其他线程发出的通知信号。一旦收到通知,线程会重新获得互斥锁并继续执行。
  • notify_one(): 调用 notify_one() 函数会通知等待在条件变量上的至少一个线程,使其从等待状态中唤醒。被唤醒的线程会尝试重新获得相关的互斥锁并继续执行。
  • notify_all(): 调用 notify_all() 函数会通知等待在条件变量上的所有线程,使它们从等待状态中唤醒。

使用条件变量的一般模式:一个线程在等待某个条件满足时调用 wait() 函数进入等待状态,而另一个线程在满足条件后调用 notify_one() 或 notify_all() 函数来唤醒等待的线程。

执行wait、wait_for或wait_until时,需要持有锁。

执行notify_one或notify_all时,不需要有锁。

ros中读取多传感器数据例程

#include 
#include 
#include 

mutex mtx_buffer;
condition_variable sig_buffer;  

void SigHandle(int sig)
{
    flg_exit = true;
    ROS_WARN("catch sig %d", sig);
    sig_buffer.notify_all();
}

void standard_pcl_cbk(const sensor_msgs::PointCloud2::ConstPtr &msg)
{
    mtx_buffer.lock();
    PointCloudXYZI::Ptr ptr(new PointCloudXYZI());
    p_pre->process(msg, ptr);
    lidar_buffer.push_back(ptr);
    mtx_buffer.unlock();
    sig_buffer.notify_all();  // 通知所有等待的线程
}

void imu_cbk(const sensor_msgs::Imu::ConstPtr &msg_in)
{
    sensor_msgs::Imu::Ptr msg(new sensor_msgs::Imu(*msg_in));
    mtx_buffer.lock();
    imu_buffer.push_back(msg);
    mtx_buffer.unlock();
    sig_buffer.notify_all();  // 通知所有等待的线程
}

int main() {
	// 捕获信号
    signal(SIGINT, SigHandle);
    ros::Rate rate(100); 
    bool status = ros::ok();
    while (status)  
    {
        if (flg_exit) 
            break;
        ros::spinOnce(); 
        
        ...主程序
		
		status = ros::ok();
        rate.sleep();
    }
}

读写锁(C++14)

读写锁适用于能明确区分读操作和写操作的场景。
原理(读者-写者问题):
写锁是独占锁,因为任何时刻只能有一个线程持有写锁,类似互斥锁和自旋锁;
读锁是共享锁,因为读锁可以被多个线程同时持有。

公平读写锁:比较简单的一种方式是,用队列把获取锁的线程排队,不管是写线程还是读线程都按照先进先出的原则加锁即可,这样读线程仍然可以并发,也不会出现「饥饿」的现象。

信号量(C++11)

定义于头文件
信号量 (semaphore) 是一种轻量的同步原件,用于制约对共享资源的并发访问( 控制线程的并发数量) 。在可以使用两者时,信号量能比条件变量更有效率。
① counting_semaphore 实现非负资源计数的信号量
② binary_semaphore 仅拥有二个状态的信号量

原子操作

原子操作:原子操作是指在并发编程中具有不可分割性的操作,它可以作为一个不可分割的单元执行,要么完全执行成功,要么完全不执行。在执行过程中不会被中断,也不会被其他线程干扰。

人话:不会被线程调度机制打断的操作。

比如经典的i++;就不是原子操作,会被线程的调度所打断。

原子操作的操作最接近机器的指令,这个和硬件相关了,虽然和硬件相关,但我们的C++还是整合了这一切,让原子操作有了共同的调用接口
目的:使用这个的目的说实话,就是让你更了解机器已及多线程同步的原理和秘密,当然有一些需求较简单的,使用原子操作可能比封装好的更有效率!!用了百遍的mutex可能你现在还不知道他们是怎么互斥的~当然内部还是通过了原子操作来的!

简单实例(没有任何用)

#include 
#include 
#include 

std::atomic<int> counter(0); // 原子计数器

void incrementCounter()
{
    counter.fetch_add(1); // 原子增加1
}

void decrementCounter()
{
    counter.fetch_sub(1); // 原子减少1
}

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

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

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

    return 0;
}

三、生产者消费者

C++11多线程实现生产者消费者

单生产者单消费者

多生产者多消费者

四、交叉打印

条件变量的应用,将while换为if不行。

#include 
#include 
#include 
#include 

using namespace std;

mutex mymutex;
condition_variable cv;
int flag = 0;

void printa(){
    unique_lock<mutex> lk(mymutex);
    for(int i = 0; i < 10; i++){
        while(flag != 0) cv.wait(lk);
        cout << "thread 1: a" << endl;
        flag = 1;
        cv.notify_all();
    }
    cout << "my thread 1 finish"<<endl;
}

void printb(){
    unique_lock<mutex> lk(mymutex);
    for(int i = 0; i < 10; i++){
        while(flag != 1) cv.wait(lk);
        cout << "thread 2: b" << endl;
        flag = 2;
        cv.notify_all();
    }
    cout << "my thread 2 finish" << endl;
}

void printc(){
    unique_lock<mutex> lk(mymutex);
    for(int i = 0; i < 10; i++){
        while(flag != 2) cv.wait(lk);
        cout << "thread 3: c" << endl;
        flag = 0;
        cv.notify_all();
    }
    cout << "my thread 3 finish" << endl;
}


int main(){
    thread th1(printa);
    thread th2(printb);
    thread th3(printc);

    th1.join();
    th2.join();
    th3.join();

    cout << "main thread " << endl;

    return 0;
}

你可能感兴趣的:(c++)