10.2 自旋锁
如果每个临界区都能像增加变量这样简单就好了,现实是残酷的。现实世界里,临界区甚至可以跨越多个函数。例如,经常会碰到这种情况:先得从一个数据结构中移出数据,对其进行格式转换和解析,最后再把它加入到另一个数据结构中。整个执行过程必须是原子的,在数据被更新完毕前,不能有其他代码读取这些数据。显然,简单的原子操作对此无能为力,这就需要更复杂的同步方法——锁来提供保护。
Linux内核中最常见的锁是自旋锁。自旋锁最多只能被一个可执行线程持有。如果一个执行线程试图获得一个被已经持有的自旋锁,那么该线程就会一直进行忙循环——旋转——等待锁重新可用。要是锁未被争用,请求锁的执行线程便能立刻得到它,继续执行。在任意时间,自旋锁都可以防止多于一个的执行线程同时进入临界区。同一个锁可以用在多个位置,例如,对于给定数据的所有访问都可以得到保护和同步。
门和锁的例子,自旋锁相当于坐在门外等待同伴从里面出来,并把钥匙交给你。如果到了门口,发现里面没人,就可以抓到钥匙进入房间。如果到了门口发现里面有人,就必须在门外等待钥匙,不断地检查房间是否为空。当房间为空时,就可以抓到钥匙进入。正是因为有了钥匙(自旋锁),才允许一次只有一个人(相当于执行线程)进入房间(相当于临界区)。
一个被争用的自旋锁使得请求它的线程在等待锁重新可用时自旋(特别浪费处理器时间),这种行为是自旋锁的要点。所以自旋锁不应该被长时间占有。事实上,这点正是使用自旋锁的初衷:在短期间内进行轻量级加锁。还可以采取另外的方式来处理对锁的争用:让请求线程睡眠,直到锁重新可用时再唤醒它。这样处理器就不必循环等待,可以去执行其他代码。这也会带来一定的开销——这里有两次明显的上下文切换,被阻塞的线程要换出和换入,与实现自旋锁的少数几行代码相比,上下文切换当然有较多的代码。因此,持有自旋锁的时间最好小于完成两次上下文切换的耗时。当然大多数人都不会去测量上下文切换的耗时,所以让持有自旋锁的时间应尽可能的短就可以了。信号量,它使得在发生争用时,等待的线程能投入睡眠,而不是旋转。
1、自旋锁方法
自旋锁的实现和体系结构密切相关,代码通过汇编实现。这些与体系结构相关的代码定义在文件<asm/spinlock.h>中,实际需要用到的接口定义在文件<linux/spinlock.h>中。自旋锁的基本使用形式如下:
DEFINE_SPINLOCK(lock);// 初始化自旋锁
spin_lock(&lock);
//临界区
spin_unlock(&lock);
因为自旋锁在同一时刻至多被一个执行线程持有,所以一个时刻只能有一个线程位于临界区内,这就为多处理器机器提供了防止并发访问所需的保护机制。注意在单处理器机器上,编译的时候并不会加入自旋锁。它仅仅被当做一个设置内核抢占机制是否被启用的开关。如果禁止内核抢占,那么在编译时自旋锁会被完全剔除出内核。
警告:自旋锁是不可递归的!
Linux内核实现的自旋锁是不可递归的,这点不同于自旋锁在其他操作系统中的实现。所以,如果试图得到一个正持有的锁,必须自旋,等待自己释放这个锁。但出于自旋忙等待中,所以永远没有机会释放锁,于是被自己锁死了。要小心自旋锁!
自旋锁可以使用在中断处理程序中(此处不能使用信号量,因为它们会导致睡眠)。在中断处理程序中使用自旋锁时,一定要在获取锁之前,首先禁止本地中断,否则,中断处理程序会打断正持有锁的内核代码,有可能会试图去争用这个已经被持有的自旋锁。这样一来,中断处理程序就会自旋,等待锁重新可用,但是锁的持有者在这个中断处理程序执行完毕前不可能运行。注意:需要关闭的只是当前处理器上的中断。如果中断发生在不同的处理器上,即使中断处理器程序在同一锁上自旋,也不会妨碍锁的持有者最终释放锁。
内核提供的禁止中断同时请求锁的接口,方法如下:
DEFINE_SPINLOCK(lock);//初始化自旋锁
unsigned long flags;
spin_lock_irqsave(&lock, flags);
//临界区
spin_unlock_irqrestore(&lock, flags);
spin_lock_irqsave:保存中断的当前状态,并禁止本地中断,再获取指定的锁。
spin_unlock_irqrestore:对指定的锁解锁,然后让中断恢复到加锁之前的状态。所以即使中断最初是被禁止的,代码也不会错误地激活它们,相反,会继续让它们禁止。注意,flags变量看起来像是由数值传递的,这是因为这些锁函数有些部分是通过宏的方式实现的。
在单处理器系统上,虽然在编译时抛弃掉了锁机制,但在上面例子中仍需要关闭中断,以禁止中断处理程序访问共享数据。加锁和解锁分别可以禁止和允许内核抢占。
锁什么?
使用锁时要有针对性。要知道需要保护的是数据而不是代码。本章讲的都是保护临界区的重要性,但是真正保护的其实是临界区中的数据,而不是代码。
大原则:针对代码加锁会使得程序难以理解,并且容易引发竞争条件,正确的做法应该是对数据而不是代码加锁。
既然不是对代码加锁,那就一定要用特定的锁来保护自己的共享数据。无论何时需要访问共享数据,一定要保证数据是安全的。而保证数据安全意味着在对数据进行操作前,首先占用恰当的锁,完成操作后再释放它。
如果能确定中断在加锁前是激活的,就不需要在解锁后恢复中断以前的状态了。可以无条件地在解锁时激活中断。这时,使用spin_lock_irq()和spin_unlock_irq()会更好一些。
DEFINE_SPINLOCK(lock);// 定义自旋锁
spin_lock_irq(&lock);//关中断,获取自旋锁
//临界区
spin_unlock_irq(&lock);//开中断,释放自旋锁
由于内核变得庞大而复杂,在内核的执行路线上,你很难搞清楚中断在当前调用点上到底是不是处于激活状态。正因为如此,并不提倡使用spin_lock_irq()方法。如果一定要使用它,那应该确定中断原来就处于激活状态,否则,当其他人期望中断处于未激活状态时却发现处于激活状态,可能会非常不开心。
调试自旋锁
配置选项CONFIG_DEBUG_SPINLOCK为使用自旋锁的代码加入了许多调试检测的手段。例如,激活该选项,内核就会检查是否使用了未初始化的锁,是否在还没加锁的时候就要对锁执行开锁操作。在测试代码时,应该激活这个选项。如果需要进一步全程调试锁,还应该打开CONFIG_DEBUG_LOCK_ALLOC选项。
2、其他针对自旋锁的操作
可以使用spin_lock_init()方法来初始化动态创建的自旋锁。spin_try_lock()试图获得某个特定的自旋锁,如果该锁已经被争用,那么该方法会立刻返回一个非0值,而不会自旋等待锁被释放;如果成功地获得了这个自旋锁,该函数返回0。同理,spin_is_locked()方法用于检查特定的锁当前是否已被占用,如果已被占用,返回非0值;否则返回0。该方法只做判断,并不实际占用。
static inline int spin_is_locked(spinlock_t *lock)
{
return raw_spin_is_locked(&lock->rlock);
}
表10-4给出了标准的自旋锁操作的完整列表。
spin_lock():获取指定的自旋锁
spin_unlock():释放指定的锁
spin_lock_irq():禁止本地中断并获取指定的锁
spin_unlock_irq():释放指定的锁,并激活本地中断
spin_lock_irqsave():保存本地中断的当前状态,禁止本地中断,并获取指定的锁
spin_unlock_irqrestore():释放指定的锁,并让本地中断恢复到以前状态
spin_lock_init():动态初始化指定的自旋锁
spin_try_lock():试图获取指定的锁,如果未获取,则返回非0
spin_is_locked():如果指定的锁当前正在被获取,则返回非0,否则返回0
3、自旋锁和下半部
在与下半部配合使用时,必须小心地使用锁机制。spin_lock_bh()用于获取指定锁,同时禁止所有下半部的执行。spin_unlock_bh()执行相反的操作。
由于下半部可以抢占进程上下文中的代码,所以当下半部和进程上下文共享数据时,必须对进程上下文中的共享数据进行保护,所以需要加锁的同时还要禁止下半部执行。同样,由于中断处理程序可以抢占下半部,所以如果中断处理程序和下半部共享数据,那么就必须在获取恰当的锁的同时还要禁止中断。
同类的tasklet不可能同时运行,所以对于同类tasklet中的共享数据不需要保护。但是当数据被两个不同种类的tasklet共享时,就需要在访问下半部中的数据前先获得一个普通的自旋锁。这里不需要禁止下半部,因为在同一个处理器上绝不会有tasklet相互抢占的情况。
对于软中断,无论是否同种类型,如果数据被软中断共享,那么它必须得到锁的保护。这是因为,即使是同种类型的两个软中断也可以同时运行在一个系统的多个处理器上。但是,同一个处理器上的一个软中断绝不会抢占另一个软中断,因此,根本没必要禁止下半部。