常见的“锁”有哪些?

news/2025/2/24 18:54:26

悲观

悲观认为在并发环境中,数据随时可能被其他线程修改,因此在访问数据之前会先加,以防止其他线程对数据进行修改。常见的悲观实现有:

1.互斥

原理:互斥是一种最基本的类型,同一时间只允许一个线程访问共享资源。当一个线程获取到互斥后,其他线程如果想要访问该资源,就必须等待被释放。

应用场景:适用于写操作频繁的场景,如数据库中的数据更新操作。在 C++ 中可以使用 std::mutex 来实现互斥,示例代码如下:

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

std::mutex mtx;
int sharedResource = 0;

void increment() {
    std::lock_guard<std::mutex> lock(mtx);
    sharedResource++;
}

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

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

    std::cout << "Shared resource: " << sharedResource << std::endl;
    return 0;
}

2.读写

原理:读写允许多个线程同时进行读操作,但在进行写操作时,会独占资源,不允许其他线程进行读或写操作。读写分为读和写,多个线程可以同时获取读,但写是排他的。

应用场景:适用于读多写少的场景,如缓存系统。在 C++ 中可以使用 std::shared_mutex 来实现读写,示例代码如下: 

#include <iostream>
#include <shared_mutex>
#include <thread>

std::shared_mutex rwMutex;
int sharedData = 0;

void readData() {
    std::shared_lock<std::shared_mutex> lock(rwMutex);
    std::cout << "Read data: " << sharedData << std::endl;
}

void writeData() {
    std::unique_lock<std::shared_mutex> lock(rwMutex);
    sharedData++;
    std::cout << "Write data: " << sharedData << std::endl;
}

int main() {
    std::thread t1(readData);
    std::thread t2(writeData);

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

    return 0;
}

    乐观

    乐观是一种在多线程环境中避免阻塞的同步技术,它假设大部分操作是不会发生冲突的,因此在操作数据时不会直接加,而是通过检查数据是否发生了变化来决定是否提交。如果在提交数据时发现数据已被其他线程修改,则会放弃当前操作,重新读取数据并重试。

    应用场景:适用于读多写少、冲突较少的场景,如电商系统中的库存管理。

    在 C++ 中,乐观的实现通常依赖于版本号时间戳的机制。每个线程在操作数据时,会记录数据的版本或时间戳,操作完成后再通过比较版本号或时间戳来判断是否发生了冲突。

    下面是一个使用版本号实现乐观的简单示例代码:

    #include <iostream>
    #include <thread>
    #include <atomic>
    #include <chrono>
    
    // 共享数据结构
    struct SharedData {
        int value;            // 数据的实际值
        std::atomic<int> version; // 数据的版本号,用于检查是否发生了修改
    };
    
    // 线程安全的乐观实现
    bool optimisticLockUpdate(SharedData& data, int expectedVersion, int newValue) {
        // 检查数据的版本号是否与预期一致
        if (data.version.load() == expectedVersion) {
            // 进行数据更新
            data.value = newValue;
            // 增加版本号
            data.version.fetch_add(1, std::memory_order_relaxed);
            return true; // 成功提交更新
        }
        return false; // 数据版本不一致,操作失败
    }
    
    void threadFunction(SharedData& data, int threadId) {
        int expectedVersion = data.version.load();
        int newValue = threadId * 10;
    
        std::cout << "Thread " << threadId << " starting with version " << expectedVersion << "...\n";
    
        std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟工作
    
        // 尝试更新数据
        if (optimisticLockUpdate(data, expectedVersion, newValue)) {
            std::cout << "Thread " << threadId << " successfully updated value to " << newValue << "\n";
        } else {
            std::cout << "Thread " << threadId << " failed to update (version mismatch)\n";
        }
    }
    
    int main() {
        // 初始化共享数据,值为 0,版本号为 0
        SharedData data{0, 0};
    
        // 启动多个线程进行乐观测试
        std::thread t1(threadFunction, std::ref(data), 1);//std::ref(data) 将 data 包装成一个引用包装器,确保 data 在传递给函数时以引用的方式传递,而不是被复制。
        std::thread t2(threadFunction, std::ref(data), 2);
        std::thread t3(threadFunction, std::ref(data), 3);
    
        t1.join();
        t2.join();
        t3.join();
    
        std::cout << "Final value: " << data.value << ", Final version: " << data.version.load() << "\n";
    
        return 0;
    }

    原子

    原子是一种基于原子操作(如CAS、test_and_set)的机制。与传统的基于互斥量(如 std::mutex)的不同,原子依赖于硬件提供的原子操作,允许对共享资源的访问进行同步,且通常比传统更加高效。它通过原子操作保证对共享资源的独占访问,而不需要显式的线程调度。

    原子的适用场景:

    1.简单数据类型:原子最常用于定简单的基础数据类型,例如整数、布尔值、指针等。通过原子操作,多个线程可以安全地对这些数据进行读写,而不会发生数据竞争。

    示例:std::atomic<int>, std::atomic<bool>, std::atomic<long long>

    2.计数器、标志位:当需要在多线程中维护计数器、标志位或状态变量时,原子操作非常合适。例如,当多个线程需要递增计数器时,可以用原子操作避免使用传统的互斥

    示例:使用 std::atomic<int> 来维护线程安全的计数器。

    注:原子通常不能容器类型。

    什么是原子操作?

    原子操作是指不可分割的操作,在执行过程中不会被中断或干扰。原子操作保证了操作的完整性,要么完全执行,要么完全不执行,避免了在操作过程中被线程切换打断,从而避免了数据竞争和不一致的情况。

    1.自旋

    什么是自旋

    自旋是一种使用原子操作来检测是否可用的机制。自旋是一种忙等待的,当线程尝试获取失败时,会不断地检查的状态,直到成功获取。 

    在 C++ 中,可以使用 std::atomic_flag 结合 test_and_set 操作来实现一个简单的自旋

    test_and_set 是一个原子操作,它会检查一个布尔标志的值,然后将该标志设置为 true。整个操作过程是不可分割的,即不会被其他线程的操作打断。这个布尔标志通常被用作,线程通过检查并设置这个标志来尝试获取

    工作原理

    • 检查标志状态:线程首先检查布尔标志的当前值。
    • 设置标志为 true:如果标志当前为 false,表示未被占用,线程将标志设置为 true,表示成功获取到;如果标志当前为 true,表示已被其他线程占用,线程未能获取到
    • 返回旧值:test_and_set 操作会返回标志的旧值。线程可以根据这个返回值判断是否成功获取到。如果返回 false,说明成功获取到;如果返回 true,则需要等待被释放后再次尝试获取。
    #include <iostream>
    #include <atomic>
    #include <thread>
    #include <vector>
    
    std::atomic_flag lock = ATOMIC_FLAG_INIT;
    
    // 自旋类
    class SpinLock {
    public:
        void lock() {
            // 持续尝试获取,直到成功
            while (lock.test_and_set(std::memory_order_acquire)) {
                // 自旋等待
            }
        }
    
        void unlock() {
            // 释放,将标志设置为 false
            lock.clear(std::memory_order_release);
        }
    };
    
    SpinLock spinLock;
    int sharedResource = 0;
    
    // 线程函数
    void worker() {
        for (int i = 0; i < 100000; ++i) {
            spinLock.lock();
            ++sharedResource;
            spinLock.unlock();
        }
    }
    
    int main() {
        std::vector<std::thread> threads;
        // 创建多个线程
        for (int i = 0; i < 4; ++i) {
            threads.emplace_back(worker);
        }
    
        // 等待所有线程完成
        for (auto& thread : threads) {
            thread.join();
        }
    
        std::cout << "Shared resource value: " << sharedResource << std::endl;
        return 0;
    }

    自旋优点:

    1. 无上下文切换:自旋不会引起线程挂起,因此避免了上下文切换的开销。在竞争较轻时,自旋可以高效地工作。

    2. 简单高效:实现简单,且不依赖操作系统调度,适合竞争不严重的场景。

    自旋缺点:

    1. CPU资源浪费:如果被占用,自旋会不断地循环检查的状态,浪费 CPU 时间,尤其是在持有时间较长时,可能导致性能问题。

    2. 不适合竞争场景:当有大量线程竞争同一个时,自旋的性能将大幅下降,因为大部分时间都在自旋,浪费了 CPU 资源。

    自旋的适用场景:

    1. 短时间竞争:自旋适用于临界区代码执行时间非常短的情况。如果持有时间较长,使用自旋就不合适了。

    2. 竞争较轻:在多线程程序中,如果线程数量较少且资源竞争较少,自旋可以有效减少线程上下文切换,提升性能。

    3. 实时系统或高性能系统:在某些对延迟非常敏感的应用场景中,自旋可以通过减少上下文切换来提供更低的延迟。

    总结:自旋是一种简单且高效的机制,通过原子操作避免了线程上下文切换,适合用于短时间竞争和低延迟要求的场景。在竞争激烈或持有时间较长时,自旋的性能会受到影响,这时传统的互斥(如 std::mutex)可能更为合适。

    递归

    在 C++ 中,递归也被称为可重入,它是一种特殊的机制,允许同一个线程多次获取同一把而不会产生死

    原理

    普通的互斥(如 std::mutex)不允许同一个线程在已经持有的情况下再次获取该,否则会导致死。因为当线程第一次获取后,处于被占用状态,再次尝试获取时,由于未被释放,线程会被阻塞,而该线程又因为被阻塞无法释放,从而陷入死循环。

    递归则不同,它内部维护了一个计数器和一个持有的线程标识。当一个线程第一次获取递归时,计数器加 1,同时记录该线程的标识。如果该线程再次请求获取同一把,计数器会继续加 1,而不会被阻塞。当线程释放时,计数器减 1,直到计数器为 0 时,才会真正被释放,其他线程才可以获取该

    应用场景:

    • 递归调用:在递归函数中,如果需要对共享资源进行保护,使用递归可以避免死问题。例如,在一个递归遍历树结构的函数中,可能需要对树节点的某些属性进行修改,此时可以使用递归来保证线程安全。
    • 嵌套:当代码中存在多层嵌套的获取操作,且这些操作可能由同一个线程执行时,递归可以避免死。例如,一个函数内部调用了另一个函数,这两个函数都需要获取同一把

    注意事项:

    1. 性能开销

    递归的实现比普通互斥更为复杂。普通互斥只需简单地标记的占用状态,当一个线程请求时,检查该状态并进行相应操作。而递归除了要维护的占用状态,还需要记录持有的线程标识以及一个计数器,用于跟踪同一个线程获取的次数。每次获取和释放时,都需要对这些额外信息进行更新和检查,这无疑增加了系统的开销。

    • 时间开销:由于额外的状态检查和更新操作,递归的加和解操作通常比普通互斥更耗时。在高并发、对性能要求极高的场景下,频繁使用递归可能会成为性能瓶颈。
    • 资源开销:记录线程标识和计数器需要额外的内存空间,虽然这部分开销相对较小,但在资源受限的系统中,也可能会产生一定的影响。

    建议:在不需要递归获取的场景下,应优先使用普通互斥(如 std::mutex)。

    2. 死风险

    虽然递归允许同一个线程多次获取同一把而不会死,但如果在递归调用过程中,的获取和释放逻辑出现错误,仍然可能导致死。例如,在递归函数中,获取后在某些条件下没有正确释放就进行了递归调用,可能会导致无法正常释放,其他线程请求该时就会陷入死

    #include <iostream>
    #include <thread>
    #include <mutex>
    
    std::recursive_mutex recMutex;
    
    void faultyRecursiveFunction(int n) {
        if (n == 0) return;
    
        std::lock_guard<std::recursive_mutex> lock(recMutex);
        std::cout << "Recursive call: " << n << std::endl;
    
        if (n == 2) {
            // 错误:没有释放就返回,可能导致死
            return;
        }
    
        faultyRecursiveFunction(n - 1);
    }
    
    int main() {
        std::thread t(faultyRecursiveFunction, 3);
        t.join();
        return 0;
    }
    

    3.不同递归之间的交叉

    当存在多个递归时,如果不同线程以不同的顺序获取这些,就可能会产生死。例如,线程 A 先获取了递归 L1,然后尝试获取递归 L2;而线程 B 先获取了递归 L2,然后尝试获取递归 L1。此时,两个线程都在等待对方释放,从而陷入死状态。

    在 C++ 标准库中,std::recursive_mutex 是递归的实现。以下是一个简单的示例代码:

    #include <iostream>
    #include <thread>
    #include <mutex>
    
    std::recursive_mutex recMutex;
    
    // 递归函数,多次获取递归
    void recursiveFunction(int n) {
        if (n == 0) return;
    
        // 加
        std::lock_guard<std::recursive_mutex> lock(recMutex);
        std::cout << "Recursive call: " << n << std::endl;
    
        // 递归调用
        recursiveFunction(n - 1);
    
        // 在离开作用域时自动释放
    }
    
    int main() {
        std::thread t(recursiveFunction, 3);
        t.join();
    
        return 0;
    }
    

    什么是的重入与不可重入?

    可重入也叫递归,允许同一个线程在已经持有该的情况下,再次获取同一把而不会产生死。可重入内部会维护一个持有的线程标识和一个计数器。当线程第一次获取时,会记录该线程的标识,并将计数器初始化为 1。如果该线程再次请求获取同一把会检查请求线程的标识是否与当前持有的线程标识相同,如果相同,则将计数器加 1,而不会阻塞该线程。释放时,计数器减 1,直到计数器为 0 时,才会释放,其他线程才可以获取该

    不可重入不允许同一个线程在已经持有该的情况下再次获取同一把。如果一个线程已经持有了不可重入,再次请求获取该时,会导致线程阻塞,进而可能产生死。不可重入只关注的占用状态,不记录持有的线程标识和获取的次数。当一个线程请求获取时,会检查其是否已被占用,如果已被占用,无论请求线程是否就是持有的线程,都会将该线程阻塞。


    http://www.niftyadmin.cn/n/5864721.html

    相关文章

    软件工程中涉及的多种图表

    软件工程中涉及多种图表&#xff08;Diagram&#xff09;&#xff0c;它们用于不同阶段的需求分析、系统设计、实现和维护。以下是常见的图表类型及其之间的转化关系&#xff1a; 一、主要图表分类 1. 需求分析阶段 用例图&#xff08;Use Case Diagram&#xff09; 描述系统…

    基于SpringBoot的校园消费点评管理系统

    作者&#xff1a;计算机学姐 开发技术&#xff1a;SpringBoot、SSM、Vue、MySQL、JSP、ElementUI、Python、小程序等&#xff0c;“文末源码”。 专栏推荐&#xff1a;前后端分离项目源码、SpringBoot项目源码、Vue项目源码、SSM项目源码、微信小程序源码 精品专栏&#xff1a;…

    TCP半连接、长连接

    在 TCP 三次握手的时候&#xff0c;Linux 内核会维护两个队列&#xff0c;分别是&#xff1a; 半连接队列(SYN 队列)全连接队列(accept 队列) 服务端收到客户端发起的 SYN 请求后&#xff0c;内核会把该连接存储到半连接队列&#xff0c;服务端收到第三次握手的 ACK 后&#x…

    钉钉快捷免登录 通过浏览器打开第三方系统,

    一、钉钉内跳转至浏览器的实现 使用钉钉JSAPI的跳转接口 在钉钉内通过dd.biz.navigation.openLink方法强制在系统浏览器中打开链接。此方法需在钉钉开发者后台配置应用权限&#xff0c;确保应用具备调用该API的资格37。 示例代码&#xff1a; dd.ready(() > {dd.biz.navigat…

    vscode@右键文件夹或文件vscode打开一键配置

    文章目录 abstract一键脚本在线下载代码并运行说明备用源码 abstract 有两大类方法:用vscode安装包重新安装,在双击安装包后勾选上相关选项(添加右键vscode打开菜单)另一类是你不想重新安装,现在也可以很方便的一键配置(还可以完成一定的自定义设置,比如菜单名称) 一键脚本 …

    Unity VRoid+Blender+Unity 3D人物模型导入使用

    Unity VRoid模型导出VRM后,经Blender导出FBX格式, 再放入Unity中调整的全过程实操 实在没有最新的解决方案,只能参考老视频教程 VRoid (.vrm) 导入Blender导入Unity和动画 详解全流程_哔哩哔哩_bilibili 诸多尝试后,整理出必要的软件版本搭配如下: VRoid: 由于导出的VRM模型…

    【透过 C++ 实现数据结构:链表、数组、树和图蕴含的逻辑深度解析】

    一、数组 (Array) 实现 1. 基础数组 #include <iostream> using namespace std;int main() {// 静态数组int staticArr[5] {1,2,3,4,5};// 动态数组int size 5;int* dynamicArr new int[size];// 访问元素cout << "Third element: " << dynam…

    全面汇总windows进程通信(二)

    在Windows操作系统下,实现进程间通信(IPC, Inter-Process Communication)有几种常见的方法,包括使用管道(Pipe)、共享内存(Shared Memory)、消息队列(Message Queue)、命名管道(Named Pipe)、套接字(Socket)等。本文介绍如下几种: 信号量(Semaphore)和互斥量(…