C语言 取票自旋锁互斥体的存储顺序

bhmjp9jg  于 2022-12-17  发布在  其他
关注(0)|答案(1)|浏览(143)

假设我有以下获取票证的自旋锁互斥体实现(在C中使用GCC原子内置函数)。据我所知,在unlock函数中使用“release”内存顺序是正确的。但是,我不确定lock函数。因为这是一个取票互斥锁,所以有一个字段指示要分发的下一个票号,和一个字段来指示当前持有锁的票证号。我在票证增量上使用了acquire-release,在自旋加载上使用了acquire。这是否是不必要的强大,如果是,为什么?
另外,这两个字段(ticket和serving)是否应该分开,以便它们位于不同的缓存行,或者这无关紧要?我主要对arm 64和amd 64感兴趣。

typedef struct {
        u64 ticket;
        u64 serving;
} ticket_mutex;

void
ticket_mutex_lock(ticket_mutex *m)
{
        u64 my_ticket = __atomic_fetch_add(&m->ticket, 1, __ATOMIC_ACQ_REL);
        while (my_ticket != __atomic_load_n(&m->serving, __ATOMIC_ACQUIRE));
}

void
ticket_mutex_unlock(ticket_mutex *m)
{
        (void) __atomic_fetch_add(&m->serving, 1, __ATOMIC_RELEASE);
}

**更新:**基于已接受答案中的建议,我将实现调整为以下内容。这个互斥锁是为低争用情况设计的。

typedef struct {
        u32 ticket;
        u32 serving;
} ticket_mutex;

void
ticket_mutex_lock(ticket_mutex *m)
{
        u32 my_ticket = __atomic_fetch_add(&m->ticket, 1, __ATOMIC_RELAXED);
        while (my_ticket != __atomic_load_n(&m->serving, __ATOMIC_ACQUIRE)) {
                #ifdef __x86_64__
                __asm __volatile ("pause");
                #endif
        }
}

void
ticket_mutex_unlock(ticket_mutex *m)
{
        u32 my_ticket = __atomic_load_n(&m->serving, __ATOMIC_RELAXED);
        (void) __atomic_store_n(&m->serving, my_ticket+1, __ATOMIC_RELEASE);
}
wlp8pajw

wlp8pajw1#

m->ticket增量只需为RELAXED,每个线程只需要得到不同的票号即可;它可以发生早或晚,因为你想wrt.其他操作在同一线程。
load(&m->serving, acquire)是对临界区排序的操作,防止临界区在我们与锁的前一个保持器的unlock函数中的RELEASE操作同步之前启动。
即使m->ticket++直到m->serving的获取加载之后才完成,也没关系。while条件仍然确定执行是否继续(非推测性地)进入临界区。推测性地执行进入临界区是好的,因为这可能意味着它更快地准备提交,减少了持有锁的时间。
对RMW操作进行额外的排序不会使其在本地或线程间可见性方面更快,而且会降低获取锁的线程的速度。

一个或两个缓存线

就性能而言,我认为在高争用情况下,将成员保持在单独的缓存行中会有好处。
需要独占该高速缓存行所有权以获取票证编号的线程不会与解锁.serving的线程争用,因此这些线程间延迟可能会并行发生。
在spin-wait while(load(serving))循环中有多个内核,它们可以命中本地L1 d缓存,直到某个东西使缓存线的共享副本无效,而不会产生任何额外的流量。但是,除非使用x86 _mm_pause()之类的东西,否则会浪费大量的功率。以及浪费可与同一物理上的另一逻辑核心共享的执行资源。x86 X1 M13 N1 X还避免了在离开自旋循环时的分支误预测。相关:

通常建议在检查之间进行一定数量的暂停的指数回退,但在这里我们可以做得更好:检查之间的pause指令数,以my_ticket - m->serving为单位进行调整,因此您可以在票证到期时更频繁地进行检查。
在竞争非常激烈的情况下,如果您要等待很长时间,例如Linux futex,或者因为我们可以看到我们离队列头有多近,yieldnanosleep、或者futex,如果你的等待间隔将超过3或8个票号或其他。(可调取决于多久需要服务一张票。)
(使用futex,你可以在解锁中引入一个m->ticket的读取,以判断是否有线程在等待通知。就像C++20 atomic<>.wait()atomic.notify_all()。不幸的是,我不知道一个好的方法来判断通知哪个线程,而不是唤醒它们来检查它们是否是幸运的赢家。

在平均争用率较低的情况下,应将两者保留在同一高速缓存行中。访问.ticket后总是紧接着加载.serving。在未锁定无争用情况下,这意味着只有一个高速缓存行在来回移动,或者必须保持热状态,以便同一内核获取/释放锁。

如果锁已经被持有,则想要解锁的线程需要对RMW或存储该高速缓存行的独占所有权。无论另一个核在包含.serving的行上执行RMW还是仅执行纯加载,它都将失去该独占所有权。
多个等待者都在同一个锁上旋转的情况不会太多,新线程获得一个票证号会延迟解锁,并延迟它对等待它的线程的可见性。
反正这是我的直觉;这可能很难进行微基准测试,除非缓存未命中原子RMW阻止以后的加载,甚至阻止开始请求后面的行,在这种情况下,在获取锁时可能会有两个缓存未命中延迟。

在解锁时避免原子RMW?

持有锁的线程知道它拥有独占所有权,没有其他线程会同时修改m->serving。如果你让锁所有者记住它自己的票证号,你可以优化解锁,使之只包含一个存储。

void ticket_mutex_unlock(ticket_mutex *m, uint32_t ticket_num)
{
        (void) __atomic_store_n(&m->serving, ticket_num+1, __ATOMIC_RELEASE);
}

或者不进行API更改(从u32 ticket_mutex_lock()返回整数)

void ticket_mutex_unlock(ticket_mutex *m)
{
        uint32_t ticket = __atomic_load_n(&m->serving, __ATOMIC_RELAXED);  // we already own the lock
        // and no other thread can be writing concurrently, so a non-atomic increment is safe
        (void) __atomic_store_n(&m->serving, ticket+1, __ATOMIC_RELEASE);
}

这对于需要LL/SC重试循环来执行原子RMW的ISA来说具有很好的效率优势,在这种情况下,可能会发生来自另一个内核阅读值的虚假失败。在x86上,唯一可能的原子RMW是一个完整的屏障,甚至比C seq_cst语义所需的更强。
顺便说一句,锁域可以是uint32_t。你不会有2^32个线程等待一个锁。所以我使用uint32_t而不是u64。 Package 是很好定义的。即使像ticket - serving这样的减法也可以工作,即使跨越 Package 边界,比如1 - 0xffffffffUL得到2,所以你仍然可以计算你离被服务的时间有多近,来决定睡眠。
在x86-64上不是什么大问题,只是节省了一点代码大小,在AArch 64上可能根本不是一个因素,但在一些32位ISA上会有很大帮助。

相关问题