并发编程(三):互斥

本文介绍了Peterson算法用于解决多线程同步问题,以及在现代多处理器环境下其假设不再成立。随后讨论了C++标准库中的自旋锁实现,如SpinLock类,及其与std::mutex的性能对比,强调了自旋锁的局限性和用户态编程中mutex的优点。
摘要由CSDN通过智能技术生成

Peterson 算法

实验假设

  1. 任何时候可以执行一条load/store 指令
  2. 读写本身是原子的

实验步骤

假设有线程A, 线程B并发访问临界区O,如果想要访问临界区O,则每个线程顺序执行以下步骤:

  1. 举自己的旗:store 一个自己的标志,A线程标志为a,B线程标志为b
  2. 将另一个线程的标志贴到临界区O上,比如A线程贴b,B线程贴a

然后,进入持续的观察模式:

  1. 观察对方是否举旗: 即A load B的标志b, B load A的标志a
  2. 观察对方没有举旗,或者临界区O上的标志是自己的,则进入临界区,否则继续观察。

离开临界区O之后,放下自己的旗,不用管O的标志(这样另一个线程就可以访问了 )

真实场景

实验假设在现代多处理器上并不成立m,且Peterson 算法是为2个线程设计的,不能扩展至多个线程

在这里插入图片描述

多处理器系统上的互斥

软件解决不了的问题,只能依靠硬件来解决,硬件平台提供了一些原子指令,使用这些原子指令,就可以保证共享内存在一定程度上的互斥性。

C++标准提供了一些跨平台的api,可以让用户在无需了解每个平台差异的情况下,实现跨平台的互斥程序编写:

#include <thread>
#include <iostream>
#include <atomic>

class SpinLock {
public:
    SpinLock() noexcept : m_locked(false) {}
    void lock() {
        while(true) {
            // 尝试将m_locked从false设置为true,如果当前m_locked值已经是true,则说明锁已被其他线程持有
            bool expected = false;
            if (m_locked.compare_exchange_weak(expected, true, std::memory_order_acquire)) {
                break; // 设置成功,获取到锁,退出循环
            }
            // 强制读取m_locked以刷新缓存并避免编译器优化
            std::atomic_thread_fence(std::memory_order_acquire);

        }    
    }
    void unlock() {
        m_locked.store(false, std::memory_order_release); // 释放锁,将locked_设回false
    }

private:
    std::atomic<bool> m_locked; // 0: unlocked, 1: locked
};

int sum = 0;
static constexpr int N = 1000000;
SpinLock lock{};
void add() {
    for(int i = 0; i < N; i++) {
        lock.lock();
        sum++;
        lock.unlock();
    }
}

int main() {
    std::thread t1(add);
    std::thread t2(add);
    t1.join();
    t2.join();
    printf("sum = %d\n",sum);
}

上述是使用C++提供的标准api,实现的简易自旋锁。

std::atomic::compare_exchange_weak 和 std::atomic::compare_exchange_strong 是两种基于比较并交换(Compare-and-Swap, CAS)操作的原子函数,它们的区别在于对失败时的行为处理:

std::atomic::compare_exchange_weak:

行为: 它允许在某些情况下“弱”地失败,即使CAS操作在硬件级别实际上是可以成功的。具体而言,当遇到“弱”失败时,compare_exchange_weak可能会不执行任何操作就返回false,而不是一直尝试直到成功。
场景: 这种设计是为了在某些特定硬件平台上提高效率,尤其是在高并发环境中,减少不必要的CPU循环等待。然而,这也意味着在某些情况下,程序可能需要多次调用才能成功完成CAS操作。
适用情况: 当系统对失败后立即重新尝试的成本较低,或者期望尽可能减少潜在的CPU阻塞时,可以选择使用compare_exchange_weak。

std::atomic::compare_exchange_strong:

行为: 它保证只要CAS操作在硬件级别是可行的,就会一直尝试直到成功。这意味着它不会出现“弱”失败的情况,除非确实有其他线程更改了目标值,使得预期值与实际值不再匹配。
场景: 对于那些要求CAS操作必须在有限次数内成功,或者对失败后重新尝试成本较高的情况,compare_exchange_strong更为合适。它提供了更强的确定性和更低的出错概率。
适用情况: 当需要确保CAS操作尽快成功,或者在失败后重新尝试成本较高(如涉及系统调用、上下文切换等)的场景下,应优先选用compare_exchange_strong。

关于内存屏障atomic_thread_fence和内存序std::memory_order_*, 会在之后的章节介绍。
lock 也可以设计成如下形式:

void lock() {
            // 尝试将m_locked从false设置为true,如果当前m_locked值已经是true,则说明锁已被其他线程持有
            bool expected = false;
            while (!(m_locked.compare_exchange_weak(expected, true, std::memory_order_acquire))) {
                expected = false; // 每次都需要重置
            }
            // 设置成功,获取到锁,退出循环
    }

自旋锁应用场景

自旋锁的一个弊端是,除了进入临界区的那个线程,其他处理器上的线程都在空转(自旋),所以争抢的处理器越多,其利用率越低。
由于存在大量的自旋操作,当线程数量达到一定数量后,整个代码的性能会出现急剧下降,且cpu的开销也会飙升,所以对自旋锁的使用需要慎之又慎。且在用户态实现的自旋锁,是不完备的,因为用户态无法关中断,即,如果lock后,触发了中断,中断处理程序中,也需要lock,可能会导致死锁等各种问题。

所以在用户态编程,老老实实用std::mutex。

pthread_mutex 在设计上就做出了优化,如果没有争抢的情况,线程只会在用户态,加一次锁,便进入了临界区;而当自旋失败,才会请求系统调用,让操作系统帮助,达到自旋的效果(会有各种优化),所以对于用户态程序来说,mutex在多竞争场景下,不会同上面实现的自旋锁那样,有巨大的性能损失。

参考

https://github.com/jiangyy/mosaic
https://www.bilibili.com/video/BV1fi421R7j3/?spm_id_from=pageDriver&vd_source=af7e0c9e1572f87d3612cbcf3801eda6

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值