文章目录
- 原子操作
- 原子整数操作
- 64 位原子操作
- 原子位操作
- 自旋锁
- 读写自旋锁
- 信号量
- 计数信号量和二值信号量
- 信号量方法列表
- 读写信号量
- 互斥体
- 信号量和互斥体
- 自旋锁和互斥体
- 完成变量
- BLK:大内核锁
- 顺序锁
- 禁止抢占
- 顺序和屏障
原子操作
原子操作:可以保证指令以原子的方式执行,即执行过程不被打断。
原子整数操作
整数的原子操作只针对 atomic_t 类型。因为:
- 让原子函数只接收 atomic_t 类型的操作数,可以确保原子操作只与这种特殊类型数据一起使用。同时这也保证了该类型的数据不会被传递给任何非原子函数。
- 使用 atomic_t 类型确保编译器不对相应的值进行访问初始化 —— 这点使得原子操作最终接收到正确的内存地址,而不只是一个别名。
atomic_t 类型定义在 include/linux/types.h:
typedef struct {
volatile int counter;
} atomic_t;
在 Linux 上 atomic_t 整数类型都是 32 位,其中数据位为高 24 位,低 8 位嵌入了一个锁,因为 SPARC 体系结构对原子操作缺乏指令级支持,所以只能利用该锁来避免对原子类型数据的并发访问。因此在 SPARC 机器上只能用 24 位,在其它机器上可以用 32 位。
(貌似已经在 SPARC 机器上只能使用 24 位的问题已经被解决了)
原子操作在 arch/alpha/include/asm/atomic.h 中:
原子操作通常是内联函数,往往是通过内嵌汇编指令来实现的。若某个函数本就具备原子性,那么通常会被定义为一个宏。
在编写代码时,尽量使用原子操作,而不是使用复杂的加锁机制。相对于锁机制,原子操作可以给系统带来较小的开销,对高速缓存行的影响也小。
64 位原子操作
因为移植性的问题,atomic_t 变量无法在体系结构之间改变。因此 atmoic_t 类型即便在 64 位下也是 32 位的,要使用 64 位的,则要使用 atomic64_t 类型,其功能与 32 位无异。
#ifdef CONFIG_64BIT // 判断系统是不是 64 位的
typedef struct {
volatile long counter;
} atomic64_t;
#endif
开发者应该使用 32 位的 atomic_t 类型,因为为了可以在各体系结构之间可以移植代码。
原子位操作
路径:arch/alpha/include/asm/bitops.h
非原子位函数在其原子位函数名字前多了两条下划线,例如 test_bit() 的非原子位函数为 __test_bit()。
自旋锁
-
Linux 内中最常见的锁。
-
最多只能被一个线程持有。
-
若一个线程试图得到一个已被持有的锁,那么该线程就会一直循环等待该锁被释放为止。
-
在任意时间,自旋锁都可以防止多于一个线程同时进入临界区。
-
自旋锁因为会循环等待锁,因此特别浪费处理器时间,所有自旋锁不该被长时间占有。
也可以让请求线程休眠,直到锁重新可用为止。(这会带来一定的开销,如两次上下文切换)
-
自旋锁的设计初衷:在短期内进行轻量级加锁。
路径:arch/alpha/include/asm/spinlock.h
也可能是这个:include/linux/spinlock.h
自旋锁不可递归:你试图得到一个你持有的锁,必须自旋等待,可是你处于自旋等待中,因此无法释放锁,导致给自己锁死了。
根据此情况,可知在中断使用自旋锁时,一定要先关闭中断。
CONFIG_DEBUG_SPINLOCK 配置选择为使用自旋锁的代码加入了许多调试检测的手段。
读写自旋锁
- 一个或多个读任务可以并发地持有读者锁(读者锁有多个)。
- 用于写的锁最多只能被一个写任务持有(写者锁只有一个),且此时不能有并发的读操作。
- 在中断在使用该锁时:
- 读任务:无需关闭中断
- 写任务:必须关闭有写操作的中断
信号量
- Linux 中的信号量是一种睡眠锁。
- 一个任务试图得到一个已被占用的信号量时,信号量会将其推进一个等待队列,然后让其睡眠。直到那个信号量被释放,处于等待队列中的那个任务将被唤醒,并获得该信号量。
- 相比于自旋锁,信号量拥有更好的处理器利用率,当却比自旋锁有更大的开销。
从信号量的睡眠特性得出的一些结论:
计数信号量和二值信号量
- 使用者数量(usage count)或数量(count):信号量同时允许持有的数量可以在声明信号量中指定。
- 同一时刻,当只允许一个锁持有者时,信号量(count)为 1,这种情况下,被称为二值信号量或互斥信号量。
- 数量设置为大于 1 时,即同一时刻最多允许 count 个锁持有者,这被称为计数信号量。
信号量支持的两个原子操作:
- P() / down():对信号量减一来得到一个信号量。
- V() / up():对信号量加一释放信号量。
信号量方法列表
路径:include/linux/semaphore.h
读写信号量
位于:include/linux/rwsem.h
down_read_trylock() 和 down_write_trylock() 方法,若成功获得了信号量锁,返回非0,若信号量锁被争用,则返回0,这与普通信号量情况相反,要小心。
互斥体
-
信号量是内核中唯一允许睡眠的锁。
信号量适用于较复杂、未明情况下的互斥访问。简单的锁定,使用信号量并不方便,且信号量调试也不方便。
-
互斥体是比信号量更简单允许休眠的锁。
-
互斥体指的是任何可以睡眠的强制互斥锁,如计数是 1 的信号量。
-
互斥体是一种互斥信号。
-
mutex 是简化版的信号量,因为不再需要管理任何使用计数。
-
开启 mutex 的调试功能:CONFIG_DEBUG_MUTEXES
路径:include\linux\mutex.h
信号量和互斥体
互斥体和信号量很相似,应该优先使用 mutex,不到万不得已就别用信号量,一般只有很底层的代码才需要信号量。因此首选 mutex,若发现不能满足其约束条件,且没有别的选择时,再考虑选择信号量。
自旋锁和互斥体
完成变量
在内核中一个任务需要发出信号通知另一个任务发生了某个特定事件,利用完成变量是使两个任务得以同步的简单方法。当一个任务要执行一些工作时,另一个任务就会在完成变量上等待。当这个任务完成工作后,会使用完成变量去唤醒在等待的任务。
完成变量仅仅只是代替信号量的一个简单方案。例如,当子进程执行或退出时,vfork() 系统调用使用完成变量唤醒父进程。
路径:include\linux\completion.h
BLK:大内核锁
- 是一个全局自旋锁。
- 最初的目的是为了方便实现从 SMP 过度到细粒度加锁机制,这个过程当然是有效的,但是现在已经成了内核可扩展性的障碍。
- 在内核中不鼓励使用 BKL。
- 一个执行线程递归的请求锁,那么释放锁也必须以同样的次序进行。
- BKL 更像是保护代码的,而不是保护数据。
- 难以发现所有 BKL 的用户之间的关系。
BKL 的特性:
顺序锁
- 简单的锁机制,用于读写共享数据。
- 该锁的依靠一个序列计数器实现。
- 当数据被写入时,会得到一个锁,并且序列值增加。在读取数据前后,序列号都被读取。如果读取的序列号值相同,说明在读操作进行的过程中没有被写操作打断过(因为锁的初值是 0,所以写锁会使值成奇数,释放时成偶数)。
- 如果读取的值是偶数,表面写操作没有发生。
- seq 锁对写者更有利,只要没有其它写者,写锁总能被成功获得。
- 读者不会影响写锁。
- 挂起的写者会不断地使得读操作循环,直到不再有任何写者持有锁为止。
路径:include/linux/seqlock.h
seq 锁使用场景:
禁止抢占
- 由于内核是抢占的,意味着一个任务与抢占任务可能会在同一个临界区内。
- 为了避免这种情况,内核抢占代码使用自旋锁作为非抢占区域的标记。
- 如果一个自旋锁被持有,内核便不能进行抢占。
- 在多处理器中,若自旋锁未被持有,内核又是抢占式的,那么新调度任务就可能访问同一个变量。
- 即便是单处理器,变量也可能被伪并发访问。
顺序和屏障
- 在处理多处理器之间或硬件之间的同步问题时,需要在程序中以指定的顺序发出读指令和写指令。
- 但编译器和处理器为了提高效率,可能对读和写重新排序。(x86 处理器不会对写重新排序)
- 所有可能重新排序的处理器都提供了机器指令来确保顺序执行。同样也确保编译器不会对给定点周围的指令序列重新排序。
重新排序的例子:
a = 1;
b = 2;
重新排序导致可能会在 a 中存放新的值之前就在 b 中存放新值。
编译器和处理器都看不出两者之间的关系。编译器会在编译时按这种顺序编译,这种顺序会是静态的,编译的目标代码就只把 a 放在 b 之前。但是处理器会重新动态排序,因为处理器在执行指令期间,会在取指令和分配时,把表面看似无关的指令按自认为最好的顺序排列。大多数情况下,这样的排序是最佳的。
处理器和编译器绝不会重新排序的情况:
a = 1;
b = a;
书中后面这小段我没懂,此处略…