PostgreSQL中的LWLock
上一篇文章介绍了PostgreSQL中的SpinLock,本文将介绍的LWLock是基于SpinLock实现的一种轻量级锁( Lightweight Lock)。
1. What is LWLock?
从PG 10.5的注释来看,LWLock主要提供对共享内存变量的互斥访问,比如Clog buffer(事务提交状态缓存)、Shared buffers(数据页缓存)、Substran buffer(子事务缓存)等等。
LWLock的数据结构定义如下:
typedef struct LWLock
{
uint16 tranche; /* tranche ID */
pg_atomic_uint32 state; /* state of exclusive/nonexclusive lockers */
proclist_head waiters; /* list of waiting PGPROCs */
#ifdef LOCK_DEBUG
pg_atomic_uint32 nwaiters; /* number of waiters */
struct PGPROC *owner; /* last exclusive owner of the lock */
#endif
} LWLock;
从代码上看,LWLock的互斥访问依赖于pg_atomic_uint32 state这个变量,这个变量是PG内部实现的原子变量,在上篇文章中也提过一句,PG中的原子操作也是依赖于SpinLock来实现的。tranche:相当于LWLock的id,唯一标记一个LWLock,主要用于查找某个LWLock,以观察其状态。waiters:记录等待获取LWLog的进程号。nwaiters:等待的进程数量,调试相关。owner:上一次获取LWLock的进程,调试相关。
我们知道,锁的应用场景是多并发的控制访问,也就是不同的进程/线程会并发地访问这把锁。当代的CPU架构对于访存采用的基本都是多级的构架:L1 Cache -> L2 Cache -> L3 Cache -> Memory -> 磁盘。其中访问L1 Cache速度最快,L2 Cache次之(数据不再L1中,去L2找,后面依次类推),后面的存储访问速度依次类推。关于CPU cache的更多信息,读者可以阅读这篇wiki。另外,不同的CPU拥有的自己本地的L1 Cache和L2 Cache,因此,当某个CPU上的线程更新了L1 Cache上的数据时,就需要在CPU之间进行通信,更新其它CPU上的Cache,否则就会出现“脏读”。
因此,考虑到当代计算机的这种访存体系结构,PG对LWLock的数据结构做了优化,如下述代码所示:
#define LWLOCK_PADDED_SIZE PG_CACHE_LINE_SIZE // 128
#define LWLOCK_MINIMAL_SIZE (sizeof(LWLock) <= 32 ? 32 : 64)
/* LWLock, padded to a full cache line size */
typedef union LWLockPadded
{
LWLock lock;
char pad[LWLOCK_PADDED_SIZE];
} LWLockPadded;
/* LWLock, minimally padded */
typedef union LWLockMinimallyPadded
{
LWLock lock;
char pad[LWLOCK_MINIMAL_SIZE];
} LWLockMinimallyPadded;
其实也没有什么高大上的优化==,就是在分配LWLock的时候,做了padding。其中,LWLockPadded保证LWLock的分配大小占满一个或者多个cache line,防止存在false sharing,而影响性能。现在PG中大部分情况下,分配LWLock时,采用的是LWLockPadded数据类型。LWLockMinimallyPadded(当前,只有shared buffer会使用此类型的LWLock),保证LWLock分配不会跨cache line,而只会在一个cache line上,因为如果跨两个cache line了,读取/更新的时候,就需要2个cache line,从而影响性能。
false sharing,是两个不同CPU上的线程t1 ,t2。其中t1改了本地cache中一个cache line上的一个数据x,而t2改了本地相同cache line上的另外一个数据y。而当t1需要访问这个cache line上的另一个数据z(也可以是x)的时候,根据当前CPU Cache失效的逻辑,需要重新从共享的内存中重新把这个cache line的数据load进来(这个过程是很慢的),而实际上这个cache line上的z并没有被任何其它线程修改过。
为什么保证LWLock不跨cache line是32或者64?因为LWLock的实际大小在16字节左右,而PG中认为Cache line大小为128字节,实际的cache line大小也为32字节/64字节/128字节,因此如果不padding,按照其本身大小对齐,可能会跨cache line的。而按32或者64字节对齐分配,则可以保证在一个cache line内。
2. Some details
在PG内部按模块(叫做named tranches)来划分LWLock,每个模块有自己独立的LWLock(一个或多个),对某个模块的共享变量的访问,则使用对应模块的LWLock。这样做的的好处:1,可以减少加锁的冲突,每个模块用自己的锁访问本模块的共享变量;2、便于追踪调试加锁的状态,这样便于开发和用户观察到那个模块加锁比较严重,从而优化代码逻辑或者业务逻辑。
查看锁等待命令:SELECT pid, wait_event_type, wait_event FROM pg_stat_activity; 其中wait_event_type的为WAIT_LWLOCK_NAMED和WAIT_LWLOCK_TRANCHE时,就是LWLock的加锁类型等待。其中WAIT_LWLOCK_NAMED为The backend is waiting for a specific named lightweight lock. Each such lock protects a particular data structure in shared memory.wait_eventwill contain the name of the lightweight lock.,一个ID只会有1个LWLock,共有45个,如ShmemIndexLock,OidGenLock等。所以在下面的子模块的LWLock中,起始ID从NUM_INDIVIDUAL_LWLOCKS(46)开始。LWLockTranche: The backend is waiting for one of a group of related lightweight locks. All locks in the group perform a similar function;wait_eventwill identify the general purpose of locks in that group. 这种ID对应的Lock通常有多个。
目前LWLock的加锁子模块有:
/*
* Every tranche ID less than NUM_INDIVIDUAL_LWLOCKS is reserved; also,
* we reserve additional tranche IDs for builtin tranches not included in
* the set of individual LWLocks. A call to LWLockNewTrancheId will never
* return a value less than LWTRANCHE_FIRST_USER_DEFINED.
*/
typedef enum BuiltinTrancheIds
{
LWTRANCHE_CLOG_BUFFERS = NUM_INDIVIDUAL_LWLOCKS,
LWTRANCHE_COMMITTS_BUFFERS,
LWTRANCHE_SUBTRANS_BUFFERS,
LWTRANCHE_MXACTOFFSET_BUFFERS,
LWTRANCHE_MXACTMEMBER_BUFFERS,
LWTRANCHE_ASYNC_BUFFERS,
LWTRANCHE_OLDSERXID_BUFFERS,
LWTRANCHE_WAL_INSERT,
LWTRANCHE_BUFFER_CONTENT,
LWTRANCHE_BUFFER_IO_IN_PROGRESS,
LWTRANCHE_REPLICATION_ORIGIN,
LWTRANCHE_REPLICATION_SLOT_IO_IN_PROGRESS,
LWTRANCHE_PROC,
LWTRANCHE_BUFFER_MAPPING,
LWTRANCHE_LOCK_MANAGER,
LWTRANCHE_PREDICATE_LOCK_MANAGER,
LWTRANCHE_PARALLEL_QUERY_DSA,
LWTRANCHE_TBM,
LWTRANCHE_FIRST_USER_DEFINED
} BuiltinTrancheIds;
2.1 LWLock的初始化
在PG初始化shared memory和信号量时,会初始化LWLock array(CreateLWLocks)。具体做的事情就是:1. 算出LWLock需要占用的shared memory的内存空间:算出固定的和每个子模块(requested named tranches)LWLock的个数(固定在系统初始化的时候,就需要分配的LWLock有:buffer_mapping,lock_manager,predicate_lock_manager,parallel_query_dsa,tbm),每个LWLock的大小(LWLOCK_PADDED_SIZE+counter,couter为锁的计算器,用于记录有多少个加了share锁),子模块的信息占用大小。2. 分配内存空间,与cache line对齐。3. 依次对每个LWLock调用LWLockInitialize进行初始化,将LWLock的状态置为LW_FLAG_RELEASE_OK。4. 使用LWLockRegisterTranche函数注册所有的初始化了LWLock的子模块,包括系统预先定义(BuiltinTrancheIds)的和用户自定义的。
用户自定义的LWLock,需要使用 RequestNamedLWLockTranche函数,目前PG10.5的代码中,自定义了pg_stat_statements模块的LWLock。该模块用于监控SQL的执行时的统计信息。自定义的LWLock一般用于PG extension,需要在_PG_init函数中调用RequestNamedLWLockTranche,否则一旦shared memory分配完毕(上面的LWLock化阶段肯定也执行完毕了),那么自定义的锁将不会被分配出来。
/*
* LWLockInitialize - initialize a new lwlock; it's initially unlocked
*/
void
LWLockInitialize(LWLock *lock, int tranche_id)
{
pg_atomic_init_u32(&lock->state, LW_FLAG_RELEASE_OK);
#ifdef LOCK_DEBUG
pg_atomic_init_u32(&lock->nwaiters, 0);
#endif
lock->tranche = tranche_id;
proclist_init(&lock->waiters);
}
/*
* Register a tranche ID in the lookup table for the current process. This
* routine will save a pointer to the tranche name passed as an argument,
* so the name should be allocated in a backend-lifetime context
* (TopMemoryContext, static variable, or similar).
*/
void
LWLockRegisterTranche(int tranche_id, char *tranche_name)
{
Assert(LWLockTrancheArray != NULL);
if (tranche_id >= LWLockTranchesAllocated)
{
int i = LWLockTranchesAllocated;
int j = LWLockTranchesAllocated;
while (i <= tranche_id)
i *= 2;
LWLockTrancheArray = (char **)
repalloc(LWLockTrancheArray, i * sizeof(char *));
LWLockTranchesAllocated = i;
while (j < LWLockTranchesAllocated)
LWLockTrancheArray[j++] = NULL;
}
LWLockTrancheArray[tranche_id] = tranche_name;
}
/*
* RequestNamedLWLockTranche
* Request that extra LWLocks be allocated during postmaster
* startup.
*
* This is only useful for extensions if called from the _PG_init hook
* of a library that is loaded into the postmaster via
* shared_preload_libraries. Once shared memory has been allocated, calls
* will be ignored. (We could raise an error, but it seems better to make
* it a no-op, so that libraries containing such calls can be reloaded if
* needed.)
*/
void
RequestNamedLWLockTranche(const char *tranche_name, int num_lwlocks)
{
NamedLWLockTrancheRequest *request;
if (IsUnderPostmaster || !lock_named_request_allowed)
return; /* too late */
if (NamedLWLockTrancheRequestArray == NULL)
{
NamedLWLockTrancheRequestsAllocated = 16;
NamedLWLockTrancheRequestArray = (NamedLWLockTrancheRequest *)
MemoryContextAlloc(TopMemoryContext,
NamedLWLockTrancheRequestsAllocated
* sizeof(NamedLWLockTrancheRequest));
}
if (NamedLWLockTrancheRequests >= NamedLWLockTrancheRequestsAllocated)
{
int i = NamedLWLockTrancheRequestsAllocated;
while (i <= NamedLWLockTrancheRequests)
i *= 2;
NamedLWLockTrancheRequestArray = (NamedLWLockTrancheRequest *)
repalloc(NamedLWLockTrancheRequestArray,
i * sizeof(NamedLWLockTrancheRequest));
NamedLWLockTrancheRequestsAllocated = i;
}
request = &NamedLWLockTrancheRequestArray[NamedLWLockTrancheRequests];
Assert(strlen(tranche_name) + 1 < NAMEDATALEN);
StrNCpy(request->tranche_name, tranche_name, NAMEDATALEN);
request->num_lwlocks = num_lwlocks;
NamedLWLockTrancheRequests++;
}
2.2 LWLock的使用
LWLock的使用跟其它锁一样,主要分为加锁,放锁和等锁三个行为。
加锁:使用LWLockAcquire(LWLock *lock, LWLockMode mode)来进行加锁,其中mode可以为LW_SHARED(共享)和LW_EXCLUSIVE(排他)。加锁时,首先把需要加的锁放入等待队列,然后通过LWLock中的state状态判断是否可以加锁成功,如果可以加锁成功,使用原子操作campare and set来修改LWLock的状态,把锁从等待队列中删除。否则,需要等锁。还可以使用LWLockConditionalAcquire(LWLock *lock, LWLockMode mode)来获取锁,与LWLockAcquire不同的是,如果获取不到直接返回,不会休眠等待。LWLockAcquireOrWait函数,如果加锁不成功,会一直等待,但是如果锁变成了free之后,不会再加锁而是直接返回;当前这个函数在WALWriteLock中被使用,当一个backend需要flush WAL时,会加上WALWriteLock,然后会顺带把其它backend产生的WAL也flush了,因此,其它等锁去flush WAL的backend其实也并不需要再去flush WAL了。
放锁:使用LWLockRelease(LWLock *lock)来释放给定的一把锁。从当前proc的获取的LWLockRelease(LWLock *lock)LWLock中找到给定的锁,判断这把锁是否有其它进程在等待,如果是,则调用LWLockWakeup来唤醒等待这把锁的backend。
等锁:等锁逻辑也在LWLockAcquire函数中。当没有加上锁时,会等待一个信号量proc->sem(此时会休眠,不会消耗CPU),由于该信号量regular lock manager and ProcWaitForSignal都会使用到,当在此时获取到信号量时,并不一定是LWLockRelease发出来的,因此,如果不是LWLockRelease发出来的则需要在等信号量,如果是,则重新进行上述的加锁。
锁的mode除了LW_SHARED和LW_EXCLUSIVE外,还有一种模式为LW_WAIT_UNTIL_FREE,此模式仅通过LWLockWaitForVar(LWLock *lock, uint64 *valptr, uint64 oldval, uint64 *newval)
来使用,其场景是:如果锁被其它backend持有(模式需为LW_EXCLUSIVE。若为LW_SHARED模式不会阻塞,直接返回),那么会等待对应的backend释放锁,或者持有该锁的backend通过LWLockUpdateVar函数(不会释放锁)更新了valptr(如果更新了valptr,会将这个值赋给newval)。在PG10.5中,LWLockWaitForVar仅被使用在了XLog的插入中:在flush WAL到磁盘时,需要调用WaitXLogInsertionsToFinish将某个点的XLOG flush磁盘,此时要等待当前可能正在进行的insertion完成,因此,在这里面会调用LWLockWaitForVar,如果锁处于free状态,LWLockWaitForVar返回true,那么表示没有等锁即没有insert在进行,所以insert的位置也没有变,将上次Xlog写入的位置取出来即可。如果锁在exclusive状态,说明有backend正在进行插入,LWLockWaitForVar会或取得xlog插入后新的位置,然后返回。
3. LWLock和SpinLock比较
从上面的介绍中可以看出,LWLock中使用了原子操作(基于SpinLock实现)来进行互斥访问,因此PG中的LWLock是基于SpinLock来实现的。
LWLock提供了share和exclusive两种模式,而SpinLock只有一种模式,那就是exclusive。显然,如果某个变量需要在share模式下频繁被访问,那么使用LWLock是更好的选择。
LWLock是wait-free的,也就是说LWLock当需要等锁的时候基本不会消耗CPU资源,为此,LWLock实现了一个等待队列可以减少判断状态的原子操作,从而降低了原子操作时产生的竞争开销。而SpinLock在等锁状态时,会消耗大量的CPU资源。
基于第2点,LWLock可以应用于会被较长时间锁定的共享变量上,而SpinLock锁操作的变量,一定需要非常短的,否则会造成很大的开销。
参考资料
https://www.postgresql.org/docs/9.6/monitoring-stats.html