Linux内核之自旋锁和信号量

时间:2021-05-20 15:14:55

Linux内核实现了多种同步方法,指令级支持的原子操作、自旋锁、信号量、互斥锁、完成量、大内核锁等等,我就挑比较有代表性的两个锁——自旋锁和信号量来分析。

自旋锁

Linux内核中最常用的锁就是自旋锁(spin lock),自旋锁最多只能被一个执行线程持有。如果一个执行线程试图获得一个被已经持有(即所谓争用)的自旋锁,那么该线程就会一直进行忙循环-旋转-等待锁重新可用。在任意时间,自旋锁都可以防止多于一个的执行线程同时进入临界区。自旋锁的实现和体系结构密切相关,代码往往通过汇编实现。

#define __LOCK(lock) \
do { preempt_disable(); __acquire(lock); (void)(lock); } while (0)

#define __LOCK_BH(lock) \
do { local_bh_disable(); __LOCK(lock); } while (0)

#define __LOCK_IRQ(lock) \
do { local_irq_disable(); __LOCK(lock); } while (0)

#define __LOCK_IRQSAVE(lock, flags) \
do { local_irq_save(flags); __LOCK(lock); } while (0)

#define __UNLOCK(lock) \
do { preempt_enable(); __release(lock); (void)(lock); } while (0)

#define __UNLOCK_BH(lock) \
do { preempt_enable_no_resched(); local_bh_enable(); __release(lock); (void)(lock); } while (0)

#define __UNLOCK_IRQ(lock) \
do { local_irq_enable(); __UNLOCK(lock); } while (0)

#define __UNLOCK_IRQRESTORE(lock, flags) \
do { local_irq_restore(flags); __UNLOCK(lock); } while (0)

#define _spin_lock(lock)__LOCK(lock)
#define _spin_lock_nested(lock, subclass)__LOCK(lock)
#define _read_lock(lock)__LOCK(lock)
#define _write_lock(lock)__LOCK(lock)
#define _spin_lock_bh(lock)__LOCK_BH(lock)
#define _read_lock_bh(lock)__LOCK_BH(lock)
#define _write_lock_bh(lock)__LOCK_BH(lock)
#define _spin_lock_irq(lock)__LOCK_IRQ(lock)
#define _read_lock_irq(lock)__LOCK_IRQ(lock)
#define _write_lock_irq(lock)__LOCK_IRQ(lock)
#define _spin_lock_irqsave(lock, flags)__LOCK_IRQSAVE(lock, flags)
#define _read_lock_irqsave(lock, flags)__LOCK_IRQSAVE(lock, flags)
#define _write_lock_irqsave(lock, flags)__LOCK_IRQSAVE(lock, flags)
#define _spin_trylock(lock)({ __LOCK(lock); 1; })
#define _read_trylock(lock)({ __LOCK(lock); 1; })
#define _write_trylock(lock)({ __LOCK(lock); 1; })
#define _spin_trylock_bh(lock)({ __LOCK_BH(lock); 1; })
#define _spin_unlock(lock)__UNLOCK(lock)
#define _read_unlock(lock)__UNLOCK(lock)
#define _write_unlock(lock)__UNLOCK(lock)
#define _spin_unlock_bh(lock)__UNLOCK_BH(lock)
#define _write_unlock_bh(lock)__UNLOCK_BH(lock)
#define _read_unlock_bh(lock)__UNLOCK_BH(lock)
#define _spin_unlock_irq(lock)__UNLOCK_IRQ(lock)
#define _read_unlock_irq(lock)__UNLOCK_IRQ(lock)
#define _write_unlock_irq(lock)__UNLOCK_IRQ(lock)
#define _spin_unlock_irqrestore(lock, flags)__UNLOCK_IRQRESTORE(lock, flags)
#define _read_unlock_irqrestore(lock, flags)__UNLOCK_IRQRESTORE(lock, flags)
#define _write_unlock_irqrestore(lock, flags)__UNLOCK_IRQRESTORE(lock, flags)


在spinlock_api_up.h文件中定义了所有的自旋锁函数的实现,基本方式就是禁止中断->禁止抢占->获得锁,spin_lock这个函数没有禁止中断,因为它主要用于中断处理程序中保护临界区,中断处理程序本身就是禁止中断的。

因为自旋锁在同一时刻至多被一个执行线程持有,所以一个时刻只能有一个线程位于临界区内,这就是为多处理机器提供了防止并发访问所需的保护机制。注意在单处理器机器上,编译的时候并不会加入自旋锁。它仅仅被当做一个设置内核抢占机制是否被启用的开关。如果禁止内核抢占,那么在编译时自旋锁会被完全剔除出内核。

关于自旋锁的使用注意

1.自旋锁不可递归

Linux内核实现的自旋锁是不可递归的,这点不同于自旋锁在其他操作系统的实现,所以如果你试图得到一个你正在持有的锁,你必须自旋,等待你自己释放这个锁。但是你处于自旋忙等待中,你永远没有机会释放锁,内核就崩了。

2.中断重入会导致死锁

在中断处理程序中使用自旋锁时,一定要在获取锁之前,首先禁止本地中断(在当前处理器上的中断请求),否则,中断处理程序就会打断正在持有锁的内核代码,有可能会试图去争用这个已经被持有的自旋锁。这样一来,中断处理程序就会自旋,等待该锁重新可用,但是锁的持有者在这个中断处理程序执行完毕前不可能运行,所以就造成了死锁。这里要注意一点,需要关闭的只是本地中断(当前处理器)。如果中断发生在不同的处理器上,即使中断处理程序在同一锁上自旋,也不会妨碍锁的持有者(在不同处理器上)最终释放锁。

3.持有自旋锁时不可睡眠

持有自旋锁后如果进程睡眠了,那么就不知道何时才能重新被唤醒,此时如果还有另外的进程要获得这个自旋锁,那它就会一直忙等,非常浪费处理器资源


信号量

不同于自旋锁,Linux中的信号量是一种睡眠锁。如果有一个任务试图获得一个不可用(已经被占用)的信号量时,信号量会将其推进一个等待队列,然后让其睡眠。这时处理器能重获*,从而去执行其他代码。当持有的信号量可用(被释放)后,处于等待队列的那个任务将被唤醒,并获得该信号量。

接下来看看信号量是怎么实现的

void down(struct semaphore *sem)
{
unsigned long flags;

spin_lock_irqsave(&sem->lock, flags);
if (likely(sem->count > 0))
sem->count--;
else
__down(sem);
spin_unlock_irqrestore(&sem->lock, flags);
}
static noinline void __sched __down(struct semaphore *sem)
{
__down_common(sem, TASK_UNINTERRUPTIBLE, MAX_SCHEDULE_TIMEOUT);
}
static inline int __sched __down_common(struct semaphore *sem, long state,
long timeout)
{
struct task_struct *task = current;
struct semaphore_waiter waiter;


//把当前进程加入信号量的等待队列
list_add_tail(&waiter.list, &sem->wait_list);
waiter.task = task;
waiter.up = 0;


for (;;) {
//如果有信号要处理,那就当什么都没发生,直接退出
if (state == TASK_INTERRUPTIBLE && signal_pending(task))
goto interrupted;
//如果当前进程收到了SIGKILL信号,代表这个进程要被杀死了,所以也啥都不干
if (state == TASK_KILLABLE && fatal_signal_pending(task))
goto interrupted;
if (timeout <= 0)
goto timed_out;
__set_task_state(task, state);
//调度之前一定要释放自旋锁
spin_unlock_irq(&sem->lock);
//延迟调度,如果timeout=MAX_SCHEDULE_TIMEOUT,和直接调动schedule函数没区别
timeout = schedule_timeout(timeout);
//重新拿到锁
spin_lock_irq(&sem->lock);
//如果是由up函数唤醒的返回正常,如果是用户空间的信号所中断或超时信号所引起的唤醒
//那就接着执行,返回错误
if (waiter.up)
return 0;
}


timed_out:
list_del(&waiter.list);
return -ETIME;


interrupted:
list_del(&waiter.list);
return -EINTR;
}
void up(struct semaphore *sem)
{
unsigned long flags;


spin_lock_irqsave(&sem->lock, flags);
if (likely(list_empty(&sem->wait_list)))
sem->count++;
else
__up(sem);
spin_unlock_irqrestore(&sem->lock, flags);
}
static noinline void __sched __up(struct semaphore *sem)
{
//找到第一个等待的进程
struct semaphore_waiter *waiter = list_first_entry(&sem->wait_list,
struct semaphore_waiter, list);
//从等待队列中移除
list_del(&waiter->list);
//表明是该进程是up函数唤醒
waiter->up = 1;
//唤醒进程
wake_up_process(waiter->task);
}

从上面的代码可以看到,信号量也是依赖于自旋锁的。
信号量分为计数信号量和二值信号量(也叫互斥信号量),计数信号量不能用来进行强制互斥,因为它允许多个执行线程同时访问临界区。相反,这种信号量用来对特定代码加以限制,内核使用它的机会不多,基本用到的都是互斥信号量(计数等于1的信号量),除互斥外,信号量还能进行进程同步,把计数初始化为0就可以实现进程间的同步。

再来说说自旋锁和信号量使用场景上的区别
  • 自旋锁适用于较短时间持有锁,信号量适用于较长时间持有锁
  • 自旋锁是忙等,而信号量是睡眠
  • 自旋锁可用于进程上下文和中断上下文,而信号量只能用于进程上下文
  • 自旋锁禁止内核抢占,信号量不是
  • 在你占用信号量的同时不能占用自旋锁。因为在你等待信号量时可能会睡眠,而在持有自旋锁时时不允许睡眠的
  • 自旋锁不会发生上下文切换,而信号量会(我猜这就是为什么Linux内核用的最多的是自旋锁,而不是信号量的原因)