可可西

并发编程的原子性和顺序性

资源互斥(Resources Mutex):当一个线程正在访问某共享资源(如:共享变量、共享数据结构)时,就不允许其他线程对其访问。

线程同步(Thread Synchronization):多个相关线程在执行次序上的协调。具体分为用户模式下的线程同步和基于内核对象的线程同步。

用户模式下的线程同步有:Interlocked函数、临界区段(CriticalSection)、自旋锁(SpinLock,又称旋转锁)、排号自旋锁(TicketLock)、Slim读写锁(SRWLock)。注:CriticalSection及各种锁的内部都直接或间接使用了Interlocked函数

内核对象的线程同步有:事件对象(Event)、互斥量对象(Mutex,或互斥体对象)、信号量对象(Semaphore)等。基于内核对象的同步,会带来昂贵的上下文切换(用户态切换到内核态,占用1000个以上的cpu周期)。

临界资源(Critical Resource):一段时间内仅允许一个线程使用的资源。

临界区段(Critical Section):访问临界资源的程序片段。特点:当有线程进入临界区段时,其他线程必须等待。

锁(Lock):一个代表资源状态的变量,通常用0表示资源可用(开锁,即不锁),用1表示资源已被占用(关锁,即锁住)。

 

原子性

原子操作(又称原子访问,Atomic Access):硬件支持的特性。在多线程并发情况下,一个操作的状态要么没有发生,要么发生。即:一个线程不会看到另外一个线程执行的中间状态。

可简单地理解为:线程A在执行原子操作时,其他线程像全部暂停了一样。线程A执行完原子操作后,其他线程才继续执行。

即使是简单的语句也不保证是原子操作。如:int64_t n = 100;   // 即使地址对齐的情况下,在x86的CPU架构中,写入int64需要2个CPU指令   x64下则是1个CPU指令

另外,仅仅使用一条CPU指令的内存操作,在个别CPU架构上也是非原子的。

 

无锁(lock free)的数据结构正是通过原子操作其内部共享数据来实现的。常见的有:无锁队列(lock free queue)、无锁栈

 

常见的原子操作有:

1. 原子读

如:C++11标准中的load函数

// 全局变量x
std::atomic<int> g_x = 10// 原子读取g_x的数据
int n = g_x.load(std::memory_order_relaxed);

 

 

2. 原子写

如:C++11标准中的store函数

// 全局变量x
std::atomic<int> g_x = 10// 原子修改g_x的值为25
g_x.store(25, std::memory_order_relaxed);

 

 

3. TSL(Test & Set  wikichs):设置成1,并返回原来的值。伪代码如下:

int test_and_set(int* lockPtr)
 {
    int oldValue =  *lockPtr;
    *lockPtr = 1;

    return oldValue;
}

可用来实现自旋锁SpinLock,又称旋转锁),伪代码如下:

volatile int lock_val = 0;
 
void CriticalTest() 
{
     while (test_and_set(&lock_val) == 1);  // 后续线程会在此死循环等待

     // 临界段(critical section) // 同时只能有一个线程执行该逻辑

     lock_val = 0; // 临界段结束后,释放锁
}

自旋锁是多线程同步的一种锁,线程反复检查锁变量是否可用(线程在这一过程中保持执行,处于忙等待状态)。一旦获取了自旋锁,线程会一直保持该锁,直至显式释放自旋锁。 

 

可用来实现Slim读写锁SRWLock,Slim Reader Writer Lock),伪代码如下:

volatile int read_lock = 0;  // 读线程使用的旋转锁,用于互斥访问
volatile int write_lock = 0;

int read_count = 0;

// 读线程  获取锁
void acquire_lock_share
{
    while (test_and_set(&read_lock) == 1);  // 后续读线程会在此死循环等待
    
    read_count++;
    if (read_count == 1) 
    {
        while (test_and_set(&write_lock) == 1); // 有写线程时,当前读线程在此死循环等待
    }
    
    // 没有写线程时,所有的读线程都可以执行该逻辑
    
    read_lock = 0;
}

// 读线程  释放锁
void release_lock_share
{ 
    while (test_and_set(&read_lock) == 1);  // 后续读线程会在此死循环等待
    
    read_count--;
    if (read_count == 0) 
    {
        write_lock = 0;
    }
    
    read_lock = 0;
}

// 写线程  获取锁
void acquire_lock_exclusive
{
    while (test_and_set(&write_lock) == 1);  // 有读线程或写操作线程时,后续写线程会在此死循环等待
    
    // 没有读线程时,当且仅有一个写线程执行该逻辑
}

// 写线程  释放锁
void release_lock_exclusive
{
     write_lock = 0;
}

由于读线程并不会破坏数据,因此Slim读写锁将对锁操作的线程分成:读线程和写线程。以提高并发效率。

读线程以共享模式(share)来获取和释放锁,写线程以独占模式(exclusive)来获取和释放锁。

当没有写线程时,各个读线程可并发运行。写线程与写线程是互斥的;写线程与读线程也是互斥的。

 

Test and Test-and-set,用来降低Test & Set的资源争夺情况。用它来实现的自旋锁的伪代码如下:

volatile int lock_val = 0;
 
void CriticalTest() 
{
     do
     {
         while (lock_val == 1) yield(); // 如果lock住,释放cpu占用
     } while (test_and_set(&lock_val) == 1) // lock住,继续循环等待

     // 临界段(critical section) // 同时只能有一个线程执行该逻辑

     lock_val = 0; // 临界段结束后,释放锁
}

 

4. CAS( Compare & Swap   wikichs):比较并交换操作,若交换成功则返回true,否则返回失败。伪代码如下:

bool compare_and_swap(int* reg, int comparand, int exchange)
{
  int old_reg_val = *reg;
  if (old_reg_val == comparand)
  {
     *reg = exchange;
     return true;
  }

  return false;
}

 

在x86/64 和 Itanium 架构,CAS被认为是最基础的一种原子性的RMW(Read-Modify-Write   wikichs)操作。RMW:读一个内存位置,修改其值,再写回原位置。

而在PowerPC、MIPS 和 ARM 架构,通过 Load-Link/Store-Conditional (LL/SC   wikichs) 方式来实现最基础的一种原子性的RMW操作,然后通过LL/SC对来实现CAS,但在一些情况下,实现出来的CAS并不具有原子性。

 

问题1:为什么在PowerPC、MIPS 和 ARM 架构上,会存在LL/SC对的使用,而不直接实现CAS原语呢?

主要是为了解决ABA问题(又称掉包问题)。

下面以CAS原语实现的无锁栈,来讲解什么是ABA问题。

① 有一个栈(先入后出)中有top和节点A,节点A目前位于栈顶top指针指向A。

   top
    |
    V   
  0x0014
| Node A | --> |  Node X | --> ……

② 现在有一个进程P1想要pop一个节点,因此按照如下无锁操作进行

pop()
{
  do{
    ptr = top;            // ptr = top = NodeA
    next_prt = top->next; // next_ptr = NodeX
  } while(CAS(top, ptr, next_ptr) != true);
  return ptr;   
}

③而进程P2在执行CAS操作之前打断了P1,并对栈进行了一系列的pop和push操作:首先pop出NodeA,之后又Push了两个NodeB和C

由于内存管理机制中广泛使用的内存重用机制,导致NodeC的地址与之前的NodeA一致。使栈变为如下结构:

   top
    |
    V  
  0x0014
| Node C | --> | Node B | --> |  Node X | --> ……

④这时P1又开始继续运行,在执行CAS操作时,由于top依旧指向的是NodeA的地址(实际上已经变为NodeC),因此将top的值修改为了NodeX,这时栈结构如下:

                                    top
                                     |
   0x0014                            V
 | Node C | --> | Node B | --> |  Node X | --> ……

最后使得top指针错误的指向了NodeX而不是NodeB。

 

结论:通过 "值相同" 来判定 "值没变"是不可靠的。后面的线程可能多次修改了该值,这样就相当于欺骗了前面的线程,使其认为 "值没变",实际上值已经被篡改过了。

 

LL和SC的伪代码实现如下:

int LL( int* pAddr )
{
    return *pAddr ;
}

bool SC( int* pAddr, int New ) 
{
    if ( data in pAddr has not been changed since the LL call) // LL调用后,pAddr指向的数据没有发生变化时
    {
        *pAddr = New;
        return true;
    }
    else
    {
        return false;
    }
}

注:在实现上,处理器开发者给每个Cahce Line添加额外的比特状态值(status bit)。一旦LL执行读运算,就会关联此比特值。任何的缓存行一旦有写入,此比特值就会被重置;

在存储之前,SC操作会检查此比特值是否针对特定的缓存行。如果比特值为1,意味着缓存行没有任何改变,pAddr 地址中的值会变更为新值,SC操作成功。否则本操作就会失败,pAddr 地址中的值不会变更为新值。 

 

问题2:LL/SC对实现的CAS,但在一些情况下,为什么不具有原子性?

实现的CAS的伪代码如下:

int CAS( int* pAddr, int nExpected, int nNew )
{
    if ( LL( pAddr ) == nExpected )
        return SC( pAddr, nNew );
        
    return false;
}

该CAS在执行过程中,可能会被中断,例如:线程X在执行LL后,OS决定将X调度出去,等OS重新调度恢复X之后,SC将不再响应,CAS失败的原因不在数据本身是否有变化,而是其他外部事件(线程被中断导致的)。

注:正是因为如此,C++11标准中添入两个compare_exchange原语分别被命名为compare_exchange_weakcompare_exchange_strong

任何weak CAS都可能破坏CAS语义,失败并返回false,而它本应返回true。而Strong CAS会严格遵循CAS语义。

 

我们可以非常简单地利用CAS来实现一个自旋锁,伪代码如下:

volatile int lock_val = 0;
 
void CriticalTest() 
{
     while (compare_and_swap(&lock_val, 0, 1));  // 后续线程会在此死循环等待

     // 临界段(critical section) // 同时只能有一个线程执行该逻辑

     lock_val = 0; // 临界段结束后,释放锁
}

 

5. value CAS( Compare & Swap   wikichs):比较并交换操作并返回原来的值。伪代码如下:

int compare_and_swap(int* reg, int comparand, int exchange)
{
  int old_reg_val = *reg;
  if (old_reg_val == comparand)
     *reg = exchange;

  return old_reg_val;
}

 

6. FAA(Fetch & Add  wikichs):进行加法操作并返回原来的值。伪代码如下:

int fetch_and_add(int* reg, int amount)
{
  int old_reg_val = *reg;
  *reg = old_reg_val + amount;
  
  return old_reg_val;
}

FAA也是一种原子性的RMW操作,可通过最基础的RMW操作CAS来实现。

 

可用来实现排号自旋锁TicketLock),伪代码如下:

typedef struct lock_t {
    int ticket;
    int turn;
} lock_t g_lock;

g_lock.ticket = 0;
g_lock.turn = 0;

void CriticalTest() 
{
    int myturn = fetch_and_add(g_lock->ticket, 1);
    while (g_lock->turn != myturn) ; //后续各线程按进入的先后顺序在此死循环等待

    // 临界段(critical section) // 同时只能有一个线程执行该逻辑
    fetch_and_add(g_lock->turn, 1);  // 临界段结束后,将turn变量也+1
}

排号自旋锁是多线程同步的一种锁,类似于自旋锁,但每一个申请排队自旋锁的线程获得一个排队号(ticket)。

至多一个线程拥有自旋锁,当它释放锁时,把自身的ticket加1作为下一个可获得锁的ticket,持有该ticket的线程在自旋检查时就可发现已经获得了自旋锁,是一种先进先出(FIFO)的公平锁。

这种机制类似于一些提供社会服务的场所(如银行):进门的顾客从排号机获取一个等待号,然后不断检查当前可服务的号,直至轮到其手持的号。

 

Interlocked函数原子性的实现原理

Interlocked函数会编译成特定的汇编指令,该指令在执行时会在总线上维持一个硬件信号,来阻止其他cpu核心访问同一个内存地址。

与切换到内核模式然后等待相比,Interlocked函数执行是极快的,只占用几个cpu周期(通常小于50)。但与普通的mov等指令相比,Interlocked函数的汇编指令是很昂贵的,要避免滥用。

需要注意的是:传给Interlocked函数的变量的地址必须是经过对齐的,否则这些函数可能会失去原子性。

 

顺序性

编译期乱序:编译器优化而调整了代码指令的先后顺序。

          int x = 0;     // global variable
          int y = 0;     // global variable
          
// Thread-1:           // Thread-2:
x = 100;               while (y != 200)
y = 200;                   ;
                       std::cout << x;

如果 CPU 没有乱序执行指令,那么 Thread-2 将输出100。然而,对于 Thread-1 来说,x = 100;和y = 200;这两个语句之间没有依赖关系,因此,Thread-1 允许调整语句的执行顺序:

// Thread-1:
y = 200;
x = 100;

在这种情况下,Thread-2 将输出0或100。

 

运行期乱序:各个cpu有自己独立的cache,变量被cpu1修改后,为了提升运行效率没有及时通知其它cpu,导致在其他cpu上读到了老的数据。

下面的例子中,假设从时间上来讲,A 操作先于 B 操作发生:

          int x = 0;     // global variable
          
// Thread-1:                 // Thread-2:
x = 100;    /* A */          std::cout << x;    /* B */ 

尽管从时间上来讲,A 先于 B,但 CPU cache 的影响下,Thread-2 不能保证立即看到 A 操作的结果,所以 Thread-2 可能输出0或100。

 

当然这些乱序指令都是为了同一个目的,优化执行效率。

但是也不能无止境的乱序,至少要保证这一原则:不能改变单线程程序的执行行为。

 

先于发生(happens-before)关系

“先于发生”是现代计算科学用来在Java,Go甚至LLVM中描述软件内存模型的术语。


在同一个线程内,”先于发生”关系由编码顺序(Program Order)保证
也就是:代码A写在代码B前面,即能得出 A happens-before B
不管编译器如何优化,CPU如何乱序,同一线程内的happens-before关系必定存在,因为这是任何优化必须遵循的原则。
可以说:线程内,源码顺序就是happens-before的顺序

在多个线程之间,happens-before关系则由同步关系(synchronizes-with)保证,可通过获取和释放语义达成
具体来说,如果:
线程1中,操作A 对共享变量 m 以 Release 的方式进行修改,
线程2中,操作B 对共享变量 m 以 Acquire 的方式读取,并且正确读到了操作A所写入的值,
则操作B与操作A之间存在同步(synchronizes-with)关系。

 

内存屏障Memory Barriers),也称内存栅栏、内存栅障、屏障指令,它实现了获取和释放语义,是编译器或CPU在对内存随机访问的操作中的一个同步点。通过此机制来解决编译期和运行期乱序问题。

在内存屏障之前的指令一定在内存屏障之后的指令之前执行。并保证如下内存序Memory order):内存屏障之前的所有写操作都要写入内存;内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。

因此,对于敏感的程序块,写操作之后、读操作之前可以插入内存屏障。

windows下通过_mm_sfence()函数来实现,其他平台通过__sync_synchronize()函数来实现。UE4对不同操作系统的内存屏障函数进行了封装,提供了FPlatformMisc::MemoryBarrier()函数

c++11使用std::atomic_thread_fence( std::memory_order order )函数(帮助:zh  en)来实现

memory_order可以为以下类型:

typedef enum memory_order {
    memory_order_relaxed,  // 无fencing作用,cpu和编译器可以重排指令
    memory_order_consume, // 后面依赖此原子变量的访存指令勿重排至此条指令之前
    memory_order_acquire, // 后面访存指令勿重排至此条指令之前
    memory_order_release, // 前面访存指令勿重排到此条指令之后
    memory_order_acq_rel, // acquare + release
    memory_order_seq_cst  // acq_rel + 所有使用seq_cst的指令有严格的全序关系
} memory_order;

 

写单线程代码的程序员不需要关心内存乱序的问题。在多线程编程中,由于互斥量,信号量和事件等在设计的时候都阻止了它们调用点中的内存乱序(已经隐式包含memory barrier),内存乱序的问题同样不需要考虑了。

只有当使用无锁(lock-free)技术时,内存在线程间共享而没有任何的互斥量,内存乱序的效果才会显露无疑,这样我们才需要考虑在合适的地方加入合适的memory barrier。

 

volatile关键字

① 用volatile修饰的基本类型变量能保障内存对齐

② 始终从内存中读取volatile变量的值(而不是从寄存器cache中) 注:volatile本意是“易变的”

③ 告诉编译器,volatile变量可能会被操作系统、硬件或一个并发的线程进行修改,不要对其进行各种激进的优化(如:将变量直接消除)

④ 保证Volatile变量与Volatile变量之间的顺序性,编译器不会进行乱序优化。注:Volatile变量与非Volatile变量的顺序,编译器不保证顺序,可能会进行乱序优化。

注1:给一个结构体加volatile,等于给结构体所有的成员加上valatile限定符。

注2:volatile是针对编译器的,而不是CPU,对CPU相关的实现细节,是无能为力的。

注2:volatile基本类型变量的操作是原子的;但其他类型不是原子的。

注3:vloatile不会有任何的Memory Barrier(内存栅栏)的保证。

注4:vloatile不会有任何的线程同步的保证。

 

参考

无锁队列的实现

无锁编程基础 

说说无锁(Lock-Free)编程那些事(上篇 

说说无锁(Lock-Free)编程那些事(下篇 )

理解 C++ 的 Memory Order 

False sharing 隱藏在多核系統的效能陷阱 

关于C++memory_order的理解

atomic_instructions(百度)

 

posted on 2020-10-11 01:37  可可西  阅读(714)  评论(0编辑  收藏  举报

导航