小布源码分析札记-06 :PostgreSQL的自旋锁简介

xiaobu 7天前 40

自旋锁(spinlock),是操作系统和数据库的重要基石。可以说:没有自旋锁,就没有操作系统和数据库软件。自旋锁是PG其它类型的锁的基础。其它类型的锁都是用共享内存加自旋锁组成的。在本文中,我们对PG中的自旋锁机制做一个入门介绍。

 

英文单词spin的含义是turn or cause to turn or whirl around quickly,中文的意思就是“快速旋转”的意思。这个单词很容易让我们想到小时候打的陀螺。你用一个鞭子不停地抽打陀螺,它就会在地面或者冰面上快速旋转。反复地做一件事情,就是自旋锁的基本特征。

我们想象一下自己坐飞机的经历。当你内急,就会去上卫生间。但是飞机上的卫生间只有一个,所以就存在一个协调如何使用的问题。当你看到卫生间门上有一个标识牌,其上写着"Occupied",你就知道有人在使用卫生间,你能做的事情就是在门口等待,或者离开一会,再回来检查那个标识牌的文字是否变成了“vacancy",即”空闲“的意思。如果没有,你就会重复上面的动作,离开一会,再次检查标识牌。你的行为就是一个自旋锁的行为。

 

所谓自旋锁,就是一个共享内存单元,一个字节就足够了,它的值为0,表示某个共享资源R没有别的进程在使用,如果为1,则表示R正在被别的进程使用中。所以自旋锁L和共享资源R都是共享内存中的某些字节,L用来保护R,使得对R的访问是串行执行的,类似我们使用飞机上的卫生间也是先来后到,一次只能一个人使用卫生间。由于L的值只需要0和1,分别表示”空闲“和”占用“,所以L一般只要一个字节即可,考虑到对齐的原因,可以扩展到4字节或者8字节。R的大小就根据需要来定,没有什么限定。

 

当一个进程A想访问R,主要是往R中写入一些信息,再次提醒,R是一段共享内存,A会首先检查L中的值是多少。如果L=0,表示没有别的进程访问R,则A把L设置为1,表示我占用了,俗称“加锁”,然后A就可以放心大胆地访问R了。访问结束后,A再把L的值设为0,俗称“释放锁”。如果A发现L的值为1,表明另外一个进程B正在往R中写数据,A就就陷入一个无限循环,反复检查L的值什么时候变成0。B在访问R结束后,也会让L=0,表示释放自旋锁L。A的这种无限循环,反复检查L的值啥时候从1变成0的行为,就类似一个陀螺在快速旋转,这就是“自旋锁”名称的来历。

 

从上面的描述可以看出,如果B使用R很长时间,A就会陷入疯狂的循环中,白白浪费大量CPU资源,所以自旋锁一般适合对共享资源R进行快速访问的场景。譬如B仅仅是修改R中的少量字节。我们会在PG的源代码中看到类似如下的代码:

/*
 * Update the WalWriterSleeping flag.
 */
void
SetWalWriterSleeping(bool sleeping)
{
	SpinLockAcquire(&XLogCtl->info_lck);
	XLogCtl->WalWriterSleeping = sleeping;
	SpinLockRelease(&XLogCtl->info_lck);
}

函数SetWalWriterSleeping()的逻辑就是设置共享内存XLogCtl->WalWriterSleeping的值,这个操作非常快。这种场景就非常适合自旋锁。所以任何进程想修改这个值时,先调用SpinLockAcquire()获得自旋锁XLogCtl->info_lck,操作完毕后,再调用SpinLockRelease()释放该自旋锁。

在上述代码中,自旋锁L就是XLogCtl->info_lck,而被保护的共享资源R就是XLogCtl->WalWriterSleeping。访问共享资源的模式就是三部曲:加锁,修改,释放锁。上述代码就是这种模式的很好的范例。

 

很自然,当一个进程执行SpinLockAcquire()时,主要的工作就是检查info_lck的值是否为0,如果是,则就执行下面的操作。如果不是,该进程就会陷入疯狂的循环中,即“自旋”状态。当然,因为对共享资源的访问操作速度极快,所以一个进程陷入疯狂自旋的几率是极小的。SpinLockRelease()函数的逻辑自然是让info_lck的值变成0。

 

关于SpinLockAcquire()和SpinLockRelease()的细节,我们在后面的文章中进行剖析。

 

最新回复 (1)
  • xiaobu 7天前
    引用 2

    自旋锁的技术要点


    从我们对自旋锁的原理学习中,我们发现,一个进程A获取一个自旋锁L的函数SpinLockAcquire()的逻辑应该是这样的:

    trygain:
        if(L == 1) goto trygain;
        L = 1;

    在上述代码中,if语句是测试TEST,L=1是设置SET。上面的逻辑就是先TEST,然后再SET,简称TAS(Test And Set)。上述代码在逻辑上是没问题的,但是有一个致命的缺陷,即TEST和SET是两条指令。我们知道计算机的指令流可以在任何时候被中断(interrupt),如果TEST和SET是两条指令,则中断可能会发生在这两条指令之间,即TEST和SET不是一个原子性操作。所谓原子性操作就是指不可能被中断的操作。

    在上述代码中,当if语句检测到L为0时,就放心大胆地执行下一个操作L=1,但是此时另外一个进程可能捷足先登,已经把L设置为1了。这种场景,本进程是无法察觉的,它以为没有人抢占L呢,实际上已经有另外进程仅仅比它快一点点,就抢占了自旋锁L。这种情况无疑会导致混乱。

    在计算机世界有一个常识,即任何一条指令都是原子性操作。这意味着任何一条指令不可能被外部中断,中断只可能发生在两条指令执行序列的中间。所以我们必须把TEST和SET变成一条不可能被中断的指令TAS。硬件支持的TAS指令是提供高性能自旋锁的基石。幸运的是目前主流的CPU硬件都只提供TAS指令。在常用的X64平台下,典型的TAS指令是XCHG。

     

    XCHG指令后面跟着两个参数,R和M,所以一条完整的XCHG指令的格式是:

    XCHG R M

    R是CPU内部的寄存器,M表示一个内存单元。XCHG指令是原子性,不可中断地把R中的值和M中的值进行互换。为什么这条指令就是TAS呢?其实这个不难理解。

    假设我们先在R中存放了一个值1,然后执行XCHG R M,这条指令执行完毕后,M中的值被设置为了R中的值,即1,这就是SET操作。同时,M中的值被读进了R中。在XCHG指令后面,我们紧接着判断R中的值是否为1,这个就是TEST。但是这个TEST只是检查一下而已,M的值已经在XCHG指令执行后被拷贝到了R中,所以TEST和SET是一条指令完成的,不可能被中断。

    ARM平台也提供了类似XCHG的指令,其原理都是一样的。关于TAS,大家可以搜索TAS Wiki,获得更完整和严谨的论述。

    https://en.wikipedia.org/wiki/Test-and-set

     

    结论:

    硬件平台(CPU)提供的TAS指令是实现高性能自旋锁的基石。自旋锁是操作系统和数据库等基础性系统软件的基石。

     

返回
发新帖