在多线程的编程中,我们经常会遇到临界区资源的访问问题。这些资源无法被多个内核路径同时访问和操作数据,如果操作不当,则会导致数据访问不一致问题,严重的将会导致程序的崩溃,系统的不稳定。
在单核系统中,容易造成并发访问的因素是中断的发生;在多核系统中,运行在不同CPU上的线程都有可能同时操作到同一块共享数据;而支持内核抢占的系统,也会因为进程的被抢占而有可能导致并发访问的发生。为此,linux系统提供了多种对共享数据的保护机制,包括:原子操作、自旋锁、信号量、互斥量、读写锁、RCU等。为了能够熟练恰当地使用好这些保护机制,故对此做一个分析和总结。
一、原子操作
在对单一数据进行累加、递减、判断赋值等简单的操作时,我们可以使用系统提供的原子操作。例如常见的atomic_inc,atomic_dec,atomic_add,atomic_sub等。另外,gcc从4.1.2提供了__sync_*系列的built_in函数,用于提供加减和逻辑运算的原子操作,如__sync_fetch_and_add,__sync_fetch_and_sub,或者__sync_add_and_fetch,__sync_sub_and_fetch,两者的区别在于前者先返回先前值再操作,后者先操作再返回值,类似于i++和++i的区别。linux中arm体系关于atomic的实现在arch/arm/include/asm/atomic.h中:
41 #define ATOMIC_OP(op, c_op, asm_op) \
42 static inline void atomic_##op(int i, atomic_t *v) \
43 { \
44 unsigned long tmp; \
45 int result; \
46 \
47 prefetchw(&v->counter); \
48 __asm__ __volatile__("@ atomic_" #op "\n" \
49 "1: ldrex %0, [%3]\n" \
50 " " #asm_op " %0, %0, %4\n" \
51 " strex %1, %0, [%3]\n" \
52 " teq %1, #0\n" \
53 " bne 1b" \
54 : "=&r" (result), "=&r" (tmp), "+Qo" (v->counter) \
55 : "r" (&v->counter), "Ir" (i) \
56 : "cc"); \
57 }
这段代码首先通过prefetchw将需要操作的数加载到cache中,提高访问速度。__asm__是gcc的内嵌汇编,__volatile__防止编译器的优化,@后为注释。ldrex将要操作的数赋值给result,下面会根据不同的操作将result加上或者减去i的值,strex再会将得到的result值存放到v->counter中,操作结果会保存到tmp中。下面是对操作结果是否成功的判断,如果不成功,则跳到标号为1的地方重新执行一遍。最后三行为变量的一些约束条件,比如result使用一个通用寄存器,只可写并且只能作为输出。这段代码的关键是使用了ldrex和strex实现了数据的独占访问。其优点在于实现速度快,没有其他锁的开销,但只能做些简单操作,无法对大块数据进行复杂操作。
二、自旋锁
对于更复杂的类型,比如结构体,链表之类的,原子操作便显的不适用了,这些数据通常都需要一个连续的操作过程,需要对一整段的代码操作进行保护,自旋锁便应运而生。
自旋锁是一种忙等待锁,当无法获取到相应的资源时,程序会一直卡在原地重复尝试获取锁,相当于while循环。所以这就要求获取到自旋锁的线程应尽快完成任务,实现解锁,不然长时间的自旋等待对于CPU是一种严重的浪费。
早先的自旋锁只用一个无符号的变量来表示锁持有状态,1为未持有,其他值为锁被占用。自旋锁的实现考虑了不同体系的差异以及大小端的问题,arm体系的自旋锁定义如下:
10 typedef struct {
11 union {
12 u32 slock;
13 struct __raw_tickets {
14 #ifdef __ARMEB__
15 u16 next;
16 u16 owner;
17 #else
18 u16 owner;
19 u16 next;
20 #endif
21 } tickets;
22 };
23 } arch_spinlock_t;
现在的自旋锁考虑到了锁竞争时的公平性问题,采用了类似排队的机制来保证性能。next指代下一个申请锁的号牌,owner表示当前持有者锁的号牌。所以每当有线程申请锁时,next会自增后将此号牌留给线程;当有线程释放锁时,owner会自增指代下一个线程可以申请锁了。而处在自旋状态的线程会一直将自己手中的号牌与自旋锁的owner进行比较,一旦相同便可以获取锁并进行操作。
自旋锁的加锁关键在于首先关闭内核抢占,因为不关闭的话在执行临界区代码时便有可能被中断打断从而导致睡眠,这不符合自旋锁需要快速处理完临界区数据的初衷。之后的操作便会调用到体系结构相关的操作,arm32代码如下:
58 static inline void arch_spin_lock(arch_spinlock_t *lock)
59 {
60 unsigned long tmp;
61 u32 newval;
62 arch_spinlock_t lockval;
63
64 prefetchw(&lock->slock);
65 __asm__ __volatile__(
66 "1: ldrex %0, [%3]\n"
67 " add %1, %0, %4\n"
68 " strex %2, %1, [%3]\n"
69 " teq %2, #0\n"
70 " bne 1b"
71 : "=&r" (lockval), "=&r" (newval), "=&r" (tmp)
72 : "r" (&lock->slock), "I" (1 << TICKET_SHIFT)
73 : "cc");
74
75 while (lockval.tickets.next != lockval.tickets.owner) {
76 wfe();
77 lockval.tickets.owner = ACCESS_ONCE(lock->tickets.owner);
78 }
79
80 smp_mb();
81 }
这里通过原子操作相同的方式为lock->slock值自增,也是对lock->tickets.next的自增,之后便是一段循环等待程序,直到next和owner相同时,加锁过程结束。最后的smp_mb为内存屏障,保证汇编代码和C的运行顺序。如果不等的话,程序便会调用arm体系结构相关的睡眠函数wfe()来使CPU进入到等待事件到来的standby模式。自旋锁的开销较小,但在使用中应避免在加锁的过程中调用到使程序进入睡眠的函数,另外自旋锁也是中断中处理同步问题的首选,最好是选用spin_loc