在 linux 内核中, 有很多同步机制。比较经典的有原子操作、spin_lock(忙等待的锁)、mutex(互斥锁)、semaphore(信号量)等。并且它们几乎都有对应的 rw_XXX(读写锁), 以便在能够区分读与写的情况下, 让读操作相互不互斥(读写、写写依然互斥)。而 seqlock 和 rcu 应该可以不算在经典之列, 它们是两种比较有意思的同步机制。
所谓原子操作, 就是该操作绝不会在执行完毕前被任何其他任务或事件打断, 也就说, 它的最小的执行单位, 不可能有比它更小的执行单位, 因此这里的原子实际是使用了物理学里的物质微粒的概念。
原子操作需要硬件的支持, 因此是架构相关的, 其 API 和原子类型的定义都定义在内核源码树的 include/asm/atomic.h 文件中, 它们都使用汇编语言实现, 因为 C 语言并不能实现这样的操作。
原子操作主要用于实现资源计数, 很多引用计数 (refcnt) 就是通过原子操作实现的。
互斥锁主要用于实现内核中的互斥访问功能。内核互斥锁是在原子 API 之上实现的, 但这对于内核用户是不可见的。对它的访问必须遵循一些规则: 同一时间只能有一个任务持有互斥锁, 而且只有这个任务可以对互斥锁进行解锁。互斥锁不能进行递归锁定或解锁。一个互斥锁对象必须通过其 API 初始化, 而不能使用 memset 或复制初始化。一个任务在持有互斥锁的时候是不能结束的。互斥锁所使用的内存区域是不能被释放的。使用中的互斥锁是不能被重新初始化的。并且互斥锁不能用于中断上下文。但是互斥锁比当前的内核信号量选项更快, 并且更加紧凑, 因此如果它们满足您的需求, 那么它们将是您明智的选择。
自旋锁与互斥锁有点类似, 只是自旋锁不会引起调用者睡眠, 如果自旋锁已经被别的执行单元保持, 调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁, "自旋"一词就是因此而得名。由于自旋锁使用者一般保持锁时间非常短, 因此选择自旋而不是睡眠是非常必要的, 自旋锁的效率远高于互斥锁。
信号量和读写信号量适合于保持时间较长的情况, 它们会导致调用者睡眠, 因此只能在进程上下文使用(_trylock 的变种能够在中断上下文使用), 而自旋锁适合于保持时间非常短的情况, 它可以在任何上下文使用。如果被保护的共享资源只在进程上下文访问, 使用信号量保护该共享资源非常合适, 如果对共巷资源的访问时间非常短, 自旋锁也可以。但是如果被保护的共享资源需要在中断上下文访问(包括底半部即中断处理句柄和顶半部即软中断), 就必须使用自旋锁。
自旋锁保持期间是抢占失效的, 而信号量和读写信号量保持期间是可以被抢占的。自旋锁只有在内核可抢占或 SMP 的情况下才真正需要, 在单 CPU 且不可抢占的内核下, 自旋锁的所有操作都是空操作。
跟互斥锁一样, 一个执行单元要想访问被自旋锁保护的共享资源, 必须先得到锁, 在访问完共享资源后, 必须释放锁。如果在获取自旋锁时, 没有任何执行单元保持该锁, 那么将立即得到锁; 如果在获取自旋锁时锁已经有保持者, 那么获取锁操作将自旋在那里, 直到该自旋锁的保持者释放了锁。
无论是互斥锁, 还是自旋锁, 在任何时刻, 最多只能有一个保持者, 也就说, 在任何时刻最多只能有一个执行单元获得锁。
Linux 内核的信号量在概念和原理上与用户态的 SystemV 的 IPC 机制信号量是一样的, 但是它绝不可能在内核之外使用, 因此它与 SystemV 的 IPC 机制信号量毫不相干。
信号量在创建时需要设置一个初始值, 表示同时可以有几个任务可以访问该信号量保护的共享资源, 初始值为 1 就变成互斥锁(Mutex), 即同时只能有一个任务可以访问信号量保护的共享资源。一个任务要想访问共享资源, 首先必须得到信号量, 获取信号量的操作将把信号量的值减 1, 若当前信号量的值为负数, 表明无法获得信号量, 该任务必须挂起在该信号量的等待队列等待该信号量可用; 若当前信号量的值为非负数, 表示可以获得信号量, 因而可以立刻访问被该信号量保护的共享资源。当任务访问完被信号量保护的共享资源后, 必须释放信号量, 释放信号量通过把信号量的值加 1 实现, 如果信号量的值为非正数, 表明有任务等待当前信号量, 因此它也唤醒所有等待该信号量的任务。
读写信号量对访问者进行了细分, 或者为读者, 或者为写者, 读者在保持读写信号量期间只能对该读写信号量保护的共享资源进行读访问, 如果一个任务除了需要读, 可能还需要写, 那么它必须被归类为写者, 它在对共享资源访问之前必须先获得写者身份, 写者在发现自己不需要写访问的情况下可以降级为读者。读写信号量同时拥有的读者数不受限制, 也就说可以有任意多个读者同时拥有一个读写信号量。如果一个读写信号量当前没有被写者拥有并且也没有写者等待读者释放信号量, 那么任何读者都可以成功获得该读写信号量; 否则, 读者必须被挂起直到写者释放该信号量。如果一个读写信号量当前没有被读者或写者拥有并且也没有写者等待该信号量, 那么一个写者可以成功获得该读写信号量, 否则写者将被挂起, 直到没有任何访问者。因此, 写者是排他性的, 独占性的。
读写信号量有两种实现, 一种是通用的, 不依赖于硬件架构, 因此, 增加新的架构不需要重新实现它, 但缺点是性能低, 获得和释放读写信号量的开销大; 另一种是架构相关的, 因此性能高, 获取和释放读写信号量的开销小, 但增加新的架构需要重新实现。在内核配置时, 可以通过选项去控制使用哪一种实现。
用于能够区分读与写的场合, 并且是读操作很多、写操作很少, 写操作的优先权大于读操作。
seqlock 的实现思路是, 用一个递增的整型数表示 sequence。写操作进入临界区时, sequence++; 退出临界区时, sequence 再++。写操作还需要获得一个锁(比如 mutex), 这个锁仅用于写写互斥, 以保证同一时间最多只有一个正在进行的写操作。
当 sequence 为奇数时, 表示有写操作正在进行, 这时读操作要进入临界区需要等待, 直到 sequence 变为偶数。读操作进入临界区时, 需要记录下当前 sequence 的值, 等它退出临界区的时候用记录的 sequence 与当前 sequence 做比较, 不相等则表示在读操作进入临界区期间发生了写操作, 这时候读操作读到的东西是无效的, 需要返回重试。
seqlock 写写是必须要互斥的。但是 seqlock 的应用场景本身就是读多写少的情况, 写冲突的概率是很低的。所以这里的写写互斥基本上不会有什么性能损失。
而读写操作是不需要互斥的。seqlock 的应用场景是写操作优先于读操作, 对于写操作来说, 几乎是没有阻塞的(除非发生写写冲突这一小概率事件), 只需要做 sequence++这一附加动作。而读操作也不需要阻塞, 只是当发现读写冲突时需要 retry。
seqlock 的一个典型应用是时钟的更新, 系统中每 1 毫秒会有一个时钟中断, 相应的中断处理程序会更新时钟(见《linux 时钟浅析》)(写操作)。而用户程序可以调用 gettimeofday 之类的系统调用来获取当前时间(读操作)。在这种情况下, 使用 seqlock 可以避免过多的 gettimeofday 系统调用把中断处理程序给阻塞了(如果使用读写锁, 而不用 seqlock 的话就会这样)。中断处理程序总是优先的, 而如果 gettimeofday 系统调用与之冲突了, 那用户程序多等等也无妨。
读写锁实际是一种特殊的自旋锁, 它把对共享资源的访问者划分成读者和写者, 读者只对共享资源进行读访问, 写者则需要对共享资源进行写操作。这种锁相对于自旋锁而言, 能提高并发性, 因为在多处理器系统中, 它允许同时有多个读者来访问共享资源, 最大可能的读者数为实际的逻辑 CPU 数。写者是排他性的, 一个读写锁同时只能有一个写者或多个读者(与 CPU 数相关), 但不能同时既有读者又有写者。
在读写锁保持期间也是抢占失效的。
如果读写锁当前没有读者, 也没有写者, 那么写者可以立刻获得读写锁, 否则它必须自旋在那里, 直到没有任何写者或读者。如果读写锁没有写者, 那么读者可以立即获得该读写锁, 否则读者必须自旋在那里, 直到写者释放该读写锁。