1 Linux内核同步
Linux内核中有许多共享资源,这些共享资源是内核中进程都有机会访问到的。内核对其中一些共享资源的访问是独占的,因此需要提供机制对共享资源进行保护,确保任意时刻只有一个进程在访问共享资源。自旋锁就是一种共享资源保护机制,确保同一时刻只有一个进程能访问到共享资源。
2 普通自旋锁
内核中提供的普通自旋锁API为spin_lock()何spin_unlock(),使用的方法为:
spin_lock();
...临界区...
spin_unlock();
内核保证spin_lock()和spin_unlock()之间的临界区代码在任意时刻只会由一个CPU进行访问,并且当前CPU访问期间不会发生进程切换,当前进程也不会进入睡眠,这个在后面还会进一步分析。
3 单处理器(UP)普通自旋锁
之前讲过,自旋锁的功能有两点,一是临界区代码任意时刻由一个CPU进行访问,二是当前CPU访问期间不会发生进程切换。对于单处理器来说第一个问题就不存在了,因为只有一个CPU,不存在多处理器访问的问题。因此单处理器自旋锁只要保证CPU对临界区代码访问期间不发生进程切换就行了。知道了这一点后让我们来看看单处理器普通自旋锁使用的数据结构和API的实现代码。以下代码和数据结构使用的内核版本是Linux-2.6.24,特此注明。
3.1 数据结构
普通自旋锁的数据类型为spinlock_t:
typedef struct {
raw_spinlock_t raw_lock;
#if defined(CONFIG_PREEMPT) && defined(CONFIG_SMP)
unsigned int break_lock;
#endif
#ifdef CONFIG_DEBUG_SPINLOCK
unsigned int magic, owner_cpu;
void *owner;
#endif
#ifdef CONFIG_DEBUG_LOCK_ALLOC
struct lockdep_map dep_map;
#endif
} spinlock_t;
可以看出,去掉了编译配置选项之后其实spinlock_t里只剩下了类型为raw_spinlock_t的数据结构raw_lock:
typedef struct { } raw_spinlock_t;
这里读者可能要有疑问了,问什么raw_spinlock_t是个空的数据结构呢,这就和下面将要说到的单处理器普通自旋锁的实现了,因为单处理器的实现根本就不需要对数据结构进行处理。
3.2 API的实现
先看spin_lock()的实现:
#define spin_lock(lock) _spin_lock(lock) //lock数据类型为*spinlock_t,虽然没有用到-_-。
#define _spin_lock(lock) __LOCK(lock)
#define __LOCK(lock) \
do { preempt_disable(); __acquire(lock); (void)(lock); } while (0)
#define __acquire(x) (void)0
其中preempt_disable()的工作是关闭内核抢占,__acquire(lock)是空操作,(void)(lock)是简单的数据转型操作,防止编译器对lock未使用报警。看到这里,读者应该就明白了,在单处理器中spin_lock()所做的工作仅仅是关闭内核抢占而已,仅此而已,这就保证了在运行期间不会发生进程抢占,从而也就保证了临界区里的代码只有当前进程才会访问到,在当前进程释放临界区之前都不会有别的进程能够访问。
再来看看spin_unlock()的实现:
#define spin_unlock(lock) _spin_unlock(lock)
#define _spin_unlock(lock) __UNLOCK(lock)
#define __UNLOCK(lock) \
do { preempt_enable(); __release(lock); (void)(lock); } while (0)
#define __release(x) (void)0
看懂了上面的spin_lock(),spin_unlock()所做的工作也就很清楚了。spin_unlock()和spin_lock()所做的工作是相反的,spin_lock()关闭了内核抢占,则spin_unlock()开启内核抢占。也就是说在关闭了内核抢占后,进程进入临界区,由于内核抢占已经关闭,因此当前进程不会被其他进程所抢占。完成相应任务后开启内核抢占,释放临界区,此时其他进程就可以抢占CPU从而访问临界区了。也就是说,普通自旋锁执行过程就是 关闭内核抢占->访问临界区代码->开启内核抢占。
3.3 普通自旋锁存在的风险
虽然普通自旋锁通过关闭内核抢占独占CPU资源阻止了了其余进程访问临界区资源,但是还有一种特殊的情况。关闭内核抢占只是组织了其余进程对CPU的抢占,但是并不能阻止中断程序对CPU的抢占,如果中断服务程序想要访问临界区的话就有可能造成资源的并发访问,从而导致中断结束后进程访问的资源被改变了,从而导致错误。为此,Linux内核提供了更加安全的自旋锁API spin_lock_irqsave()和spin_unlock_irqrestore()。
spin_lock_irqsave()先将处理器状态指令寄存器IF的内容保存起来,然后通过cli指令关闭中断,然后再执行和spin_lock()相同的步骤。
spin_unlock_irqrestore()先执行和spin_unlock()相同的步骤,即打开内核抢占,然后通过sti指令开启中断,最后之前保存的值恢复到处理器状态指令寄存器IF中。
之前讲过在运行由自旋锁保护的临界区代码时不允许进入睡眠状态,不仅临界区内的代码不允许进入睡眠状态,临界区内代码所调用的代码也不允许进入睡眠状态。首先,自旋锁一般只在需要短时间访问共享资源是才会使用,一般都是马上就能完成的任务,不需要进入睡眠等待什么资源。其次,进入临界区之后提供的中断和内核抢占都已经关闭了,基于系统时钟中断的进程切换也就失效了,也就是说进入睡眠状态之后可能就会永远的睡下去了,因为没有激励信号来把进入睡眠的进程唤醒。但是有一个特例,那就是kmalloc函数,当分配失败时它可以进入睡眠状态。总而言之,运行由自旋锁保护的临界区代码时,不允许程序进入睡眠;同时临界区应该是短时间就可以完成的任务,因为在多处理器架构中自旋锁会进行忙等待,白白占用CPU资源,接下来就讲解多处理器架构中自旋锁的实现。
4 多处理器(SMP)普通自旋锁
之前讲过,自旋锁要保证任意时刻只有一个CPU运行在临界区内,同时运行在临界区内的CPU不允许进行进程切换。在单处理器中通过关闭内核抢占保证了进程对资源的访问不被打扰,在多处理器中情况就要麻烦一些了,因为还要应付来自其他处理器的干扰。在多处理器中也是通过关闭内核抢占来保证对临界区的访问不受运行在当前CPU上的其余进程的打扰,那么怎么应付来自其他处理器的干扰呢,带着这个问题让我们接着往下看。
4.1 数据结构
多处理器中spinlock_t的定义和单处理器中的一样,这里就不再赘述了,不同的是raw_spinlock_t不再是空数据结构了:
typedef struct {
unsigned int slock;
} raw_spinlock_t;
由此可以看出,在多处理其中普通自旋锁其实是使用了一个整数作为计数器,自旋锁初始化的时候会将计数器的值设为1,表示自旋锁当前可用。下面来看自旋锁API的实现。
4.2 API的实现
#define spin_lock(lock) _spin_lock(lock) //lock数据类型为*spinlock_t,虽然没有用到-_-。
void __lockfunc _spin_lock(spinlock_t *lock)
{
preempt_disable();//关抢占
spin_acquire(&lock->dep_map, 0, 0, _RET_IP_);//空操作
LOCK_CONTENDED(lock, _raw_spin_trylock, _raw_spin_lock);
}
LOCK_CONTENDED是个宏定义,在当前lock为普通自旋锁时,会以lock为参数运行_raw_spin_lock()函数,_raw_spin_lock()定义如下:
#define _raw_spin_lock(lock) __raw_spin_lock(&(lock)->raw_lock);
__raw_spin_lock()的实现是跟体系结构相关的,下面来看看在x86里面的实现:
static inline void __raw_spin_lock(raw_spinlock_t *lock)
{
asm volatile(
"\n1:\t"
LOCK_PREFIX " ; decl %0\n\t"
"jns 2f\n"
"3:\n"
"rep;nop\n\t"
"cmpl $0,%0\n\t"
"jle 3b\n\t"
"jmp 1b\n"
"2:\t" : "=m" (lock->slock) : : "memory");
}
指令前缀LOCK_PREFIX表示执行这条指令时将总线锁住,不让其他处理器方位,以此来保证这条指令执行的“原子性”,%0表示lock->slock,第一句话表示将lock->slock减一。第二句话进行判断,如果减一之后大于或等于零则表示加锁成功,则调到标号2处,代码2后面没有继续执行的代码了,因此会返回。如果减一之后小于零,则表示之前已经有进程进行了加锁操作,则跳到标号3处执行,将lock->slock与0进行比较,如果小于零则再次跳到3处执行,即循环执行标号3处的指令。直到加锁者释放锁将lock->slock设为1,此时会跳到标号1处进行加锁操作。
与 __raw_spin_lock()相对应的解锁函数是 __raw_spin_unlock(),他的作用是将lock->slock的值设为1,仅此而已。只有加锁者将lock->slock设为1之后其他在忙等待的CPU才能进行加锁,结合之前的__raw_spin_lock()应该不难理解。
static inline void __raw_spin_unlock(raw_spinlock_t *lock)
{
asm volatile("movl $1,%0" :"=m" (lock->slock) :: "memory");
}
5 总结
单处理器自旋锁的工作流程是:关闭内核抢占->运行临界区代码->开启内核抢占。更加安全的单处理器自旋锁工作流程是:保存IF寄存器->关闭当前CPU中断->关闭内核抢占->运行临界区代码->开启内核抢占->开启当前CPU中断->恢复IF寄存器。
多处理器自旋锁的工作流程是:关闭内核抢占->(忙等待->)获取自旋锁->运行临界区代码->释放自旋锁->开启内核抢占。更加安全的多处理器自旋锁工作流程是:保存IF寄存器->关闭当前CPU中断->关闭内核抢占->(忙等待->)获取自旋锁->运行临界区代码->释放自旋锁->开启内核抢占->开启当前CPU中断->恢复IF寄存器。