第四节:读写锁——并发读写的优雅解法

大家好,我是你们的Linux内核讲师。今天我们来聊聊读写锁。

说实话,我在刚接触内核并发编程时,有个困惑一直挥之不去:自旋锁虽然好用,但很多时候我们明明只是读数据,为什么也要让其他读者等着? 这就像图书馆里一群人都在看书,结果来一个人把门锁了,所有人都得排队进——这合理吗?

显然不合理。于是,读写锁(rwlock)应运而生。

一、读写锁的概念与原理

读写锁的核心思想很简单:读读不互斥,读写互斥,写写互斥

什么意思呢?

  • 多个线程可以同时持有读锁——大家一起读,没问题
  • 但只要有一个线程想写,它就必须等所有读者释放锁
  • 写锁持有期间,任何读者或其他写者都不能进入

你想想看,这其实是对现实场景的精准映射。我做过一个网络包处理模块,统计数据显示,读操作占了90%以上,写操作不到10%。如果用自旋锁,那90%的读操作都在互相阻塞,性能损失太大了。

核心原则:读写锁适用于“读多写少”的场景。如果读写比例接近1:1,读写锁反而可能比自旋锁更慢——因为锁管理的开销变大了。

读写锁在Linux内核中的实现,底层其实还是依赖自旋锁或原子操作。但它在逻辑上做了分层:锁的状态分为“空闲”、“读锁定”和“写锁定”三种。读者进来时,如果当前是空闲或读锁定状态,就直接把读者计数加1;写者进来时,必须等读者计数为0才能拿到锁。

嗯,这里要注意:读写锁是有“写者饥饿”风险的。如果读者源源不断,写者可能永远拿不到锁。Linux内核的读写锁实现默认是“写者优先”的——一旦有写者在等待,新来的读者会被阻塞。这一点我在调优一个数据库缓存模块时踩过坑,后面会细说。

二、读写锁的API

Linux内核提供了两套读写锁API:经典的rwlock_tRCU(Read-Copy-Update)。今天我们重点讲rwlock_t,RCU后面有专门章节。

先看定义和初始化:

// 静态初始化
rwlock_t my_rwlock = __RW_LOCK_UNLOCKED(my_rwlock);

// 动态初始化
rwlock_t my_rwlock;
rwlock_init(&my_rwlock);

加锁和解锁的API如下:

操作 读锁API 写锁API
加锁 read_lock(&lock) write_lock(&lock)
尝试加锁 read_trylock(&lock) write_trylock(&lock)
解锁 read_unlock(&lock) write_unlock(&lock)
中断安全版 read_lock_irqsave() write_lock_irqsave()

我个人习惯:只要在中断上下文中可能访问共享数据,一律用irqsave版本。别问我为什么——我曾经因为省事用了普通版本,结果半夜被叫起来修bug,那种滋味不好受。

小技巧:read_trylock()和write_trylock()返回非0表示成功,0表示失败。这在需要“能读就读,读不到就做别的事”的场景下非常有用。

三、读写锁与自旋锁的对比

我们来做个直观对比:

对比维度 自旋锁 读写锁
并发读 互斥,只能一个读 多个读者可同时进入
并发写 互斥 互斥
读-写并发 互斥 互斥
适用场景 临界区小,操作快 读多写少,临界区较大
开销 低(纯自旋) 稍高(维护读者计数)
写者饥饿 不存在 可能存在(需写者优先策略)

说白了,自旋锁是“一刀切”的互斥,读写锁是“看人下菜碟”的互斥。选择哪个,取决于你的数据访问模式。

我记得有一次优化一个路由表查询模块,原来用的自旋锁,压测时发现CPU大部分时间都在自旋等待。换成读写锁后,吞吐量直接翻了一倍。原因很简单:路由表是典型的读多写少——配置下发后基本不变,但查询每秒几百万次。

避坑指南:千万不要在持有读锁时去获取写锁!这会导致死锁。我曾经在一个重构项目中,不小心在读者路径里调了一个可能触发写操作的函数,结果系统直接hang住。排查了整整两天才找到原因。

四、读写锁实战案例

我们来看一个实际的内核模块例子。假设我们要保护一个全局的配置表,多个CPU核心会并发读取,但只有控制面会偶尔更新。

#include <linux/rwlock.h>
#include <linux/module.h>
#include <linux/kernel.h>

// 定义配置表结构
struct config_entry {
    int id;
    char value[64];
};

#define MAX_ENTRIES 100
static struct config_entry config_table[MAX_ENTRIES];
static rwlock_t config_lock;

// 初始化
static int __init config_init(void)
{
    rwlock_init(&config_lock);
    pr_info("Config table initialized\n");
    return 0;
}

// 读取配置——读者路径
int config_read(int id, char *out_buf, size_t buf_size)
{
    int ret = -EINVAL;

    read_lock(&config_lock);

    if (id >= 0 && id < MAX_ENTRIES) {
        strncpy(out_buf, config_table[id].value, buf_size - 1);
        out_buf[buf_size - 1] = '\0';
        ret = 0;
    }

    read_unlock(&config_lock);
    return ret;
}

// 更新配置——写者路径
int config_write(int id, const char *new_value)
{
    int ret = -EINVAL;

    write_lock(&config_lock);

    if (id >= 0 && id < MAX_ENTRIES) {
        strncpy(config_table[id].value, new_value,
                sizeof(config_table[id].value) - 1);
        config_table[id].value[sizeof(config_table[id].value) - 1] = '\0';
        ret = 0;
    }

    write_unlock(&config_lock);
    return ret;
}

module_init(config_init);
MODULE_LICENSE("GPL");

这个例子很简单,但包含了读写锁的核心用法。注意看:读路径用read_lock,写路径用write_lock。多个CPU可以同时调用config_read(),但config_write()必须等所有读者离开后才能执行。

实际项目中,我还会在写路径里加一个引用计数或序列号,让读者能检测到数据是否在读取过程中被修改了。不过那是更高级的话题,我们后面再聊。

五、知识体系总览

为了帮你更直观地理解读写锁在整个内核同步机制中的位置,我画了一张图:

读写锁知识体系 内核同步机制 自旋锁 读写锁 RCU/信号量等 原理:读读不互斥,读写/写写互斥 API:read_lock / write_lock 系列 对比自旋锁:读多写少场景优势明显 实战:配置表读写保护

这张图把读写锁的知识点串起来了。你可以看到,读写锁是自旋锁的“升级版”,但不是万能的。选型时一定要先问自己:我的场景是读多写少吗?临界区够大吗?写者能接受偶尔的延迟吗?

好了,读写锁的内容就讲到这里。记住一句话:工具没有好坏,只有合不合适。读写锁用对了,性能起飞;用错了,反而比自旋锁更慢。


专注资料整理