我正在使用"弱内存模型的正确和有效的工作窃取"中描述的工作队列。我希望队列项的大小为16字节,我只关心英特尔/AMD Windows x64和VS 2019。
我理解16字节(例如__m128)对齐的加载/存储在现代处理器中通常是原子的,但它们不受规范的保证。
deque的类型有:
typedef struct {
atomic_size_t size;
atomic_int buffer[];
} Array;
typedef struct {
atomic_size_t top, bottom;
Atomic(Array *) array;
} Deque;
重要的是,数组缓冲区项具有特定的原子类型。如果我用VS2019编译这个,我可以看到它用自旋锁增加了缓冲区项目的大小-我不想这样。有可能预防吗?特别是我只关心x64,它有一定的保证。
deque上的动作由以下函数给出:
int take(Deque* q) {
size_t b = load_explicit(&q->bottom, relaxed) - 1;
Array* a = load_explicit(&q->array, relaxed);
store_explicit(&q->bottom, b, relaxed);
thread_fence(seq_cst);
size_t t = load_explicit(&q->top, relaxed);
int x;
if( t <= b ) {
/* Non-empty queue. */
x = load_explicit(&a->buffer[b % a->size], relaxed);
if( t == b ) {
/* Single last element in queue. */
if( !compare_exchange_strong_explicit(&q->top, &t, t + 1, seq_cst, relaxed) )
/* Failed race. */
x = EMPTY;
store_explicit(&q->bottom, b + 1, relaxed);
}
} else { /* Empty queue. */
x = EMPTY;
store_explicit(&q->bottom, b + 1, relaxed);
}
return x;
}
void push(Deque* q, int x) {
size_t b = load_explicit(&q->bottom, relaxed);
size_t t = load_explicit(&q->top, acquire);
Array* a = load_explicit(&q->array, relaxed);
if( b - t > a->size - 1 ) { /* Full queue. */
resize(q);
a = load_explicit(&q->array, relaxed);
}
store_explicit(&a->buffer[b % a->size], x, relaxed);
thread_fence(release);
store_explicit(&q->bottom, b + 1, relaxed);
}
int steal(Deque* q) {
size_t t = load_explicit(&q->top, acquire);
thread_fence(seq_cst);
size_t b = load_explicit(&q->bottom, acquire);
int x = EMPTY;
if( t < b ) {
/* Non-empty queue. */
Array* a = load_explicit(&q->array, consume);
x = load_explicit(&a->buffer[t % a->size], relaxed);
if( !compare_exchange_strong_explicit(&q->top, &t, t + 1, seq_cst, relaxed) )
/* Failed race. */
return ABORT;
}
return x;
}
有很多是多余的,应该在x64上优化。实际上,本文只指定了在thread_fence(seq_cst)行的take函数中需要一个内存fence。虽然我不确定这是真的,如果队列项目类型是16字节的大小?
似乎take()/push()必须发生在同一个线程中,所以它们之间没有问题。因此,任何调用steal()读取部分写入的16字节项的线程都存在危险。但是,由于push()只在所有16个字节写入之后才进行内存围栏,并且只有在此之后才进行更新,因此在x64上这似乎不是一个问题?
我做了一个实验,我删除了缓冲区项上的原子限定符,并通过volatile指针对缓冲区使用普通赋值。它似乎工作得很好,但显然这是不确定的!
如果这是不可能的,那么也许使用cmpxchg16b是一个更好的选择来加载/存储16字节我的具体情况?或者通过将队列项作为索引,并无锁地分配被索引的16字节槽来使其复杂化。
所以我的问题的简化版本是:在x64上,我可以简单地将Array缓冲区类型的定义更改为指向非原子限定的16字节结构项数组的易失性指针,并将上述函数中这些项的加载和存储更改为简单的非原子赋值表达式吗?
这可能只是部分答案,因为我试图回答大约16字节的原子,但我不知道为什么您需要队列的元素是原子的,而不仅仅是计数器。
使用MSVC 2019完成此操作的最简单方法是:
- 使用
std::atomic_ref<16 bytes type>
#define _STD_ATOMIC_ALWAYS_USE_CMPXCHG16B
static_assert
对atomic_ref<...>::is_always_lock_free
确保你已经得到真正的无锁原子
或:
- 使用
boost::atomic<16 bytes type>
或boost::atomic_ref<16 bytes type>
- 再次,
static_assert
在is_always_lock_free
上确保你得到了真正的无锁原子
MSVC可能已经为std::atomic<16 bytes type>
实现了同样的事情,但由于ABI兼容性问题,它仍然没有实现。(预计在将来的某个版本中实现此功能,将打破当前的ABI)
这将为每个操作提供cmpxchg16b
,即使是放松加载和放松存储。这是你能在任何CPU上得到的最好的保证。
对于128位类型有单指令加载/存储,但是它们不能保证是原子的。参见SSE指令:哪些cpu可以做原子16B内存操作?
关于volatile
在MSVC中,volatile
的读取和赋值由/volatile:ms
//volatile:iso
控制。参见/volatile (volatile关键字解释)。这是/volatile:ms
,这是x86-64的默认设置,加载应该是获取的,存储应该是释放的。
然而:
- 简单加载/存储不能保证在任何CPU上对128位类型是原子的(参见上面的链接答案)
- 即使对于CPU来说它们是原子的,128位类型对于编译器来说也不是原子的,所以它不一定会产生单指令访问。
因此,如果您仍然希望依赖128位加载/存储作为原子在目标CPU上工作,您最好使用intrinsic_mm_load_si128
/_mm_store_si128
,使用alignas(16)
来确保正确的对齐,并使用_读写屏障来防止编译器重新排序。这个可以工作(编译器可能会根据要求生成128位的load/store,并且这些load/store在给定的CPU上可能确实是原子的)。