个人公众号:linux进击之路
锁是多CPU运行时保证功能正常必备的同步机制,本文先总结内核常用锁的实现原理,然后借鉴内核实现,开发用户态直接操作的锁同步机制。
自旋锁
实现机制
自旋锁不会导致进程睡眠和调度,因此对于不可打断的场景,如中断上下文中,通常会使用自旋锁来保护数据。老版内核的自旋锁维护一个计数值count并初始为1,第一个进程加持锁时,使用CAS原子操作更新count值,CAS语义实现如下:
int CAS(int *ptr,int old, int new)
{
int actual = *ptr;
if (actual == old) {
*ptr = new;
}
return actual;
}
void spin_lock(spinlock *lock)
{
while (CAS(*lock->count, 1, 1) == 1) {
//spin;
}
}
void spin_unlock(spinlock *lock)
{
lock->count = 0;
}
这种简单的CAS实现,虽然能够满足互斥的功能,但是资源释放后,等待在自旋锁上的多个进程是去竞争自旋锁的,而非先等待的先获取锁。为了解决这个问题,linux更新了自旋锁实现机制:类似于银行取票排队等待的逻辑,先到先得,叫号到谁,谁就上,避免无序竞争。内核实现自旋锁的结构体简化如下:
struct tickets {
int owner; // 当前允许办理业务(加持锁)的序号
int next; // 下一个要办理业务的序号
}
struct raw_spinlock {
struct tickets ticket;
}
struct spinlock {
struct raw_spinlock rlock;
}
排队逻辑要求owner等于next时,进程才会成功持有自选锁,否则自选。每个进程加锁时,都会记录当前的next值,表示自己的排队号,并且next递增,保证下一个进程加锁时,获取的next值是稍大的,从而达到排序的目的。自旋锁加锁解锁实现逻辑简化如下:
void spin_lock(spinlock *lock)
{
int tmp = lock->rlock.ticket.next;
lock->rlock.ticket.next++;
while (tmp != lock->rlock.ticket.owner) {
// spin;
}
}
void spin_unlock(spinlock *lock)
{
lock->rlock.ticket.owner++;
}
上述自旋锁结构的设计逻辑可以很好的保证先到先得的思想,但是在多cpu场景会存在一个性能问题:自旋锁变量是存放在主存上的,每个cpu有自己的L1/L2缓存机制,这就要求主存到cpu缓存之间存在同步机制。当cpu0运行的进程加持锁之后,会更新主存上的next值,导致其他cpu的缓存值失效,这就比较浪费性能了。
linux内核针对上述性能问题,重新设计了自旋锁结构,使得不同cpu在主存上各自拥有排队结构,然后等待在各自的排队结构体变量上。想要深入了解新机制的,请移驾搜索引擎或者查阅新版Linux内核。
初学者看到这里的时候,一定会有个疑惑:为什么会有raw_spinlock和spinlock两种自旋锁结构?其实还有第三种arch_spinlock,arch_spinlock是用来兼容不同架构的,不同的架构其arch_spinlock实现机制大同小异,内核也会遵循"存在即合理"的真谛。回归正题,部分内核支持PREEMPT_RT选项,该选项会导致spinlock依旧会被抢占。而raw_spinlock加锁时,会禁用抢占机制,确保进程会自旋等待,而不是休眠。所以内核开发时,对于不能抢占的场景,可以直接使用底层的raw_spinlock来实现同步互斥的目的。
自旋锁使用场景
-
多个进程上下文使用自选锁保护共享自选
-
同一个cpu上运行的多个进程竞争
假使进程A使用共享资源前,使用spinlock进入临界区。在使用资源的过程中,被中断打断,中断处理函数中调度进程B,进程B获取自选锁时,会自选等待A退出临界区,这就导致了死锁。针对这种场景,获取锁时,还必须禁用中断(如果是中断上半部即硬中断,则调用spin_lock_irq内核原生接口禁用硬中断并加锁,如果是中断下半部bottom_half即软中断,则调用spin_lock_bh接口关闭软中断并加锁)
-
不同cpu运行的多进程竞争
cpu0的A进程加锁使用资源时,cpu1的B进程加持自旋锁,B会原地等待。由于不同cpu的调度是独立的,A正常使用完资源退出临界区时,B就会退出自旋状态
小结:上述场景虽然是在多个进程上下文中竞争资源,但是穿插了中断后,才会引入死锁的问题。因此可以归结为中断上下文+进程上下文同时使用资源的场景,这类场景需要自旋锁+禁用中断或者自旋锁+禁用抢占功能(raw_spinlock属于此类型)。
-
多中断上下文竞争资源
对于同一种硬中断来说,linux内核不会并发执行,无需保护。对于同一种软中断来说,多cpu并发时,存在同时运行的可能,这种场景只需要加自旋锁保护即可,无需禁用中断。
信号量
信号量内部是基于自选锁来实现的,并且用的是底层raw_spinlock。信号量的结构设计如下:
struct semaphore {
struct raw_spinlock lock; // 确保信号量计数器的操作是互斥的
int count; // 计数器
struct list_head wait_list; // 等待队列头节点
}
struct semaphore_wait {
struct list_head list; // 挂接在等待队列上
struct task_struct *task; // 记录等待的进程
}
理解信号量结构设计思想后,对应的down、up接口实现逻辑也就不难了。实现逻辑简化如下:
// down接口
void down(struct semaphore *sem)
{
unsigned long flag;
struct semaphore_wait waiter;
/* 保存硬中断寄存器值,关闭硬中断,加锁 */
raw_spin_lock_irqsave(flag);
if (sem->count > 0)
sem->count--;
else {
list_add_tail(&waiter.list, &sem->wait_list);
waiter.task = current;
/* 设置进程状态为不可打断模式 */
__set_task_state(waiter.task, UNINPTERRUPTIBLE);
schedule();
}
/* 恢复硬中断寄存器值,打开硬中断,解锁 */
raw_spin_lock_irqrestore(flag);
}
void up(struct semaphore *sem)
{
unsigned long flag;
struct semaphore_wait *waiter = NULL;
raw_spin_lock_irqsave(flag);
if (list_empty(&sem->wait_list))
sem->count++;
else {
waiter = list_first_entry(&sem->wait_list, struct semaphore_wait, list);
list_del(&waiter->list);
wake_up(waiter->task);
}
raw_spin_lock_irqrestore(flag);
}
互斥量mutex
mutex同步机制和semaphore机制类似,信号量的计数值可以为多个,mutex的计数值只能为1,0,负数。二者的差异点允许多个进程同时使用资源,mutex只允许同一时刻只有一个进程能持有锁使用资源。自旋锁也是基于自选锁实现的。mutex结构体设计简化如下:
struct mutex {
atomic_t count; // 不知道为啥有自旋锁还用原子类型的计数
spinlock wait_lock;
struct list_head wait_list;
}
其实现逻辑类似于信号量,不再赘述。
读写锁
上述讲解的同步机制不区分读取数据和更新数据的情况,锁的粒度相关粗一些。而读写锁将读场景和写数据的场景分割开,读锁允许多个进程同时进入临界区;写锁具有排他性,写锁会阻塞,但是后续新加持的读锁依旧能够进入临界区(这种机制会导致加持写锁的进程饿死的情况,正常应该是加写锁后,后续新增的读锁也阻塞才对,但是还未见到这种实现)。加持写锁的进程必须等待读锁都退出临界区后,才会进入临界区。因此,读写锁需要统计读锁的数量以及是否存在写锁。内核采用一个volatile类型的整数计数器counter来记录:最高位表示是否存在写锁,低31位记录当前存在的读锁。volatile类型保证counter是修改对别的进程来说是立刻可见的(同自选锁的计票系统一样,存在cpu缓存和主存间同步的问题,volatile可以使得cpu对主存上counter的修改,快速让别的进程感知),但是volatile是无法保证原子性的,所以对counter的操作必须是原子的。读写锁设计实现简化如下:
typedef struct {
volatile unsigned int counter;
} arch_rwlock_t;
/* 内核使用汇编语言实现加解锁,对应C逻辑如下 */
void read_lock(arch_rwlock_t *lock)
{
unsigned int tmp;
selv; // 汇编指令,保证第一次执行wfe时,不会睡眠
do {
wfe; // 汇编指令,使进程睡眠
tmp = lock->counter;
tmp++;
} while (tmp & (1 << 32)); // 没有写锁处于临界区时,退出循环
lock->counter = tmp;
}
void read_unlock(arch_rwlock_t *lock)
{
lock->counter--;
sev; // 唤醒睡眠的CPU
}
void write_lock(arch_rwlock_t *lock)
{
unsigned int tmp;
sel;
do {
wfe;
tmp = lock->counter;
} while(tmp);
lock->counter = 1 << 32;
}
void write_unlock(arch_rwlock_t *lock)
{
lock->counter = 0; // 写锁存在时,不可能有读锁,所以直接置0
sev;
}
RCU同步机制
看过内核网络协议栈的人,对RCU不会陌生,很多函数中都用到了RCU机制,比如内核接收报文后判断是否需要送入桥模块处理时,获取skb->dev->rx_handler指针前,调用了rcu_dereference接口将该指针转换成受RCU保护的指针。RCU类似于读写锁,将读写场景分开,RCU机制允许多个读者随意读和一个写者更新数据同时发生,但是不允许多个写者同时发生。存在多个写者时,需要采用其他同步机制来实现互斥,RCU机制并不提供写者间的互斥功能。
RCU展开了说就是读-拷贝更新,当受RCU保护的指针需要更新时,先申请一块内存,将数据拷贝出来(和COW一样,写时拷贝),在副本上做需改,然后调用synchronize_rcu等待读者都退出临界区,或者调用call_rcu注册回调函数,等待宽限期到来后,将原数据地址引用修改为新申请的内存地址,并释放原有数据占据的内存空间,避免内存泄漏。因此RCU同步机制受限于以下场景:
-
RCU只能保护动态生成的数据结构,并且必须通过指针来访问数据接口,常见于链表
-
受RCU保护的临界区不能睡眠,因此RCU通常用自旋锁来保证写者间互斥
-
对写场景的性能要求不高,读场景要求很高
-
读者对新旧数据不敏感
自实现用户态同步机制
几种常用同步机制的内核实现就先总结到这里。总体来看,锁机制的是实现逻辑是很好理解的,而且也来源于生活中存在的场景,比如自旋锁的银行排队机制。上述锁都是内核中实现的,如果在用户态直接执行相应的系统调用是会耗费性能(系统调用会使进程限于内核态,涉及用户态堆栈、cpu寄存器值的保存与恢复)。理解了锁的实现后,可以认为同步即原子操作,只要在用户态能实现原子操作,就可以实现用户态使用的同步机制了——atomic_t类型为实现用户态同步机制提供了方便。
atomic_t类型声明的变量具有原子性,CPU执行该类型变量对应的汇编执行时原子性的。借鉴内核的锁实现思想,在用户态声明一个atomic_t类型的整型计数器lockval,用户态进程直接原子操作lockval实现互斥。lockval原子递减后,如果不为0,说明不存在竞争关系,可以在用户态直接使用资源;反之在执行系统调用wait让进程睡眠,等待资源释放。
上述思路抽象成下述代码逻辑:
// 用户态加锁接口
void lock(int lockval)
{
/* trylock尝试加锁:原子性递减locval */
while (trylock(lockval) == 0) {
wait();
}
}
上述逻辑和内核中信号量的加锁接口类似。区别点是信号量操作lockval前,使用自旋锁保护了临界区,而上述实现逻辑是没有的,所以上述逻辑是存在缺陷的:trylock和wait之间存在时间窗口,如果trylock执行后,持有锁的进程释放了锁,而本进程不感知,会继续调用wait睡眠,且后续不会被唤醒。对于这种情况,我们也可以像内核一样,在trylock原子操作计数器前加锁保护,但是这违背了我们避免直接陷入内核的初衷。
感恩内核开发者,内核提供了futex同步机制(快速用户态互斥体),可以很好的解决这个问题。futex机制提供了futex_wait/futex_wake来替代上述实现中的wait。futex_wait接口要求用户进程调用时,除了传递lockval外,还必须把存储lockval的地址uaddr传递下去,futex_wait内部判断当前uaddr记录的值和lockval一致时,才会睡眠,否则直接返回。
futex在内核态维护了futex_hash_bucket类型的大数组,来记录不同uaddr记录的锁变量上等待的队列,每个队列都会采用spinlock来同步,原型如下:
struct futex_hash_bucket {
spinlock lock; // 保证多进程操作chain时互斥
struct plist_head chain; // 等待队列的头结点
}
static futex_hash_bucket futex_queue[N]; // hash桶
struct futex_q {
struct plist_head list; // 该节点挂接到hash桶的chain上
struct task_struct *task; // 准备睡眠的进程
spinlock *lock_ptr; // 指向futex_hash_bucket中的lock,确保判断uaddr记录的值等于lockval的比较逻辑和进程睡眠逻辑处于同一临界区中
union futex_key key; // 用于计算hash值,找到对应的hash桶
}
每次调用futex_wait都会创建futex_q结构变量,用于表征*uaddr对应的锁。futex_wait内部实现简化如下:
/* 只有当*uaddr == lockval时才会睡眠 */
void futex_wait(u32 *uaddr, u32 lockval)
{
struct futex_q q;
struct futex_hash_bucket *bh;
init(&q);
bh = queue_lock(&q);
u32 uval = get_phyaddr_val(uaddr); //找到对应物理地址上存储的数值
if (uval != lockval) {
queue_unlock(&q);
}
set_current_state(TASK_INTERRUPTIBLE);
queue_me(&q); // 计算q对应的hash值,插入到对应hash桶的等待队列上,并解锁
schedule();
}
/* 用户态加锁接口实现更新如下 */
void lock(int lockval)
{
/* trylock尝试加锁:原子性递减locval */
while (trylock(lockval) == 0) {
futex_wait(&lockval, lockval);
}
}
the end.
本文理解如果有误,请大神指点修正。