Home 内核-spinlock(自旋锁)
Post
Cancel

内核-spinlock(自旋锁)

在Linux内核中,自旋锁(spinlock)是一种用于保护临界区(critical section)的同步机制,确保在多个执行上下文中的独占访问。 它是一种忙等待的锁,与传统的互斥锁(mutex)相比,自旋锁不会使线程进入睡眠状态而是通过不断自旋(循环检查锁状态)来等待临界区的释放,它会一直等待。

这里记录的是内核层的自旋锁的使用,不是应用层多线程中的自旋锁。

大致原理

自旋锁是通过一个原子操作的标志位来表示锁的状态,通常使用一个整数变量作为标志,并且是在多个处理器之间共享该标志的。 这是自旋锁实现的关键,这个原子操作的具体实现在不同的CPU架构上各不相同。核心就是对该标志进行原子操作的修改。 当线程需要进入临界区时,它首先尝试获取自旋锁。如果锁处于未被占用的状态(标志位为0),线程可以成功获取锁,并进入临界区执行。 如果锁已经被其他线程占用(标志位为1),当前线程将进入一个自旋循环,不断检查锁的状态。它会忙等待,直到锁被释放。 当线程执行完临界区的代码后,它会释放自旋锁,将标志位置为0,以允许其他线程获取锁并进入临界区。

特点-适用场景-注意点

  1. 其自选等待具有双刃剑的特性,不会使线程进入睡眠,即不会发生线程上下文切换,可以避免额外的开销。
  2. 其自选等待具有双刃剑的特性,要求临界区代码执行时间较短,否则忙等的开销就大了,省下来的线程切换开销就没有意义了。
  3. 锁竞争问题,如果对临界区的竞争情况较轻,即锁的争用不频繁,自旋锁是可以有效避免线程切换开销的, 但如果锁竞争的很频繁,那么使用自旋锁可能会导致大量的忙等,浪费CPU资源,此时就不建议用自旋锁了,可以使用其他同步机制,如互斥锁(mutex)。
  4. 死锁问题,自旋锁是非递归锁,在一个线程已获取自旋锁的情况下再次尝试获取自旋锁会导致死锁。编程时需要注意避免死锁的情况。

概念特点小结

  • 自旋锁是linux多处理器系统中的一个常见同步机制,适用于多处理器系统中临界区较短且锁的争用不频繁的场景,能够提供低延迟和高吞吐量的同步机制。
  • 自旋锁要求 临界区代码执行时间短,且临界区不能调用会引起睡眠的代码(因为自旋锁不主动释放CPU,会导致系统无响应)。此时忙等的时间是小于线程上下文切换开销的,可以提高性能表现。否则不合适。
  • 如果同一个自旋锁的锁竞争比较激烈,也不适合使用自旋锁,因为只有一个线程能获得自旋锁,其他试图获取锁的线程都要忙等,也会浪费CPU性能,就不如让其他线程切换上下文先执行其他线程(换用其他可睡眠的锁,如互斥锁)

常用API接口

在Linux内核中,自旋锁的API使用接口主要包括以下几个函数:

  1. spin_lock_init(spinlock_t *lock):初始化一个自旋锁。在使用自旋锁之前,需要先对其进行初始化操作。

  2. spin_lock(spinlock_t *lock):获取自旋锁。如果自旋锁已被占用,当前线程将进入忙等待状态,直到获取到自旋锁为止。

  3. spin_trylock(spinlock_t *lock):尝试获取自旋锁,如果自旋锁已被占用,则立即返回0,表示获取失败;如果成功获取到自旋锁,则返回非0值。

  4. spin_unlock(spinlock_t *lock):释放自旋锁,允许其他线程获取到该自旋锁。

  5. spin_lock_irqsave(spinlock_t *lock, unsigned long flags):获取自旋锁,并临时禁止本地CPU的中断。此函数会将当前CPU的中断状态保存到flags参数中,以便在解锁时恢复中断状态。

  6. spin_unlock_irqrestore(spinlock_t *lock, unsigned long flags):释放自旋锁,并根据保存的中断状态flags恢复中断。此函数用于与spin_lock_irqsave()函数成对使用,确保在解锁时恢复之前的中断状态。

  7. spin_lock_bh(spinlock_t *lock):获取自旋锁,并禁止本地CPU的软中断。与spin_lock_irqsave()函数类似,但仅禁止软中断。

  8. spin_unlock_bh(spinlock_t *lock):释放自旋锁,恢复软中断。此函数用于与spin_lock_bh()函数成对使用,确保在解锁时恢复软中断状态。

这些函数是自旋锁的基本操作接口,用于获取和释放自旋锁,以及在需要时处理中断。通过这些接口,开发者可以在Linux内核中使用自旋锁实现对临界区的保护和同步。注意,在使用自旋锁时,需要遵循正确的使用规范,避免死锁和竞争条件等问题。

一些说明:这些api根据实际情况使用。


如果临界区代码只出现在线程上下文中,没有中断上下文去访问临界区,那么使用上面4个API就足够了,不用担心获得自旋锁的线程会睡眠,因为它定义中要求不会让线程进入睡眠。代码实现其实是先禁用了调度器的可抢占性preempt_disable(),之后再尝试获取自旋锁的。 除了直观的保证临界区代码执行不会被高优先级线程抢占,还有一个原因,就是该cpu发生中断时,中断上下文退出后,系统也会出现一个调度点,也有可能切换到更高优先级的线程,这里和中断上下文中是否执行临界代码无关,而是内核的调度器特性,现在的内核默认都是抢占式调度器。 这样处理以后,可以实现在多个线程上下文中临界执行。


如果临界区代码还出现在了中断上下文,如中断处理函数,软中断等地方,就额外需要下面4个API了。

首先对于线程上下文,获取了自旋锁,使用普通的spin_lock是没有禁止本地中断的,完全可能出现,获取锁后,发生中断,中断上下文又访问临界区,这样肯定不行。所以在线程上下文中一定会使用 spin_lock_irqsave来保护临界代码区域。

而中断上下文中,访问临界代码区域,就复杂些。首先需要对中断有简单了解,如果是在中断处理程序的上半部分中访问临界代码区域,考虑到linux中断处理上半部分的中断屏蔽机制,会暂时屏蔽同优先级和低优先级的中断, 所以除非有更高优先级的中断中有访问临界代码区域(外部中断其实都是一个优先级),通常使用spin_lock保护即可。这个保护是一定需要的,有可能线程上下文中先获取了该锁,那么此时中断上下文就要先等一等才能获取到该锁(同步), 再进入临界代码区域。在多核系统中,线程上下文和中断上下文可能在两个CPU核心上执行。线程上下文屏蔽本地的中断,是完全有可能在其他cpu核心上产生中断并执行中断处理程序的,这样就会出现并行的情况,两边的锁都是必须要加的。

中断上下文还有一种情况,就是只在中断的下半部分访问临界代码,这时候,其实需要屏蔽本地CPU外部中断的,仅禁用软中断即可,也就是一个更细粒度的自旋锁。使用spin_lock_bh来保护线程上下文中的临界代码区域即可, 可以优化系统性能,更合适,当然,使用上面的spin_lock_irqsave来保护也肯定没有问题的,只是有点“过保护”了,无法响应外部中断,性能会有一点损失。


使用小结

使用自旋锁保护临界代码区域,和哪些地方访问临界代码区域是密切相关的。

  • 如果都是线程上下文中访问临界代码区域,那么,在线程上下文中都使用spin_lock保护即可。
  • 更常见的情况应该是中断上下文和线程上下文中都存在,中断上下文通常使用spin_lock保护临界代码区即可。而线程上下文视情况而定,可以都简单使用spin_lock_irqsave来保护临界代码区域, 如果是中断上半部分中有访问临界代码,线程上下文需要使用spin_lock_irqsave来保护,如果只有下半部分访问临界代码,线程上下文可以优化使用spin_lock_bh来保护,系统性能表现更好。
  • 通常情况,只有外设中断访问临界代码,没有更高优先级的中断去访问临界代码,那么在中断上下文中使用spin_lock保护临界代码区即可,如果有更高优先级的中断能打断当前的外部中断,且会访问 临界代码区的,中断上下文中就也要使用spin_lock_irqsave来保护临界代码区域了。这个对于设备驱动程序而言,不太常见。
  • 总的来说,根据临界代码区域的实际访问情况来决定的。

一些扩展接口

spinlock还有一些扩展接口,也可以看情况使用。

1
2
3
4
5
6
7
8
9
//非阻塞版本,获取不到也立即返回,自行判断即可
int spin_trylock(spinlock_t *lock);
int spin_trylock_bh(spinlock_t *lock);
#define spin_trylock_irqsave(lock, flags)
//支持嵌套的spinlock,有时还可以避免一些死锁问题。
#define spin_lock_nested(lock, subclass)
#define spin_lock_irqsave_nested(lock, flags, subclass)
// Check whether a spinlock is locked.
int spin_is_locked(spinlock_t *lock)

example code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/interrupt.h>
#include <linux/spinlock.h>

static spinlock_t lock;
static int shared_data = 0;

/* 临界区代码段 A */
void critical_section(void)
{
    /* 临界区代码,对共享资源进行访问和修改 */
    shared_data++;
}

/* 中断处理函数 */
static irqreturn_t my_interrupt_handler(int irq, void *dev_id)
{
    /* 在中断上下文中访问临界区,也需要获取自旋锁,
       这里没有更高优先级的中断处理函数去访问临界区,使用spin_lock保护即可 */
    spin_lock(&lock);

    critical_section();  // 访问临界区

    spin_unlock(&lock);

    return IRQ_HANDLED;
}

/* 模拟线程上下文中的访问 */
void simulate_thread_context(void)
{
    unsigned long flags;

    /* 在线程上下文中访问临界区,由于在中断处理程序上半部分也有访问,使用irqsave版本 */
    spin_lock_irqsave(&lock, flags);

    critical_section();  // 访问临界区

    spin_unlock_irqrestore(&lock, flags);
}

static int __init my_driver_init(void)
{
    int irq = 123;  // 假设中断号为 123

    /* 初始化自旋锁 */
    spin_lock_init(&lock);

    /* 注册中断处理函数 */
    if (request_irq(irq, my_interrupt_handler, IRQF_SHARED, "my_interrupt", NULL) < 0) {
        printk(KERN_ERR "Failed to register interrupt handler\n");
        return -ENODEV;
    }

    /* 在线程上下文中模拟访问临界区 */
    simulate_thread_context();

    return 0;
}

static void __exit my_driver_exit(void)
{
    int irq = 123;  // 假设中断号为 123

    /* 释放中断处理函数 */
    free_irq(irq, NULL);

    printk(KERN_INFO "Exiting the driver\n");
}

module_init(my_driver_init);
module_exit(my_driver_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("Sample driver to demonstrate spinlock usage");

中断基本简单介绍

linux的中断系统比较复杂。如linux中,中断处理程序分为两部分,上半部(top half)和下半部(bottom half)。这对于高性能和实时要求较高的系统是一种折中的方案。

上半部是指中断处理程序的第一阶段,它需要在最短的时间内完成,以尽快响应中断。上半部通常包括执行一些必要的处理,如保存寄存器状态、检查中断原因、处理紧急任务等。 在上半部执行期间,中断的响应是被屏蔽的,这是为了防止其他同级或更低级别的中断干扰当前中断处理。

下半部是指中断处理程序的第二阶段,它是在中断的上下文之外执行的延迟处理部分。下半部可以延迟执行,因为它通常涉及到一些较为耗时的操作,如I/O操作、内存分配、进程调度等。 下半部可以以多种方式实现,包括软中断、任务队列、工作队列等。通过将这些操作延迟到下半部处理,上半部可以尽快退出以响应其他中断或处理更高优先级的任务。

在上半部执行期间,中断的响应被屏蔽意味着系统不会响应来自相同或更低优先级的中断。这是为了确保当前正在处理的中断能够迅速完成,避免中断嵌套和处理冲突。通过屏蔽其他中断,上半部可以在一个受控的环境中运行,以保证关键任务的执行。 需要注意的是,不是所有中断都会被屏蔽。高优先级的中断仍然可以打断正在执行的中断处理程序。这种机制确保了系统可以响应更紧急的中断事件,并及时处理。中断处理程序的上半部分是一定有的,下半部分可以没有,看实际情况而定。

在驱动程序中,中断处理函数的上半部期间,一般情况下是会屏蔽来自同一中断源的重复中断的。即在处理函数的上半部期间,同一个外部设备再次产生中断信号,该中断信号将被屏蔽,不会再次触发中断处理函数。 这种中断屏蔽机制,防止中断处理函数出现嵌套调用,避免复杂的中断处理和可能的竞争条件。当中断处理函数的上半部完成后,中断屏蔽会解除,之后进入中断下半部(如果有的话),中断下半部分通常处于中断上下文(需要看实现方式), 但是这里是可以响应中断的。因为下半部分通常做一些不是很紧急的事情,可以稍后进行。

This post is licensed under CC BY 4.0 by the author.

关于local_irq_save(了解)

平台总线及设备和驱动