2

Linux学习第29节,从C语言源码分析,信号量和自旋锁有何区别

 3 years ago
source link: https://blog.popkx.com/in-section-29-of-linux-learning-what-are-the-differences-between-semaphores-and-spinlocks-from-c-source-code-analysis/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

上一节主要介绍了Linux内核中的自旋锁,知道了自旋锁是不能睡眠的,因此只适合用于短时间的保护临界区。如果需要较长时间的持有锁,就不应该再使用自旋锁了,因为这会大量消耗 cpu 的性能,大大降低整个系统的效率。

不过,在Linux内核开发中,不可避免的会遇到需要长时间保护的临界区,该怎么办呢?因此,Linux 内核还提供了一种同步共享数据的机制——信号量。

721f8a1e9987ec6ea71e0948293ca151.png

信号量在 1968 年由计算机科学家 Edsger Wyde Dijkstra 提出,此后便逐渐成为一种常用的锁机制。不同于自旋锁,信号量允许线程睡眠因此即使某个某个线程需要较长时间的持有信号量,也是被允许的,因为其他线程在等待信号量的过程中可以睡眠,Linux 内核可以调度其他线程投入运行。

Linux 内核中的信号量的数据结构

首先来看下Linux 内核中的信号量使用的数据结构,它是由结构体 semaphore 描述的,相关的C语言代码如下,请看:

struct semaphore {
     spinlock_t      lock;
     unsigned int        count;
     struct list_head    wait_list;
};
e8c2669bd2128c66c85845ad49443623.png

容易看出,结构体 semaphore 包含一个自旋锁,这说明信号量的某些临界区也是需要使用自旋锁保护的。count 则是信号量计数,wait_list 则是一个等待队列。

自旋锁则是依赖原子操作实现的,所以原子操作一节,我们提到原子操作是其他同步机制的基础。

上一节介绍的自旋锁,只能同时被一个线程持有,而信号量则不一定,它可以同时被 count 个线程持有。不过大多情况下 count 都等于 1,此时信号量被称作二值信号量,或者互斥信号量。如果某个信号量已经被 count 个线程持有,若还有新线程申请信号量,则该线程会被放入等待队列等待,处理器会先执行其他任务。

创建信号量

可以使用 sema_init() 函数初始化一个信号量,它是一个 inline 函数,相关C语言代码如下,请看:

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);
 }
af91959357e63d1356c1d0de3aef0cf5.png

容易看出, sema_init() 函数其实就是对信号量 sem 赋初值而已,val 会传递给 count 成员。因此互斥信号量的初始化只需将把 1 传递给 val 就可以了,事实上,Linux 内核的确是这么干的,相关C语言代码如下:
#define init_MUTEX(sem)     sema_init(sem, 1)
#define init_MUTEX_LOCKED(sem)  sema_init(sem, 0)

init_MUTEX_LOCKED() 宏创建了一个 count 等于 0 的信号量,这说明该信号量一开始就是被初始化线程持有的。

使用信号量

早期的信号量支持两个原子操作 P() 和 V(),分别是指测试操作和增加操作,后来的系统则把这两种操作命名为 down() 和 up(),Linux 内核也遵从这种叫法。down() 函数负责申请信号量并将信号量的 count 减 1,显然,如果 count 大于 0,则任务就可以获得信号量并进入临界区。down() 函数的C语言代码如下,请看:

     52 void down(struct semaphore *sem)
-    53 {
|    54     unsigned long flags;
|    55 
|    56     spin_lock_irqsave(&sem->lock, flags);
|    57     if (likely(sem->count > 0))
|    58         sem->count--;
|    59     else
|    60         __down(sem);
|    61     spin_unlock_irqrestore(&sem->lock, flags);
|    62 }
945ff24aeaec6d2b3ba7da9d583f203d.png

如果 count 不大于 0,则任务就会进入睡眠,并被设置为 TASK_UNINTERRUPTIBLE 状态,此时任务不会再响应信号。
 static noinline void __sched __down(struct semaphore *sem)
{
     __down_common(sem, TASK_UNINTERRUPTIBLE, MAX_SCHEDULE_TIMEOUT);
}

__down_common()函数的C语言代码如下,请看:

5180601e3b32774d0c28643f2a207586.png

如果希望申请信号量失败而进入睡眠的进程仍然能够响应信号,则可以使用 down_interruptible() 函数,它的核心代码如下:
static noinline int __sched __down_interruptible(struct semaphore *sem)
{
     return __down_common(sem, TASK_INTERRUPTIBLE, MAX_SCHEDULE_TIMEOUT);
}

类似的还有 down_killable() 函数,它的C语言代码如下,请看:

static noinline int __sched __down_killable(struct semaphore *sem)
 {
     return __down_common(sem, TASK_KILLABLE, MAX_SCHEDULE_TIMEOUT);
 }

在临界区完成工作后,up() 函数可以释放信号量,它的C语言代码如下,请看:

 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);
 }

根据上述代码,能够发现,up() 函数会在任务队列为空的时候把信号量的引用计数count 加一,否则就会调用 __up() 函数,相关的C语言代码如下:

8f98961e21247fdbd4a7bda50650d7a3.png

__up()函数会唤醒等待队列里的任务,确保在释放信号量的时候等待队列里的任务都有机会执行。

自旋锁提供了一种快速简单的加锁方法,不过它并不适合较长时间的保护临界区,这一需求最好借助可以睡眠的信号量。而如果锁仅会被短时间持有,再使用信号量就不太合适了,因为睡眠和维护等待队列,以及唤醒任务所花费的开销可能比锁占用的全部时间还要大。

3dcdb3f8f5f0b900ac7be4991d65fe19.png

另外,由于中断上下文中是不能调度的,因此不能使用信号量,因为线程在申请信号量失败时可能会进入睡眠。引申一点,应该明白信号量最好不要和自旋锁共用,因为线程持有自旋锁的时候是不允许睡眠的。

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK