Linux --读者写者问题、读写锁与自旋锁

为什么需要读者写者问题?

在多线程编程中,同步 是一个永恒的话题。我们之前接触过生产者-消费者问题,它描述的是:生产者往缓冲区放数据,消费者从缓冲区取数据,两者需要互斥地访问缓冲区,同时还要在缓冲区空/满时进行等待和唤醒。

而读者写者问题是另一类经典的同步问题,它的场景是:

  • 有一块共享数据(比如一个文件、一个数据库、一块内存)。

  • 多个读者可以同时读取这块数据,因为读操作不会改变数据,所以读者之间不需要互斥。

  • 写者在写入数据时,必须独占访问,因为写操作会改变数据,如果此时有其他读者或写者在访问,就会导致数据不一致。

  • 写者与读者之间 、写者与写者之间必须互斥。

简单来说:读共享,写独占。

读者写者 vs 生产者消费者

对比项 生产者-消费者 读者-写者
核心矛盾 缓冲区空/满时的等待与唤醒 读与写、写与写之间的互斥
互斥关系 生产者与消费者互斥访问缓冲区 写者与所有人互斥,读者之间不互斥
同步关系 生产者等待缓冲区有空位,消费者等待缓冲区有数据 读者等待写者离开,写者等待所有读者离开
典型场景 消息队列、任务队列 文件读写、数据库读写、配置中心

重点理解 :读者写者问题的核心在于如何协调读者和写者之间的同步,使得读操作可以并发,写操作必须独占,同时还要避免某一方"饿死"。

读者写者问题的伪代码

公共部分

复制代码
uint32_t reader_count = 0;   // 当前正在读取的读者数量
lock_t count_lock;           // 保护 reader_count 的锁
lock_t writer_lock;          // 写者锁,读者和写者共享
  • reader_count:记录当前有多少个读者正在读。

  • count_lock:因为多个读者可能同时修改 reader_count,所以需要一把锁来保护它。

  • writer_lock:写者需要持有的锁,同时第一个读者进入时也要持有它,最后一个读者离开时释放它。

Reader(读者)

cpp 复制代码
// 加锁
lock(count_lock);
if (reader_count == 0)
    lock(writer_lock);   // 第一个读者,锁住写者锁
++reader_count;
unlock(count_lock);

// read;

// 解锁
lock(count_lock);
--reader_count;
if (reader_count == 0)
    unlock(writer_lock); // 最后一个读者,释放写者锁
unlock(count_lock);

逻辑解读:

  1. 读者先锁住 count_lock,然后检查自己是不是第一个读者。

  2. 如果是第一个读者(reader_count == 0),说明当前没有读者在读,但可能有写者在写,所以需要获取 writer_lock。如果写者正在写,读者会在这里阻塞,直到写者释放 writer_lock。

  3. 然后 reader_count++,表示自己开始读了,释放 count_lock。

  4. 读取数据。

  5. 读完后,再次锁住 count_lock,reader_count--。

  6. 如果自己是最后一个读者(reader_count == 0),说明所有读者都离开了,此时需要释放 writer_lock,让等待的写者可以进入。

  7. 释放 count_lock。

关键点 :第一个读者负责"锁住"写者,最后一个读者负责"释放"写者。中间的读者只是简单地增加/减少计数,不会触碰 writer_lock。

Writer(写者)

cpp 复制代码
lock(writer_lock);
// write
unlock(writer_lock);

逻辑解读:

  1. 写者直接尝试获取 writer_lock。

  2. 如果此时有读者正在读(writer_lock 被第一个读者持有),写者会阻塞。

  3. 如果此时有其他写者在写,写者也会阻塞。

  4. 获取到锁后,独占写入。

  5. 写完释放锁。

注意 :这个伪代码实现的是读者优先 策略。因为只要有一个读者持有 writer_lock,后续的读者都可以直接进入(它们只需要 count_lock),而写者必须等待所有读者离开。如果读者源源不断,写者可能永远等待,即写者饥饿。

读写锁(pthread_rwlock)

在实际编程中,我们不需要自己手动实现上述逻辑,POSIX 提供了读写锁 (pthread_rwlock_t),它封装了读者写者的同步机制。

读写锁的行为

当前锁状态 读锁请求 写锁请求
无锁 可以 可以
读锁 可以 阻塞
写锁 阻塞 阻塞

总结:写独占,读共享,读锁优先级高(默认)。

读写锁的接口

初始化与销毁
cpp 复制代码
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,
                        const pthread_rwlockattr_t *restrict attr);
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
  • attr 通常传 NULL,表示使用默认属性。

  • 使用前必须初始化,使用后必须销毁。

加锁与解锁
cpp 复制代码
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);  // 读锁
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);  // 写锁
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);  // 解锁
  • 读锁:多个线程可以同时持有读锁。

  • 写锁:同一时刻只能有一个线程持有写锁,且此时不能有读锁。

  • 解锁:无论是读锁还是写锁,都用同一个 unlock。

设置读写优先级
cpp 复制代码
int pthread_rwlockattr_setkind_np(pthread_rwlockattr_t *attr, int pref);

pref 有三种选择:

选项 含义
PTHREAD_RWLOCK_PREFER_READER_NP 读者优先(默认),可能导致写者饥饿
PTHREAD_RWLOCK_PREFER_WRITER_NP 写者优先,但目前有 BUG,表现和读者优先一致
PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP 写者优先,但写者不能递归加锁

注意:写者优先可以缓解写者饥饿,但可能导致读者饥饿。实际使用时需要根据场景权衡。

读者优先 vs 写者优先

  • 读者优先:只要有读者在读,后续读者可以直接进入,写者必须等待所有读者离开。可能导致写者饥饿。

  • 写者优先:一旦有写者到达,后续读者会被阻塞,直到写者完成。可能导致读者饥饿。

选择建议:

  • 如果读操作非常频繁,写操作很少,且写者饥饿不是问题,可以用读者优先。

  • 如果写操作也很重要,不能长时间等待,可以用写者优先(注意 PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP)。

自旋锁(Spinlock)

什么是自旋锁?

自旋锁是一种忙等待的锁。当一个线程尝试获取自旋锁但锁已被占用时,它不会进入休眠,而是在一个循环中不断检查锁是否可用。一旦锁被释放,线程立即获取。

类比:你去上厕所,发现门锁着,你不会回去睡觉,而是站在门口一直问"好了没?好了没?",直到里面的人出来。

自旋锁的原理

自旋锁通常使用一个原子标志位来表示锁的状态:

  • false:锁可用。

  • true:锁已被占用。

获取锁时,使用 CAS(Compare-And-Swap) 原子操作:

cpp 复制代码
while (atomic_flag_test_and_set(&spinlock)) {
    // 忙等待
}
  • atomic_flag_test_and_set:如果标志位为 false,则设置为 true 并返回 false(表示获取成功);如果为 true,则返回 true(表示获取失败)。

  • 释放锁:atomic_flag_clear(&spinlock); 将标志位设为 false。

自旋锁的优缺点

优点:

  1. 低延迟:不会让线程休眠,避免了线程切换的开销。

  2. 减少系统调度开销:等待锁的线程不会被阻塞,不需要上下文切换。

缺点:

  1. CPU 资源浪费:如果锁持有时间较长,等待线程会一直自旋,浪费 CPU。

  2. 可能引起活锁:多个线程同时自旋,如果没有退避策略,可能都无法进入临界区。

使用场景

  • 短暂等待:锁被占用的时间很短,比如简单的计数器操作。

  • 多线程锁使用:通常用于系统底层,同步多个 CPU 对共享资源的访问。

  • 不可睡眠的场景:比如中断处理程序中,不能休眠,只能用自旋锁。

Linux 提供的自旋锁系统调用

cpp 复制代码
#include <pthread.h>

int pthread_spin_lock(pthread_spinlock_t *lock);
int pthread_spin_trylock(pthread_spinlock_t *lock);
int pthread_spin_unlock(pthread_spinlock_t *lock);
int pthread_spin_init(pthread_spinlock_t *lock, int pshared);
int pthread_spin_destroy(pthread_spinlock_t *lock);
  • pshared:PTHREAD_PROCESS_PRIVATE 表示线程间共享,PTHREAD_PROCESS_SHARED 表示进程间共享。

互斥锁 vs 自旋锁 vs 读写锁

锁类型 行为 适用场景 缺点
互斥锁(Mutex) 获取失败时休眠,释放时唤醒 锁持有时间较长,竞争不激烈 上下文切换开销
自旋锁(Spinlock) 获取失败时忙等待 锁持有时间极短,多核环境 CPU 浪费,可能活锁
读写锁(Rwlock) 读共享,写独占 多读少写 可能读者/写者饥饿

选择原则:

  • 如果临界区执行时间短,且 CPU 多核,用自旋锁。

  • 如果临界区执行时间长,或者单核,用互斥锁。

  • 如果读多写少,用读写锁。

读者写者问题的变种

  1. 读者优先:只要有一个读者在读,后续读者直接进入,写者等待。

  2. 写者优先:一旦有写者等待,后续读者阻塞,写者优先进入。

  3. 公平竞争:读者和写者按到达顺序竞争,避免饥饿。

读写锁的实现细节

读写锁的实现通常需要一个计数器 和两个条件变量(或信号量):

  • reader_count:当前读者数量。

  • writer_count:当前写者数量(通常为 0 或 1)。

  • mutex:保护计数器。

  • read_cond:读者等待的条件变量。

  • write_cond:写者等待的条件变量。

读者进入时:

  • 如果写者正在写,等待。

  • 否则,reader_count++,进入读。

写者进入时:

  • 如果读者正在读或写者正在写,等待。

  • 否则,进入写。

自旋锁的优化

  • 退避策略:自旋失败后,暂停一段时间再重试,减少 CPU 浪费。

  • 队列自旋锁:每个线程在一个队列中等待,避免所有线程同时自旋。

  • 自适应自旋锁:根据历史成功率动态调整自旋时间。

实际应用中的建议

  • 避免锁的嵌套:容易导致死锁。

  • 锁的粒度要小:只保护必要的临界区。

  • 读写锁并不是解决所有并发问题的万能工具:如果写操作也很频繁,读写锁可能不如互斥锁。

  • 自旋锁不要用于单核:单核自旋没有意义,因为自旋的线程占着 CPU,持有锁的线程无法运行。

相关推荐
谷哥的小弟1 小时前
CentOS寿终正寝
linux·运维·centos
醇氧1 小时前
nohup 后台启动 uvicorn(仅测试 / 临时运行)
linux·运维·网络·python·python3.11
lisanmengmeng1 小时前
nagios的安装错误
linux
雪落漂泊1 小时前
Linux基本指令(上)
linux·运维·服务器
LongRunning1 小时前
【Linux】RK3568-系统镜像(六)
linux
阳光九叶草LXGZXJ1 小时前
达梦数据库-学习-68-dmasm0X_XXXXXX.log日志激增
linux·运维·数据库·sql·学习
KING-WU5121 小时前
Linux 工具之 yum、vim、gcc
linux·运维·服务器·后端
Mortalbreeze1 小时前
MySQL 基础篇(三):一文掌握 MySQL 常见数据类型
linux·服务器·数据库·mysql
zhangrelay2 小时前
机器人或计算机智能与时间相关案例汇总(ROS2-ROS1)
linux·笔记·学习·ubuntu·机器人