『 C++ 』多线程相关

news2024/11/14 15:37:44

文章目录

    • 极短临界区互斥锁的短板
    • 原子操作类 atomic
    • atomic 原子操作原理 CAS
    • CAS 操作解决多线程创建链表的节点丢失问题
    • 多线程下的 shared_ptr 智能指针
    • 最简单的单例模式


极短临界区互斥锁的短板

请添加图片描述

如果两个线程同时对一个共享资源变量x进行自增操作将会出现线程安全问题,这个线程安全问题主要是两个线程同时对同一个变量进行控制,而自增操作不是原子操作,将会导致数据不一致问题;

原子操作是指在多线程或并发环境下,某个操作能够保证不会被中断,即这个操作一旦开始就会运行到结束,中见不会由任何原因被其他线程干扰,这意味着从外部观察到的这些操作只有两种状态;

  • 未开始
  • 已结束

原子操作不存在中间状态;

int x = 0;

class Func
{
public:
    void operator()()
    {
        for (int i = 0; i < 10000; ++i)
        {
            ++x;
        }
    }
};

int main()
{
    Func func;
    thread t1(func);
    thread t2(func);
    t1.join();
    t2.join();
    cout << x << endl;
    return 0;
}

在这个例子中定义了一个Func类作为仿函数,定义了一个全局变量x并初始化为0,在仿函数中对全局变量x自增10000次,创建两个线程都调用这个仿函数函数对象;

运行结果为:

$ ./mytest 
18797
$ ./mytest 
18475
$ ./mytest 
13067

每次的调用结果都不同但是每次的结果都不会是预期结果20000,因为自增操作不是原子操作;

一个自增操作,如++x将会有三个步骤;

mov eax, DWORD PTR [rip+x]  ; 从全局变量 `x` 加载值到寄存器 `eax`
inc eax                     ; 增加寄存器 `eax` 的值
mov DWORD PTR [rip+x], eax  ; 将增加后的值存回全局变量 `x`

三条指令代表线程不会被一直调度,而是存在一个时间片,当时间片到了之后就要调度其他的线程进行执行,而线程的调度不仅仅是直接切换,它需要保存当前线程执行的上下文;

假设t1t2两个线程,t1线程将全局变量x加载值到寄存器eax中,加载过后增加eax的值,此时t1的时间片到了,t2被调度,在t2被调度之前t1将需要保存当前上下文,即当前eax寄存器中保存的值,此时内存中全局变量的值仍为0,即t1线程中eax寄存器中被自增后的值并未写回内存中;

t2被调度后同样的执行了与t1相同的步骤,但t2将数据加载进寄存器,进行自增,写回内存并没有被中断,写回内存后当前的全局变量x即为1;

t2调度完毕后切换t1进行调度,t1首先加载上下文,即将之前保存的上下文eax中的值加载回eax寄存器中,而后进行最后一步,即把eax中的值写回内存中,当写回内存后x仍为1,这就是进程安全导致的数据不一致问题;

只有单条指令的操作是具有原子性的,凡是具有多条指令的操作都为非原子性的;

这种情况可以通过加锁来解决问题,使用mutex互斥锁;

int x = 0;
class Func
{
public:
    void operator()(mutex &mtx)
    {
        for (int i = 0; i < 10000; ++i)
        {
            unique_lock<mutex> lock(mtx);
            ++x;
        }
    }
};
int main()
{
    Func func;
    mutex mtx;
    thread t1(func, ref(mtx));
    thread t2(func, ref(mtx));
    t1.join();
    t2.join();
    cout << x << endl;
    return 0;
}

/*
	运行结果:
	$ ./mytest 
	20000
*/

但是这里的互斥锁有一个问题,由于临界资源很短,且对临界资源的操作是非常小的操作,使用互斥锁反而有种"杀鸡焉用牛刀"的感觉;

这里还有一种加锁方式是不直接lock进行锁定而是反复循环try_lock尝试加锁,当互斥锁未被占用时这个线程将会获得这个锁资源,若是被占用则直接返回,不进行等待;

  • mutex::try_lock

    std::mutex::try_lock
    bool try_lock();
    

    返回值为一个bool值,当try_lock成功锁定互斥锁对象时返回true,否则返回false;

这种思路是由于使用lockunlock需要使另一个进程进入等待队列,而等待和唤醒是需要开销的,如果是使获取锁的线程不进入等待队列而是循环询问,即循环尝试获取锁获取会减少这个等待和唤醒的时间,但实际上使用try_lock的方式会变得更慢,因为循环使用try_lock代表需要大量占用CPU资源;

int x1 = 0; // 用于 try_lock 的变量
int x2 = 0; // 用于 lock 的变量

class Func1 {
public:
    void operator()(mutex &mtx) {
        for (int i = 0; i < 10000000; ++i) {
            while (!mtx.try_lock()); // 使用 try_lock 忙等待
            ++x1;
            mtx.unlock();
        }
    }
};

class Func2 {
public:
    void operator()(mutex &mtx) {
        for (int i = 0; i < 10000000; ++i) {
            mtx.lock(); // 使用 lock 获取锁
            ++x2;
            mtx.unlock();
        }
    }
};

int main() {
    mutex mtx1, mtx2;
    
    // 创建并启动线程来测试 try_lock
    Func1 func1;
    auto start_try_lock = chrono::high_resolution_clock::now();
    
    thread t1(func1, ref(mtx1));
    thread t2(func1, ref(mtx1));
    t1.join();
    t2.join();   
   
    auto end_try_lock = chrono::high_resolution_clock::now();
    chrono::duration<double> diff_try_lock = end_try_lock - start_try_lock;

    // 创建并启动线程来测试 lock
    Func2 func2;
    auto start_lock = chrono::high_resolution_clock::now();
    
    thread t3(func2, ref(mtx2));
    thread t4(func2, ref(mtx2));
    t3.join();
    t4.join();
    
    auto end_lock = chrono::high_resolution_clock::now();
    chrono::duration<double> diff_lock = end_lock - start_lock;

    // 输出结果
    cout << "x1 (try_lock): " << x1 << endl;
    cout << "Time taken with try_lock: " << diff_try_lock.count() << " s" << endl;

    cout << "x2 (lock): " << x2 << endl;
    cout << "Time taken with lock: " << diff_lock.count() << " s" << endl;

    return 0;
}
/*
	运行结果:
	$ ./mytest 
    x1 (try_lock): 20000000
    Time taken with try_lock: 3.49147 s
    x2 (lock): 20000000
    Time taken with lock: 1.10093 s
*/

这里定义了两个全局变量为x1x2分别用来测试直接使用lock和循环使用try_lock进行测试,定义了两个仿函数分别作为两组多线程的入口点,一个仿函数直接使用lock,一个仿函数循环使用try_lock,并且调用了high_resolution_clock来计算两次的时间并进行计算,这里为了测试结果更加明显使用了一个较大的数值进行自增(10000000);

测试结果表明直接使用lock反而要远远快于循环使用try_lock;

当然这种场景下使用自旋锁的效率要远远高于直接使用lock,循环使用try_lock虽然也是一种自旋动作,但循环调用try_lock将会大大占用CPU资源,因为try_lock的上层开销要更大,这意味着CPU需要处理的指令要更多,但C++标准并未提供自旋锁;


原子操作类 atomic

请添加图片描述

atomic是C++封装的一个类,这个类专门用来处理较短临界区的操作,如自增自减,赋值等操作;

这个类中提供了原生的相关操作CAS,即比较并交换(compare and swap),而这些操作本质上是封装了来自不同平台下的操作,层与层之间之间解耦合使得使用atomic能使用相同的原子操作;

template <class T> struct atomic;

这个类提供了一系列的成员函数,包括重载了自增,自减,赋值等操作;

使用这个类模板对较短的临界区临界资源进行封装时使得在对这个临界资源进行操作时进行的都是原子操作;

class Func1
{
public:
    void operator()(atomic<int> &x)
    {
        for (int i = 0; i < 10000; ++i)
        {
            ++x; // 自增
        }
    }
};

int main()
{
    Func1 func;
    atomic<int> x; // 使用 atomic 封装临界资源 x
    x = 0;
    thread t1(func, ref(x)); // 引用传递 x
    thread t2(func, ref(x));
    t1.join(); // 线程等待
    t2.join();
    cout << x << endl; // 打印 x 最终值
    return 0;
}
/*
	运行结果:
	$ ./mytest 
    20000
*/

运行结果表明由于两个线程进行的都是原子操作,最终结果与预期相同;

atomic不仅支持内置类型也支持自定义类型,但前提是所给予的自定义类型需要根据需求重载一些运算符,如自增自减赋值等,但使用自定义类型的时候需要考虑该自定义类型内容是否复杂,如果复杂的话说明临界区较大,在临界区较大的情况下使用互斥锁反而更适合场景;


atomic 原子操作原理 CAS

请添加图片描述

atomoic类的原理是CAS操作,这种操作是一种无所操作,即无锁编程;

CAS全称为compare and swap,这个操作涉及到两个步骤:

  • compare比较
  • swap交换

这个操作是由CPU支持的,即硬件支持的操作从而实现操作的原子性;

CPU会提供一系列的指令以及对应接口使得上层能够使用这些接口进行一些原子操作;

  • x86 架构使用 CMPXCHG 指令
  • ARM 架构使用 LDREXSTREX 指令
int compare_and_swap (int *reg, int oldval, int newval){
    int old_reg_val = *reg;
    if(old_reg_val == oldval){
        *reg = newval;
    }
    return old_reg_val;
}

这个代码并不是atomic的实现,而是CAS操作的类似原理的代码,用来模拟CAS操作;

以对一个全局的临界资源int x自增为例,默认情况下对这个临界资源自增时使用的是三条指令:

mov eax, DWORD PTR [rip+x]  ; 从全局变量 `x` 加载值到寄存器 `eax`
inc eax                     ; 增加寄存器 `eax` 的值
mov DWORD PTR [rip+x], eax  ; 将增加后的值存回全局变量 `x`

将全局变量x从内存中加载到寄存器eax中,增加eax的值,最后将eax的值写回内存中;

而对于CAS操作而言,CAS操作多了一个最重要的步骤,即判断当前内存中的值是否与加载进寄存器中的最初值相同,如果当前内存中的值与加载进寄存器中的最初值相同则表示内存中的这个值并没有被修改,可以把在寄存器中计算(自增)的值重新写回内存中,表示一次CAS操作成功;

但如果在最后的比较中,当前内存中的值与最初加载进寄存器中的值不同则表示这个值已经被其他线程修改过了,如果再把当前寄存器的值写回内存中则是一个无用功,会导致数据不一致问题,那么这次的CAS操作则是失败,此时需要重新将内存中的值加载进寄存器中并再次进行上述操作,当比较时当前内存中的值与原来加载进寄存器的值相同时才能把在寄存器中计算后的值写回内存中,才算一次CAS操作成功;

class Func
{
public:
    void operator()(atomic<int> &x)
    {
        for (int i = 0; i < 10000; ++i)
        {
            int old = 0, newval = 0;
            do
            {
                old = x.load();          // 获取旧值
                newval = old + 1; // 计算新值
            } while (!atomic_compare_exchange_weak<int>(&x, &old, newval)); // 比较并交换
        }
    }
};

int main()
{
    Func func;        // 实例化函数对象
    atomic<int> x(0); // 实例化一个 atomic 类对象

    thread t1(func, ref(x)); // 创建线程
    thread t2(func, ref(x));
    t1.join();
    t2.join();
    cout << x << endl; // 打印最终结果
    return 0;
}

这段代码为即为CAS操作的具体实现,通过每次循环读取x的值(old)并计算新值newval,尝试通过CAS操作将新值newval写回x;

如果在这个过程中x已经被修改,则CAS操作失败,并重新尝试,直到成功为止;


CAS 操作解决多线程创建链表的节点丢失问题

请添加图片描述

这是一个单链表结构,当需要为这个链表插入节点时需要先创建一个节点,而后将这个节点的地址赋值给链表中尾节点的next指针;

但若是出现多个线程同时对同一个链表进行节点插入则可能会出现节点丢失从而导致内存泄漏;

在这张图中线程t1创建了一个Node4节点,线程t2创建了一个Node5节点,节点创建时两个线程之间互不干扰,当节点创建完毕后不论先后一定会存在一个线程创建的节点先链接在链表尾节点的next处成为新的尾节点,但后来的线程所创建的节点再次对同一个节点的next进行链接时就会导致上一个节点丢失,在这个例子中t1创建的节点Node4率先被链接在链表尾节点的位置成为新的尾节点,而t2创建的Node5节点对同一个位置进行链接,导致Node4节点丢失,从而导致内存泄漏问题;

但如果使用了CAS操作则可以避免这种情况的发生,假设两个线程都各创建了一个节点并准备进行插入,t1所创建的节点在进行链接时判断当前插入的链表尾节点的next是否为nullptr空,若是为空则表示该位置可以进行插入,当前检查为空,进行插入,CAS操作成功;

t2准备对该节点进行链接时首先会检查要插入的位置的next是否为空从而决定这个位置是否可以插入,检查不为空说明无法进行插入,当前CAS操作失败,但CAS操作必须要成功不能一直失败,所以将会取到当前节点的next节点作为插入位置,同样检查该节点的next是否为空,此处不为空则表示可以插入并进行插入,t2线程插入链表节点的CAS操作成功;

由于CAS操作是原子的,即一个线程在对链表进行新节点的插入时不会因为另一个线程的调度而导致节点丢失等问题;


多线程下的 shared_ptr 智能指针

请添加图片描述

标准库中的std::shared_ptr是具有一定的线程安全的,但他的线程安全的保证和限制为:

  • 安全的引用计数

    当多个线程同时持有指向同意对象的std::shared_ptr时,对象的引用计数增减操作是原子的,因此创建,复制或销毁shared_ptr实例不会导致数据竞争;

  • 独立的shared_ptr是线程安全的

    每个线程可以独立创建,复制和销毁自己的shared_ptr实例,这些操作之间不会相互干扰;

shared_ptr智能指针对共享对象本身的访问不是线程安全的,换句话来说shared_ptr只需要保证自身是线程安全的,对于用户所使用的共享对象是不具责任的;

同时当多个线程同时读写同一个shared_ptr实例,如通过赋值操作则可能会导致数据竞争,因此需要使用同步语句来保护这些操作;

int main()
{
    shared_ptr<int> nptr(new int(0));
    int n = 100000;
    thread t1([=]
              {
                for(int i = 0;i<n;++i){
                ++(*nptr);
        } });

    thread t2([=]
              {
                for(int i = 0;i<n;++i){
                ++(*nptr);
        } });

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

    cout << *nptr << endl;

    return 0;
}
/*
	运行结果:
	$ ./mytest 
    138056
*/

在这段代码中两个线程同时对一个shared_ptr智能指针所管理的对象进行自增操作10000次,运行结果为明显与预期不符,因为智能指针只会保证自身的线程安全不会保证智能指针所维护生命周期的变量的线程安全,所以需要加锁;


最简单的单例模式

请添加图片描述

class SingleLazy
{
public:
    static SingleLazy &GetInstance()
    {
        static SingleLazy inst;
        return inst;
    }

private:
    // 私有化构造
    SingleLazy() {
        std::cout << "SingleLazy()" << std::endl;
    };

    // 防拷贝
    SingleLazy(const SingleLazy &) = delete;
    SingleLazy &operator=(const SingleLazy &) = delete;
};

这是一个最简单的单例模式,并且这是一个懒汉模式,懒汉模式为只有第一次调用的时候才会实例化单例实例,饿汉模式为在主线程进入main函数前就将单例实例资源准备就绪,这里的单例只有调用GetInstance()函数才能获取单例;

int main()
{
    sleep(3);
    std::cout << "------------" << std::endl;
    SingleLazy::GetInstance();
    return 0;
}
/*
	运行结果:
	$ ./mytest 
    ------------
    SingleLazy()
*/

这里的单例模式的创建将会打印他的构造函数;

主函数在调用单例模式前首先调用sleep(3)使进程sleep三秒钟,随后打印------------,如果是饿汉模式,这个打印操作和sleep将会在单例模式的构造函数之后,而这里的运行结果明显为先进行了sleep和打印操作才调用单例,所以这是一个懒汉模式,因为局部的静态对象是在第一次调用时初始化;

在C++11之前这个最简单的懒汉模式单例不是线程安全的,而C++11之后引入了可以保证局部静态初始化是线程安全的,因此这个单例模式是线程安全的(C++11之后),能够保证只初始化一次;

也可以理解为在C++11之后,局部静态对象的初始化是原子操作;

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2096515.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

官方宣布Navicat免费使用!

官方宣布Navicat免费使用&#xff01; 对于开发者和数据库管理员来说&#xff0c;Navicat一直是不可或缺的工具之一。官方宣布Navicat可以免费使用&#xff0c;这无疑是个令人振奋的消息&#xff01;虽然是精简版&#xff0c;但足够日常使用。文末有下载链接。 无论你是管理M…

Linux 文件接口和文件管理

目录 一、回顾c语言文件操作 二、系统调用的文件操作 系统调用文件接口 open&#xff1a; close&#xff1a; write&#xff1a; 代码测试&#xff1a; ​编辑 ​编辑 read&#xff1a; 语言和系统函数间的关系&#xff1a; flags的实现思路 三、OS内文件的管理 语…

时序预测 | 基于MAMbaS+transformer时间序列预测模型(pytorch)

目录 效果一览基本介绍程序设计参考资料 效果一览 基本介绍 MAMBAS,transformer python代码&#xff0c;pytorch架构 可以发刊&#xff0c;先发先的&#xff0c;高精度代码。 需知&#xff1a;好的创新性模型可以事半功倍。。 适合功率预测&#xff0c;风电光伏预测&#xff0…

ubuntu通过smba访问华为设备

文章目录 ubuntu通过smba访问华为设备华为设备设置ubuntu设置访问测试 ubuntu通过smba访问华为设备 华为设备设置 华为设备在华为分享一栏下有共享至电脑的选项&#xff0c;打开即可&#xff0c;这里会创建用户名和密码进入设置 -> 关于手机/平板电脑 -> 状态信息&…

Android 10.0 mtk平板camera2横屏预览旋转90度功能实现

1.前言 在10.0的系统rom定制化开发中,在进行一些平板等默认横屏的设备开发的过程中,需要在进入camera2的 时候,默认预览图像也是需要横屏显示的,所以就需要看下mtk的camera2的相关预览功能,然后看下进入 launcher camera的时候看下如何实现预览横屏显示 如图所示: 2.mtk平…

【Linux】文件魔法师:时间与日历的解密(8/15完成)

欢迎来到 CILMY23 的博客 &#x1f3c6;本篇主题为&#xff1a;文件魔法师&#xff1a;时间与日历的解密 &#x1f3c6;个人主页&#xff1a;CILMY23-CSDN博客 &#x1f3c6;系列专栏&#xff1a;Python | C | C语言 | 数据结构与算法 | 贪心算法 | Linux | 算法专题 | 代码…

Golang | Leetcode Golang题解之第390题消除游戏

题目&#xff1a; 题解&#xff1a; func lastRemaining(n int) int {a1 : 1k, cnt, step : 0, n, 1for cnt > 1 {if k%2 0 { // 正向a1 step} else { // 反向if cnt%2 1 {a1 step}}kcnt >> 1step << 1}return a1 }

数学建模强化宝典(6)0-1规划

前言 0-1规划是决策变量仅取值0或1的一类特殊的整数规划。这种规划的决策变量称为0-1变量或二进制变量&#xff0c;因为一个非负整数都可以用二进制记数法用若干个0-1变量表示。在处理经济管理和运筹学中的某些规划问题时&#xff0c;若决策变量采用0-1变量&#xff0c;可把本来…

upload-labs-master靶场通关攻略

第一关 上传并进行抓包&#xff0c;修改后缀为php 第二关 抓包修改后缀 第三关 改后缀为php3 第4关 使用Apache的配置文件.htaccess来上传文件 然后再上传php文件 第5关 使用.user.ini来上传文件 然后再上传jpg文件 访问upload目录下的readme.php文件 第6关 大小写绕过 第…

公钥密码学

1. 非对称密码学 非对称密码学&#xff08;Asymmetric Cryptography) 中的 “非对称” 指的是用于加密数据的密钥和用于解密数据的密钥是不一样的&#xff08;如果一样&#xff0c;那就是对称密码学&#xff09;。对称密码学也称为共享密钥密码学。类似地&#xff0c;非对称密码…

大模型笔记01--基于ollama和open-webui快速部署chatgpt

大模型笔记01--基于ollama和open-webui快速部署chatgpt 介绍部署&测试安装ollama运行open-webui测试 注意事项说明 介绍 近年来AI大模型得到快速发展&#xff0c;各种大模型如雨后春笋一样涌出&#xff0c;逐步融入各行各业。与之相关的各类开源大模型系统工具也得到了快速…

neural-admixture:基于AI的快速基因组聚类

最近学习祖源分析方面的内容&#xff0c;发现已经有了GPU版的软件&#xff0c;可以几十倍地加快运算速度&#xff0c;推荐使用&#xff01;小数据集的话家用显卡即可hold住&#xff0c;十分给力&#xff01; ADMIXTURE 是常用的群体遗传学分析工具&#xff0c;可以估计个体的祖…

注册中心技术选型

优质博文&#xff1a;IT-BLOG-CN 市面上流行的开源注册中心很多&#xff0c;耳熟能详的有Eureka、Zookeeper、Nacos、Consul。我们在选型的时候也主要从这四个组件中进行筛选。下面就对我们内部的讨论内容进行整理&#xff1a; 第一个维度&#xff1a;开源公司的实力 Eureka…

InceptionV4 Pytorch 实现图片分类

一、目录结构 训练过程&#xff1a; 在训练集和测试集分类目录中放好待训练的分类图片&#xff08;f1,f2,f3&#xff09;运行模型训练代码&#xff0c;生成模型参数文件运行分类测试文件&#xff0c;设置待验证的图片路径&#xff0c;调用模型文件得出分类结果 二、模型构建代…

Auto-Unit-Test-Case-Generator -- java项目自动测试生成

0.Pre-预备知识&#xff1a; 0.1.Maven是什么&#xff1f; [by Maven是什么&#xff1f;有什么作用&#xff1f;Maven的核心内容简述_maven是干什么用-CSDN博客 ] 是Java 领域中最流行的自动化构建工具之一&#xff0c;Maven 作为 Java 项目管理工具&#xff0c;具有: 包管…

AI的基本使用

AI使用 一、网页端AI二、手机端AI三、AI提问指令大全四、AI绘画 一、网页端AI 讯飞星火网页版百度文心一言通义万相&#xff08;主要用于生图&#xff09;通义听悟&#xff08;主要用于音频&#xff09;通义智文&#xff08;主要用于生文&#xff09;腾讯文档里的智能助手&…

Laravel 中间件与事件应用教程

前言 在 Laravel 框架中&#xff0c;中间件&#xff08;Middleware&#xff09;和事件&#xff08;Events&#xff09;是两种强大的机制&#xff0c;用于处理 HTTP 请求和应用程序中的特定动作。它们各自有独特的应用场景和优势。本教程将详细介绍中间件和事件的基本概念、区别…

网络压缩之稀疏模型设计

通过网络架构的设计来达到减少参数量的效果。等一下 要跟大家介绍深度可分离卷积&#xff08;depthwise separable convolution&#xff09;。在讲这个方法之前&#xff0c;先复 习一下CNN。在 CNN 的这种卷积层里面&#xff0c;每一个层的输入是一个特征映射。如图1 所 示&…

Mysql——高可用集群部署

目录 一、源码编译mysql 二、mysql的主从复制 2.1、主从复制 2.2、延迟复制 2.3、慢查询日志 2.4、MySQL的并行复制 三、MySQL半同步模式 四、mysql高可用组复制 五、mysql-router 六、mysql高可用MHA 七、为MHA添加VIP功能 一、源码编译mysql 1、安装依赖 [rootm…

HX711—称重模块

1、简介 HX711 采用了海芯科技集成电路专利技术&#xff0c; 是一款专为高精度电子秤而设计的 24 位 A/D 转 换器芯片。 2、原理图 PCB参考设计原理图 3、模块驱动代码&#xff08;固件库&#xff09; 数据读取代码分析 HX711信号读取时序 初始化&#xff1a; 将 PD_SCK&…