Linux内核学习——同步管理
@
linux-6.12
背景知识
临界区:是指访问和操作共享数据的代码段,这些资源无法同时被多个执行线程访问,访问临界区的执行线程或代码路径称为并发源。
为了避免临界区中的并发访问,必须保证访问临界区的原子性。
在内核中产生并发访问的并发源主要有如下4种:
- 中断和异常:中断发生后,中断处理程序和被中断的进程之间有可能产生并发访问。
- 软中断和tasklet:软中断或者tasklet可能随时被调度执行,从而打断当前正在执行的进程上下文。
- 内核抢占:调度器支持可抢占特性,会导致进程和进程之间的并发访问。
- 多处理器并发执行:多处理器上可以同时运行多个进程。
Linux内核提供了多种并发访问的保护机制,例如原子操作、自旋锁、信号量、互斥体、读写锁、RCU 等。
原子操作
原子操作是指保证指令以原子的方式执行,执行过程不会被打断。
// include/linux/types.h
typedef struct {
int counter;
} atomic_t;
同时linux 内核提供了很多原子变量操作函数,具体定义在include/linux/atomic.h
内存屏障
不同的架构提供相应的实现,必须x86架构的在arch/x86/include/asm/barrier.h
自旋锁
如果临界区只是一个变量,那么原子变量可以解决问题。但是临界区大多是一个数据操作的集合,例如先从一个数据结构中移出数据,对其进行数据解析,再写回到该数据结构或者其他数据结构中,类似read->modify->write操作;再比如临界区是一个链表操作等。整个执行过程需要保证原子性,在数据被更新完毕前,不能有其他内核代码路径访问和改写这些数据。这个过程使用原子变量显得不合适,需要锁机制来完成。自旋锁(spinlock)是Linux内核中最常见的锁机制。
自旋锁同一时刻只能被一个内核代码路径持有,如果有另一个内核代码路径试图获取一个已经被持有的自旋锁,那么该内核代码路径需要一直忙等待,直到锁持有者释放了该锁。如果该锁没有被别人持有(或争用),那么可以立即获得该锁。自旋锁的特性如下。
- 忙等待的锁机制。操作系统中锁的机制分为两类,一类是忙等待,另一类是睡眠等待。自旋锁属于前者,当无法获取自旋锁时会不断尝试,直到获取锁为止。
- 同一时刻只能有一个内核代码路径可以获得该锁。
- 要求自旋锁持有者尽快完成临界区的执行任务。如果临界区执行时间过长,在锁外面忙等待的CPU比较浪费,特别是自旋锁临界区里不能睡眠。
- 自旋锁可以在中断上下文中使用。
定义
include/linux/spinlock_types.h
/* Non PREEMPT_RT kernels map spinlock to raw_spinlock */
typedef struct spinlock {
union {
struct raw_spinlock rlock;
#ifdef CONFIG_DEBUG_LOCK_ALLOC
# define LOCK_PADSIZE (offsetof(struct raw_spinlock, dep_map))
struct {
u8 __padding[LOCK_PADSIZE];
struct lockdep_map dep_map;
};
#endif
};
} spinlock_t;
include/linux/spinlock_types_raw.h
typedef struct raw_spinlock {
arch_spinlock_t raw_lock;
#ifdef CONFIG_DEBUG_SPINLOCK
unsigned int magic, owner_cpu;
void *owner;
#endif
#ifdef CONFIG_DEBUG_LOCK_ALLOC
struct lockdep_map dep_map;
#endif
} raw_spinlock_t;
include/asm-generic/qspinlock_types.h
typedef struct qspinlock {
union {
atomic_t val;
/*
* By using the whole 2nd least significant byte for the
* pending bit, we can allow better optimization of the lock
* acquisition for the pending bit holder.
*/
#ifdef __LITTLE_ENDIAN
struct {
u8 locked;
u8 pending;
};
struct {
u16 locked_pending;
u16 tail;
};
#else
struct {
u16 tail;
u16 locked_pending;
};
struct {
u8 reserved[2];
u8 pending;
u8 locked;
};
#endif
};
} arch_spinlock_t;
自旋锁数据结构定义考虑了不同处理器体系结构的支持和实时性内核(RT patches)的要求,定义了 raw_spinlock 和 arch_spinlock_t 数据结构,其中 arch_spinlock_t 数据结构和体系结构有关,不同的架构具有不同的定义(上面是x86的通用定义)。
自旋锁变种
在中断上下文出现忙等待或者睡眠状态是致命的,中断处理程序要求“短”和“快”,锁的持有者因为被中断打断而不能尽快释放锁,而中断处理程序一直在忙等待锁,从而导致死锁的发生。
Linux内核的自旋锁的变种spin_lock_irq()函数在获取自旋锁时关闭本地CPU中断。
include/linux/spinlock.h
static __always_inline void spin_lock_irq(spinlock_t *lock)
{
raw_spin_lock_irq(&lock->rlock);
}
include/linux/spinlock_api_smp.h
static inline void __raw_spin_lock_irq(raw_spinlock_t *lock)
{
local_irq_disable();
preempt_disable();
spin_acquire(&lock->dep_map, 0, 0, _RET_IP_);
LOCK_CONTENDED(lock, do_raw_spin_trylock, do_raw_spin_lock);
}
对比spin_lock
include/linux/spinlock.h
static __always_inline void spin_lock(spinlock_t *lock)
{
raw_spin_lock(&lock->rlock);
}
include/linux/spinlock_api_smp.h
static inline void __raw_spin_lock(raw_spinlock_t *lock)
{
preempt_disable();
spin_acquire(&lock->dep_map, 0, 0, _RET_IP_);
LOCK_CONTENDED(lock, do_raw_spin_trylock, do_raw_spin_lock);
}
spin_lock_irq()函数的实现比spin_lock()函数多了一个local_irq_disable()函数,该函数用于关闭本地处理器中断,这样在获取自旋锁时可以确保不会发生中断,从而避免发生死锁问题,spin_lock_irq()主要防止本地中断处理程序和持有锁者之间存在锁的争用。
自旋锁的改进
在Linux 2.6.25内核中,为了解决自旋锁在锁争用激烈时导致的性能低下的问题,引入了“FIFO ticket-based”算法。但是ticket-based算法依然没能解决CPU cacheline bouncing现象,学术界因此提出了MCS锁的概念。MCS锁机制会导致自旋锁数据结构变大,在内核很多数据结构内嵌自旋锁结构,这些数据结构对大小很敏感,这也导致了MCS锁机制一直没能在自旋锁上应用,只能屈就于Mutex和读写信号量。
在Linux 4.2内核中引进了队列自旋锁(Queued Spinlock)机制,从Linux 4.2内核开始,队列自旋锁机制已经成为Linux x86内核的自旋锁默认实现。
信号量
信号量(semaphore)是操作系统中最常用的同步原语之一。自旋锁是实现一种忙等待的锁,信号量则允许进程进入睡眠状态。简单来说,信号量是一个计数器,它支持两个操作原语,即P和V操作。P和V取自荷兰语中的两个单词,分别表示减少和增加,后来美国人把它改成down和up,现在Linux内核里也叫这两个名字。
定义
include/linux/semaphore.h
#ifndef __LINUX_SEMAPHORE_H
#define __LINUX_SEMAPHORE_H
#include <linux/list.h>
#include <linux/spinlock.h>
/* Please don't access any members of this structure directly */
struct semaphore {
raw_spinlock_t lock;
unsigned int count;
struct list_head wait_list;
};
#define __SEMAPHORE_INITIALIZER(name, n) \
{ \
.lock = __RAW_SPIN_LOCK_UNLOCKED((name).lock), \
.count = n, \
.wait_list = LIST_HEAD_INIT((name).wait_list), \
}
/*
* Unlike mutexes, binary semaphores do not have an owner, so up() can
* be called in a different thread from the one which called down().
* It is also safe to call down_trylock() and up() from interrupt
* context.
*/
#define DEFINE_SEMAPHORE(_name, _n) \
struct semaphore _name = __SEMAPHORE_INITIALIZER(_name, _n)
static inline void sema_init(struct semaphore *sem, int val)
{
static struct lock_class_key __key;
*sem = (struct semaphore) __SEMAPHORE_INITIALIZER(*sem, val);
lockdep_init_map(&sem->lock.dep_map, "semaphore->lock", &__key, 0);
}
extern void down(struct semaphore *sem);
extern int __must_check down_interruptible(struct semaphore *sem);
extern int __must_check down_killable(struct semaphore *sem);
extern int __must_check down_trylock(struct semaphore *sem);
extern int __must_check down_timeout(struct semaphore *sem, long jiffies);
extern void up(struct semaphore *sem);
#endif /* __LINUX_SEMAPHORE_H */
lock是自旋锁变量,用于对信号量数据结构里count和wait_list成员的保护。
count表示允许进入临界区的内核执行路径个数。
wait_list链表用于管理所有在该信号量上睡眠的进程,没有成功获取锁的进程会睡眠在这个链表上。
信号量可以同时允许任意数量的锁持有者。信号量初始化函数为sema_init(struct semaphore *sem, int count),其中count的值可以大于等于1。当count大于1时,表示允许在同一时刻至多有count个锁持有者,操作系统书中把这种信号量叫作计数信号量;当count等于1时,同一时刻仅允许一个人持有锁,操作系统书中把这种信号量称为互斥信号量或者二进制信号量。在Linux内核中,大多使用count计数为1的信号量。相比自旋锁,信号量是一个允许睡眠的锁。信号量适用于一些情况复杂、加锁时间比较长的应用场景,例如内核与用户空间复杂的交互行为等。
互斥体
在Linux内核中,还有一个类似信号量的实现叫作互斥体(Mutex)。信号量是在并行处理环境中对多个处理器访问某个公共资源进行保护的机制,互斥体则用于互斥操作。
信号量根据初始化count的大小,可以分为计数信号量和互斥信号量。
定义
include/linux/mutex_types.h
struct mutex {
atomic_long_t owner;
raw_spinlock_t wait_lock;
#ifdef CONFIG_MUTEX_SPIN_ON_OWNER
struct optimistic_spin_queue osq; /* Spinner MCS lock */
#endif
struct list_head wait_list;
#ifdef CONFIG_DEBUG_MUTEXES
void *magic;
#endif
#ifdef CONFIG_DEBUG_LOCK_ALLOC
struct lockdep_map dep_map;
#endif
};
owner:用于指向锁持有者的task_struct数据结构。
wait_lock:自旋锁,用于保护wait_list睡眠等待队列。
osq:用于实现MCS锁机制
wait_list:用于管理所有在 Mutex 上睡眠的进程,没有成功获取锁的进程会睡眠在此链表上。
互斥锁实现了自旋等待的机制,准确地说,应该是互斥锁比读写信号量更早地实现了自旋等待机制。自旋等待机制的核心原理是当发现持有锁者正在临界区执行并且没有其他优先级高的进程要被调度时,那么当前进程坚信锁持有者会很快离开临界区并释放锁,因此与其睡眠等待不如乐观地自旋等待,以减少睡眠唤醒的开销。在实现自旋等待机制时,内核实现了一套MCS锁机制来保证只有一个人自旋等待持锁者释放锁。
总的来说,互斥锁比信号量的实现要高效很多。
- 互斥锁最先实现自旋等待机制。
- 互斥锁在睡眠之前尝试获取锁。
- 互斥锁实现MCS锁来避免多个CPU争用锁而导致CPU高速缓存行颠簸现象。
正是因为互斥体的简洁性和高效性,互斥体的使用场景比信号量要更严格,使用Mutex需要注意的约束条件如下。 - 同一时刻只有一个线程可以持有互斥体。
- 只有锁持有者可以解锁。不能在一个进程中持有互斥体,而在另一个进程中释放它。互斥体不适合内核同用户空间复杂的同步场景,信号量和读写信号量比较适合。
- 不允许递归地加锁和解锁。
- 当进程持有互斥体时,进程不可以退出。
- 互斥体必须使用官方API来初始化。
- 互斥体可以睡眠,所以不允许在中断处理程序或者中断下半部中使用,例如tasklet、定时器等。
在实际工程项目中,如何选择自旋锁、信号量和互斥体:
- 在中断上下文中毫不犹豫地使用自旋锁,如果临界区有睡眠、隐含睡眠的动作及内核API,应避免选择自旋锁。
在信号量和互斥体中该如何选择: - 除非代码场景不符合上述互斥体的约束中的某一条,否则都优先使用互斥体。
读写锁
上述介绍的信号量有一个明显的缺点—没有区分临界区的读写属性。读写锁通常允许多个线程并发地读访问临界区,但是写访问只限制于一个线程。读写锁能有效地提高并发性,在多处理器系统中允许同时有多个读者访问共享资源,但写者是排他的,读写锁具有如下特性:
- 允许多个读者同时进入临界区,但同一时刻写者不能进入。
- 同一时刻只允许一个写者进入临界区。
- 读者和写者不能同时进入临界区。
定义
include/linux/rwlock_types.h
typedef struct {
arch_rwlock_t raw_lock;
#ifdef CONFIG_DEBUG_SPINLOCK
unsigned int magic, owner_cpu;
void *owner;
#endif
#ifdef CONFIG_DEBUG_LOCK_ALLOC
struct lockdep_map dep_map;
#endif
} rwlock_t;
对读写锁的操作定义在include/linux/rwlock.h
读写信号量
include/linux/rwsem.h
struct rw_semaphore {
atomic_long_t count;
/*
* Write owner or one of the read owners as well flags regarding
* the current state of the rwsem. Can be used as a speculative
* check to see if the write owner is running on the cpu.
*/
atomic_long_t owner;
#ifdef CONFIG_RWSEM_SPIN_ON_OWNER
struct optimistic_spin_queue osq; /* spinner MCS lock */
#endif
raw_spinlock_t wait_lock;
struct list_head wait_list;
#ifdef CONFIG_DEBUG_RWSEMS
void *magic;
#endif
#ifdef CONFIG_DEBUG_LOCK_ALLOC
struct lockdep_map dep_map;
#endif
};
count 用于表示读写信号量的计数。以前读写信号量的实现用 activity 来表示,activity=0表示没有读者和写者,activity=−1表示有写者,activity>0表示有读者。现在count的计数方法已经发生了变化。
owner:当写者成功获取锁时,owner指向锁持有者的task_struct数据结构。
osq:MCS锁。
wait_lock是一个自旋锁变量,用于实现对读写信号量数据结构中count成员的原子操作和保护。
wait_list链表用于管理所有在该信号量上睡眠的进程,没有成功获取锁的进程会睡眠在这个链表上。
操作读写信号量的接口定义在include/linux/rwsem.h
总结
总结读写锁的重要特性:
- down_read():如果一个进程持有了读者锁,那么允许继续申请多个读者锁,申请写者锁则要睡眠等待。
- down_write():如果一个进程持有了写者锁,那么第二个进程申请该写者锁要自旋等待,申请读者锁则要睡眠等待。
- up_write()/up_read():如果等待队列中第一个成员是写者,那么唤醒该写者,否则唤醒排在等待队列中最前面连续的几个读者。
RCU
RCU的全称是read-copy-update,是Linux内核中一种重要的同步机制。
自旋锁、读写信号量和互斥锁的实现,它们都使用了原子操作指令,即原子地访问内存,多 CPU 争用共享的变量会让缓存一致性变得很糟,导致性能下降。
RCU 机制要实现的目标是希望读者线程没有同步开销,或者说同步开销变得很小,甚至可以忽略不计,不需要额外的锁,不需要使用原子操作指令和内存屏障,即可畅通无阻地访问;而把需要同步的任务交给写者线程,写者线程等待所有读者线程完成后才会把旧数据销毁。在RCU中,如果有多个写者同时存在,那么需要额外的保护机制。RCU机制的原理可以概括为RCU记录了所有指向共享数据的指针的使用者,当要修改该共享数据时,首先创建一个副本,并在副本中修改。所有读访问线程都离开读临界区之后,使用者的指针指向新修改后的副本,并且删除旧数据。
RCU 的一个重要的应用场景是链表,可有效地提高遍历读取数据的效率。读取链表成员数据时通常只需要rcu_read_lock(),允许多个线程同时读取该链表,并且允许一个线程同时修改链表。
为了这个过程能保证链表访问的正确性,在读者遍历链表时,假设另外一个线程删除了一个节点。删除线程会把这个节点从链表中移出,不会直接销毁它。RCU会等到所有读线程读取完成后,才会销毁这个节点。
定义
RCU提供的接口如下(include/linux/rcupdate.h):
- rcu_read_lock()/ rcu_read_unlock():组成一个RCU读临界。
- rcu_dereference():用于获取被RCU保护的指针,读者线程要访问RCU保护的共享数据,需要使用该函数创建一个新指针,并且指向RCU被保护的指针。
- rcu_assign_pointer():通常用在写者线程。在写者线程完成新数据的修改后,调用该接口可以让被 RCU 保护的指针指向新建的数据,用 RCU 的术语是发布了更新后的数据。
- synchronize_rcu():同步等待所有现存的读访问完成。
- call_rcu():注册一个回调函数,当所有现存的读访问完成后,调用这个回调函数销毁旧数据。
等待队列
等待队列本质上是一个双向链表,当运行中的进程需要获取某一个资源而该资源暂时不能提供时,可以把进程挂入等待队列中等待该资源的释放,进程会进入睡眠状态。
定义
include/linux/wait.h
/*
* A single wait-queue entry structure:
*/
struct wait_queue_entry {
unsigned int flags;
void *private;
wait_queue_func_t func;
struct list_head entry;
};
flags为等待队列上的操作行为。
private为等待队列的私有数据,通常用来指向进程的task_struct数据结构。
func为进程被唤醒时执行的唤醒函数。
entry为链表的节点。
struct wait_queue_head {
spinlock_t lock;
struct list_head head;
};
typedef struct wait_queue_head wait_queue_head_t;
lock为等待队列的自旋锁,用来保护等待队列的并发访问。
head为等待队列的双向链表。

浙公网安备 33010602011771号