原子操作的原理
微信公众号:deepcoder
微信号:deepcoder_001
关注可了解操作系统知识。问题或建议,请公众号留言;
如果你觉得文章对你有帮助,欢迎转发[^1]
目录
1,简介
前一阵了又朋友在群里争论原子操作的原理,各种说法。今天以x86为例子写一下原子操作的原理,以及注意事项
原子操作可以保证对一个数据的操作是排他性的。为什么需要这样呢,原子操作比锁轻量级,但是比普通的操作又重量级。
举例说明,我们假设下面对a的两个操作是并发进行的。
int a=0
int add_one()
{
a++;
}
int add_two()
{
a+=2;
}
假设add_one和add_two这两个函数在两个不同的cpu核心同时运行,当两个函数都运行结束之后。猜测一下a最后的可能结果。我们肯定会想到因为两个都结束了,所以最后的结果是3.但是实际却不是这样的。最终的结果可能是1,2,3中的某一个。因为两个core可能同时读a得到的都是0,但后执行加法操作然后写回去。这个时候可能是第一个先写回去,也可能是第二个先写回去。但是原来的值是0,所以最终的结果是1或者2。当然也有可能一个执行完了,两外一个再执行,那么最终结果是3.
怎么解决呢?我们想到了可以使用软件上的锁,保证一个执行完两外一个再次执行。这样可以解决问题。但是结果是极大的影响性能,锁本身就是软件机制,执行了很多的代码,还需要唤醒检查等太多的动作。
所以呢,处理器提供了特殊的指令用来在更小的范围内,硬件上保证对a的一次操作时一个整体,这就是原子操作。这样对性能的影响最小。不同的硬件体系架构的区别在于提供的指令不同而已。接下来讲解linux中的原子操作。
实际中呢是如果硬件没有硬件没有原子操作指令,那么确实是通过刚才说的锁来当成原子操作,保持驱动接口一致性。
2,linux中的原子操作
- 定义一个原子变量
typedef struct {
int counter;
} atomic_t;
#ifdef CONFIG_64BIT
typedef struct {
long counter;
} atomic64_t;
#endif
#if BITS_PER_LONG == 64
typedef atomic64_t atomic_long_t;
#else
typedef atomic_t atomic_long_t;
#endif
atomic_t a;
atomic64_t b;
- 原子操作接口
//32bit的接口
void atomic_add(int i, atomic_t *v);
void atomic_set(atomic_t *v, int i);
void atomic_sub(int i, atomic_t *v);
//long类型的接口
inline void atomic_long_add(long i, atomic_long_t*v);
void atomic_long_set(atomic_long_t*v, long i);
void atomic_long_sub(long i, atomic_long_t*v);
//64bit的接口
void atomic64_set(atomic64_t *v, long long i);
long long atomic64_read(const atomic64_t *v);
void atomic64_add(long long i, atomic64_t *v);
void atomic64_sub(long long i, atomic64_t *v);
//还有一些比较高级的接口,我们列举几个
atomic64_cmpxchg
atomic_long_add_return
atomic_long_cmpxchg_acquire
3,x86的原子操作实现
上面的64bit和32bit的区别仅仅在指令上稍微有点区别,所以我们就以64bit的为例子解释
atomic64_set接口由于没有读直接写内存,所以仅仅需要volatile 关键字描述就可以了,似乎看不到硬件的特殊操作
static __always_inline void __write_once_size(volatile void *p, void *res, int size)
{
switch (size) {
case 1: *(volatile __u8 *)p = *(__u8 *)res; break;
case 2: *(volatile __u16 *)p = *(__u16 *)res; break;
case 4: *(volatile __u32 *)p = *(__u32 *)res; break;
case 8: *(volatile __u64 *)p = *(__u64 *)res; break;
default:
barrier();
__builtin_memcpy((void *)p, (const void *)res, size);
barrier();
}
}
#define WRITE_ONCE(x, val) \
({ \
union { typeof(x) __val; char __c[1]; } __u = \
{ .__val = (__force typeof(x)) (val) }; \
__write_once_size(&(x), __u.__c, sizeof(x)); \
__u.__val; \
})
//因为set接口没有读的动作,所以这里看不到特殊的硬件指令
static inline void atomic64_set(atomic64_t *v, long i)
{
WRITE_ONCE(v->counter, i);//反汇编出来这里是就是一个mov指令
}
再看看atomic64_add
static __always_inline void atomic64_add(long i, atomic64_t *v)
{
asm volatile(LOCK_PREFIX "addq %1,%0"
: "=m" (v->counter)
: "er" (i), "m" (v->counter) : "memory");
}//这里反汇编出来就是lock addq xx xx可以看到这里的多了lock前缀
接口反汇编出来就是lock addq xx xx,就是多了lock前缀,因为我们对原子变量执行加操作,这里会先从内存读到原来的值,然后执行加法,然后写回去。所以有了lock指令之后。这个操作就是一个整体。不会引入中间过程。所以保证了原子性。
再看看
static inline void atomic64_sub(long i, atomic64_t *v)
{
asm volatile(LOCK_PREFIX "subq %1,%0"
: "=m" (v->counter)
: "er" (i), "m" (v->counter) : "memory");
}//反汇编出来时lock subq xx xx
同样时多了lock前缀,保证了原子性。
4,为啥原子变量要定义为结构体
原子变量的定义可以看到每一个原子变量都是定义为一个结构体。但是原子变量都是32bit或者64bit为啥不直接定义为int或者int64_t呢?
下面我来解释这个问题
我们看到我们对原子变量的操作都必须时系统提供的atomic64_sub之类的接口来操作,而不是直接的减法操作。那么为啥定义为结构体呢?
首先
如果我们将原子变量定义为如下方式
//内核中不是这样,这样的定义是一种不友好的定义,会隐藏错误的风险
typedef int atomic_t;
typedef int64_t atomic64_t;
atomic64_t b;
atomic_t a;
假设原子变量是上面的定义,那么我们就可以通过a++和b++来操作。同时也可以使用lock addq之类的来操作。这样意味着对a和b的操作既可以原子操作也可以非原子操作。本来就是内存操作。
如果我们在代码中执行a++和b++之类的,我们编译代码并不会出错。也就是导致了一个我们期望是原子变量的执行了非原子操作。这个肯定是错误的。所以这就是为啥在软件上将原子变量定义为结构体。这样我们对一个原子变量执行a++之类的直接操作,编译是会报错的。防止了编码错误,但是编译却不会报错。这个属于编码技巧