在现代操作系统里,同一时间可能有多个内核执行流在执行,因此内核其实象多进程多线程编程一样也需要一些同步机制来同步各执行单元对共享数据的访问。尤其是在多处理器系统上,更需要一些同步机制来同步不同处理器上的执行单元对共享的数据的访问。
在主流的Linux内核中包含了几乎所有现代的操作系统具有的同步机制,这些同步机制包括:原子操作、信号量(semaphore)、读写信号量(rw_semaphore)、spinlock、BKL(Big Kernel Lock)、rwlock、brlock(只包含在2.4内核中)、RCU(只包含在2.6内核中)和seqlock(只包含在2.6内核中)。
比较经典的有原子操作、spin_lock(忙等待的锁)、mutex(互斥锁)、semaphore(信号量)等。并且它们几乎都有对应的rw_XXX(读写锁),以便在能够区分读与写的情况下,让读操作相互不互斥(读写、写写依然互斥)。而seqlock和rcu应该可以不算在经典之列,它们是两种比较有意思的同步机制。
原子操作
原子操作就是指某一个操作在执行过程中不可以被打断,要么全部执行,要不就一点也不执行。原子操作需要硬件的支持,与体系结构相关,使用汇编语言实现。原子操作主要用于实现资源计数,很多引用计数就是通过原子操作实现。Linux中提供了两种原子操作接口,分别是原子整数操作和原子位操作。
原子整数操作只对atomic_t类型的数据进行操作,不能对C语言的int进行操作,使用atomic_t只能将其作为24位数据处理,主要是在SPARC体系结构中int的低8为中设置了一个锁,避免对原子类型数据的并发访问。
原子位操作是针对由指针变量指定的任意一块内存区域的位序列的某一位进行操作。它只是针对普通指针的操作,不需要定义一个与该操作相对应的数据类型。
原子类型定义如下:
typedefstruct { volatile int counter; }atomic_t;
volatile修饰字段告诉gcc不要对该类型的数据做优化处理,对它的访问都是对内存的访问,而不是对寄存器的访问。
原子操作API包括:
atomic_read(atomic_t* v);
该函数对原子类型的变量进行原子读操作,它返回原子类型的变量v的值。
atomic_set(atomic_t* v, int i);
该函数设置原子类型的变量v的值为i。
voidatomic_add(int i, atomic_t *v);
该函数给原子类型的变量v增加值i。
atomic_sub(inti, atomic_t *v);
该函数从原子类型的变量v中减去i。
intatomic_sub_and_test(int i, atomic_t *v);
该函数从原子类型的变量v中减去i,并判断结果是否为0,如果为0,返回真,否则返回假。
voidatomic_inc(atomic_t *v);
该函数对原子类型变量v原子地增加1。
voidatomic_dec(atomic_t *v);
该函数对原子类型的变量v原子地减1。
intatomic_dec_and_test(atomic_t *v);
该函数对原子类型的变量v原子地减1,并判断结果是否为0,如果为0,返回真,否则返回假。
intatomic_inc_and_test(atomic_t *v);
该函数对原子类型的变量v原子地增加1,并判断结果是否为0,如果为0,返回真,否则返回假。
intatomic_add_negative(int i, atomic_t*v);
该函数对原子类型的变量v原子地增加I,并判断结果是否为负数,如果是,返回真,否则返回假。
intatomic_add_return(int i, atomic_t *v);
该函数对原子类型的变量v原子地增加i,并且返回指向v的指针。
intatomic_sub_return(int i, atomic_t *v);
该函数从原子类型的变量v中减去i,并且返回指向v的指针。
intatomic_inc_return(atomic_t * v);
该函数对原子类型的变量v原子地增加1并且返回指向v的指针。
intatomic_dec_return(atomic_t * v);
该函数对原子类型的变量v原子地减1并且返回指向v的指针。
原子操作通常用于实现资源的引用计数,在TCP/IP协议栈的IP碎片处理中,就使用了引用计数,碎片队列结构structipq描述了一个IP碎片,字段refcnt就是引用计数器,它的类型为atomic_t,当创建IP碎片时(在函数ip_frag_create中),使用atomic_set函数把它设置为1,当引用该IP碎片时,就使用函数atomic_inc把引用计数加1,当不需要引用该IP碎片时,就使用函数ipq_put来释放该IP碎片,ipq_put使用函数atomic_dec_and_test把引用计数减1并判断引用计数是否为0,如果是就释放Ip碎片。函数ipq_kill把IP碎片从ipq队列中删除,并把该删除的IP碎片的引用计数减1(通过使用函数atomic_dec实现)。
自旋锁
Linux自旋锁保证了任意时刻只能有一个执行线程进入临界区,其他试图进入临界区的线程将一直进行尝试(即自旋),直到获得该锁。自旋锁主要应用在加锁时间不长并且不会睡眠的情况。
自旋锁的本质是对内存区域的一个整数的操作,任何线程进入临界区之前都必须检查该整数,可用则进入,都则一直忙循环等待。
自旋锁机制让试图获得该锁的线程一直进行忙循环(占用CPU),因此自旋锁适合于断时间内进行轻量级加锁。而且自旋锁绝对不可以递归使用,否则会被自己锁死。
Linux自旋锁主要应用与多核处理器中,单CPU中不会进行自旋锁操作。
linux上的自旋锁有三种实现:
a. 在单cpu,不可抢占内核中,自旋锁为空操作。
b. 在单cpu,可抢占内核中,自旋锁实现为“禁止内核抢占”,并不实现“自旋”。
c. 在多cpu,可抢占内核中,自旋锁实现为“禁止内核抢占” + “自旋”。其中,禁止内核抢占只是关闭“可抢占标志”,而不是禁止进程切换。显式使用schedule或进程阻塞(此也会导致调用schedule)时,还是会发生进程调度的。
使用自旋锁需要注意有可能造成的死锁情况:
static DEFINE_SPINLOCK(xxx_lock);
unsigned long flags;
spin_lock_irqsave(&xxx_lock, flags);
。。。 critical section here 。。
spin_unlock_irqrestore(&xxx_lock, flags);
代码中spin_lock_irqsave会禁止本地cpu中断的抢占。以上代码在任何情况下都是安全的。但问题是关中断的代价太大。
如果把spin_lock_irqsave/spin_unlock_irqrestore换成spin_lock/spin_unlock会有什么问题吗?
答案是,如果中断中调用了spin_lock,可能会引起死锁!
例如:
spin_lock(&lock);
。。。
《- interrupt comes in:
spin_lock(&lock);
值得注意的是,如果产生中断的cpu和进程中调用spin_lock的cpu不是同一个,则不会有问题。这也是irq版本的spin_lock函数实现时只需要禁止本地cpu中断的原因。
结论:要想在进程中用spin_lock代替spin_lock_irqsave,条件是中断中不会使用相应的spin_lock
何时使用自旋锁?
不允许睡眠的上下文且临界区操作较短时使用自旋锁。
读/写自旋锁
Linux中规定,读/写自旋锁允许多个线程同时以只读的方式访问临界资源,只有当一个线程想更新数据时,才会互斥访问资源。
读写自旋锁包括一个24位读者计数和一个解锁标记来实现的。
注意:读写锁需要比spinlocks更多的访问原子内存操作,如果读临界区不是很大,最好别使用读写锁。
读写锁代码:
点击(此处)折叠或打开rwlock_t xxx_lock = __RW_LOCK_UNLOCKED(xxx_lock);
unsigned long flags;
read_lock_irqsave(&xxx_lock, flags);
。。 critical section that only reads the info 。。。
read_unlock_irqrestore(&xxx_lock, flags);
write_lock_irqsave(&xxx_lock, flags);
。。 read and write exclusive access to the info 。。。
write_unlock_irqrestore(&xxx_lock, flags);
读写锁比较适合链表等数据结构,特别是查找远多于修改的情况。
另外,可以灵活的使用read-write和irq版本的自旋锁。例如,如果中断中只是用了读锁,进程中就可以使用non-irq版本的读锁和irq版本的写锁。
注意:RCU比读写锁更适合遍历list,但需要更关注细节。目前kernel社区正在努力用RCU代替读写锁。
BKL(Big Kernel Lock)
BKL即全局内核锁,也称大内核锁,它是一个全局自旋锁。大内核锁也是用来保护临界区资源的,避免出现多个处理器上的进程同时访问同一区域,整个内核中只有一个大内核锁。
BKL是一个名为kernel_flag的自旋锁,持有该锁的进程仍可以睡眠,当睡眠时持有的锁将被自动释放,该进程被唤醒时重新持有该锁。Linux允许一个进程可以递归的持有BKL,BKL是一个递归锁。
它的设计思想是,一旦某个内核路径获取了这把锁,那么其他所有的内核路径都不能再获取到这把锁。自旋锁加锁的对象一般是一个全局变量,大内核锁加锁的对象是一段代码,里面可能包含多个全局变量。那么他带来的问题是,虽然A只需要互斥访问全局变量a,但附带锁了全局变量b,从而导致B不能访问b了
mutex(互斥锁)
/linux/include/linux/mutex.h
47struct mutex {
48
49 atomic_t count;
50 spinlock_t wait_lock;
51 struct list_head wait_list;
52#ifdef CONFIG_DEBUG_MUTEXES
53 struct thread_info *owner;
54 const char *name;
55 void *magic;
56#endif
57#ifdef CONFIG_DEBUG_LOCK_ALLOC
58 struct lockdep_map dep_map;
59#endif
60};
一、作用及访问规则:
互斥锁主要用于实现内核中的互斥访问功能。内核互斥锁是在原子API之上实现的,但这对于内核用户是不可见的。对它的访问必须遵循一些规则:同一时间只能有一个任务持有互斥锁,而且只有这个任务可以对互斥锁进行解锁。互斥锁不能进行递归锁定或解锁。一个互斥锁对象必须通过其API初始化,而不能使用memset或复制初始化。一个任务在持有互斥锁的时候是不能结束的。互斥锁所使用的内存区域是不能被释放的。使用中的互斥锁是不能被重新初始化的。并且互斥锁不能用于中断上下文。但是互斥锁比当前的内核信号量选项更快,并且更加紧凑,因此如果它们满足您的需求,那么它们将是您明智的选择。
二、各字段详解:
1、atomic_t count; --指示互斥锁的状态:1没有上锁,可以获得;0被锁定,不能获得;负数被锁定,且可能在该锁上有等待进程初始化为没有上锁。
2、spinlock_t wait_lock;--等待获取互斥锁中使用的自旋锁。在获取互斥锁的过程中,操作会在自旋锁的保护中进行。初始化为为锁定。
3、struct list_head wait_list;--等待互斥锁的进程队列。
四、操作:
1、定义并初始化:
struct mutex mutex;
mutex_init(&mutex);
79# define mutex_init(mutex) \
80do { \
81 static struct lock_class_key __key; \
82 \
83 __mutex_init((mutex), #mutex, &__key); \
84} while (0)
42void
43__mutex_init(struct mutex *lock, const char *name, structlock_class_key *key)
44{
45 atomic_set(&lock-》count, 1);
46 spin_lock_init(&lock-》wait_lock);
47 INIT_LIST_HEAD(&lock-》wait_list);
48
49 debug_mutex_init(lock, name, key);
50}
直接定于互斥锁mutex并初始化为未锁定,己count为1,wait_lock为未上锁,等待队列wait_list为空。
2、获取互斥锁:
(1)具体参见linux/kernel/mutex.c
void inline __sched mutex_lock(struct mutex *lock)
{
might_sleep();
__mutex_fastpath_lock(&lock-》count,__mutex_lock_slowpath);
}
获取互斥锁。实际上是先给count做自减操作,然后使用本身的自旋锁进入临界区操作。首先取得count的值,再将count置为-1,判断如果原来count的值为1,也即互斥锁可以获得,则直接获取,跳出。否则进入循环反复测试互斥锁的状态。在循环中,也是先取得互斥锁原来的状态,再将其置为-1,判断如果可以获取(等于1),则退出循环,否则设置当前进程的状态为不可中断状态,解锁自身的自旋锁,进入睡眠状态,待被在调度唤醒时,再获得自身的自旋锁,进入新一次的查询其自身状态(该互斥锁的状态)的循环。
(2)具体参见linux/kernel/mutex.c
int __sched mutex_lock_interruptible(struct mutex *lock)
{
might_sleep();
return __mutex_fastpath_lock_retval(&lock-》count,__mutex_lock_interruptible_slowpath);
}
和mutex_lock()一样,也是获取互斥锁。在获得了互斥锁或进入睡眠直到获得互斥锁之后会返回0。如果在等待获取锁的时候进入睡眠状态收到一个信号(被信号打断睡眠),则返回_EINIR。
(3)具体参见linux/kernel/mutex.c
int __sched mutex_trylock(struct mutex *lock)
{
return __mutex_fastpath_trylock(&lock-》count,
__mutex_trylock_slowpath);
}
试图获取互斥锁,如果成功获取则返回1,否则返回0,不等待。
3、释放互斥锁:
具体参见linux/kernel/mutex.c
void __sched mutex_unlock(struct mutex *lock)
{
__mutex_fastpath_unlock(&lock-》count,__mutex_unlock_slowpath);
}
释放被当前进程获取的互斥锁。该函数不能用在中断上下文中,而且不允许去释放一个没有上锁的互斥锁。
4.void mutex_destroy(struct mutex *lock) --清除互斥锁,使互斥锁不可用
用mutex_destroy()函数解除由lock指向的互斥锁的任何状态。在调用执行这个函数的时候,lock指向的互斥锁不能在被锁状态。储存互斥锁的内存不被释放。
返回值--mutex_destroy()在成功执行后返回零。其他值意味着错误。在以下情况发生时,函数失败并返回相关值。
EINVAL 非法参数
EFAULT mp指向一个非法地址。
5.static inline int mutex_is_locked(struct mutex *lock)--测试互斥锁的状态
这个调用实际上编译成一个内联函数。如果互斥锁被持有(锁定),那么就会返回1;否则,返回0。
五、使用形式:
struct mutex mutex;
mutex_init(&mutex);
。。。
mutex_lock(&mutex);
。。。
mutex_unlock(&mutex);
顺序锁
顺序锁为写者赋予更高的优先级,写者永远不会等待读者。缺点是读者有时不得不读多次数据以获取正确的结果。
顺序锁的数据结构中除了有spinlock外,还有一个顺序号。如果成功获得锁,顺序锁的顺序号会加1,以便读者能够检查出是否在读期间有写者访问过。读者在读取数据前后两次读顺序值,如果两次值不相同,则说明读取期间有新的写者操作过数据了,那么本次读取就是无效的。
典型使用:
读端:
do {
seqnum = read_seqbegin(&seqlock_a);
//读操作代码块
。。.
} while (read_seqretry(&seqlock_a, seqnum));
写端:
spin_lock(&lock);
write_seqlock(&seqlock_a)
。。.
write_sequnlock(&seqlock_a)
spin_unlock(&lock);
写者通过调用write_seqlock()和write_sequnlock()获取和释放顺序锁。write_seqlock()函数获取seqlock_t数据结构中的自旋锁,然后使顺序计数器sequence加1;write_sequnlock()函数再次增加顺序计数器sequence,然后释放自旋锁。这样可以保证写者在整个写的过程中,计数器sequence的值是奇数,并且当没有写者在改变数据的时候,计数器的值是偶数。
read_seqbegin()返回顺序锁的当前顺序号;如果局部变量seq的值是奇数(写者在read_seqbegin()函数被调用后,正更新数据结构),或seq的值与顺序锁的顺序计数器的当前值不匹配(当读者正执行临界区代码时,写者开始工作),read_seqretry()就返回1,说明本次读取失败,需要重新读取 。
并不是每一种资源都可以使用顺序锁来保护。一般来说,必须在满足下述条件时才能使用顺序锁:
1. 读者的临界区资源不包括被写者修改和被读者取值的指针,否则,写者有可能使指针失效,读者读取时会产生OPPs。
2. 读者的临界区代码没有副作用。
何时使用顺序锁?
读操作远多于写操作、且写操作很紧急时使用顺序锁。
评论
查看更多