假设我有以下获取票证的自旋锁互斥体实现(在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);
}
1条答案
按热度按时间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
,或者因为我们可以看到我们离队列头有多近,yield
、nanosleep
、或者futex
,如果你的等待间隔将超过3或8个票号或其他。(可调取决于多久需要服务一张票。)(使用
futex
,你可以在解锁中引入一个m->ticket
的读取,以判断是否有线程在等待通知。就像C++20atomic<>.wait()
和atomic.notify_all()
。不幸的是,我不知道一个好的方法来判断通知哪个线程,而不是唤醒它们来检查它们是否是幸运的赢家。在平均争用率较低的情况下,应将两者保留在同一高速缓存行中。访问
.ticket
后总是紧接着加载.serving
。在未锁定无争用情况下,这意味着只有一个高速缓存行在来回移动,或者必须保持热状态,以便同一内核获取/释放锁。如果锁已经被持有,则想要解锁的线程需要对RMW或存储该高速缓存行的独占所有权。无论另一个核在包含
.serving
的行上执行RMW还是仅执行纯加载,它都将失去该独占所有权。多个等待者都在同一个锁上旋转的情况不会太多,新线程获得一个票证号会延迟解锁,并延迟它对等待它的线程的可见性。
反正这是我的直觉;这可能很难进行微基准测试,除非缓存未命中原子RMW阻止以后的加载,甚至阻止开始请求后面的行,在这种情况下,在获取锁时可能会有两个缓存未命中延迟。
在解锁时避免原子RMW?
持有锁的线程知道它拥有独占所有权,没有其他线程会同时修改
m->serving
。如果你让锁所有者记住它自己的票证号,你可以优化解锁,使之只包含一个存储。或者不进行API更改(从
u32 ticket_mutex_lock()
返回整数)这对于需要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上会有很大帮助。