内核同步方法
原子操作
原子操作的意义在于要么就完整地执行所有的指令,要么就都不执行。在内核中提供了两组原子操作的接口——原子整数和原子位。
原子整数操作
原子整数操作提供了一种新的数据类型,以避免原子操作函数与非原子操作函数使用相同的函数,同时也能屏蔽不同体系结构之间的差异:
1 | typedef struct { |
在<asm/atomic.h>中提供了一系列原子整数操作的接口:
1 | atomic_t v; |
原子整数操作最常见的应用就是计数器,例如可以在c++的智能指针中增加原子整数作为计数器。能用原子操作就尽量不用锁机制,增加效率。
64位原子操作
随着64位系统的发展,内核开发者也引入了64位的atomic64_t。
1 | typedef struct { |
原子位操作
与原子整数操作不同,原子位操作是在普通的内存地址上进行操作,因此原子位操作没有特定的类型,针对的只是普通指针类型。
1 | unsigned long word = 0; |
自旋锁
临界区的情况可能非常复杂,不能像上面那样只是简单声明原子变量就可以保证同步,因此就引入更为复杂的同步方法——锁。
Linux内核中最常见的锁是自旋锁(spin lock)——这种锁最多只能被一个可执行线程持有。一个可争用的自旋锁会使得请求线程busy wait,不断消耗处理器的时间。当然有方法可以让线程先行睡眠,待锁可用时再唤醒。但考虑到上下文切换的消耗,如果持有锁的时间比较短,自旋锁是一个比较好的选择。
如果持有自旋锁的时间小于两次上下文切换,那么可以选用自旋锁。
自旋锁方法
自旋锁的实现是和体系结构相关的,代码往往通过汇编来实现。基本的使用形式如下:
1 | DEFINE_SPINLOCK(mr_lock); |
自旋锁不可递归,如果一个线程试图去获取一个自己已经拥有的自旋锁,就会陷入死锁
其它针对自旋锁的操作
可以使用spin_lock_init()来初始化动态创建的自旋锁(此时返回一个指向spinlock_t类型的指针)。spin_try_lock()会试图获取某个特定的自旋锁,而spin_is_locked()方法则只是判断特定锁是否被占用。
自旋锁和下半部
与下半部配合使用时,我们在获取锁的时候需要禁止所有下半部的执行,因为下半部可以抢占进程上下文中的代码,此时可以使用方法——spin_lock_bh()。
读-写自旋锁
由于锁的用途可以明确分为读取和写入两个场景,因此提供了读-写自旋锁进行保护。一个或者多个任务可以并发地拥有读者锁,但写者锁只能被一个写任务拥有。
1 | DEFINE_SPINLOCK(mr_rwlock); |
注意这样的一种死锁情况,写者锁不断自旋,等待读者锁释放
1 | read_lock(&mr_lock); |
信号量
linux中的信号量实际上是一种睡眠锁,则任务试图获得一个不可用的信号量时,就会进入一个等待队列,然后处于休眠状态,此时处理器就可以释放。与自旋锁不同,信号量在等待锁时会睡眠,所以信号量适用于锁被长时间占有的情况。如果占有锁时间较短,那么维护队列,切换上下文的开销就不值得了。另外,在占有信号量时,不能同时占用自旋锁,因为拥有自旋锁时不允许睡眠。
计数信号量和二值信号量
信号量可以允许持有计数器,如果一个时刻只允许一个锁持有者,那就是二值信号量,如果计数可以大于1,那就是计数信号量。
由于引入了计数机制,因此信号量支持两个原子操作P()和V()。一个将信号量计数减一来请求一个信号量,另一个则是讲信号加一来释放信号量。
创建和初始化信号量
1 | struct semaphore name; |
使用信号量
函数down_interruptible()会试图获取指定信号量,如果信号量不可用,它会将进程置成TASK_INTERRUPTLE状态,进入睡眠。
读-写信号量
读写信号量与读写自旋锁差别不大,但是读写信号量相比读写自旋锁多一种特有的操作——downgrade_write(),这个函数可以动态地将获取的写锁转换为读锁。
互斥体
互斥体(mutex)是指任何可以睡眠的强制互斥锁,就像计数是1的信号量。互斥体在内核中对应的数据结构为mutex:
1 | //静态创建 |
相比信号量,mutex的限制更多:
- 只有一个任务可以拥有mutex;
- 给mutex上锁的任务必须负责解锁;
- 不能递归地上锁和解锁;
- mutex不能在中断或者下半部使用;
- mutex只能通过官方API管理;
尽量使用mutex,除非mutex的限制影响了你;除非是低开销的加锁、或者短期锁定,又或者需要在中断上下文加锁,某人都用mutex;
顺序锁
在2.6版本内核中引进了一种新型锁,顺序锁(seq),通过一个序列计数器,在有数据写入的时候,就会得到一个锁,并且序列值增加。在读取数据之前和之后,会比较序列号,相同才证明数据没有被修改。
1 | unsigned long seq; |
seq锁对于写者更有利,如果有写者,那么读数据就会不断进入循环。
禁止抢占
由于内核是抢占性的,内核中的进程在任何时刻都可能停下了,另一个进程运行,这就可能出现临界区有两个进程。自旋锁可以避免这个问题,但某些情况不需要自旋锁,可以通过禁止抢占来实现:
1 | preempt_disable(); |
顺序和屏障
当处理多处理器之间的同步问题时,我们可能需要在代码中指定读内存和写内存的指令,这种确保顺序的指令就叫屏障(barriers)。
- rmb()提供了读内存屏障,它确保跨越rmb()的载入动作不会发送重排;
- wmb()提供的是写内存屏障;