并发一定要处理同步(synchronization)这个问题,而很多同步问题,都要靠锁来解决,所以锁的策略(锁什么、怎么锁)和锁是否高效(用什么锁)会很大程度上影响并发的能力。
比较常见的锁是 mutex,如果在尝试获取资源的时候,发现锁已经被占用,线程会 sleep,让其他线程执行,等锁被释放后,才有可能继续执行。
所以,当 mutex 拿不到锁的时候,线程会 sleep,之后被 wakeup,而这些都是相对比较耗时操作。
想要避免这些的话,可以使用自旋锁 (spinlock)。
自旋锁 (spinlock) 的思路是,如果锁被锁住,就让 CPU 空转一段时间,等待锁的释放。以此来避免线程切换。
在实现上,会使用一个变量表示资源是否被占用,如果这个变量是 0,表示锁是开着的,1 表示被锁了。
使用 compare_and_set
获取并设置这个变量,compare_and_set
有三个参数,变量,预期的值,要设置的值。
如果设置的时候,预期的值和变量的实际值不一样,设置变量会失败,返回 false。compare_and_set
一般会由硬件保证原子性。
如果锁是锁住的,自旋锁就会忙等 (busy wait) 一段时间,再次尝试获得锁。
自旋锁除了可以更快速获取资源外,还可以保持当前线程一直处在执行状态。
比如 Erlang scheduler 没有任务的时候,会调用 spinlock 忙等,避免 scheduler 的线程被挂起,从而获得更好的并发性能。
自旋锁一般适用于资源很快被释放的情况,因为 CPU 在等待过程中,一直在空转。如果一个资源会被占用很长时间,使用自旋锁明显比较浪费资源,这个时候 mutex 是更好的选择。
如果很多个线程同时竞争一个锁,冲突的可能就会变大,则可以使用指数退避算法来避免这个问题。
下面来看一下 nginx 的实现(去掉了一些不相关的代码)。
void
ngx_spinlock(ngx_atomic_t *lock, ngx_atomic_int_t value, ngx_uint_t spin)
{
ngx_uint_t i, n;
for ( ;; ) {
if (*lock == 0 && ngx_atomic_cmp_set(lock, 0, value)) {
return;
}
if (ngx_ncpu > 1) {
for (n = 1; n < spin; n <<= 1) {
for (i = 0; i < n; i++) {
ngx_cpu_pause();
}
if (*lock == 0 && ngx_atomic_cmp_set(lock, 0, value)) {
return;
}
}
}
ngx_sched_yield();
}
}
首先,如果只有一个 CPU core 的话,直接调研 ngx_sched_yield,ngx_sched_yield 会调用 sched_yield 让出 CPU。也就是说单核 CPU 完全不需要自旋锁(这个应该就不需要解释了)。
之后 n 会随指数增长 n <<= 1
,每次循环都会调用 ngx_cpu_pause
,循环执行完之后,会调用 ngx_atomic_cmp_set 尝试获取锁。
ngx_cpu_pause
的实现是,如果 CPU 支持 pause
指令的话,会调用 pause
指令,如果不支持的话会使用 nop
。
ngx_spinlock(&ngx_thread_pool_done_lock, 1, 2048);
*ngx_thread_pool_done.last = task;
ngx_thread_pool_done.last = &task->next;
ngx_memory_barrier();
ngx_unlock(&ngx_thread_pool_done_lock);
关于锁还有很多有趣的问题可以研究,比如 pthread mutex 为了性能,其实是会 user mode spin 一段时间的。所以多数的情况下,使用 mutex 是更合适的。 再比如 mutex 和 spinlock 的性能对比。再比如 queuing lock 可以更进一步优化新能。本文主要简单介绍自旋锁以及其一种实现,所以并不展开过多话题。