首页 > 编程语言 >C++多线程详解 | 线程创建 | 互斥锁 | 条件变量 | 线程池

C++多线程详解 | 线程创建 | 互斥锁 | 条件变量 | 线程池

时间:2024-08-17 14:23:18浏览次数:13  
标签:std include lock 互斥 加锁 线程 多线程

目录

前言

1.线程创建

2.互斥锁

3.lock_guard与std::unique_lock

4.condition_variable

 5.线程池

前言

在说线程之前,先说说进程和线程的关系,以及什么是多线程(为了方便理解就用大白话来说)

进程:进程就是运行中的程序,比如说一个微信的程序,你双击它,它运行起来了就是一个进程,在还没有运行之间,就是要一个可执行的程序文件exe。

线程:线程可以理解为进程中的进程,就有人把进程比喻为一台火车,而线程就是每一个车厢。其实说的就是一个进程里可以有多个线程。

那我们为什么要使用多线程呢

 在回答这个问题之前,我们先假设如果没有多线程,只有单线程的话,整个任务的运行就是串行的,只能依次的执行,这也就是我们所说的多进程并发,这种模式的缺点就是使用复杂,系统开销大,所以就引入了多线程。

多线程就是在同一个进程中执行多个线程,也称为多线程并发,也就是任务并行,线程是轻量级的进程,每个线程可以独立的运行不同的指令序列,同一进程中的多个线程共享相同的地址空间,可以访问进程中的大部分数据,指针和引用可以在线程间进行传递。这样,同一进程内的多个线程能够很方便的进行数据共享以及通信,也就比进程更适用于并发操作。但是缺少操作系统提供的保护机制,所以在多线程共享数据及通信时,就需要程序员做更多的工作以保证对共享数据段的操作是以预想的操作顺序进行的,并且要极力的避免死锁(deadlock)。

1.线程创建

要创建线程,我们需要一个可调用的函数或函数对象,作为线程的入口点。在C++11中,我们可以使用函数指针、函数对象或lambda表达式来实现。创建线程的基本语法如下: 

#include <thread>
std::thread t(function_name, args...);    //创建语法
  • function_name是线程入口点的函数或可调用对象
  • args...是传递给函数的参数

创建线程后,我们可以

  • 使用t.join()等待线程完成(为了避免主线程运行结束 但是子线程还行运行完的情况),或者
  • 使用t.detach()分离线程,让它在后台运行(也就是主线程结束了 但是子线程让他继续运行),注意一旦线程被分离,就不能再使用`t.join()`方法等待它完成。而且,我们需要确保线程不会在主线程结束前退出,否则可能会导致未定义行为。
  • 使用joinable()方法判断能否使用join或detach,该方法返回一个布尔值,如果线程可以被join()或detach(),则返回true,否则返回false。如果我们试图对一个不可加入的线程调用join()或detach(),则会抛出一个std::system_error异常。
#include<iostream>
#include<thread>
#include<string>

void printHelloWorld(std::string msg )
{
    std::cout << msg << std::endl;
}

int  main()
{
    //创建线程
    std::thread thread1(printHelloWorld,"hello world!");

    thread1.join(); //主程序等待所有线程执行结束

    thread1.detach(); //一般都不会用这个
    
    bool isJoin = thread1.joinable(); //判断这个线程可不可以用join或detach, 他返回的是bool值
    
    if(isJoin)
    {
        thread1.join(); 
    }

    return 0;
}

2.互斥锁

前面就提到 多线程虽然轻量 方便 但是没有系统提供保护机制,需要我们程序员来掌控他的安全,那么如何来保证他的安全呢,下面详细说明:

在多个线程中共享数据时,需要注意线程安全问题。如果多个线程同时访问同一个变量,并且其中至少有一个线程对该变量进行了写操作,那么就会出现数据竞争问题。数据竞争可能会导致程序崩溃、产生未定义的结果,或者得到错误的结果。

为了避免数据竞争问题,需要使用同步机制来确保多个线程之间对共享数据的访问是安全的。常见的同步机制包括互斥量、条件变量、原子操作等。

 简单理解就是在多线程运行时,当一个线程要访问一个变量的时候,就采用一个机制手段不让其他的线程访问,这样就不会产生数据竞争的问题。这个手段就是所谓的互斥量(mutex) 

互斥量提供了两个基本操作:lock() 和 unlock()。当一个线程调用 lock() 函数时,如果互斥量当前没有被其他线程占用,则该线程获得该互斥量的所有权,可以对共享资源进行访问。如果互斥量当前已经被其他线程占用,则调用 lock() 函数的线程会被阻塞,直到该互斥量被释放为止

 #include<mutex>

std::mutex mtx;        //定义互斥锁的语法

mtx.lock();        //加锁

mtx.unlock();        //解锁

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

int shared_data = 0;
std::mutex mtx;     //定义一个互斥锁

void func(int n) 
{
    for (int i = 0; i < 100000; ++i) 
    {
        mtx.lock();    //锁住,单个线程访问的时候,其他线程不能进行访问

        shared_data++;

        std::cout << "thread" << n << "incremnet sharad_data" << sharad_data << std::endl;
       
        mxt.ubnlock();    //解锁,访问结束进行解锁,这样其他的线程进行访问
    }
}

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

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

    std::cout << " Final shared_data = " << shared_data << std::endl; 
   
    return 0;
}

 上面的代码中,定义了一个名为 shared_data 的全局变量,并使用互斥量 mtx 来确保多个线程对其进行访问时的线程安全。在两个线程中,分别调用了 func 函数,并传递了不同的参数。在 func 函数中,先获取互斥量的所有权,然后对 shared_data 变量进行累加操作,并输出变量的当前值。最后再释放互斥量的所有权。

3.lock_guard与std::unique_lock

 上述的互斥锁他们都是成对存在的,有lock加锁必须就有unlock解锁。需要手动 加锁和解锁

 而 lock_guard无需手工解锁,当我们进行初始化的时候,他就会自动加锁,运行结束后 ,也会自动解锁, lock_guard与std::unique_lock都是C++标准库提供的互斥量封装类,在类里面的构造函数写好了加锁,析构函数里写好了解锁,只需要调用就好了。

  • 当构造函数被调用时,该互斥量会被自动锁定。

  • 当析构函数被调用时,该互斥量会被自动解锁。

  • std::lock_guard 对象不能复制或移动,因此它只能在局部作用域中使用

 代码演示:

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

int shared_data = 0;
std::mutex mtx;     //定义一个互斥锁

void func(int n) 
{
    for (int i = 0; i < 100000; ++i) 
    {
        //mtx.lock();   不在需要手动的加锁

        std::lock_guard<std::mutex> lg(mtx);    //使用 lock_guard 定义一个互斥锁
        shared_data++;

        std::cout << "thread" << n << "incremnet sharad_data" << sharad_data << std::endl;
       
        //mxt.ubnlock();    不需要手动解锁
    }
}

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

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

    std::cout << " Final shared_data = " << shared_data << std::endl; 
   
    return 0;
}

 std::unique_lock除了能够自动加锁解锁以外,它的主要特点是可以对互斥量进行更加灵活的管理,包括延迟加锁、条件变量、超时等

 std::unique_lock提供以下几个成员函数:

  • lock():尝试对互斥量进行加锁操作,如果当前互斥量已经被其他线程持有,则当前线程会被阻塞,直到互斥量被成功加锁。

  • try_lock():尝试对互斥量进行加锁操作,如果当前互斥量已经被其他线程持有,则函数立即返回 false,否则返回 true

  • try_lock_for(const std::chrono::duration<Rep, Period>& rel_time):尝试对互斥量进行加锁操作,如果当前互斥量已经被其他线程持有,则当前线程会被阻塞,直到互斥量被成功加锁,或者超过了指定的时间。

  • try_lock_until(const std::chrono::time_point<Clock, Duration>& abs_time):尝试对互斥量进行加锁操作,如果当前互斥量已经被其他线程持有,则当前线程会被阻塞,直到互斥量被成功加锁,或者超过了指定的时间点。

  • unlock():对互斥量进行解锁操作。

 除了上述成员函数外,std::unique_lock 还提供了以下几个构造函数:

  • unique_lock() noexcept = default:默认构造函数,创建一个未关联任何互斥量的 std::unique_lock 对象。

  • explicit unique_lock(mutex_type& m):构造函数,使用给定的互斥量 m 进行初始化,并对该互斥量进行加锁操作。

  • unique_lock(mutex_type& m, defer_lock_t) noexcept:构造函数,使用给定的互斥量 m 进行初始化,但不对该互斥量进行加锁操作。

  • unique_lock(mutex_type& m, try_to_lock_t) noexcept:构造函数,使用给定的互斥量 m 进行初始化,并尝试对该互斥量进行加锁操作。如果加锁失败,则创建的 std::unique_lock 对象不与任何互斥量关联。

  • unique_lock(mutex_type& m, adopt_lock_t) noexcept:构造函数,使用给定的互斥量 m 进行初始化,并假设该互斥量已经被当前线程成功加锁

 代码演示:

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

int shared_data = 0;
std::mutex mtx;     //定义一个互斥锁

void func(int n) 
{
    for (int i = 0; i < 100000; ++i) 
    {
        //mtx.lock();   不在需要手动的加锁

        std::unique_lock<std::mutex> lg(mtx);    //使用 unique_lock 定义一个互斥锁

        shared_data++;

        std::cout << "thread" << n << "incremnet sharad_data" << sharad_data << std::endl;
       
        //mxt.ubnlock();    不需要手动解锁
    }
}

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

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

    std::cout << " Final shared_data = " << shared_data << std::endl; 
   
    return 0;
}

4.condition_variable

 在说这个条件变量之前,先说明一下与之相关联的的生产者与消费者模型,

生产者消费者模式可以理解为在生产者和消费者之间添加一个缓冲区,生产者只负责向缓冲区添加元素,而消费者只负责从缓冲区提取元素并使用。如果没有任务了消费者就进行等待,等到生产者通知。如下图所示

 

这么做可以对生产者与消费者进行解耦,这样一来消费者不直接调用生产者,使得生产者的不会因为生产者的具体处理而阻塞,充分利用资源。 

​​#include <iostream>
#include <thread>
#include <mutex>
#include <condition_vairable>
#include <queue >

std::queue<int> g_queue;    //创建一个队列
std::condition_vairable g_cv;    //创建一个条件变量
std::mutex mtx;     //定义一个互斥锁

//构建一个生产者 往队列里面加任务
void Producer()    
{
    
    for (int i = 0; i < 10; ++i) 
    {
       std::unique_lock<std::mutex> lock(mtx);    //使用 unique_lock 定义一个互斥锁

       // 生产者往队列里加任务,通知消费者来取任务
       g_cv.notify_one();
       g_queue.push(i);
        
    }
}

//构建一个消费者 在队列里面取任务
void Consumer()    
{
    
    while(1)
    {
        std::unique_lock<std::mutex> lock(mtx);    //使用 unique_lock 定义一个互斥锁

        //如果队列为空消费者就要等待,等待生产者下发任务,此时就用到条件变量
        g_cv.wait(lock,[] () {return !g_queue.empty();});    //如果为空 就阻塞 直到生产者加任务 通知了    

        int value = g_queue.front(); //取任务
        g_queue.pop();    //取完就pop
    }
}

int main() 
{
    std::thread t1(Produer);
    std::thread t2(Consumer);

    t1.join();
    t2.join();
   
    return 0;
}

使用 std::condition_variable可以实现线程的等待和通知机制,从而在多线程环境中实现同步操作。在生产者-消费者模型中,使用 std::condition_variable可以让消费者线程等待生产者线程生产数据后再进行消费,避免了数据丢失或者数据不一致的问题。

 5.线程池

一种线程的使用模式,线程过多会带来调度开销,进而影响缓存局部性和整体性。而线程池维护着多个线程,等待监督管理者分配可并行执行的任务。这样避免了在短时间内创建和销毁线程的代价。线程池不仅能够内核的充分利用,还能防止过分调度。

线程池由四部分组成;

  • 线程池管理器:创建一定数量的线程,启动线程,调配任务,管理着线程池。线程池目前只需要Start()启动方法,Stop()方法,AddTask()方法。Start():创建一定数量的线程,进入线程循环。Stop():停止线程循环,回收所有的线程。AddTask():添加任务。
  • 工作者线程:线程池中线程,在线程池中等待并执行任务。该文使用条件变量condition_variable实现等待和唤醒进制。
  • 任务接口:添加任务接口,以供工作线程的调度任务和执行。
  • 任务队列:用于存放没有处理的任务,提供一种缓存机制 

 下面用C++11构造一个线程池

#include <iostream>
#include <thread>
#include <mutex>
#include <queue>
#include <condition_vairable>
#include <string>
#include <vector>
#include <function>

class ThreadPool
{
    //构造函数来初始化线程池
    ThreadPool(int numThread):stop(false)
    {
        for(int i = o, i < numThread; i++)     //往线程里面加线程
        {
            threads.emplace([this]
            {
                while(1)
                {
                    std::unique_lock<std::mutex> lock(mtx);

                    condition.wait(lock, (this) {return !tasks.empty() || stop})

                    if(stop && tasks.empty())
                    {
                        return;
                    }
                    std::function<void()> task(std::move(tasks.front()))
                    tasks.pop();
                    lock.unlock();
                    task();
                 }
                 
             })   //使用这个函数的好处是可以节省资源
        }
    }

    //构造一个函数加任务,因为加的任务是不确定的 所以用模板
    template <class F,class...Arga>
    //&&是右值引用。在函数模板里 就是万能引用, & 是左值引用
    void enqueue(F && f, Args&&...args)   
    {
        //取任务
        std::function<void()>task = 
            std::bind(std::foreard<F>(f),std::forward<Args>((args)...);
        {
            std::unique_lock<std::mutex> mtx;

            //放任务
            tasks.empalce(std::move(ask))
        }
        //用条件变量进行通知
        condition.notify_one(); 
    }

    //析构函数
    ~ThreadPool()
    {
          std::unique_lock<std::mutex> mtx;
          stop = true;
          condition.notify_all();
          for(auto& t: threads)
          {
               t.join();
          }
    }
    
    private:
        std::vector<std::thread> threads;    //创建一个线程数组
        std::queue <std:: function<void()>> tasks;    // 创建一个任务队列
        std::mutex mtx;

        std::condition_vairable condition;

        bool stop;    //    线程池什么时候终止
}

int main()
{
    ThreadPool(5); //这个线程池维护5个线程
    for(int i = 0;i < 10;i++)
    {
        //加任务
        pool.enqueue([i]{std::cout << "task" << i << std::endl;
        std::this_tr=hread::sleep_for(std::chrono::seconds(1));    //暂停1秒,
        std::cout << "task :" << i << atd::endl;
        });
    }
}

 

 参考视频:​​​​​​​1.线程库的基本使用_哔哩哔哩_bilibili

标签:std,include,lock,互斥,加锁,线程,多线程
From: https://blog.csdn.net/weixin_45754224/article/details/141139153

相关文章

  • 面试题:在Java中,线程之间的通信主要通过哪几种方式实现?并简述其中一种方式的基本工作原
    面试题:在Java中,线程之间的通信主要通过哪几种方式实现?并简述其中一种方式的基本工作原理。请注意,除了直接回答此问题外,我们还为您准备了更多深入的学习资源和面试技巧。想要了解更多关于Java线程通信、优化简历、模拟面试、企业项目源码、大厂高并发面试题、项目场景题、算法......
  • 面试题:在Java中,多线程编程是常见的并发处理方式。请简述Java中实现多线程的几种主要方
    面试题:在Java中,多线程编程是常见的并发处理方式。请简述Java中实现多线程的几种主要方式,并解释每种方式的基本思想。更多关于多线程编程的深入解析、面试技巧、以及实战项目源码,手机浏览器即可访问面霸宝典【全拼音】.com,这里不仅可以优化你的简历,还能进行模拟面试,获取最新最......
  • 面试题:在Java中,volatile 关键字的作用是什么?它与 synchronized 关键字在实现线程同步
    面试题:在Java中,volatile 关键字的作用是什么?它与 synchronized 关键字在实现线程同步方面有何不同?请深入探讨其背后的原理和应用场景。更多答案在这里,手机或电脑浏览器就可以打开, 面霸宝典【全 拼音】.com 这里可以优化简历,模拟面试,企业项目源码,最新最全大厂高并......
  • C++编程:内存栅栏(Memory Barrier)详解及在多线程编程中的应用
    文章目录0.引言1.什么是内存栅栏?2.为什么需要内存栅栏?本质原因是什么?2.1编译器优化2.2CPU乱序执行3.ARM64和x86架构下的内存栅栏差异3.1x86架构3.2ARM64架构4.代码示例4.1代码解析4.2memory_order_release和memory_order_acquire解释4.3为什么是“releas......
  • 线程间的顺序执行(信息量)进程间的通信
    信号量是一种用于进程间或线程间同步和互斥的机制。它的核心机制基于计数和操作,用来管理对共享资源的访问。信号量的基本机制1.**信号量的定义**:  -信号量是一个用于控制对共享资源访问的整数计数器。它能够记录可用资源的数量或进程/线程的等待状态。2.**操作**: ......
  • Linux线程实用场景
    文章目录前言生产者消费者模型1.基于阻塞队列特点实现使用2.基于环形队列和信号量实现使用读者写者模型实现思想线程池实现前言    生产者消费者模型和读者写者模型这些模型是用于在线程间协调和管理资源访问的模式,我们在之前已经理解了线程的概念以及同......
  • 【JAVA】深入理解守护线程与非守护线程:概念、应用及示例
    文章目录介绍1.线程的基础知识2.守护线程与非守护线程2.1什么是守护线程?特点:2.2什么是非守护线程?特点:3.为什么需要守护线程?示例:后台任务处理示例:日志记录4.非守护线程的应用场景示例:数据库连接处理5.守护线程与非守护线程的对比6.总结更多相关内容可查......
  • 进程与线程
    进程和线程的区别1.定义进程(Process):是操作系统中资源分配的基本单位。每个进程有自己的独立内存空间、文件描述符、程序计数器等资源。进程之间是相互独立的。线程(Thread):是操作系统调度的基本单位,一个进程可以包含多个线程,线程共享进程的内存空间和其他资源,但每个线程有自己......
  • 线程第二部分
    一、线程退出1.线程结束方式:    1.pthread_exit       2.在线程执行函数中return  (此时与1式相等)    3.pthread_cancel:    4.任何一个线程调用了exit或者主线程main函数return都会使进程结束2.pthread_cancel:intpthrea......
  • Java创建线程的方式
    1.继承Thread类第一步,创建一个线程类并继承Thread类第二步,重写run()方法,内部自定义线程执行体第三步,创建自定义的线程类对象,调用start()方法,启动线程示例代码如下publicclassMyThread1extendsThread{@Overridepublicvoidrun(){for(inti=0;i<......