常见的“锁”有哪些?

悲观锁

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

1.互斥锁

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

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

#include

#include

#include

std::mutex mtx;

int sharedResource = 0;

void increment() {

std::lock_guard 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

#include

#include

std::shared_mutex rwMutex;

int sharedData = 0;

void readData() {

std::shared_lock lock(rwMutex);

std::cout << "Read data: " << sharedData << std::endl;

}

void writeData() {

std::unique_lock 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

#include

#include

#include

// 共享数据结构

struct SharedData {

int value; // 数据的实际值

std::atomic 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, std::atomic, std::atomic

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

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

注:原子锁通常不能锁容器类型。原因:原子锁基于硬件提供的原子操作,拿CAS举例,就是先比较,如果相等就交换,这个过程是原子的,而对于容器类型通常包含多个元素,并且涉及复杂的操作(增删改查),比如向容器插入元素,可能涉及内存分配、元素构造等操作,而这两个操作就不能保证原子性。比如一个线程在插入一个元素时,另一个线程正在读取元素,原子操作就无法保证该情况下的线程安全。

什么是原子操作?

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

1.自旋锁

什么是自旋锁?

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

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

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

工作原理

检查标志状态:线程首先检查布尔标志的当前值。设置标志为 true:如果标志当前为 false,表示锁未被占用,线程将标志设置为 true,表示成功获取到锁;如果标志当前为 true,表示锁已被其他线程占用,线程未能获取到锁。返回旧值:test_and_set 操作会返回标志的旧值。线程可以根据这个返回值判断是否成功获取到锁。如果返回 false,说明成功获取到锁;如果返回 true,则需要等待锁被释放后再次尝试获取。

#include

#include

#include

#include

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 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;

}

自旋锁优点:

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

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

自旋锁缺点:

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

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

自旋锁的适用场景:

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

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

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

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

递归锁

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

原理

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

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

应用场景:

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

注意事项:

1. 性能开销

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

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

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

2. 死锁风险

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

#include

#include

#include

std::recursive_mutex recMutex;

void faultyRecursiveFunction(int n) {

if (n == 0) return;

std::lock_guard 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

#include

#include

std::recursive_mutex recMutex;

// 递归函数,多次获取递归锁

void recursiveFunction(int n) {

if (n == 0) return;

// 加锁

std::lock_guard 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 时,锁才会释放,其他线程才可以获取该锁。

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