常用的同步原语锁,到多核处理器时代锁已经是必不可少的同步方式之一了。无论设计多优秀的多线程数据结构,都避不开有竞争的临界区,此时高效的锁显得至关重要。锁的颗粒度是框架/程序设计者所关注的,当然越细越好(也不尽然),同时不同的锁往往也会体现出完全不同的效率,Linux有posix的pthread_mutex_t,Windows有CreateMutex创造的HANDLE,boost有mutex而且C++11也定义了std::mutex,这些锁在获取不到锁时都会进入睡眠状态(try_lock方法例外)并将CPU让出来给其他的进程使用。但是在很多情况下,多个进程竞争的临界区的代码可能非常的短,例如:锁住数据数据锁,读取数据,修改计数器,释放临界区,再用数据计算…在这种(临界区很短的)情况下,用上面提到的那些锁会导致繁琐的用户态和内核态的切换和繁重的进程切换,这都是非常耗费计算资源的。
在临界区很短的情况下,可以考虑自旋锁作为同步原语,也因为公共数据的读取和修改是非常短暂的,但同时又是需要同步原语保护的临界区,许多所谓的无锁(lock-free)数据结构也是基于自旋锁实现的,那么什么是自旋锁呢?就是死循环检查锁是否处于未上锁状态,如果处于加锁并且退出循环,这个过程可以有xchgb这个汇编语句来保证原子性。
那么自旋锁是如何实现的呢?下面用Linux的i386架构的源码中自旋锁作为例子说明三种数据结构的实现原理,三种数据结构分别是:自旋锁(互斥锁),读写锁和顺序锁。
先说明xchgb这条汇编所代表的的含义:xchgb a, b,表达原子性的交换,即a复制给b,b复制给a。
自旋锁
1 2 3 |
typedef struct { volatile unsigned int slock; /* 值1表示“未加锁”,任何负数和0都表示“加锁状态” */ } spinlock_t; |
见上面的注释,slock值1表示“未加锁”,任何负数和0都表示“加锁状态”,volatile是类型修饰符,确保本变量相关指令不会因编译器的优化而省略,要求每次直接读值(读内存),防止编译器优化掉。旋转加锁的过程如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
/* 不阻塞的锁,无法上锁直接返回 */ inline int spin_trylock(spinlock_t *lock) { char oldval; __asm__ __volatile__( "xchgb %b0,%1" :"=q" (oldval), "=m" (lock->slock) :"0" (0) : "memory"); return oldval > 0; } /* 自旋锁,未获取锁则进入死循环,直到获取锁为止 */ inline void spin_lock(spinlock_t *lock) { while(!spin_trylock(lock)){ ; } } |
使用旋转锁的主要原因在于效率高,执行时间短(避免用户态和内核态之间的切换),因此Linux选择使用gcc编译器的内联汇编,__asm__ 是内联汇编的关键字,__volatile__关键字就是让gcc不要优化内存使用,如需语法见链接。
读写锁
1 2 3 |
typedef struct { volatile unsigned int lock; } rwlock_t; |
lock为0x01000000是标识“空”状态,如果写者已经获得自旋锁,那么lock字段的值为0x00000000,如果一个,两个或多个进程因为读而获得了自旋锁,那么lock字段上的值为0x00ffffff, 0x00fffffe等…读锁和写锁加锁如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
/* 写锁尝试加锁 */ inline int _raw_read_trylock(rwlock_t *lock) { atomic_t *count = (atomic_t *)lock; atomic_dec(count); if (atomic_read(count) >= 0) return 1; atomic_inc(count); return 0; } /* 读锁尝试加锁 */ inline int _raw_write_trylock(rwlock_t *lock) { atomic_t *count = (atomic_t *)lock; /* 如果计算结果为0,则返回1,否则返回0 */ if (atomic_sub_and_test(RW_LOCK_BIAS, count)) return 1; atomic_add(RW_LOCK_BIAS, count); return 0; } |
顺序锁
读写锁明显有一个巨大的缺点,如果读操作非常频繁,写锁会有获取不到锁的可能,解决这个问题的办法就是使用顺序锁,但压下葫芦起了瓢,它也有自己的缺点,读者临界区的代码如下:
1 2 3 4 5 |
unsigned int seq;; do{ seq = read_seqbegin(&seqlock); /* 临界区 */ } while(read_seqretry(&reqlock, seq)); |
可以看出来,读者不得不反复多次读相同的数据直到获取有效的副本,但是仔细考量一下,在读操作非常大量,写操作基本没有的情况下,这个锁的效果也还是可以的。
不过还有一些限制条件:
- 被保护的数据结构不包括间接指针指向的内容。
- 读者临界代码区没有其他副本。
End