首页 > 编程语言 >C++多线程原理详解

C++多线程原理详解

时间:2024-06-01 22:32:52浏览次数:29  
标签:mtx 多线程 lock C++ flag 详解 线程 unique wait

学习 C++ 多线程时,我有如下疑问:

  1. mutex 的 lock 和 unlock 做了什么?
  2. mutex、lock_guard、unique_lock,它们之间的关系是什么?
  3. condition_variable 中的 wait 做了什么?

带着这些疑问,我查阅了一些资料,整理出本文。

文章目录

一、mutex

看一个经典的代码:

#include <iostream>
#include <thread>
using namespace std;

int n = 0;

void test() {
    for (int i = 1; i <= 100000; i++) {
        n++;
    }
}

int main() {
    thread t1(test);
    thread t2(test);
    t1.join();
    t2.join();
    cout << n << endl;
    return 0;
}

上面的代码创建了 2 个线程,每个线程使 n 自增 100000 次,但是输出的 n 往往不会达到 200000,从小林coding的这篇文章解释了背后的原理,即 n++ (或者 n = n+1)这种操作,包含从内存取值放入寄存器、对寄存器中的值加1、将寄存器中的值放回内存三个步,比如当 n 的值是0 时,如果恰好 t1、t2 都取出 0 放入寄存器,然后 0+1 变成 1,再写回内存,此时 n 变成了 1 而不是期望的 2。

于是引入互斥量 mutex,n++ 计算获取锁,计算完成后释放锁:

#include <iostream>
#include <thread>
#include <mutex>
using namespace std;

int n = 0;
mutex mtx;  // 互斥量

void test() {
    for (int i = 1; i <= 100000; i++) {
        mtx.lock();
        n++;
        mtx.unlock();
    }
}

int main() {
    thread t1(test);
    thread t2(test);
    t1.join();
    t2.join();
    cout << n << endl;
    return 0;
}

lock() 操作包含以下步骤:

  1. 检查锁的状态:如果锁是空闲的(未被其他线程持有),线程将锁状态设置为已锁定,并且允许线程进入临界区。
  2. 等待:如果锁已经被其他线程持有,当前线程将进入等待状态,通常会被阻塞直到锁变为可用。

unlock() 操作包含以下步骤:

  1. 释放锁:线程将锁的状态设置为空闲(未被持有)。
  2. 唤醒等待线程:如果有其他线程在等待这个锁,操作系统将从等待队列中选择一个线程,并唤醒它以便重新尝试获取锁。

二、lock_guard

lock_guard 封装了 mutex 的 lock 和 unlock,其好处是提供一种 RAll 机制,创建对象时,尝试获取锁,离开作用域时释放锁,不需要再手动 unlock,解决了忘记 unlock 或者因为其他原因(例如unlock前提前返回)造成的没有执行unlock而造成的死锁,于是前面的代码可以写成这样:

#include <iostream>
#include <thread>
#include <mutex>
using namespace std;

int n = 0;
mutex mtx;

void test() {
    for (int i = 1; i <= 100000; i++) {
        lock_guard<mutex> lock(mtx);
        n++;
    }
}

int main() {
    thread t1(test);
    thread t2(test);
    t1.join();
    t2.join();
    cout << n << endl;
    return 0;
}

lock_guard<mutex> lock(mtx) 包含以下步骤:

  1. 对象构造:当 lock_guard<std::mutex> lock(mtx) 被创建时,构造函数会立即尝试锁定传递给它的互斥锁 mtx
  2. 锁定操作lock_guard 在其构造函数中调用 std::mutexlock 方法。如果互斥锁 mtx 当前没有被其他线程持有,它会被锁定,当前线程获得对该互斥锁的所有权。如果 mtx 已经被其他线程持有,当前线程将被阻塞,直到该互斥锁可用。
  3. 作用域管理lock_guard 对象的生命周期管理着互斥锁的持有时间。当 lock_guard 对象超出作用域(即不再需要时),它的析构函数会被调用。
  4. 解锁操作: 在 lock_guard 的析构函数中,会自动调用传递给它的互斥锁 mtxunlock 方法,释放该互斥锁。这确保了即使在函数内发生异常,互斥锁也会被正确释放。

三、unique_lock

unique_lock 可以完全替代 lock_guard,例如上面的代码可以直接改写为下面这样:

#include <iostream>
#include <thread>
#include <mutex>
using namespace std;

int n = 0;
mutex mtx;

void test() {
    for (int i = 1; i <= 100000; i++) {
        unique_lock<mutex> lock(mtx);
        n++;
    }
}

int main() {
    thread t1(test);
    thread t2(test);
    t1.join();
    t2.join();
    cout << n << endl;
    return 0;
}

那么 unqiue_lock 和 lock_guard 的区别是什么呢?

  1. lock_guard 是一种简单、轻量的锁管理器,提供基本的锁定和解锁功能。
  2. unqiue_lock 可以看作是 lock_guard 的增强版,除了 lock_guard 已有的功能外,还提供了更多的功能,推荐阅读这篇文章
  3. 在使用的选择上,lock_guard 足够简单,如果不需要其他功能,就用 lock_guard,如果 lock_guard 不能满足你的需求,再用 unqiue_lock!

四、condition_variable

当两个线程之间,需要进行同步,则可以使用条件变量 condition_variable。

小林coding:所谓同步,就是并发进程/线程在一些关键点上可能需要互相等待与互通消息,这种相互制约的等待与互通信息称为进程/线程同步。

例如,我想让两个线程交替打印,一个打印奇数,一个打印偶数,也就是一个线程在打印的时候,另一个线程要等待,代码如下:

例1:两个线程交替打印,一个打印奇数,一个打印偶数

#include <iostream>
#include <thread>
#include <condition_variable>
using namespace std;

int n = 1;
mutex mtx;
condition_variable cond;
bool flag = true;

void printA() {
    for (int i = 0; i < 10; i++) {
        unique_lock<mutex> lock(mtx);
        cond.wait(lock, [&]{return flag;});
        cout << "n = " << n++ << ", " << 'A' << endl;
        flag = false;
        cond.notify_one();
    }
}

void printB() {
    for (int i = 0; i < 10; i++) {
        unique_lock<mutex> lock(mtx);
        cond.wait(lock, [&]{return !flag;});
        cout << "n = " << n++ << ", " << 'B' << endl;
        flag = true;
        cond.notify_one();
    }
}

int main() {
    thread t1(printA);
    thread t2(printB);
    t1.join();
    t2.join();
    return 0;
}

输出:

n = 1, A
n = 2, B
n = 3, A
n = 4, B
n = 5, A
n = 6, B
n = 7, A
n = 8, B
n = 9, A
n = 10, B
n = 11, A
n = 12, B
n = 13, A
n = 14, B
n = 15, A
n = 16, B
n = 17, A
n = 18, B
n = 19, A
n = 20, B

重点是这两行代码:

unique_lock<mutex> lock(mtx);
cond.wait(lock, [&]{return flag;});

在解释上面的完整代码之前,先了解一下这两行代码背后的原理。

unique_lock<mutex> lock(mtx); :尝试获取互斥锁,如果失败(互斥锁已经被其他线程持有)则阻塞当前线程,直到该互斥锁可用,这部分原理已在前面 lock_guard 的小节讲过,二者是一样的。

而 wait() 有两种重载,第一种是仅接收一个参数 unique_lock,其作用是:

  1. 阻塞当前线程,并释放互斥锁
  2. 当被 notify_one 或者 notify_all 唤醒时,wait 将重新尝试获取互斥锁,成功获取锁后,线程继续执行

但是直接使用会造成问题,比如下面代码:

#include <iostream>
#include <thread>
#include <condition_variable>
using namespace std;

int n = 1;
mutex mtx;
condition_variable cond;

void printA() {
    for (int i = 0; i < 10; i++) {
        unique_lock<mutex> lock(mtx);
        cond.wait(lock);
        cout << "n = " << n++ << ", " << 'A' << endl;
        cond.notify_one();
    }
}

void printB() {
    for (int i = 0; i < 10; i++) {
        unique_lock<mutex> lock(mtx);
        cond.wait(lock);
        cout << "n = " << n++ << ", " << 'B' << endl;
        cond.notify_one();
    }
}

int main() {
    thread t1(printA);
    thread t2(printB);
    t1.join();
    t2.join();
    return 0;
}

这个代码执行后,将一直阻塞下去,不会有任何输出。为什么呢,我们一步一步看:

  1. t1 线程,执行 printA,unique_lock<mutex> lock(mtx); 获取锁,然后 cond.wait(lock); 阻塞并释放锁
  2. t2 线程,执行 printB,unique_lock<mutex> lock(mtx); 获取锁,然后 cond.wait(lock); 阻塞并释放锁

结果是两个线程都被阻塞了,而且始终无法到达 cond.notify_one();,无法被唤醒,一直阻塞下去。

所以 cond.wait(lock); 常常要配合 while 和 一个 flag 标志来使用,例如:

#include <iostream>
#include <thread>
#include <condition_variable>
using namespace std;

int n = 1;
mutex mtx;
condition_variable cond;
bool flag = true;

void printA() {
    for (int i = 0; i < 10; i++) {
        unique_lock<mutex> lock(mtx);
        while (flag) {
            cond.wait(lock);
        }
        cout << "n = " << n++ << ", " << 'A' << endl;
        flag = true;
        cond.notify_one();
    }
}

void printB() {
    for (int i = 0; i < 10; i++) {
        unique_lock<mutex> lock(mtx);
        while (!flag) {
            cond.wait(lock);
        }
        cout << "n = " << n++ << ", " << 'B' << endl;
        flag = false;
        cond.notify_one();
    }
}

int main() {
    thread t1(printA);
    thread t2(printB);
    t1.join();
    t2.join();
    return 0;
}

输出:

n = 1, B
n = 2, A
n = 3, B
n = 4, A
n = 5, B
n = 6, A
n = 7, B
n = 8, A
n = 9, B
n = 10, A
n = 11, B
n = 12, A
n = 13, B
n = 14, A
n = 15, B
n = 16, A
n = 17, B
n = 18, A
n = 19, B
n = 20, A

我们一步一步分析:

  1. flag 初始值是 true
  2. t1 执行 printA,unique_lock 获取锁(同时t2获取锁失败,阻塞),进入 while (flag),执行 cond.wait(lock),阻塞线程,并释放锁
  3. t2 执行 printB,unique_lock 获取锁,不进入 while (!flag),打印 “n = 1, B”,将 flag 置为 false,唤醒阻塞中的一个线程,同时进行第二轮循环,unique_lock 获取锁,进入 while (!flag),执行 cond.wait(lock),阻塞线程,并释放锁
  4. t1 得到唤醒,因为 t2 已经释放锁,因此成功获取锁,同时 flag 已经为 false,跳出循环,打印 “n = 2, A”,将 flag 置为 true,唤醒阻塞中的一个线程,同时进行第二轮循环,unique_lock 获取锁,进入 while (flag),执行 cond.wait(lock),阻塞线程,并释放锁
  5. 重复3、4

第二种 wait 接收两个参数,第一个参数是 unique_lock;第二个参数是一个可调用的对象(lambda 表达式、函数指针、仿函数),被称为谓词(predicate),其背后运行的原理如下:

  • 如果阻塞中,则当接到唤醒时,wait 将尝试重新获取互斥锁
  • 如果持有锁,则检查谓词
  • 当谓词返回 true 时,wait 立即返回,线程继续运行;
  • 当谓词返回 false 时,阻塞当前线程,并释放互斥锁

可以看出第二种 wait 就是对前面的 while 进行了封装,所以二者基本是等价的:

cond.wait(lock, [&]{return flag;});

// 上面的代码等价于下面这段代码
while (!flag) {  // 注意这里的 "!"
	cond.wait(lock);
}

但是,需要注意一个细节,谓词返回 true 时,表示继续持有互斥锁,线程继续运行,所以上面的等价代码,while 里面的 flag 前有个 “!”。

现在,让我们回到开始,分析一下例1的代码:

#include <iostream>
#include <thread>
#include <condition_variable>
using namespace std;

int n = 1;
mutex mtx;
condition_variable cond;
bool flag = true;

void printA() {
    for (int i = 0; i < 10; i++) {
        unique_lock<mutex> lock(mtx);
        cond.wait(lock, [&]{return flag;});
        cout << "n = " << n++ << ", " << 'A' << endl;
        flag = false;
        cond.notify_one();
    }
}

void printB() {
    for (int i = 0; i < 10; i++) {
        unique_lock<mutex> lock(mtx);
        cond.wait(lock, [&]{return !flag;});
        cout << "n = " << n++ << ", " << 'B' << endl;
        flag = true;
        cond.notify_one();
    }
}

int main() {
    thread t1(printA);
    thread t2(printB);
    t1.join();
    t2.join();
    return 0;
}

我们一步一步分析:

  1. flag 初始值是 true
  2. t1 执行 printA,unique_lock 获取锁(同时t2获取锁失败,阻塞),wait 持有锁,检查谓词是 true,线程继续执行,打印 “n = 1, A”,将 flag 置为 false,唤醒阻塞中的一个线程,同时进行第二轮循环,unique_lock 获取锁,wait 检查谓词,谓词是 false,阻塞当前线程,释放互斥锁
  3. t2 执行 printB,unique_lock 获取锁,wait 持有锁,检查谓词是 true (!false),线程继续执行,打印 “n = 2, B”,将 flag 置为 true,唤醒阻塞中的一个线程,同时进行第二轮循环,unique_lock 获取锁,wait 检查谓词,谓词是 false (!true),阻塞当前线程,释放互斥锁
  4. t1 被唤醒,wait 持有锁,检查谓词是 true,线程继续执行,打印 “n = 3, A”,将 flag 置为 false,唤醒阻塞中的一个线程,同时进行第二轮循环,unique_lock 获取锁,wait 检查谓词,谓词是 false,阻塞当前线程,释放互斥锁
  5. 重复3、4

参考

标签:mtx,多线程,lock,C++,flag,详解,线程,unique,wait
From: https://blog.csdn.net/weixin_44286126/article/details/139380643

相关文章

  • 从C++示例理解开闭原则
    开闭原则要求我们在编写代码时,尽量不去修改原先的代码,当出现新的业务需求时,应该通过增加新代码的形式扩展业务而不是对原代码进行修改。假如我们现在有一批产品,每个产品都具有颜色和大小,产品其定义如下:enumclassColor{Red,Green,Blue};enumclassSize{Small,M......
  • 【包邮送书】Node-RED 物联网应用开发技术详解
    ......
  • C++:细谈Sleep和_sleep
    ZINCFFO的提醒还记得上上上上上上上上上上上上上上上上上上(上的个数是真实的)篇文章吗?随机应变——Sleep()和_sleep()但在ZINCFFO的C++怪谈-02中:我不喜欢Sleep......奤?媜煞鷥!整活!Sleep()是个什么东东?    Sleep()在windows.h和graphics.h里面都有。voidSlee......
  • [21] C++ 虚幻引擎项目结束
    Week21Day1大纲准备开始游戏踢除玩家根据职业更改外观样式内容踢除下线在玩家客户端调用让当前客户端下线,会退到默认地图voidAHallPlayerState::Client_AskLogout_Implementation(){ //下线 UKismetSystemLibrary::ExecuteConsoleCommand(this,TEXT("DISCONNECT")......
  • JSP详解,看这一篇就够了(含示例)
    JSP(JavaServerPages)是Java技术的一部分,用于创建动态Web内容。JSP的主要功能是简化服务器端的Web开发,尤其是对于HTML、XML等页面内容的动态生成。一、JSP的基础概念什么是JSP:JSP是一种基于Java的技术,用于创建动态网页。它允许在HTML中嵌入Java代码,这些代码在服务器端执......
  • 关于css预处理器sass详解
    Sass(SyntacticallyAwesomeStylesheets)是一种强大的CSS预处理器,旨在简化CSS的编写并增强其功能。以下是对Sass的详细解释,包括其特点、功能、语法格式以及使用方式。1.Sass的特点扩展CSS功能:Sass在CSS的基础上增加了变量、嵌套、混合(mixins)、继承等高级功能,使得CSS的编......
  • 进程间通信(27000字超详解)
    ......
  • 《C++primer》读书笔记---第九章:顺序容器
    9.1顺序容器概述下表列出了标准库的顺序容器,所有容器都提供了快速顺序访问元素的能力:多种容器中,通常使用vector是最好的选择,除非你有很好的理由选则其他容器。以下是一些选择容器的基本原则:除非你有很好的理由选择其他容器,否则选择vector如果你的程序有很多小的元素,且空......
  • c++内存分配
    想象一下你有一个房子,房子里有很多房间,每个房间都可以用来存放东西。在C++中,内存管理就像是你在设计和建造这个房子。你可以自己决定房间的数量和大小,也可以随时动态地改变它们。但是,你需要小心地管理这些房间,确保你不会浪费空间或者让房间里的东西互相干扰。所以,C++中的内存管......
  • C++Primer Plus第十一章类的使用,课后练习2,还是醉汉回家的故事 3,最慢和最快及平均概率
    修改程序清单11.15,使之报告N次测试中的最高、最低和平均步数(其中N是用户输入的整数)而不是报告每次测试的结果。头文件和实现文件不变,这里为大家方便还是贴上代码//vect.h--Vectorclasswith<<,modestate#if1#ifndef VECTOR_H_ #defineVECTOR_H_#include<io......