文章
怎么使用读写锁
读写锁(Read-Write Lock),也被称为共享-排他锁(Shared-Exclusive Lock),是并发编程中一种重要的同步机制,旨在解决互斥锁在读多写少场景下的性能瓶颈。
目录
读写锁(Read-Write Lock),也被称为共享-排他锁(Shared-Exclusive Lock),是并发编程中一种重要的同步机制,旨在解决互斥锁在读多写少场景下的性能瓶颈。它允许更高的并发度,允许多个线程同时读取共享资源,但在写入时则保证独占访问。#
1. 读写锁:What & Why#
1.1. 是什么 (What is it?)#
读写锁是一种优化版的锁机制。它遵循以下核心规则:
- 读读兼容: 多个读线程可以同时持有读锁,并发读取共享资源。
- 读写互斥: 当有读线程持有读锁时,写线程不能获取写锁;反之,当写线程持有写锁时,读线程不能获取读锁。
- 写写互斥: 任何时候都只允许一个写线程持有写锁,独占性地修改共享资源。 这种设计使得在读取操作远多于写入操作的场景下,系统能够实现更高的并发性能,因为它避免了所有读操作也被互斥锁串行化的问题。
1.2. 为什么引入 (Why was it introduced?)#
引入读写锁的主要驱动力是为了解决标准**互斥锁(Mutex)**在特定并发模式下的效率低下问题:
- 互斥锁的局限性: 传统的互斥锁无论读写都独占资源,即使是多个线程只是想读取数据,也必须排队等待,这在读操作频繁的场景下会严重限制并发性。
- 性能提升: 读写锁通过允许多个并发读取来突破这一限制,显著提升了在读多写少应用中的性能。
2. 读写锁的实现与支持:How#
读写锁的内部实现通常比简单互斥锁复杂,一般会包含一个或多个互斥量、条件变量、读者计数器以及写者状态标志,以协调读写请求。不同语言的实现机制如下:
2.1. C++#
- 机制: C++17 标准引入了
std::shared_mutex。它提供lock()/unlock()用于写锁,以及lock_shared()/unlock_shared()用于读锁。为了保证异常安全和简化资源管理,通常推荐使用 RAII (Resource Acquisition Is Initialization) 风格的锁卫(Lock Guards):std::unique_lock<std::shared_mutex>用于写锁,而std::shared_lock<std::shared_mutex>用于读锁。std::shared_mutex的实现通常是写者优先的,即当有写线程等待时,后续的读请求会被阻塞,以防止写者饥饿。 - 示例:
#include #include <shared_mutex> // C++17 #include #include #include #include
std::string shared_data = "Initial data"; std::shared_mutex rw_mutex; // 读写锁实例
void reader(int id) { for (int i = 0; i < 5; ++i) { std::shared_lockstd::shared_mutex lock(rw_mutex); // 获取读锁 std::cout << "Reader " << id << " reads: " << shared_data << std::endl; lock.unlock(); // 释放读锁 (shared_lock会在作用域结束时自动释放,这里显式调用用于演示) std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟读取耗时 } }
void writer(int id) { for (int i = 0; i < 2; ++i) { std::unique_lockstd::shared_mutex lock(rw_mutex); // 获取写锁 shared_data = "Data written by writer " + std::to_string(id) + " at " + std::to_string(i); std::cout << "Writer " << id << " writes: " << shared_data << std::endl; lock.unlock(); // 释放写锁 std::this_thread::sleep_for(std::chrono::milliseconds(500)); // 模拟写入耗时 } }
int main() { std::vectorstd::thread threads;
// 创建3个读线程
for (int i = 0; i < 3; ++i) {
threads.emplace_back(reader, i);
}
// 创建1个写线程
threads.emplace_back(writer, 0);
// 创建另外2个读线程
for (int i = 3; i < 5; ++i) {
threads.emplace_back(reader, i);
}
for (auto& t : threads) {
t.join();
}
std::cout << "Final data: " << shared_data << std::endl;
return 0;
} ```
2.2. Java#
- 机制: Java 在
java.util.concurrent.locks包中提供了ReadWriteLock接口,其标准库实现是ReentrantReadWriteLock。它支持重入(Reentrancy),即持有锁的线程可以再次获取同类型的锁。ReentrantReadWriteLock的实现通常也是写者优先的,以避免写者饥饿问题。你需要通过readLock()和writeLock()方法分别获取读锁和写锁的Lock实例,然后调用它们的lock()和unlock()方法。 - 示例:
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReadWriteLockDemo { private String sharedData = "Initial data"; private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock(); private final ReentrantReadWriteLock.ReadLock readLock = rwLock.readLock(); private final ReentrantReadWriteLock.WriteLock writeLock = rwLock.writeLock();
public void read(int id) {
for (int i = 0; i < 5; ++i) {
readLock.lock(); // 获取读锁
try {
System.out.println("Reader " + id + " reads: " + sharedData);
Thread.sleep(100); // 模拟读取耗时
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
readLock.unlock(); // 释放读锁
}
}
}
public void write(int id) {
for (int i = 0; i < 2; ++i) {
writeLock.lock(); // 获取写锁
try {
sharedData = "Data written by writer " + id + " at " + i;
System.out.println("Writer " + id + " writes: " + sharedData);
Thread.sleep(500); // 模拟写入耗时
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
writeLock.unlock(); // 释放写锁
}
}
}
public static void main(String[] args) {
ReadWriteLockDemo demo = new ReadWriteLockDemo();
Thread[] threads = new Thread[6];
// 创建3个读线程
for (int i = 0; i < 3; ++i) {
threads[i] = new Thread(() -> demo.read(i), "Reader-" + i);
}
// 创建1个写线程
threads[3] = new Thread(() -> demo.write(0), "Writer-0");
// 创建另外2个读线程
for (int i = 4; i < 6; ++i) {
threads[i] = new Thread(() -> demo.read(i), "Reader-" + i);
}
for (Thread t : threads) {
t.start();
}
for (Thread t : threads) {
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("Final data: " + demo.sharedData);
}
} ```
2.3. Go#
- 机制: Go 语言在
sync包中提供了sync.RWMutex。这是一个读写互斥锁,其零值(默认值)即可直接使用。sync.RWMutex通常采用写者优先策略,防止写者饥饿。它提供Lock()和Unlock()用于写锁,以及RLock()和RUnlock()用于读锁。Go 语言的defer关键字非常适合用于确保锁的释放。 - 示例:
package main
import ( "fmt" "sync" "time" )
var sharedData = "Initial data" var rwMutex sync.RWMutex // 读写锁实例
func reader(id int) { for i := 0; i < 5; i++ { rwMutex.RLock() // 获取读锁 fmt.Printf("Reader %d reads: %s\n", id, sharedData) rwMutex.RUnlock() // 释放读锁 time.Sleep(100 * time.Millisecond) // 模拟读取耗时 } }
func writer(id int) { for i := 0; i < 2; i++ { rwMutex.Lock() // 获取写锁 sharedData = fmt.Sprintf("Data written by writer %d at %d", id, i) fmt.Printf("Writer %d writes: %s\n", id, sharedData) rwMutex.Unlock() // 释放写锁 time.Sleep(500 * time.Millisecond) // 模拟写入耗时 } }
func main() { var wg sync.WaitGroup
// 创建3个读协程
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
reader(id)
}(i)
}
// 创建1个写协程
wg.Add(1)
go func(id int) {
defer wg.Done()
writer(id)
}(0)
// 创建另外2个读协程
for i := 3; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
reader(id)
}(i)
}
wg.Wait()
fmt.Printf("Final data: %s\n", sharedData)
} ```
2.4. Rust#
- 机制: Rust 标准库在
std::sync模块中提供了RwLock<T>。Rust 的RwLock具有强大的所有权和借用检查特性,它将共享数据封装在锁内部。通过read()方法获取一个RwLockReadGuard(读锁),通过write()方法获取一个RwLockWriteGuard(写锁)。这些 Guard 是智能指针,它们会在超出作用域时自动drop,从而安全地释放锁,提供了出色的恐慌安全(panic safety)。Rust 的RwLock通常不保证公平性,可能导致读者饥饿。 - 示例:
use std::sync::{RwLock, Arc}; // Arc用于多线程共享所有权 use std::thread; use std::time::Duration;
fn main() { // RwLock 通常会封装共享数据,并使用Arc进行跨线程共享 let shared_data = Arc::new(RwLock::new(String::from("Initial data")));
let mut handles = vec![];
// 创建3个读线程
for i in 0..3 {
let data_clone = Arc::clone(&shared_data);
handles.push(thread::spawn(move || {
for _ in 0..5 {
// 获取读锁。read()返回Result,unwrap()处理Ok变体
let data_guard = data_clone.read().unwrap();
println!("Reader {} reads: {}", i, *data_guard);
// data_guard 在这里作用域结束时自动释放锁
thread::sleep(Duration::from_millis(100)); // 模拟读取耗时
}
}));
}
// 创建1个写线程
let data_clone_writer = Arc::clone(&shared_data);
handles.push(thread::spawn(move || {
for i_val in 0..2 {
// 获取写锁
let mut data_guard = data_clone_writer.write().unwrap();
*data_guard = format!("Data written by writer {} at {}", 0, i_val);
println!("Writer {} writes: {}", 0, *data_guard);
// data_guard 在这里作用域结束时自动释放锁
thread::sleep(Duration::from_millis(500)); // 模拟写入耗时
}
}));
// 创建另外2个读线程
for i in 3..5 {
let data_clone = Arc::clone(&shared_data);
handles.push(thread::spawn(move || {
for _ in 0..5 {
let data_guard = data_clone.read().unwrap();
println!("Reader {} reads: {}", i, *data_guard);
thread::sleep(Duration::from_millis(100));
}
}));
}
for handle in handles {
handle.join().unwrap();
}
// 最终数据
let final_data = shared_data.read().unwrap();
println!("Final data: {}", *final_data);
} ```#
3. 使用读写锁的注意事项#
尽管读写锁能提升性能,但在使用时仍需谨慎,以避免常见的并发问题:
- 死锁 (Deadlock):
- 读锁升级为写锁: 如果线程在持有读锁的同时尝试获取写锁,而大多数标准库的读写锁不直接支持这种“升级”操作,这极易导致死锁。正确的做法是先释放读锁,再尝试获取写锁。在这期间,数据可能会被其他写者修改,需要应用程序层面的重新验证或处理。
- 多锁顺序: 如果线程需要获取多个读写锁,务必确保所有线程都遵循一致的锁获取顺序,否则可能陷入死锁。
- 饥饿 (Starvation):
- 读者饥饿: 在写者优先的读写锁实现中(C++、Java、Go 的标准库默认行为),如果写请求持续不断,写线程可能会一直获取锁,导致等待的读线程长时间无法获取读锁。
- 写者饥饿: 在读者优先的读写锁实现中(较少见),如果读请求持续不断,读线程可能会一直获取锁,导致等待的写线程长时间无法获取写锁。
- 解决方案: 理想情况下应选择支持**公平(Fair)**策略的读写锁实现(如果库提供),或者在应用层面设计退避(backoff)和重试机制。
- 性能考量:
- 开销: 读写锁的内部实现通常比互斥锁更复杂,其获取和释放的开销也更大。如果共享资源是写多读少,或者读写比例接近,使用简单的互斥锁可能反而更高效。
- 临界区长度: 读写锁的临界区不宜过长。长时间持有读或写锁都会阻塞其他线程,从而降低并发性。
- 缓存伪共享 (False Sharing): 在多核 CPU 环境下,如果被锁保护的共享数据很小,且与锁本身的数据结构或其他不相关的数据位于同一个 CPU 缓存行(Cache Line)中,可能导致缓存伪共享,从而降低性能。
- 异常安全/恐慌安全 (Exception Safety/Panic Safety):
- 在 C++ 和 Java 中,如果在使用裸锁(
lock()/unlock())时发生异常,需要确保在finally块或 RAII 机制中正确释放锁,否则会导致死锁。因此,C++ 强制推荐使用std::unique_lock和 ****std::shared_lock,Java 推荐使用try-finally。 - Rust 的
RwLockGuard和 Go 的defer关键字提供了原生的安全机制,确保锁在函数返回或恐慌时自动释放。
- 在 C++ 和 Java 中,如果在使用裸锁(
- 可重入性 (Reentrancy):
- 只有 Java 的
ReentrantReadWriteLock是可重入的,这意味着持有读锁的线程可以再次获取读锁,持有写锁的线程可以再次获取写锁。 - C++ 的
std::shared_mutex和 Go 的sync.RWMutex通常不是可重入的。尝试重新获取已持有的锁可能会导致死锁,需要开发者明确这种行为。
- 只有 Java 的
4. 读写锁的典型应用场景#
读写锁的最佳舞台是那些读操作远多于写操作的共享资源。在这种场景下,它能显著提升系统的并发吞吐量:
- 缓存系统 (Caching Systems):
- 描述: 各种内存缓存(如Web服务器缓存、数据库查询缓存)频繁被查询,但更新或失效相对较少。
- 应用: 读取缓存数据时加读锁,更新或清理缓存时加写锁。
- 优点: 允许大量并发读请求同时访问缓存,极大地提高了响应速度和缓存命中率。
- 配置管理 (Configuration Management):
- 描述: 应用程序的配置信息(如数据库连接、服务端口)通常在启动时加载一次,之后被频繁读取,只有在管理员调整配置时才会写入。
- 应用: 读取配置信息时加读锁,修改配置时加写锁。
- 优点: 大量并发线程可以同时读取配置,只有配置更新时才有短暂阻塞。
- 并发数据结构 (Concurrent Data Structures):
- 描述: 对于自定义的复杂数据结构,如内存中的链表、树、哈希表,查询(读)操作远多于插入、删除、修改(写)操作。
- 应用: 遍历、查找等只读操作加读锁;插入、删除、修改等操作加写锁。
- 优点: 多个线程可以同时进行查询,只有在修改数据结构时才需要排他访问。
- 统计计数器或聚合器:
- 描述: 对某个指标(如在线用户数、商品总销量)进行频繁查询,但每次更新(新用户上线、新订单)的频率相对较低。
- 应用: 读取计数器值加读锁,更新计数器值加写锁。
- 优点: 大多数时间可以并发读取,避免互斥锁的瓶颈。
- 内存中的字典/映射表:
- 描述: 维护一个键值对集合,其中查询某个键的值的频率远高于添加、删除或更新键值对的频率。
- 应用: 查询操作加读锁,修改操作加写锁。
- 优点: 高效支持高并发的查找请求。 读写锁是并发编程工具箱中的一个强大工具,尤其适用于读密集型的工作负载。然而,正确地选择和使用它,需要对底层机制、语言特性以及潜在的并发问题有深入的理解和考量。
📎 参考文章#
- 一些引用
- 引用文章