C11中的无锁乒乓球

时间:2019-04-10 15:46:49

标签: c multithreading lock-free

我对使用C并发还很陌生,并试图让一些基础人员了解它的工作原理。

我想写一个符合标准的无锁乒乓实现,即一个线程打印 ping ,然后另一个线程打印 pong 并使其变为无锁。这是我的尝试:

#if ATOMIC_INT_LOCK_FREE != 2
    #error atomic int should be always lock-free
#else
    static _Atomic int flag;
#endif

static void *ping(void *ignored){
    while(1){
        int val = atomic_load_explicit(&flag, memory_order_acquire);
        if(val){
            printf("ping\n");
            atomic_store_explicit(&flag, !val, memory_order_release);
        }
    }
    return NULL;
}
static void *pong(void *ignored){
    while(1){
        int val = atomic_load_explicit(&flag, memory_order_acquire);
        if(!val){
            printf("pong\n");
            atomic_store_explicit(&flag, !val, memory_order_release);
        }
    }
    return NULL;
}

int main(int args, const char *argv[]){
    pthread_t pthread_ping;
    pthread_create(&pthread_ping, NULL, &ping, NULL);

    pthread_t pthread_pong;
    pthread_create(&pthread_pong, NULL, &pong, NULL);
}

我对其进行了几次测试,并且可以正常工作,但是有些事情看起来很奇怪:

  1. 它是无锁的或无法编译

由于标准将无锁属性定义为等于2,因此原子类型上的所有操作始终都是无锁的。特别是我检查了编译代码,它看起来像

sub    $0x8,%rsp
nopl   0x0(%rax)
mov    0x20104e(%rip),%eax        # 0x20202c <flag>
test   %eax,%eax
je     0xfd8 <ping+8>
lea    0xd0(%rip),%rdi        # 0x10b9
callq  0xbc0 <puts@plt>
movl   $0x0,0x201034(%rip)        # 0x20202c <flag>
jmp    0xfd8 <ping+8>

这似乎没问题,而且我们甚至不需要栅栏,因为Intel CPU不允许使用较早的装载对商店进行重新排序。这种假设只有在我们知道硬件存储器模型不是可移植的情况下才有效

  1. 通过pthread使用stdatomics

我坚持使用glibc 2.27,其中threads.h尚未实现。问题是这样做是否严格遵守?无论如何,如果我们有原子但没有线程,这有点奇怪。那么,stdatomic在多线程应用程序中的符合用法是什么?

1 个答案:

答案 0 :(得分:3)

“无锁”一词有2个含义:

  1. 计算机科学的含义:一个线程被阻塞不会阻碍其他线程。 无法执行此任务来解除锁定,您需要线程彼此等待。 (https://en.wikipedia.org/wiki/Non-blocking_algorithm

  2. 使用无锁原子。您基本上是在创建自己的机制来制作线程块,在一个讨厌的自旋循环中等待,没有回退,最终放弃了CPU。

每个stdatomic加载和存储操作都分别是无锁的,但是您正在使用它们来创建2线程锁。


您的尝试对我来说是正确的。我看不到线程可以“错过”更新的方法,因为另一个线程要等到该线程完成后才能写另一个。而且我看不到两个线程都同时进入其关键部分的方法。

更有趣的测试是使用解锁的stdio操作,例如
fputs_unlocked("ping\n", stdio); ,以利用(并依赖于)您已经保证线程之间相互排斥的事实。参见unlocked_stdio(3)

然后将输出重定向到文件进行测试,因此stdio已完全缓冲而不是行缓冲。 (无论如何,像write()这样的系统调用都已经完全序列化了,就像atomic_thread_fence(mo_seq_cst)这样。)


  

它是无锁的或无法编译

好,那为什么很奇怪?您选择这样做。这不是必需的;即使没有始终无锁的atomic_int,该算法仍然可以在C实现中使用。

atomic_bool 可能是一个更好的选择,可以在更多平台上无锁,包括8位平台,其中int需要2个寄存器(因为至少必须是2个寄存器) 16位)。在效率更高的平台上,可以自由地将atomic_bool设为4字节类型,但实际上可以使用IDK。 (在某些非x86平台上,字节加载/存储会花费额外的延迟周期来读取/写入缓存。在这里可以忽略不计,因为您始终要处理内核间缓存未命中的情况。)

您会认为atomic_flag会是正确的选择,但它只能提供RMW操作的测试和设置,并且清晰明了。 不是普通加载或存储。

  

这种假设仅在我们知道硬件存储器模型不可移植的情况下有效

是的,但是这种无障碍的asm代码生成仅在针对x86进行编译时发生。编译器可以并且应该应用as-if规则来创建在编译目标上运行的asm,就像C源代码在C抽象机上运行一样。


  

在pthread中使用stdatomics

     

ISO C标准是否保证在所有线程实现(例如pthreads,早期的LinuxThreads等)中都明确定义了原子的行为

不,ISO C对于诸如POSIX之类的语言扩展没什么好说的。

它确实在脚注(不是规范性说明)中说,无锁原子应该是无地址的,因此它们在访问同一共享内存的不同进程之间工作。 (或者也许这个脚注仅在ISO C ++中,我没有去重新检查)。

这是我想到ISO C或C ++试图规定扩展行为的唯一情况。

但是 POSIX 标准有望说明一些有关stdatomic的信息!那是你应该看的地方;它扩展了ISO C,而不是相反,因此pthreads是必须指定其线程像C11 thread.h一样工作并且原子可以工作的标准。

当然,实际上,对于所有线程共享相同虚拟地址空间的任何线程实现,stdatomic都可以100%罚款。这包括诸如_Atomic my_large_struct foo;之类的非无锁对象。 / p>