SpinLock也就是我们常说的自旋锁,其显著的特点就是“死等”,需要获取SpinLock的线程会一直主动地check能否获取得到锁,直到获取到锁后线程才会继续执行下面的逻辑,这把锁会一直被这个线程持有,直到线程自己主动释放。因此,如果我们的应用场景,线程在锁的获取上只会被阻塞非常短的一段时间(或者锁在获取后马上会被释放),那么SpinLock的使用可以减少CPU对于进程重新调度(rescheduling)和上下文切换的开销(context swithing),例如:OS kernels大量使用了SpinLock。反之,如果线程长久地持有SpinLock,那么SpinLock就会浪费大量的系统资源。因为,其它在在等待锁的线程,就会一直处于“spinnig”状态,相当于空跑CPU,白白地浪费CPU资源。
在实现SpinLock时,需要考虑到多线程对于lock本身并发访问的问题,因为这也会产生所谓的race condition。当前,可能是唯一比较好的实现方式是使用具有原子性的汇编语言指令集test-and-set。
test-and-set指令会向一块给定的内存写1,完成后返回这块内存写之前的值。这条CPU指令是具有原子性的,如果有多进程同时更新一块内存时,某一时刻,有且仅会有1条指令被允许在这块内存地址上进行操作。
因此,使用test-and-set指令实现SpinLock只需要下面一段简单的代码即可。当old value为0的时候获取到锁,否则会一直spin去试图加锁。
void Lock(boolean *lock) {
while (test_and_set(lock) == 1);
}
然而,如果程序所运行的CPU架构不支持test-and-set指令集,那么就需要使用high-level的编程语言来实现SpinLock。比如:并发控制算法(Peterson’s algorithm),信号量等。在这两种实现方式中,基于CPU指令test-and-set实现的SpinLock,效率更高。
在PostgreSQL中,有两种实现SpinLock的方式:1)CPU指令集test-and-set;2)使用PG信号量。使用test-and-set指令集实现的SpinLock在文件s_lock.h和s_lock.c中。而使用PG信号量实现的SpinLock在文件spin.c中。
test-and-set实现的加锁逻辑。代码如下:
int
s_lock(volatile slock_t *lock, const char *file, int line, const char *func)
{
SpinDelayStatus delayStatus;
// 初始化SpinLock的状态信息,如spin的次数等
init_spin_delay(&delayStatus, file, line, func);
while (TAS_SPIN(lock))
{
// spins,在cpu级别有一个delay时间,另外当spin次数大于100,
// 在此函数中会随机休眠1ms到1s
perform_spin_delay(&delayStatus);
}
// 获取锁后,根据delay的结果调整进入休眠的spin次数,如果,在获取锁的时候
// 没有休眠过,那么可以把进入休眠spin的次数调大。如果休眠过,表示锁竞争大,
// 就把进入休眠spin的次数降低,减少CPU消耗。
finish_spin_delay(&delayStatus);
return delayStatus.delays;
}
放锁逻辑,跟CPU硬件相关,包括编译器,因为,CPU和编译器可能会重新编排指令,产生乱序访问内存,因此在执行S_UNLOCK这个宏之前,必须保证在这个宏命令issue前load/store命令被先执行完了(当然TAS也是,只是对于上面的加锁函数s_lock来说是平台无关的,而里面的宏TAS_SPIN也需要保证issue在这个宏之后的load/store命令必须在这个宏之后执行,这样就能保证,对于临界区的访问被锁包住):
#define S_UNLOCK(lock) \
do { __memory_barrier(); *(lock) = 0; } while (0)
如果所运行的平台没有test-and-set指令,则使用PG信号量实现的SpinLock。PG中默认有128个信号量用于SpinLock(所以系统最大同时可用的SpinLock的数量为128),另外64个信号量用于atomic operation,因为原子操作的实现依赖于SpinLock,PG信号量实现的加锁逻辑如下:
int
tas_sema(volatile slock_t *lock)
{
int lockndx = *lock;
if (lockndx <= 0 || lockndx > NUM_SPINLOCK_SEMAPHORES)
elog(ERROR, "invalid spinlock number: %d", lockndx);
/* Note that TAS macros return 0 if *success* */
return !PGSemaphoreTryLock(SpinlockSemaArray[lockndx - 1]);
}
bool
PGSemaphoreTryLock(PGSemaphore sema)
{
DWORD ret;
ret = WaitForSingleObject(sema, 0);
if (ret == WAIT_OBJECT_0)
{
/* We got it! */
return true;
}
else if (ret == WAIT_TIMEOUT)
{
/* Can't get it */
errno = EAGAIN;
return false;
}
/* Otherwise we are in trouble */
ereport(FATAL,
(errmsg("could not try-lock semaphore: error code %lu",
GetLastError())));
/* keep compiler quiet */
return false;
}
其中WaitForSingleObject是win32系统函数,其作用相当于我们在操作系统课程上学过的信号量的知识中获得信号量操作,会将某个信号量的值减1,在SplinLock中,信号量的值初始化为1。所以,之后再有线程需要获得锁的时候,就会在这个函数中等待。而放锁的逻辑如下,也就是相当于会释放信号,将信号量+1,允许其它线程获得该信号量:
void
s_unlock_sema(volatile slock_t *lock)
{
int lockndx = *lock;
if (lockndx <= 0 || lockndx > NUM_SPINLOCK_SEMAPHORES)
elog(ERROR, "invalid spinlock number: %d", lockndx);
PGSemaphoreUnlock(SpinlockSemaArray[lockndx - 1]);
}
/*
* PGSemaphoreUnlock
*
* Unlock a semaphore (increment count)
*/
void
PGSemaphoreUnlock(PGSemaphore sema)
{
if (!ReleaseSemaphore(sema, 1, NULL))
ereport(FATAL,
(errmsg("could not unlock semaphore: error code %lu",
GetLastError())));
}
从上文的介绍上看,SpinLock不能用于需要长久持有锁的逻辑。因此,在PostgreSQL中,SpinLock主要用于对于临界变量的并发访问控制,所保护的临界区通常是简单的赋值语句,读取语句等。另外,在PG中,SpinLock没有等待队列、死锁检测机制,在事务结束之后不会自动释放,需要每次显式释放。
PostgreSQL中的SpinLock