第四节:读写锁——并发读写的优雅解法
大家好,我是你们的Linux内核讲师。今天我们来聊聊读写锁。
说实话,我在刚接触内核并发编程时,有个困惑一直挥之不去:自旋锁虽然好用,但很多时候我们明明只是读数据,为什么也要让其他读者等着? 这就像图书馆里一群人都在看书,结果来一个人把门锁了,所有人都得排队进——这合理吗?
显然不合理。于是,读写锁(rwlock)应运而生。
一、读写锁的概念与原理
读写锁的核心思想很简单:读读不互斥,读写互斥,写写互斥。
什么意思呢?
- 多个线程可以同时持有读锁——大家一起读,没问题
- 但只要有一个线程想写,它就必须等所有读者释放锁
- 写锁持有期间,任何读者或其他写者都不能进入
你想想看,这其实是对现实场景的精准映射。我做过一个网络包处理模块,统计数据显示,读操作占了90%以上,写操作不到10%。如果用自旋锁,那90%的读操作都在互相阻塞,性能损失太大了。
核心原则:读写锁适用于“读多写少”的场景。如果读写比例接近1:1,读写锁反而可能比自旋锁更慢——因为锁管理的开销变大了。
读写锁在Linux内核中的实现,底层其实还是依赖自旋锁或原子操作。但它在逻辑上做了分层:锁的状态分为“空闲”、“读锁定”和“写锁定”三种。读者进来时,如果当前是空闲或读锁定状态,就直接把读者计数加1;写者进来时,必须等读者计数为0才能拿到锁。
嗯,这里要注意:读写锁是有“写者饥饿”风险的。如果读者源源不断,写者可能永远拿不到锁。Linux内核的读写锁实现默认是“写者优先”的——一旦有写者在等待,新来的读者会被阻塞。这一点我在调优一个数据库缓存模块时踩过坑,后面会细说。
二、读写锁的API
Linux内核提供了两套读写锁API:经典的rwlock_t和RCU(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()必须等所有读者离开后才能执行。
实际项目中,我还会在写路径里加一个引用计数或序列号,让读者能检测到数据是否在读取过程中被修改了。不过那是更高级的话题,我们后面再聊。
五、知识体系总览
为了帮你更直观地理解读写锁在整个内核同步机制中的位置,我画了一张图:
这张图把读写锁的知识点串起来了。你可以看到,读写锁是自旋锁的“升级版”,但不是万能的。选型时一定要先问自己:我的场景是读多写少吗?临界区够大吗?写者能接受偶尔的延迟吗?
好了,读写锁的内容就讲到这里。记住一句话:工具没有好坏,只有合不合适。读写锁用对了,性能起飞;用错了,反而比自旋锁更慢。