[toc]

include/linux/rwlock.h 读写锁(Read-Write Lock) 允许多读者并发访问的同步原语

历史与背景

这项技术是为了解决什么特定问题而诞生的?

读写锁(Read-Write Lock, rwlock)是为了解决一个非常普遍的并发访问模式的性能问题:对共享数据的访问是“读多写少”

在使用简单的互斥锁(mutex)或自旋锁(spinlock)时,任何时候都只允许一个任务进入临界区,无论它是读取还是写入。这对于读操作来说是过度限制的,因为读操作本身不会修改数据,允许多个读者同时读取是安全的。如果一个数据结构被内核中成百上千个地方频繁地读取,而只被少数几个地方偶尔写入,那么使用互斥锁会导致所有读者被迫串行执行,极大地降低了系统的并发性能。

rwlock的诞生就是为了优化这种场景。它提供了一种更精细的锁定机制:

  • 读锁(共享锁):允许多个读者同时持有读锁。
  • 写锁(排他锁):与互斥锁一样,任何时候只允许一个写者持有写锁。
  • 互斥规则:当有任何读者持有读锁时,写者必须等待;当有写者持有写锁时,所有读者和其他写者都必须等待。

它的发展经历了哪些重要的里程碑或版本迭代?

Linux内核中的读写锁实现为了平衡性能和公平性,经历了持续的演进。

  • 区分自旋和睡眠版本:一个关键的里程碑是将读写锁机制分化为两种具体的实现:
    • rwlock_t (include/linux/rwlock.h):这是基于自旋锁的实现。获取不到锁的任务会自旋(忙等待),因此它可以在任何上下文(包括中断)中使用,但临界区必须极短且不能睡眠。
    • rw_semaphore (include/linux/rwsem.h):这是基于睡眠锁的实现。获取不到锁的任务会睡眠,因此它只能在进程上下文中使用,但临界区可以较长并允许睡眠。
  • 公平性策略的演进:早期的读写锁实现可能会倾向于读者(reader-biased),这会导致在读操作繁忙时,写者被“饿死”。现代Linux内核的rwlock实现经过了大量优化,采用了一种更公平的策略,通常会优先考虑等待中的写者,以防止写者饥饿。当一个写者在等待锁时,后续到来的新读者也会被阻塞,直到写者完成操作。
  • 性能优化:与自旋锁类似,rwlock_t的底层实现也从简单的原子计数器演变为更复杂的、对SMP缓存更友好的设计,以减少在多核高竞争下的缓存行争用。

目前该技术的社区活跃度和主流应用情况如何?

读写锁是内核中最基础、最重要的同步原语之一。其代码非常核心和稳定。社区的活跃度主要体现在对其在不同架构和负载下的性能进行微调。
它被广泛应用于内核中符合“读多写少”模式的各种场景:

  • 网络子系统:保护路由表、防火墙规则等。这些数据被每个网络包读取,但只在配置变更时写入。
  • 文件系统:保护目录缓存(dcache)和inode缓存中的某些数据结构。
  • 进程管理:著名的tasklist_lock就是一个读写锁,用于保护全局的进程列表。当ps等命令遍历进程时,获取读锁;当创建或销毁进程时,获取写锁。

核心原理与设计

它的核心工作原理是什么?

rwlock_t的核心是利用一个**原子变量(通常是一个32位整数)**来同时编码读者数量和写者状态。

  1. 状态编码:这个原子变量通常被分成两部分。例如,一个常见的实现是:

    • 如果值为0,表示锁未被持有。
    • 如果值为正数,表示读者的数量。
    • 如果值为某个特殊的负数(或设置了某个高位bit),表示有一个写者持有锁。
  2. 获取读锁 (read_lock)

    • 快速路径:原子地给计数器加一。如果结果是正数,说明之前没有写者,成功获取读锁。
    • 慢速路径:如果原子加一后的结果是负数(意味着有一个写者正在持有或等待锁),则需要回退操作(原子减一),然后进入一个自旋循环,等待写者释放锁。
  3. 获取写锁 (write_lock)

    • 快速路径:通过一次原子的比较并交换操作,尝试将计数器从0(未锁定)设置为“写入”状态。如果成功,获取写锁。
    • 慢速路径:如果锁不为0(意味着有读者或另一个写者),则进入自旋循环,等待锁的计数值变为0。
  4. 释放读锁 (read_unlock)

    • 原子地给计数器减一。如果减一后计数器变为0,并且有写者在等待,则需要唤醒等待的写者。
  5. 释放写锁 (write_unlock)

    • 原子地将锁的状态设置回0(未锁定)。然后唤醒所有在等待的读者和写者。

它的主要优势体现在哪些方面?

  • 提高并发性:对于读多写少的场景,它能极大地提高系统的吞吐量,因为它允许多个读操作并行执行。
  • 清晰的语义read_lock/write_lock的API清晰地表达了开发者对临界区内操作的意图。

它存在哪些已知的劣势、局限性或在特定场景下的不适用性?

  • 更高的开销:与简单的自旋锁相比,读写锁的实现更复杂,其原子操作和逻辑判断也更多。在无竞争或写操作频繁的场景下,它的性能可能反而不如简单的自旋锁。
  • 饥饿问题:尽管现代实现试图变得公平,但在极端的负载下,读者或写者仍然可能面临饥饿问题。
  • 使用场景的判断:需要开发者准确地判断工作负载是否真的是“读多写少”,否则使用它可能是一种负优化。

使用场景

在哪些具体的业务或技术场景下,它是首选解决方案?

rwlock是保护被频繁读取、但很少被修改的共享数据的首选方案。

  • 保护配置数据:一个数据结构在初始化时被写入一次,之后在系统的整个运行期间只被读取。
  • 保护缓存的查找结构:如上文提到的dcache、路由表等,查找操作(读)远多于插入/删除操作(写)。
  • tasklist_lock:遍历系统中的所有进程是一个读操作,而创建/销毁进程是一个写操作。显然,前者发生的频率远高于后者。

是否有不推荐使用该技术的场景?为什么?

  • 写操作频繁:如果读写操作的比例接近甚至超过1:1,那么使用rwlock的开销会使其性能劣于简单的spinlockmutex
  • 临界区非常简单且竞争不激烈:如果只是保护一个整数的修改,且竞争很少,那么一个简单的spinlock通常更轻量、更高效。
  • seqlockRCU是更好的选择时
    • 如果写者的性能至关重要,绝对不能被读者阻塞,并且被保护的数据不含指针,那么seqlock是更好的选择。
    • 如果读者的性能要求达到极致(完全无锁),并且可以接受写者付出复制数据和等待“宽限期”的代价,那么RCU是最终的解决方案。

对比分析

请将其 与 其他相似技术 进行详细对比。

特性 读写锁 (RW_Lock) 自旋锁/互斥锁 (Spinlock/Mutex) 顺序锁 (Seqlock) RCU (Read-Copy-Update)
并发模型 读者共享 / 写者互斥 完全互斥 写者优先,读者乐观验证 读者无锁 / 写者复制更新
读者开销(无争用) 中等 (原子操作) 中等 (原子操作) 极低 (普通内存读) 几乎为零 (普通内存读)
读者行为(有争用) 阻塞 (自旋或睡眠) 阻塞 (自旋或睡眠) 自旋重试 无阻塞 (看到旧数据)
写者饥饿 可能 (现代实现已缓解) 不适用 不会 不会
数据约束 不能含指针 指针管理复杂,需要遵循RCU规则
适用场景 读多写少的通用场景。 读写均衡,或写操作频繁。 读多、写极少且极快,写者性能关键。 读极多,读者性能要求极致,可容忍数据轻微延迟。

include/linux/rwlock_types.h

DEFINE_RWLOCK

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#ifndef CONFIG_PREEMPT_RT
/*
* 泛型 RWLOCK 类型定义和初始值设定项
*
*/
typedef struct {
arch_rwlock_t raw_lock;
} rwlock_t;

#define __RW_LOCK_UNLOCKED(lockname) \
(rwlock_t) { .raw_lock = __ARCH_RW_LOCK_UNLOCKED, \
RW_DEP_MAP_INIT(lockname) }

#define DEFINE_RWLOCK(x) rwlock_t x = __RW_LOCK_UNLOCKED(x)

include/linux/rwlock.h

write_lock

1
2
#define _raw_write_lock(lock)	__LOCK(lock)
#define write_lock(lock) _raw_write_lock(lock)

read_lock

1
2
#define _raw_read_lock(lock)	__LOCK(lock)
#define read_lock(lock) _raw_read_lock(lock)

write_unlock

1
2
#define _raw_write_unlock(lock)			__UNLOCK(lock)
#define write_unlock(lock) _raw_write_unlock(lock)

read_unlock

1
2
#define _raw_read_unlock(lock)			__UNLOCK(lock)
#define read_unlock(lock) _raw_read_unlock(lock)