并发编程 原子操作

i++和i–是线程安全的不?

1. 操作的原子性

实际上当我们操作i++的时候,其汇编指令为:
在这里插入图片描述
从上图能看到一个i++对应的操作是:

(1)把变量i从内存(RAM)加载到寄存器;
(2)把寄存器的值加1;
(3)把寄存器的值写回内存(RAM)。

那如果有多个线程去做i++操作的时候,也就可能导致这样一种情况:
在这里插入图片描述
上图这种执行情况就导致i变量的结果仅仅自增了一次,而不是两次,导致实际结果与预期结果不对。
总结下上面的问题,也就是说我们整个存储的结构如下图:
在这里插入图片描述
我们所有的变量首先是存储在主存(RAM)上,CPU要去操作的时候首先会加载到寄存器,然后才操作,操作好了才写会主存。
关于CPU和cache的更详细介绍可以参考: https://www.cnblogs.com/jokerjason/p/10711022.html

2. 原子操作

对于gcc、g++编译器来讲,它们提供了一组API来做原子操作:

type sync_fetch_and_add (type *ptr, type value, ...) type sync_fetch_and_sub (type *ptr, type value, ...) type sync_fetch_and_or (type *ptr, type value, ...) type sync_fetch_and_and (type *ptr, type value, ...) type sync_fetch_and_xor (type *ptr, type value, ...) type sync_fetch_and_nand (type *ptr, type value, ...)
bool sync_bool_compare_and_swap (type *ptr, type oldval type newval, ...)
// CAS
type sync_val_compare_and_swap (type *ptr, type oldval type newval, ...)

type sync_lock_test_and_set (type *ptr, type value, ...) 
void sync_lock_release (type *ptr, ...)

详细文档见: https://gcc.gnu.org/onlinedocs/gcc-4.1.1/gcc/Atomic-Builtins.html#Atomic-Builtins

对于c++11来讲,我们也有一组atomic的接口:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

Memory synchronization ordering

在这里插入图片描述
详细文档见: https://en.cppreference.com/w/cpp/atomic

但是这些原子操作都是怎么实现的呢?
X86的架构
Intel X86指令集提供了指令前缀lock用于锁定前端串行总线FSB,保证了指令执行时不会收到其他处理器的干扰。

比如:

static int lxx_atomic_add(int* ptr, int increment){ int old_value = *ptr;
    asm volatile("lock; xadd %0, %1 \n\t"
		: "=r"(old_value), "=m"(*ptr)
		: "0"(increment), "m"(*ptr)
		: "cc", "memory");

return *ptr;
}

使用lock指令前缀之后,处理期间对count内存的并发访问(Read/Write)被禁止,从而保证了指令的原子性。
如图所示:
在这里插入图片描述
其原理在Intel开发手册有如下说明:

Causes the processor’s LOCK# signal to be asserted during execution of the accompanying instruction (turns the instruction into an atomic instruction). In a multiprocessor environment, the LOCK# signal ensures that the processor has exclusive use of any shared memory while the signal is asserted.

In most IA-32 and all Intel 64 processors, locking may occur without the LOCK# signal being asserted. See the “IA32 Architecture Compatibility” section below for more details. The LOCK prefix can be prepended only to the following instructions and only to those forms of the instructions where the destination operand is a memory operand: ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, CMPXCHG16B,DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG. If the LOCK prefix is used with one of these instructions and the source operand is a memory operand, an undefined opcode exception (#UD) may be generated. An undefined opcode exception will also be generated if the LOCK prefix is used with any instruction not in the above list. The XCHG instruction always asserts the LOCK# signal regardless of the presence or absence of the LOCK prefix.

The LOCK prefix is typically used with the BTS instruction to perform a read-modify-write operation on a memory location in shared memory environment.

The integrity of the LOCK prefix is not affected by the alignment of the memory field. Memory locking is observed for arbitrarily misaligned fields.

This instruction’s operation is the same in non-64-bit modes and 64-bit mode.

PS : 注意上面标红的文字。

在执行伴随指令期间使处理器的LOCK#信号有效(将指令变为原子指令)。在多处理器环境中,LOCK#信号确保处理器在信号有效时独占使用任何共享存储器。如果LOCK前缀与这些指令之一一起使用,并且源操作数是内存操作数,则可能会生成未定义的操作码异常(#UD)。 如果LOCK前缀与任何不在上述列表中的指令一起使用,也会产生未定义的操作码异常。 无论是否存在LOCK 前缀,XCHG指令都始终声明LOCK#信号。

LOCK前缀通常与BTS指令一起使用,以在共享存储器环境中的存储器位置上执行读取 – 修改 – 写入操作。

LOCK前缀的完整性不受存储器字段对齐的影响。 内存锁定是针对任意不对齐的字段。

好了,到此,我们了解X86上如何支持原子操作了,我们看看内核的实现: 如文件:arch/x86/include/asm/atomic.h

/**
* arch_atomic_add - add integer to atomic variable
* @i: integer value to add
* @v: pointer of type atomic_t
*
* Atomically adds @i to @v.
*/
static __always_inline void arch_atomic_add(int i, atomic_t *v)
{
 asm volatile(LOCK_PREFIX "addl %1,%0"
 : "+m" (v->counter)
 : "ir" (i) : "memory");
}

LOCK_PREFIX中的实现:

#ifdef CONFIG_SMP
#define LOCK_PREFIX_HERE \
        ".pushsection .smp_locks,\"a\"\n"    \
        ".balign 4\n"                \
        ".long 671f - .\n" /* offset */        \
        ".popsection\n"                \
        "671:"
#define LOCK_PREFIX LOCK_PREFIX_HERE "\n\tlock; "
#else /* ! CONFIG_SMP */
#define LOCK_PREFIX_HERE ""
#define LOCK_PREFIX ""
#endif

也就是说在SMP的系统中,LOCK_PREFIX是lock,而非SMP系统中是空, 另外CAS的代码实现也如下:

static __always_inline int atomic_cmpxchg(atomic_t *v, int old, int new)
{
    return cmpxchg(&v->counter, old, new);
}
#define cmpxchg(ptr, old, new)                      \
    __cmpxchg(ptr, old, new, sizeof(*(ptr)))
#define __cmpxchg(ptr, old, new, size)                  \
    __raw_cmpxchg((ptr), (old), (new), (size), LOCK_PREFIX)
#define __raw_cmpxchg(ptr, old, new, size, lock)            \
({                                  \
    __typeof__(*(ptr)) __ret;                   \
    __typeof__(*(ptr)) __old = (old);               \
    __typeof__(*(ptr)) __new = (new);               \
    switch (size) {                         \
    case __X86_CASE_B:                      \
    {                               \
        volatile u8 *__ptr = (volatile u8 *)(ptr);      \
        asm volatile(lock "cmpxchgb %2,%1"          \
             : "=a" (__ret), "+m" (*__ptr)      \
             : "q" (__new), "0" (__old)         \
             : "memory");               \
        break;                          \
    }                               \
        case __X86_CASE_W:                      \
    {                               \
        volatile u16 *__ptr = (volatile u16 *)(ptr);        \
        asm volatile(lock "cmpxchgw %2,%1"          \
             : "=a" (__ret), "+m" (*__ptr)      \
             : "r" (__new), "0" (__old)         \
             : "memory");               \
        break;                          \
    }                               \
    case __X86_CASE_L:                      \
    {                               \
        volatile u32 *__ptr = (volatile u32 *)(ptr);        \
        asm volatile(lock "cmpxchgl %2,%1"          \
             : "=a" (__ret), "+m" (*__ptr)      \
             : "r" (__new), "0" (__old)         \
             : "memory");               \
        break;                          \
    }                               \
    case __X86_CASE_Q:                      \
    {                               \
        volatile u64 *__ptr = (volatile u64 *)(ptr);        \
        asm volatile(lock "cmpxchgq %2,%1"          \
             : "=a" (__ret), "+m" (*__ptr)      \
             : "r" (__new), "0" (__old)         \
             : "memory");               \
        break;                          \
    }                               \
    default:                            \
        __cmpxchg_wrong_size();                 \
    }                               \
    __ret;                              \
})

对于X86的系统我们有LOCK信号去关闭CPU和内存间并发访问,做到独占访问, 那么也阻止了其它CPU与内存间的访问,这是一种低效的处理方式。

mips或者其它类型的CPU体系

当然对于有些CPU体系结构,比如mips是通过关CPU中断实现的: arch/mips/include/asm/atomic.h

#define ATOMIC64_OP(op, c_op,
asm_op)                          \
static __inline__ void atomic64_##op(long i, atomic64_t * v)              \
{                                          \
    if (kernel_uses_llsc)
{                              \
        long temp;                              \
                                          \
        loongson_llsc_mb();                          \
        __asm__
__volatile__(                          \
        "    .set    push                    \n"   \
        "    .set    "MIPS_ISA_LEVEL"            \n"   \
        "1:    lld    %0, %1        # atomic64_" #op
"    \n"   \
        "    " #asm_op " %0, %2                \n"   \
        "    scd    %0,
%1                    \n"   \
        "\t" __scbeqz "    %0,
1b                    \n"   \
        "    .set    pop                    \n"   \
        : "=&r" (temp), "+" GCC_OFF_SMALL_ASM() (v->counter)          \
        : "Ir" (i));                              \
    } else {                                  \
        unsigned long flags;                          \
                                          \
        raw_local_irq_save(flags);                      \ 
        v->counter c_op i;                          \
        raw_local_irq_restore(flags);                      \
    }                                      \
}

raw_local_irq_save(flags)
关闭中断raw_local_irq_restore(flags) 打开中断

ARM体系

ARMv6架构引入了独占访问内存为止的概念,提供了更灵活的原子内存更新。ARMv6体系结构以Load-Exclusive和Store-Exclusive同步原语LDREX和STREX的形式引入了Load Link和Store Conditional指令。 从ARMv6T2开始,这些指令在ARM和Thumb指令集中可用。 独立加载和专有存储提供了灵活和可扩展的同步,取代了弃用的SWP和SWPB指令。
后来使用的是LDREX和STREX指令。 ## armv7之后继续看代码,在armv7之后就用了ldrex和strex

#define ATOMIC_OP(op, c_op, asm_op)                    \
static inline void atomic_##op(int i, atomic_t *v)            \
{                                    \
    unsigned long tmp;                        \
    int result;                            \
                                    \
    prefetchw(&v->counter);                        \
    __asm__ __volatile__("@ atomic_" #op "\n"            \
"1:    ldrex    %0, [%3]\n"                        \
"    " #asm_op "    %0, %0, %4\n"                    \
"    strex    %1, %0, [%3]\n"                        \
"    teq    %1, #0\n"                        \
"    bne    1b"                            \
    : "=&r" (result), "=&r" (tmp), "+Qo" (v->counter)        \
    : "r" (&v->counter), "Ir" (i)                    \
    : "cc");                            \
}                                    \

访存指令LDREX/STREX和普通的LDR/STR访存指令不一样,它是“独占”访存指令。这对指令访存过程由一个称作“exclusive monitor”的部件来监视是否可以进行独占访问。
先看看这对独占访存指令:
(1)LDREX R1 ,[R0] 指令是以独占的方式从R0所指的地址中取一个字存放到R0 中;
(2)STREX R2,R1,[R0] 指令是以独占的方式用R1来更新内存R0,如果独占访问条件允许,则更新成功并返回0到R2,否则失败返回1到R2。
上面的代码我们还可以用下图再解释一次:
在这里插入图片描述
他们的原理就不再展开,详细情况可以参考: https://blog.csdn.net/Roland_Sun/article/details/47670099
ARMv8系列的CPU体系,使用了LDXR和STXR指令来对内存进行独占访问:

#define ATOMIC64_OP(op, asm_op) \
__LL_SC_INLINE void \
__LL_SC_PREFIX(arch_atomic64_##op(long i, atomic64_t *v)) \
{ \
 long result; \
 unsigned long tmp; \
 \
 asm volatile("// atomic64_" #op "\n" \
" prfm pstl1strm, %2\n" \
"1: ldxr %0, %2\n" \
" " #asm_op " %0, %0, %3\n" \
" stxr %w1, %0, %2\n" \
" cbnz %w1, 1b" \
 : "=&r" (result), "=&r" (tmp), "+Q" (v->counter) \
 : "Ir" (i)); \
} \
__LL_SC_EXPORT(arch_atomic64_##op);

更详细的信息可以参考:
https://blog.csdn.net/juS3Ve/article/details/81784688

在此,特别推荐大家可以去看看zmq对原子操作的实现,实际上,我们在
工程开发的时候,可以依照它进行改进:
https://github.com/zeromq/libzmq/blob/master/src/atomic_counter.hpp
https://github.com/zeromq/libzmq/blob/master/src/atomic_ptr.hpp
比如自增原子操作的封装:
在这里插入图片描述

3. 自旋锁

posix提供一组自旋锁(spin lock)的API:

int pthread_spin_destroy(pthread_spinlock_t *);
int pthread_spin_init(pthread_spinlock_t *, int pshared);
int pthread_spin_lock(pthread_spinlock_t *);
int pthread_spin_trylock(pthread_spinlock_t *);
int pthread_spin_unlock(pthread_spinlock_t *);

pshared的取值:

  • PTHREAD_PROCESS_SHARED:该自旋锁可以在多个进程中的线程之间共
    享。
  • PTHREAD_PROCESS_PRIVATE:仅初始化本自旋锁的线程所在的进程内的线程才能够使用该自旋锁。

我们先来看一个示例:

#include <stdio.h>
#include <pthread.h>
#include <time.h>
#define MAX_THREAD_NUM  8
int counter = 0;
pthread_spinlock_t spinlock;
typedef void* (*thread_func_t)(void* argv);
static int lxx_atomic_add(int* ptr, int increment){
    int old_value = *ptr;
    __asm__ volatile("lock; xadd %0, %1 \n\t"
                     : "=r"(old_value), "=m"(*ptr)
                     : "0"(increment), "m"(*ptr)
                     : "cc", "memory");
    return *ptr;
}
void* atomic_thread_main(void* argv){
    for (int i =0; i < 100000000; i++){
        lxx_atomic_add(&counter, 1);
//          counter++;
    }
    return NULL;
}
void* spin_thread_main(void* argv){
    for(int i = 0; i < 100000000; i++){
        pthread_spin_lock(&spinlock);
        counter++;
        pthread_spin_unlock(&spinlock);
    }
    return NULL;
}
int test_lock(thread_func_t func, char** argv){
    clock_t start = clock();
    pthread_t tid[MAX_THREAD_NUM] = {0};
    for (int i = 0; i < MAX_THREAD_NUM; i++){
        int ret = pthread_create(&tid[i], NULL, func, argv);
        if (0 != ret){
            printf("create thread failed\n");
        }
    }
    for (int i = 0; i < MAX_THREAD_NUM; i++){
        pthread_join(tid[i], NULL);
    }
    clock_t end = clock();
    printf("spend clock : %ld,   ", end - start);
    return 0;
    }
int main(int argc, char** argv){
    test_lock(atomic_thread_main, NULL);
    printf("counter = %d\n", counter);
    counter = 0;
    pthread_spin_init(&spinlock, PTHREAD_PROCESS_PRIVATE);
    test_lock(spin_thread_main, NULL);
    printf("counter = %d\n", counter);
    return 0;
}

好,到现在,我们解释一下自旋的意思,我们应该还记得mutex如果拿取不到
锁,则会进入休眠,放在锁“等待”的队列上(详细可见
http://47.106.79.26:4001/2018/11/28/kernel_mutex/),这样操作会涉及到
进程上下文的切换,效率不高,那么自旋锁则不是一种休眠等待的方式,而是
一种忙等待的过程,什么意思呢?就是自旋锁的pthread_spin_lock里有一个死
循环,这个死循环一直检查锁的状态,如果是lock状态,则继续执行死循环,
否则上锁,结束死循环。

那么这个spin lock在内核层面是怎么实现?当然不同的CPU体系也有不同的实
现,下文我们会以ARM和其它的单核(Uni-Process)系统来介绍。

ARM体系

在ARMv6版本上,V3.2的内核之前(大约3.2版本,没有太仔细去考究了),它
的实现相对简单,详见
https://github.com/torvalds/linux/blob/v3.2/arch/arm/include/asm/spinlock_types.h

typedef struct {
    volatile unsigned int lock;
} arch_spinlock_t;

我们看到这个arch_spinlock_t其实就是一个无符号的整形,如果为1表示锁住
状态,为0表示没有锁住的状态。如下图的代码:
https://github.com/torvalds/linux/blob/v3.2/arch/arm/include/asm/spinlock.h
在这里插入图片描述
这种实现也是比较简单的,性能也不错,但是存在不公平的问题: 不公平。也
就是所有的thread都是在无序的争抢spin lock,谁先抢到谁先得,不管thread
等了很久还是刚刚开始spin。在冲突比较少的情况下,不公平不会体现的特别
明显,然而,随着硬件的发展,多核处理器的数目越来越多,多核之间的冲突
越来越剧烈,无序竞争的spinlock带来的performance issue终于浮现出来,根
据Nick Piggin的描述:

On an 8 core (2 socket) Opteron, spinlock unfairness is extremely noticable, with a
userspace test having a difference of up to 2x runtime per thread, and some threads are
starved or "unfairly" granted the lock up to 1 000 000 (!) times.

多么的不公平,有些可怜的thread需要饥饿的等待1000000次。本质上无序竞争
从概率论的角度看应该是均匀分布的,不过由于硬件特性导致这么严重的不公
平,我们来看一看硬件block:
在这里插入图片描述
lock本质上是保存在main memory中的,由于cache的存在,当然不需要
每次都有访问main memory。在多核架构下,每个CPU都有自己的L1 cache,保
存了lock的数据。假设CPU0获取了spin lock,那么执行完临界区,在释放锁的
时候会调用smp_mb invalide其他忙等待的CPU的L1 cache,这样后果就是释放
spin lock的那个cpu可以更快的访问L1cache,操作lock数据,从而大大增加的
下一次获取该spin lock的机会。

为了解决这么的问题,在最近的一些内核版本里(比如我看的是5.4版本
的),优化了这种设计,详细描述如下:
https://github.com/torvalds/linux/blob/v5.4/arch/arm/include/asm/spinlock_types.h

typedef struct {
    union {
        u32 slock;
        struct __raw_tickets {
#ifdef __ARMEB__
        u16 next;
 u16 owner;
#else
 u16 owner;
 u16 next;
#endif
 } tickets;
 };
} arch_spinlock_t;

本来一个整数的lock,被整得这么复杂了,要想理解整个数据结构,我们需要先了解ticket-based spin lock的概念,如有有人来长沙了,我建议大家去吃一次杨裕兴的面条,有点儿小贵,但是因为味道不错,所以每次去的时候可能都会排队等待,门口的美女会给你一张ticket,上面写着18号,同时会告诉你,当前状态是10号已经入席,11号在等待。

回到arch_spinlock_t,这里的owner就是当前已经入席的那个号码,next记录的是下一个要分发的号码。下面的描述使用普通的计算机语言和在九毛九就餐(假设杨裕兴面馆只有一张餐桌)的例子来进行描述,估计可以让同学们更有兴趣阅读下去。最开始的时候,slock(同上面的lock一样的含义,0表示unlock状态,1表示lock状态)被赋值为0,也就是说owner和next都是0,owner和next相等,表示unlocked。当第一个个thread调用spin_lock来申请lock(第一个人就餐)的时候,owner和next相等,表示unlocked,这时候该thread持有该spin lock(可以拥有九毛九的唯一的那个餐桌),并且执行
next++,也就是将next设定为1(再来人就分配1这个号码让他等待就餐)。也许该thread执行很快(吃饭吃的快),没有其他thread来竞争就调用spin_unlock了(无人等待就餐,生意惨淡啊),这时候执行owner++,也就是将owner设定为1(表示当前持有1这个号码牌的人可以就餐)。姗姗来迟的1号获得了直接就餐的机会,next++之后等于2。1号这个家伙吃饭巨慢,这是不文明现象(thread不能持有spin lock太久),但是存在。又来一个人就餐,分配当前next值的号码2,当然也会执行next++,以便下一个人或者3的号码牌。持续来人就会分配3、4、5、6这些号码牌,next值不断的增加,但是owner岿然不动,直到欠扁的1号吃饭完毕(调用spin_unlock),释放饭桌这个唯一资源,owner++之后等于2,表示持有2那个号码牌的人可以进入就餐了。

好了,我们先来看一个实现:

在这里插入图片描述
对于SMP的系统(多核处理器)是上文两种比较典型的实现,但是如果是UP系统(单处理器系统)则不是如此实现的,具体来讲有两种实现方式,一种是关闭CPU抢占,另一种是先关闭中断再关闭CPU抢占。
在这里插入图片描述
在2.6.8版本中,查看这些spin lock的声明和定义的时候,我们可以下文件为参考:
https://github.com/torvalds/linux/blob/v2.6.18/include/linux/spinlock.h
在这里插入图片描述
好了,到此,我们总结下,原子操作和自旋锁在不同的CPU体系上会有不同的实现,在整个内核发张过程中,也有变化,那么什么情况下适合用原子操作,什么情况下适合用自旋锁呢?

原子操作适合不叫简短的操作(单个数,以它为变种可以操作多个数),自旋锁因为一直是忙等待,所以适合那些耗时比较小的临界区。

内核在同步中涉及了barrier,有关barrier可以参考:
https://blog.csdn.net/zhangxiao93/article/details/42966279

4. mutex

在此就不详细描述了,可以参考我之前的博客:
http://47.106.79.26:4001/2018/11/28/kernel_mutex/


C/C++Linux服务器开发/高级架构师 系统学习 :https://ke.qq.com/course/417774?flowToken=1031343
学习交流群 960994558
面试题、学习资料、教学视频和学习路线图(资料包括C/C++,Linux,golang技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg等),免费分享有需要的可以自行添加学习交流群

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值