我在考虑如何使用尽可能少的asm代码来实现信号量(不是二进制)。 我没有使用互斥锁就没有成功地思考和编写它,所以这是迄今为止我能做的最好的事情:
全局:
#include <stdlib.h>
#include <pthread.h>
#include <stdatomic.h>
#include <stdbool.h>
typedef struct
{
atomic_ullong value;
pthread_mutex_t *lock_op;
bool ready;
} semaphore_t;
typedef struct
{
atomic_ullong value;
pthread_mutex_t lock_op;
bool ready;
} static_semaphore_t;
/* use with static_semaphore_t */
#define SEMAPHORE_INITIALIZER(value) = {value, PTHREAD_MUTEX_INITIALIZER, true}
的功能
bool semaphore_init(semaphore_t *semaphore, unsigned long long init_value)
{
if(semaphore->ready) if(!(semaphore->lock_op = \
calloc(1, sizeof(pthread_mutex_t)))) return false;
else pthread_mutex_destroy(semaphore->lock_op);
if(pthread_mutex_init(semaphore->lock_op, NULL))
return false;
semaphore->value = init_value;
semaphore->ready = true;
return true;
}
bool semaphore_wait(semaphore_t *semaphore)
{
if(!semaphore->ready) return false;
pthread_mutex_lock(&(semaphore->lock_op));
while(!semaphore->value) __asm__ __volatile__("nop");
(semaphore->value)--;
pthread_mutex_unlock(&(semaphore->lock_op));
return true;
}
bool semaphore_post(semaphore_t *semaphore)
{
if(!semaphore->ready) return false;
atomic_fetch_add(&(semaphore->value), (unsigned long long) 1);
return true;
}
是否可以仅使用几行,使用原子内置函数或直接在汇编中实现信号量(例如lock cmpxchg
)?
查看<bits/sempahore.h>
所包含的<semaphore.h>
中的sem_t结构,在我看来,它被选择了一条完全不同的路径......
typedef union
{
char __size[__SIZEOF_SEM_T];
long int __align;
} sem_t;
的更新:
@PeterCordes提出了一个更好的解决方案,使用原子,没有互斥,直接对信号量值进行检查。
我仍然希望更好地理解在性能方面改进代码的机会,利用内置的暂停函数或内核调用来避免CPU浪费,等待关键资源可用。
使用互斥锁和非二进制信号量的标准实现进行比较也会很不错。 从futex(7)我读到:&#34; Linux内核提供了futexes(&#34;快速用户空间互斥&#34;)作为快速用户空间锁定的构建块信号灯。 Futexes非常基础,非常适合构建更高级别的锁定抽象,例如互斥锁,条件变量,读写锁,障碍和信号量。&#34;
答案 0 :(得分:9)
请参阅我的最小天真信号量实现的部分内容,这可能有效。它编译并适合x86。对于任何C11实现,我认为这是正确的。
up
/ down
提供了算法。您不需要单独的互斥锁。如果atomic_ullong
需要一个互斥锁来支持目标CPU上的原子递增/递减,它将包含一个。 (在32位x86上可能就是这种情况,或者实现使用慢cmpxchg8
而不是快lock xadd
。一个32位计数器真的对你的信号量来说太小吗?因为64位原子在32位时会慢一些机。)
<bits/sempahore.h>
联合定义显然只是一个不透明的POD类型,具有正确的大小,并不表示实际的实现。
正如@David Schwartz所说,除非你是专家,否则实施自己的锁定实际使用是一件很愚蠢的事。然而,这可能是一种有趣的方式来了解原子操作,并找出标准实现中的内幕。请注意他的注意事项,锁定实现很难测试。您可以使用当前版本的编译器编写适用于您的测试用例的代码,并使用您选择的编译选项...
ready
布尔值只是浪费空间。如果您可以正确初始化ready
标志以使其对函数有意义,那么您可以将其他字段初始化为理智的初始状态。
您的代码还有一些我注意到的其他问题:
#define SEMAPHORE_INITIALIZER(value) = {value, PTHREAD_MUTEX_INITIALIZER, true};
static_semaphore_t my_lock = SEMAPHORE_INITIALIZER(1);
// expands to my_lock = = {1, PTHREAD_MUTEX_INITIALIZER, true};;
// you should leave out the = and ; in the macro def so it works like a value
使用动态分配的pthread_mutex_t *lock_op
只是愚蠢的。使用值,而不是指针。大多数锁定函数都使用互斥锁,因此额外的间接级别会减慢速度。记忆与计数器一起存在会好得多。互斥量并不需要很大的空间。
while(!semaphore->value) __asm__ __volatile__("nop");
我们希望这个循环避免浪费功率并减慢其他线程,甚至其他逻辑线程与超线程共享相同的核心。
nop
不会使繁忙等待循环减少CPU占用。即使使用超线程,它在x86上也没有什么区别,因为整个循环体仍然可能适合4个uop,因此每个时钟在一次迭代中发出是否存在nop
。 nop
不需要执行单元,所以至少它不会受到伤害。这个自旋循环发生在持有互斥锁的情况下,这似乎很愚蠢。所以第一个服务员将进入这个旋转循环,而服务员之后会旋转互斥锁。
我认为这是一个很好的实现,它实现了非常有限的正确和小的目标(源代码和机器代码),而不是使用其他实际的锁定原语。我甚至没有尝试解决的主要领域(例如公平/饥饿,将CPU交给其他线程,可能是其他线程)。
请参阅asm output on godbolt:down
只有12个x86个,up
个2个(包括ret
个)。 Godbolt的非x86编译器(ARM / ARM64 / PPC的gcc 4.8)太旧而无法支持C11 <stdatomic.h>
。 (但它们确实有C ++ std::atomic
)。所以我很遗憾不能轻易检查非x86上的asm输出。
#include <stdatomic.h>
#define likely(x) __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)
typedef struct {
atomic_int val; // int is plenty big. Must be signed, so an extra decrement doesn't make 0 wrap to >= 1
} naive_sem_t;
#if defined(__i386__) || defined(__x86_64__)
#include <immintrin.h>
static inline void spinloop_body(void) { _mm_pause(); } // PAUSE is "rep nop" in asm output
#else
static inline void spinloop_body(void) { }
#endif
void sem_down(naive_sem_t *sem)
{
while (1) {
while (likely(atomic_load_explicit(&(sem->val), memory_order_acquire ) < 1))
spinloop_body(); // wait for a the semaphore to be available
int tmp = atomic_fetch_add_explicit( &(sem->val), -1, memory_order_acq_rel ); // try to take the lock. Might only need mo_acquire
if (likely(tmp >= 1))
break; // we successfully got the lock
else // undo our attempt; another thread's decrement happened first
atomic_fetch_add_explicit( &(sem->val), 1, memory_order_release ); // could be "relaxed", but we're still busy-waiting and want other thread to see this ASAP
}
}
// note the release, not seq_cst. Use a stronger ordering parameter if you want it to be a full barrier.
void sem_up(naive_sem_t *sem) {
atomic_fetch_add_explicit(&(sem->val), 1, memory_order_release);
}
这里的诀窍是val
暂时太低;这只是让其他线程旋转。另请注意, fetch_add
是单个原子操作是关键。它返回旧值,因此我们可以检测while循环加载和fetch_add之间的另一个线程何时占用val
。 (请注意,我们不需要检查tmp
是否= = while循环的加载:如果另一个帖子up
编辑了信号之间的信号,那就没问题了。 load和fetch_add。这是使用fetch_add而不是cmpxchg的好处。
atomic_load
自旋循环只是让所有服务员在val
上进行原子读取 - 修改 - 写入的性能优化。 (虽然有许多服务员试图决定然后撤销公司,但让服务员看到锁解锁可能非常罕见)。
真正的实现对于更多平台而言只有x86的特殊内容。对于x86,可能不仅仅是spinloop中的PAUSE
指令。这仍然是完全便携式C11实现的玩具示例。 PAUSE
显然有助于避免对内存排序的错误推测,因此CPU在离开自旋循环后运行效率更高。 pause
与将逻辑CPU交给操作系统以使其运行的其他线程不同。它也与memory_order_???
参数的正确性和选择无关。
真正的实现可能会在经过一定数量的旋转迭代(sched_yield(2)
或更可能是futex
系统调用后)将CPU放弃到操作系统,如下所示。也许使用x86 MONITOR
/ MWAIT
更加超线程友好;我不确定。我没有实现锁定自己的真实,我只是在查找其他insn时在x86 insn参考中看到所有这些内容。
如前所述,x86的lock xadd
指令实现fetch_add
(具有顺序一致性语义,因为lock
ed指令始终是完整的内存屏障。在非x86上,仅对fetch_add使用获取+释放语义,而不是完全顺序一致性可能允许更高效的代码。我不确定,但仅使用acquire
很可能会在ARM64上提供更高效的代码。我认为我们只需要acquire
on the fetch_add, not acq_rel,但我不确定。在x86上,代码没有任何区别,因为lock
ed指令是进行原子读 - 修改 - 写的唯一方法,因此即使relaxed
也与{{1}相同(compile-time reordering除外。)
如果你想要产生CPU而不是旋转,你需要一个系统调用(正如人们所说)。显然,在Linux上尽可能高效地进行标准库锁定已经付出了很多努力。有一些专用的系统调用可以帮助内核在发布锁定时唤醒正确的线程,并且它们不易于使用。 From futex(7)
:
NOTES
重申一下,裸futexes并不是最终用户易于使用的抽象。 (没有包装功能 系统调用 在glibc中。)实现者应该具备集成能力,并且已经阅读了futex用户空间库的来源 在下面引用。
正如维基百科文章所提到的,某种唤醒队列是一个好主意,因此同一个线程每次都不会持续获取信号量。 (释放后快速锁定的代码通常会释放释放线程,而其他线程仍然处于睡眠状态。)
这是流程中核心合作的另一个主要好处(seq_cst
)。