在这里插入图片描述

本篇摘要

本文围绕C++多线程编程展开,介绍thread库、this_thread命名空间、mutex相关类。涵盖线程创建、操作,线程ID获取,线程让步、休眠等功能,还阐述互斥锁特性、用途及不同类型锁的使用,通过代码示例展示其应用与效果。

一.thread库介绍

1. 文档资源

  • thread库有相关文档地址,分别是https://zh.cppreference.com/w/cpp/thread/threadhttps://legacy.cplusplus.com/reference/thread/thread/ ,可从中获取更多详细信息。

2. 特点

  • 跨平台:thread库底层对各个系统的线程库进行封装,比如Linux下的pthread库和Windows下Thread库等,因此具备跨平台特性。
  • 面向对象与融合特性:与Linux和Windows下提供的面向过程的线程库不同,C++11 thread库是面向对象的,并且融合了一些C++11语言特点,像右值引用的移动语义、可变模板参数等,使用起来更便捷。

3. 线程创建构造函数

  • 线程创建有4个构造函数,日常最常用的是第2个(如thread(func,a,b),它支持传入一个可调用对象和参数。相比pthread_create,其优势在于不局限于只传递函数指针,参数传递也更灵活;pthread_create调用时若要传递多个参数需打包成结构体并传结构体对象的指针
  • 可以用第1个和第4个构造函数配合来创建线程,能够把右值线程对象移动构造或者移动赋值给另一个线程对象(通过移动构造完成资源转移)。
  • 线程对象不支持拷贝(第3个构造函数体现这一特性)。

4. join操作

  • join用于在主线程结束前阻塞等待创建的从线程,因为若主线程结束,进程就结束了,若不进行join操作,从线程可能还在运行就会被强行终止(主进程一走完main,所有线程都挂了所以必须阻塞)

5. 线程id相关

  • class thread::id是thread的内部类,用于表示线程id,它支持比较大小、流插入和提取操作,还可以通过特化hash仿函数用于unordered_map和unordered_set的id等。
  • 从底层看,thread本质是封装各个平台的线程库接口,由于各平台的线程id表示类型不同,所以用一个类进行封装。线程对象可通过get_id获取线程id,在执行体内可通过this_thread::get_id()获取线程id。

这里记住几个常用的即可;其他可查文档:

在这里插入图片描述

下面演示下:

在这里插入图片描述

  • 上面测试了一个线程跑对应的lamda一个是普通函数;当然也可以传参也就是按照第二种thread的构造;打印的结果看起来比较乱,因为这两个线程并发进行的特点。

源码:

#include <iostream>
#include <thread>

// 1. 最简单的线程函数
void hello() {
    std::cout << "Hello from thread1!" << std::endl;
}

int main() {
    // 2. 创建线程(最常用构造函数)
    std::thread t1(hello);  // 传入函数
    std::thread t2([]() {
        std::cout << "Hello from lambda thread2!" << std::endl;
        });


    // 3. 必须等待线程结束
    std::cout << "进行线程1等待回收,id为:" << t1.get_id() << std::endl;
    t1.join();  // 主线程等待t1结束
    std::cout << "进行线程2等待回收,id为:" << t2.get_id() << std::endl;
    t2.join();// 主线程等待t2结束
    std::cout << "主线程id为:" << std::this_thread::get_id() << std::endl;
    std::cout << "Main thread ends." << std::endl;
    return 0;
}

二.this_thread介绍

  1. this_thread命名空间
  • 是一个命名空间,主要封装了线程相关的4个全局接口函数
  • 相关参考链接:https://legacy.cplusplus.com/reference/thread/this_thread/
  1. get_id函数
  • 用于获取当前执行线程的线程id。
  1. yield函数
  • 主动让出当前线程的执行权,让其他线程先执行。
  • 此函数的确切行为依赖于实现,特别是取决于使用中的OS调度器机制和系统状态。
  • 例如,在Linux的SCHED_FIFO先进先出实时调度器中,会挂起当前线程并将它放到准备运行的同优先级线程的队列尾,若无其他线程在同优先级,则yield无效果(多线程时候使用,即排队的时候,同时线程会保存上下文资源再去等待)。
  1. sleep_for函数
  • 阻塞当前线程执行,至少经过指定的sleep_duration。
  • 因为调度或资源争议延迟,此函数可能阻塞长于sleep_duration。
  • 需要包含头文件<chrono>
  1. sleep_until函数
  • 阻塞当前线程的执行,直至抵达指定的sleep_time。
  • 函数可能会因为调度或资源纠纷延迟而阻塞到sleep_time之后的某个时间点(现线程比较多或者上下文长等情况)。
  • 需要包含头文件<chrono>
  1. chrono相关类型(参考链接)
  • https://legacy.cplusplus.com/reference/chrono :是一个计时相关的类型。

  • https://legacy.cplusplus.com/reference/chrono/duration/ :是用来管理一个相对时间段的类。

  • https://legacy.cplusplus.com/reference/chrono/time_point/ :是用来管理一个绝对时间点的类。

演示下:

在这里插入图片描述

  • 这里虽然主线程yield;但是,无其他线程同优先级即在排队,其实是没效果的;对于sleep_for和sleep_until都是定时作用。

代码:


#include <iostream>
#include <thread>
#include<chrono>
using namespace std;
int main() {
    //获取当前线程ID
    cout << "主线程ID:" << this_thread::get_id() << endl;

    // 主线程让步cpu
    cout << "主线程yield一次" << endl;
    this_thread::yield();

    // 阻塞2秒
    cout << "主线程开始sleep 2秒..." << endl;
    this_thread::sleep_for(chrono::seconds(2));
    cout << "主线程sleep结束" << endl;

    // 3秒后醒来
    chrono::system_clock::time_point wake_time = chrono::system_clock::now() + chrono::seconds(3);
    cout << "主线程开始sleep到3秒后..." << endl;
    this_thread::sleep_until(wake_time);
    cout << "主线程已醒来" << endl;

    return 0;
}

对应的yield函数的另一个功能比如创建多个线程,让它们完成并发执行效果:

在这里插入图片描述

  • 比如这里就主线程创建多个线程;让每个线程都有公平的机会走这个while(在主线程没有true标记之前);知道标记后开始并发执行打印的操作。

了解下对应chrono里面简单用法:

对于sleep_for里面休眠常用的时间:

在这里插入图片描述

对应里面system_clock下常用的:

在这里插入图片描述

  • 这里可以看出对于chrono或者system_clock的使用返回类型都是time_point类型。

如duration:

在这里插入图片描述

  • 比如对应使用:duration<int,std::ratio<60*60*24> > one_day (1);此时oneday就是system_clock::time_point类型的一天时间。
  • 这里很多不常用,随时可以查看文档。

三.mutex介绍

1. mutex 基本概念与功能

  • 定义mutex 是封装的互斥锁类,用于保护临界区共享数据。
  • 核心接口:主要提供 lockunlock 两个接口函数,实现对共享资源的加锁与解锁操作。
  • 头文件包含#include <mutex>

2. mutex 的排他性非递归所有权语义

  • 所有权获取:调用方线程从成功调用 locktry_lock (直接返回bool值)开始,到调用 unlock 为止,占有该 mutex
  • 其他线程行为:当某线程占有 mutex 时,其他线程若试图获取其所有权,调用 lock 会阻塞,调用 try_lock 则返回 false

3. mutex 相关未定义行为

  • mutex 在仍被任何线程占有的时候被销毁,或者在占有 mutex 期间线程终止,这些情况下的行为是未定义的(程序可能出现不可预期结果)。

4. timed_mutex 特点

  • 相似性:和 mutex 完全类似。
  • 额外接口:额外提供 try_lock_fortry_lock_until 接口。这两个接口和 try_lock 类似,但不会马上返回,而是直接进入阻塞状态,直到设定的时间条件满足或者 mutex 被解锁,才会唤醒去尝试获取锁资源(能拿到锁就返回true;如果拿不到就去休眠对应时间再次查看如果拿到了就true否则就false。)。

5. recursive_mutex 特点

  • 相似性:和 mutex 完全类似。
  • 递归所有权语义
    • 所有权获取与时期:调用方线程从成功调用 locktry_lock 开始占有 recursive_mutex,在这个时期内,线程可以多次调用 locktry_lock(即支持“递归”加锁,比如自己识别对应的tid判断是自己锁的然后就能解锁再加锁;从而防止死锁发生)。所有权时期在线程进行与加锁次数匹配的 unlock 调用时结束。
    • 其他线程行为:当某线程占有 recursive_mutex 时,其他线程若试图获取其所有权,调用 lock 会阻塞,调用 try_lock 则返回 false

为什么多线程需要mutex?

  • 对于单核cpu的时候,比如多线程在进行加法操作;从内存拷贝到对应寄存器的时候线程的时间片到了;因此会保存上下文;那么此时下一个线程来执行加法;然后拷贝会内存;之后第一个线程再次执行完拷贝回去;相当于只加了一次,不符合预期。

  • 对于多核cpu的时候,还是加法操作;此时存在拷贝内存数据进寄存器操作然后再拷贝回去的步骤;因此可能并发执行的时候预期结果会不同。

下面简单演示下:

简单多线程加锁场景:

在这里插入图片描述

  • 这里解锁后多线程正常进行,结果符合预期。

在这里插入图片描述

  • 如果不加锁每次结果可能不同;原因就是上面解释的那些。

对应代码:

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

int counter = 0;                
std::mutex mtx;                 

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

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

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

    std::cout << "最终计数: " << counter << std::endl;  
    return 0;
}

注:

  • 这里尽量保证锁锁住的临界资源更加全一些;即保证lock和unlock之间都是临界资源(比如整个for循环都是临界资源;因此建议加解锁在for外面而不是里面)。
  • 虽然内外都可以加锁且结果都是正常;但是明显外部效率要高于内部(因为线程得不到锁就需要进行休眠等待;会保存上下文等信息;如果上下文过大也是需要消耗时间的;因此时间不仅要算在等待锁还要算上上下文保存时间)。
  • 这里可以用for循环简单测试下,这里就不演示了。

recusive_mutex锁测试:

在这里插入图片描述

  • 这里模拟了递归锁可重入的效果;发现这个锁是可以重入的;也就是自己走递归锁是没问题的。

在这里插入图片描述

  • 但是如果是普通锁就会直接发生死锁问题崩溃。

对应代码:

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

std::recursive_mutex rmtx;
int data1 = 0;

void recursiveFunc() {
    rmtx.lock();                // 第一次加锁
    data1 += 5;

    rmtx.lock();                // 同一线程可再次加锁(不会死锁)
    data1 += 5;
    rmtx.unlock();

    rmtx.unlock();              // 需解锁两次
}

int main() {
    std::thread t(recursiveFunc);
    t.join();
    std::cout << "data = " << data1 << std::endl;  // 输出 10
    retu==rn 0;
}

timed_mutex测试:

测试思路:这里搞一个主线程和两个普通线程;然后主线程占用锁一段时间;第一个拿锁拿不到就去休眠之后发现还是拿不到,然后第二个线程然后直接拿到锁不再休眠。

在这里插入图片描述

  • 这里可以看到主线程占用锁3秒;然后第一个线程去获取,拿不到去休眠1秒再获取还是拿不到直接false;第二个线程直接发现能拿到锁直接返回true不再进行休眠。

测试代码:

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

std::timed_mutex tmtx;  // timed_mutex 对象

// 线程1:尝试在 1 秒内获取锁(会失败)
void thread_try_short() {
    std::cout << "[Thread1] 尝试获取锁(等待 1 秒)..." << std::endl;
    if (tmtx.try_lock_for(std::chrono::seconds(1))) {
        std::cout << "[Thread1] 成功获取锁!" << std::endl;
        tmtx.unlock();
    }
    else {
        std::cout << "[Thread1] 获取锁失败(超时 1 秒)" << std::endl;
    }
}

// 线程2:尝试在 5 秒内获取锁(会成功)
void thread_try_long() {
    std::cout << "[Thread2] 尝试获取锁(等待 5 秒)..." << std::endl;
    if (tmtx.try_lock_for(std::chrono::seconds(5))) {
        std::cout << "[Thread2] 成功获取锁!" << std::endl;
        tmtx.unlock();
    }
    else {
        std::cout << "[Thread2] 获取锁失败(超时 5 秒)" << std::endl;
    }
}

int main() {
    // 线程1:尝试 1 秒获取锁(主线程还没释放,会失败)
    std::thread t1(thread_try_short);

    // 主线程持有锁 3 秒
    std::cout << "[Main] 主线程获取锁,持有 3 秒..." << std::endl;
    tmtx.lock();
    std::this_thread::sleep_for(std::chrono::seconds(3));
    std::cout << "[Main] 主线程释放锁" << std::endl;
    tmtx.unlock();

    t1.join();  // 等待线程1结束

    // 线程2:尝试 5 秒获取锁(主线程已释放,会成功)
    std::thread t2(thread_try_long);
    t2.join();

    return 0;
}

然后就是 try_lock_until;这里其实和对应的上面的差不多;而上面的那个是相对时间这里是绝对时间而已。(auto timeout_time = now + std::chrono::seconds(2);结合now完成绝对时间设置然后传给它),这里就不演示了。

关于给线程跑的函数传引用需要ref修饰传递而不是直接函数引用的解释

C++ 的 ​​std::thread构造函数在打包参数时,对所有参数都是按值传递(拷贝)的,除非显式地告诉它“这个是引用”(通过 std::ref或 std::cref)​​。

首先先看下直接引用的效果:

在这里插入图片描述

  • 这里发现直接编译的时候直接报错。

在这里插入图片描述

  • 这里看到不仅需要函数参数引用;还需要传给线程的时候(也就是打包的时候)ref一下。

对应代码:

void foo(int &x) {
    x = 100;
}

int main() {
    int a = 10;
    std::thread t(foo,ref( a)); 
    t.join();
    std::cout << a << std::endl;  
}

大致解释下:

  • 因为c++11的thread是封装的原生线程库的;因此在进行构造的时候比如thread t1 (func ,a,b)这里其实是把对应的函数指针及参数打包起来(默认拷贝打包);然后传给原生线程构造的那个函数;最后有个所有线程都跑的那个模版函数比如(_Invoker_proc);会在这里面把对应的包给解引用开;进行我们传递的函数的调用。
  • 因此说我们函数里写的引用默认是对打包后对应参数的引用(即拷贝实参的参数);故最后实参并未改变。
  • 当加了ref或者cref传递进去;就是告诉底层打包的时候起别名(引用)对应实参,因此包里面的参数就是引用效果;到最后解包的时候也就是引用的我们之前传进来的实参了(类似于b引用a;c又引用b;即c引用a)。

上面测试的是函数必须ref,如果是lamda传递给线程会咋样?

在这里插入图片描述

  • 发现这里无需ref直接内部lamda引用捕捉即可改变全局变量;然后就是如果直接传值捕捉的话是不允许修改的。

对应代码:

#include <iostream>
#include <thread>

int main() {
    int value = 42;

    // 使用 lambda,并通过 [&value] 捕获 value 的引用
    std::thread t([&value]() {
        value = 100;  // 直接修改外部的 value
        std::cout << "Lambda 内部: value = " << value << std::endl;
        });

    t.join();

    std::cout << "主线程中: value = " << value << std::endl; 
    return 0;
}

大致解释下:

  • 对应传lamda其实就是传递可调用对象;也就是打包的时候虽然会拷贝;但是拷贝的是这个对象(这个lamda),然后在进行解包的时候也就是执行这个可调用对象即lamda,此时引用还是引用全局的(线程共享全局资源);一句话就是拷贝可调用对象不影响内部引用操作,但是拷贝了函数参数就会影响。
  • 因此可以给线程传递普通函数(需要ref/cref表示引用+函数参数内部引用);后者lamda只需要引用捕捉。

补充一下:

可以基于线程的移动构造函数完成通过数组来创建并保存多线程对象。

如下:

int main()
{
    std::thread threads[2];
    // 利⽤移动赋值的⽅式,将创建的临时对象(右值对象)移动赋值给创建好的空线程对象
    for (int i = 0; i < 2; ++i)
        threads[i] = std::thread(fireworks, i);
    for (auto& th : threads)
        th.join();
    return 0;
}

还有就是recursive_mutextimed_mutex的结合体的类型锁:

在这里插入图片描述

  • 这里可以理解成对应的recursive_mutex与timed_mutex这两种锁的接口融合一起的类;即满足递归重入加锁特性以及可以调用上面的try_lock_for,try_lock_until等函数接口。

四.本篇小结

thread库提供跨平台线程创建与管理功能,this_thread用于线程相关操作,mutex类保障共享数据安全。不同类型锁各有特点,合理运用可使多线程程序高效、稳定运行,避免数据竞争和死锁等问题。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐