原子操作
原子操作,就是「一个不会在执行完毕前被任何其他任务或事件打断的操作」。原子操作是最小的执行单位,不可能有比它更小的执行单位,这里的原子借用了物理学中的物质微粒概念,表示原子操作是不可被分割的操作。
内核原子操作接口
内核提供了两组原子操作接口——一组针对整数进行操作,另一组针对单独的位进行操作
在 Linux 支持的所有体系结构上都实现了这两组接口。大多数体系结构会提供支持原子操作的简单算术指令。而有些体系结构确实缺少简单的原子操作指令,但是也为单步执行提供了锁内存总线的指令,这就确保了其他改变内存的操作不能同时发生。
原子整数操作
32 位
-
针对整数的原子操作只能对 atomic_t 类型的数据进行处理
-
在这里之所以引入了一个特殊数据类型,
而没有直接使用 C 语言的 int 类型,主要是出于两个原因:
- 首先,让原子函数只接收 atomic_t 类型的操作数,可以确保原子操作只与这种特殊类型数据一起使用。同时,这也保证了 该类型的数据不会被传递给任何非原子函数。实际上,对一个数据一会儿要采用原子操作,一会儿又不用原子操作了,这又能有什么好处?
- 其次,使用 atomic_t 类型确保编译器不对(不能说完美地完成了任务但不乏自知之明)相应的值进行访问优化——这点使得原子操作最终接收到正确的内存地址,而不只是一个別名。最后,在不同体系结构上实现原子操作的时候,使用 atomic_t 可以屏蔽其间的差异
-
atomic_t 类型定义在文件<linux/types.h>中:
|
|
- 下标列出了所 有的标准原子整数操作(所有体系结构都包含这些操作)。某种特定的体系结构上实现的所有操作可以在文件<asm/atomic.h>中找到
| 原子整数操作 | 描述 |
|---|---|
| ATOMIC_INT(int i) | 在声明一个 atomic_t变量时,将它初始化为 i |
| int atomic_read(atomic_t * v) | 对原子类型的变量进行原子读操作,返回原子变量 v 的值 |
| void atomic_set(atomic_t * v, int i) | 原子地设置原子类型的变量 v 的值为 i |
| void atomic_add(int i, atomic_t *v) | 原子地给原子类型的变量 v 增加值 i |
| void atomic_sub(int i, atomic_t *v) | 原子地从原子类型的变量 v 中减去 i |
| void atomic_inc(atomic_t *v) | 原子地对原子类型变量 v 增加 1 |
| void atomic_dec(atomic_t *v) | 对原子类型的变量 v 原子地减 1 |
| int atomic_sub_and_test(int i, atomic_t *v) | 原子地从原子类型的变量 v 中减去 i,并判断结果是否为 0,如果为 0,返回真,否则返回假 |
| int atomic_dec_and_test(atomic_t *v) | 原子地对原子类型的变量 v 原子地减 1,并判断结果是否为 0,如果为 0,返回真,否则返回假 |
| int atomic_add_negative(int i, atomic_t *v) | 对原子类型的变量 v 原子地增加 i,并判断结果是否为负数,如果是返回真,否则返回假 |
| int atomic_add_return(int i, atomic_t *v) | 对原子类型的变量 v 原子地增加 i,并且结果 |
| int atomic_sub_return(int i, atomic_t *v) | 从原子类型的变量 v 中减去 i,并且结果 |
| int atomic_inc_return(atomic_t * v) | 对原子类型的变量 v 原子地增加 1 并且返回结果 |
| int atomic_dec_return(atomic_t * v) | 该函数对原子类型的变量 v 原子地减 1 并且返回结果 |
64 位
随着64 位体系结构越来越普及,内核开发者确实在考虑原子变量除 32 位 atomict 类型外, 应**引入 64 位的 atomic64 t**。因为移植性原因,atomic_t 变量大小无法在体系结构之间改变。所以,**atomict 类型即便在 64 位体系结构下也是 32 位的**,若要使用 64 位的原子变量,则要使用 atomic64 t 类型——其功能和其 32 位的兄弟无异,使用方法完全相同,不同的只有整型变量大小 32 位变成了 64 位
- 与 atomic_t 一样,atomic64_2 类型其实是对长整型的一个简单封装类
|
|
- 几乎所有的经典 32 位原子操作都有 64 位的实现,它们被冠以 atomic64 前缀,而 32 位实现冠以 atomic 前缀
- 下图是所有标准原子操作的列表
| 原子整数操作 | 描述 |
|---|---|
| ATOMIC64_INT(long i) | 在声明一个 atomic64_t变量时,将它初始化为 i |
| int atomic64_read(atomic64_t * v) | 对原子类型的变量进行原子读操作,返回原子变量 v 的值 |
| void atomic64_set(atomic64_t * v, int i) | 原子地设置原子类型的变量 v 的值为 i |
| void atomic64_add(int i, atomic64_t *v) | 原子地给原子类型的变量 v 增加值 i |
| void atomic64_sub(int i, atomic64_t *v) | 原子地从原子类型的变量 v 中减去 i |
| void atomic64_inc(atomic64_t *v) | 原子地对原子类型变量 v 增加 1 |
| void atomic64_dec(atomic64_t *v) | 对原子类型的变量 v 原子地减 1 |
| int atomic64_sub_and_test(int i, atomic64_t *v) | 原子地从原子类型的变量 v 中减去 i,并判断结果是否为 0,如果为 0,返回真,否则返回假 |
| int atomic64_dec_and_test(atomic64_t *v) | 原子地对原子类型的变量 v 原子地减 1,并判断结果是否为 0,如果为 0,返回真,否则返回假 |
| int atomic64_add_negative(int i, atomic64_t *v) | 对原子类型的变量 v 原子地增加 i,并判断结果是否为负数,如果是返回真,否则返回假 |
| int atomic64_add_return(int i, atomic64_t *v) | 对原子类型的变量 v 原子地增加 i,并且结果 |
| int atomic64_sub_return(int i, atomic64_t *v) | 从原子类型的变量 v 中减去 i,并且结果 |
| int atomic64_inc_return(atomic64_t * v) | 对原子类型的变量 v 原子地增加 1 并且返回结果 |
| int atomic64_dec_return(atomic64_t * v) | 该函数对原子类型的变量 v 原子地减 1 并且返回结果 |
操作实例
- 原子整数操作最常见的用途就是实现计数器。使用复杂的锁机制来保护一个单纯的计数器显然杀鸡用了宰牛刀,所以,开发者最好使用 atomic_inc()和 atomic_dec()这两个相对来说轻便一点的操作
- 还可以用原子整数操作原子地执行一个操作并检査结果。一个常见的例子就是原子地减操作和检査:
- 这个函数将给定的原子变量减 1 , 如果结果为 0,就返回真;否则返回假
使用原子变量使设备只能被一个进程打开
|
|
- 在编写代码的时候,能使用原子操作时,就尽量不要使用复杂的加锁机制。对多数体系结构来讲,原子操作与更复杂的同步方法相比较,给系统带来的开销小,对高速缓存行(cache-line)的影响也小。但是,对于那些有高性能要求的代码,对多种同步方法进行测试比较,不失为一种明智的做法
原子位操作
除了原子整数操作外,内核也提供了一组针对位这一级数据进行操作的函数。没什么好奇怪的,它们是与体系结构相关的操作,定义在文件<asm/bitops.h>中
令人感到奇怪的是位操作函数是对普通的内存地址进行操作的。它的参数是一个指针和一个位号,第 0 位是给定地址的最低有效位。在 32 位机上,第 31 位是给定地址的最 有效位而第 32 位是下一个字的最低有效位。虽然使用原子位操作在多数情况下是对一个字长的内 进行访问,因而位号应该位于 0~31(在 64 位机器中是 0~63),但是,对位号的范围并没有限制
由于原子位操作是对普通的指针进行的操作,所以不像原子整型对应 atomic_t,这里没有特殊的数据类型。相反,只要指针指向了任何你希望的数据,你就可以对它进行操作。来看一个例子:
|
|
下表给出了标准原子位操作列表:
| 原子位操作 | 描述 |
|---|---|
| void set_bit(int nr, void *addr) | 原子地设置 addr 所指对象的第 nr 位 |
| void clear_bit(int nr, void *addr) | 原子地清空 addr 所指对象的第 nr 位 |
| void change_bit(int nr, void *addr) | 原子地翻转 addr 所指对象的第 nr 位 |
| Int test_and_set_bit(int nr, void *addr) | 原子地设置 addr 所指对象的第 nr 位,并且返回原先的值 |
| Int test_and_clear_bit(int nr, void *addr) | 原子地清空 addr 所指对象的第 nr 位,并且返回原先的值 |
| Int test_and_set_bit(int nr, void *addr) | 原子地翻转 addr 所指对象的第 nr 位,并且返回原先的值 |
| Int test_bit(int nr, void *addr) | 原子地返回 addr 所指对象的第 nr 位 |
非原子操作
为方便起见,内核还提供了一组与上述操作对应的非原子位函数。非原子位函数与原子位函数的操作完全相同,但是,前者不保证原子性,且其名字前缀多两个下划线。例如,与 test_bit()对应的非原子形式是 __test_bit()
如果你不需要原子性操作(比如说,你已经用锁保护了自己的数据),那么这些非原子的位函数相比原子的位函数可能会执行得更快些
搜索第一个被设置的位
内核还提供了两个例程用来从指定的地址开始搜索第一个被设置(或未被设置)的位
|
|
- 这两个函数中第一个参数是一个指针,第二个参数是要搜索的总位数
- 返回值是第一个被设置的(或没被设置的)位的位号
- 如果你的搜索范围仅限于一个字,使用****_ffs()和 ffe()这两个函数****更好,它们只需要给定一个要搜索的地址做参数
与原子整数操作不同,代码一般无法选择是否使用位操作,它们是唯一的、具有可移植性的设置特定位方法,需要选择的是使用原子位操作还是非原子位操作。如果你的代码本身已经避免了竞争条件,你可以使用非原子位操作,通常这样执行得更快,当然,这还要取决于具体的体系结构
原子性与顺序性的比较
为什么关注原子操作? 1)在确认一个操作是原子的情况下,多线程环境里面,我们可以避免仅仅为保护这个操作在外围加上性能开销昂贵的锁。 2)借助于原子操作,我们可以实现互斥锁。 3)借助于互斥锁,我们可以把一些列操作变为原子操作。
关于原子读取的上述讨论引发了原子性与顺序性之间差异的讨论。正如所讨论的,一个字长的读取总是原子地发生,绝不可能对同一个字交错地进写;读总是返回一个完整的字,这或者发生在写操作之前,或者之后,绝不可能发生在写过程中。例如,如果一个整数初始化为 42,然后又置为 365,那么读取这个整数肯定会返回 42 或者 365,而绝不会是二者的混合。这就是我们所谓的原子性
也许代码比这有更多的要求。或许要求读必须在待定的写之前发生——这种需求其实不属于原子性要求,而是顺序要求。原子性确保指令执行期间不被打断,要么全部执行完,要么根本不执行。另一方面,顺序性确保即使两条或多条指令出现在独立的执行线程中,甚至独立的处理器上,它们本该的执行顺序却依然要保持
在本小节讨论的原子操作只保证原子性。顺序性通过屏障(barrier) 指令来实施,这将在后面文章介绍
内核原子操作实现
原子整数操作实现
|
|
可以看到,atomic_add 使用了 gcc 提供的内嵌汇编来实现,是用一个 addl 指令来实现增加操作。重点看一下 LOCK_PREFIX 宏,它就是上文提到的锁总线操作,也就是它保证了操作的原子性。LOCK_PREFIX 定义如下:
|
|
原子位操作实现
|
|
第一个 clear_bit 函数比较好理解,和上面 atomic 系列的函数实现类似。但是注意到 clear_bit_unlock 函数中多了一个 barrier 函数,这是什么操作呢? 这就是有名的“内存屏障“或”内存栅栏“操作,先来补充一下这方面的知识。
可以看一下 barrier 的定义:
|
|
解释一下:volatitle是防止编译器移动该指令的位置或者把它优化掉。“memory”,是提示编译器该指令对内存修改,防止使用某个寄存器中已经 load 的内存的值。lock 前缀是让 cpu 的执行下一行指令之前,保证以前的指令都被正确执行。
事实上,不止 barrier,还有一个 mb 系列的函数也起着内存屏障的功能:
|
|
这些函数在已编译的指令流中插入硬件内存屏障,具体的插入方法是平台相关的。rmb(读内存屏障)保证了屏障之前的读操作一定会在后来的读操作执行之前完成。wmb 保证写操作不会乱序,mb 指令保证了两者都不会。这些函数都是 barrier 函数的超集。解释一下:编译器或现在的处理器常会自作聪明地对指令序列进行一些处理,比如数据缓存,读写指令乱序执行等等。如果优化对象是普通内存,那么一般会提升性能而且不会产生逻辑错误。但如果对 I/O 操作进行类似优化很可能造成致命错误。所以要使用内存屏障,以强制该语句前后的指令以正确的次序完成。
其实在指令序列中放一个 wmb 的效果是使得指令执行到该处时,把所有缓存的数据写到该写的地方,同时使得 wmb 前面的写指令一定会在 wmb 后面 的写指令之前执行。
回到上面的函数,当 clear_bit 函数不用于实现锁的目的时,不用给它加上内存屏障(我的理解:不管是不是读到最新的数据,这一位就是要清零,不管加不加内存屏障,结果都是一样的);而当用于实现锁的目的时,必须使用 clear_bit_unlock 函数,其实现中使用了内存屏障,以此来确保此处的修改能在其他 CPU 上看到(我的理解:加锁操作就是为了在多个 CPU 间进行同步的目的,所以要避免寄存器优化,其他 CPU 每次都读内存这样才能看到最新的变化,这块不是太明白)。这种操作也叫做 serialization,即在执行这条指令前,CPU 必须要完成前面所有对 memory 的访问指令(read and write),这样是为了避免编译器进行某些优化。
同样使用 serialization 操作的还有 test_and_set_bit 函数:
|
|
解释一下: 1)memory 强制 gcc 编译器假设 RAM 所有内存单元均被汇编指令修改,这样 cpu 中的 registers 和 cache 中已缓存的内存单元中的数据将作废。cpu 将不得不在需要的时候重新读取内存中的数据。这就阻止了 cpu 又将 registers,cache 中的数据用于去优化指令,而避免去访问内存。
2)sbb $0,0(%%esp)表示将数值 0 减到 esp 寄存器中,而该寄存器指向栈顶的内存单元。减去一个 0,esp 寄存器的数值依然不变。即这是一条无用的汇编指令。在此利用这条无价值的汇编指令来配合 lock 指令,在asm,volatile,memory 的作用下,用作 cpu 的内存屏障。
这种写法和前面的 clear_bit_unlock 中先写一个 barrier 函数,再写一个正常内嵌汇编函数的功能是一样的。
参考资料
-
No backlinks found.