PostgreSQL Locks Tables Fast

Most lock conflicts in PostgreSQL never reach the heavyweight lock table: a fast path in shared memory handles the common cases with a single atomic word, and the lock table itself is partitioned into sixteen independently-locked hash partitions.

Ask PostgreSQL how many locks it takes and the honest answer is “it depends, and the interesting part is which lock you’re talking about”. The src/backend/storage/lmgr/README file opens with four kinds of interprocess lock — spinlocks, lightweight locks, regular (heavyweight) locks, and predicate locks — and the heavyweight one is itself split in two: a shared hash table, and a per-backend fast path.

Every SELECT on a table takes an AccessShareLock. Every INSERT takes a RowExclusiveLock. Every ALTER TABLE takes an AccessExclusiveLock and then waits for every other session to go away. If all of that went through one lock table protected by one mutex, a thousand concurrent readers would be a thousand processes fighting over one cache line, and the lock manager would be the thing that limits your database’s concurrency.

It isn’t, because most of those locks never touch the lock table at all.

There are really two managers here:

Heavyweight lock managerFast path
Where it livesshared hash tableper-backend slice of PGPROC
Protected byan LWLock, 16-way partitionedproc->fpInfoLock
Lock modesall 83 (weak relation locks)
Object typeseverythingrelations in the current database
Deadlock detectionyesno (but also no waiting)
Costhash lookup + LWLocka few instructions

The fast path exists for the overwhelmingly common case: a lock that nobody will ever wait for. If you already hold AccessShareLock on a table and you ask for AccessShareLock again, or if you take RowExclusiveLock on a table nobody is altering, you do not need a heavyweight lock. You need a counter.

This is a walk through how it all works, based on reading the PostgreSQL source (version 20devel, commit e75fcc9867f).

The lock tag: sixteen bytes that name anything

Everything in the lock manager is keyed by a LOCKTAG. It lives in src/include/storage/locktag.h (it used to live in lock.h, and was split out), and it is designed with malice aforethought:

c
/*
 * The LOCKTAG struct is defined with malice aforethought to fit into 16
 * bytes with no padding.  Note that this would need adjustment if we were
 * to widen Oid, BlockNumber, or TransactionId to more than 32 bits.
 *
 * We include lockmethodid in the locktag so that a single hash table in
 * shared memory can store locks of different lockmethods.
 */
typedef struct LOCKTAG
{
	uint32		locktag_field1; /* a 32-bit ID field */
	uint32		locktag_field2; /* a 32-bit ID field */
	uint32		locktag_field3; /* a 32-bit ID field */
	uint16		locktag_field4; /* a 16-bit ID field */
	uint8		locktag_type;	/* see enum LockTagType */
	uint8		locktag_lockmethodid;	/* lockmethod indicator */
} LOCKTAG;

Four bytes, four bytes, four bytes, two bytes, one byte, one byte: 16 bytes, no padding. The “one field per byte” framing in the field names is deliberately generic — the struct doesn’t know what it’s identifying, only how many bits each component needs. The interpretation is fixed by locktag_type:

c
typedef enum LockTagType
{
	LOCKTAG_RELATION,			/* whole relation */
	LOCKTAG_RELATION_EXTEND,	/* the right to extend a relation */
	LOCKTAG_DATABASE_FROZEN_IDS,	/* pg_database.datfrozenxid */
	LOCKTAG_PAGE,				/* one page of a relation */
	LOCKTAG_TUPLE,				/* one physical tuple */
	LOCKTAG_TRANSACTION,		/* transaction (for waiting for xact done) */
	LOCKTAG_VIRTUALTRANSACTION, /* virtual transaction (ditto) */
	LOCKTAG_SPECULATIVE_TOKEN,	/* speculative insertion Xid and token */
	LOCKTAG_OBJECT,				/* non-relation database object */
	LOCKTAG_USERLOCK,			/* reserved for old contrib/userlock code */
	LOCKTAG_ADVISORY,			/* advisory user locks */
	LOCKTAG_APPLY_TRANSACTION,	/* transaction being applied on a logical
								 * replication subscriber */
} LockTagType;

Twelve kinds of lockable object. LOCKTAG_RELATION is the one you care about most of the time; LOCKTAG_TUPLE is the one that sounds scary but isn’t — tuple locks are a serialization fiction (the “locked tuple” flag on the tuple header), not entries in the lock table.

The comment about lockmethodid is the other half of the design. There are two lock methods — DEFAULT_LOCKMETHOD and USER_LOCKMETHOD — and they have identical mode semantics today but are conceptually separate. By putting the method id in the tag, one shared hash table can hold both, and the code can look up the right semantics table from the tag alone.

Filling in the tag

Because the fields are generic, there are macros for every kind of tag:

c
/* ID info for a relation is DB OID + REL OID; DB OID = 0 if shared */
#define SET_LOCKTAG_RELATION(locktag,dboid,reloid) \
	((locktag).locktag_field1 = (dboid), \
	 (locktag).locktag_field2 = (reloid), \
	 (locktag).locktag_field3 = 0, \
	 (locktag).locktag_field4 = 0, \
	 (locktag).locktag_type = LOCKTAG_RELATION, \
	 (locktag).locktag_lockmethodid = DEFAULT_LOCKMETHOD)

Note the first field: the database OID is part of every relation tag. That is what makes a lock on mytable in db1 and a lock on mytable in db2 different locks, without any special-casing in the hash table.

SET_LOCKTAG_ADVISORY() shows the other pattern — the fields are just whatever the caller wants them to be:

c
#define SET_LOCKTAG_ADVISORY(locktag,id1,id2,id3,id4) \
	((locktag).locktag_field1 = (id1), \
	 (locktag).locktag_field2 = (id2), \
	 (locktag).locktag_field3 = (id3), \
	 (locktag).locktag_field4 = (id4), \
	 (locktag).locktag_type = LOCKTAG_ADVISORY, \
	 (locktag).locktag_lockmethodid = USER_LOCKMETHOD)

and src/backend/utils/adt/lockfuncs.c uses it for pg_advisory_lock(), with a comment describing the packing:

c
/*
 * Functions for manipulating advisory locks
 *
 * We make use of the locktag fields as follows:
 *
 *	field1: MyDatabaseId ... ensures locks are local to each database
 *	field2: first of 2 int4 keys, or high-order half of an int8 key
 *	field3: second of 2 int4 keys, or low-order half of an int8 key
 *	field4: 1 if using an int8 key, 2 if using 2 int4 keys
 */
#define SET_LOCKTAG_INT64(tag, key64) \
	SET_LOCKTAG_ADVISORY(tag, \
						 MyDatabaseId, \
						 (uint32) ((key64) >> 32), \
						 (uint32) (key64), \
						 1)

This is the SQL surface of the lock manager. lockfuncs.c is a thin layer: pg_lock_status() (the pg_locks view), pg_blocking_pids(), and the advisory-lock functions. The machinery itself is all in lock.c.

The heavyweight lock table

The shared lock table is two hash tables, both partitioned.

The first is keyed by LOCKTAG and holds one LOCK struct per lockable object that currently has locks on it (src/include/storage/lock.h):

c
typedef struct LOCK
{
	/* hash key */
	LOCKTAG		tag;			/* unique identifier of lockable object */

	/* data */
	LOCKMASK	grantMask;		/* bitmask for lock types already granted */
	LOCKMASK	waitMask;		/* bitmask for lock types awaited */
	dlist_head	procLocks;		/* list of PROCLOCK objects assoc. with lock */
	dclist_head waitProcs;		/* list of PGPROC objects waiting on lock */
	int			requested[MAX_LOCKMODES];	/* counts of requested locks */
	int			nRequested;		/* total of requested[] array */
	int			granted[MAX_LOCKMODES]; /* counts of granted locks */
	int			nGranted;		/* total of granted locks */
} LOCK;

The LOCK object is the aggregation of all holders and waiters for one object. grantMask is a bitmask of which modes are held by somebody; waitMask is which modes somebody is waiting for. Those two bitmasks are what make the common case cheap: a conflict check is conflictTab[lockmode] & lock->grantMask, one AND.

The second table is keyed by PROCLOCKTAG — the pair (LOCK, PGPROC) — and holds the per-holder detail:

c
typedef struct PROCLOCKTAG
{
	/* NB: we assume this struct contains no padding! */
	LOCK	   *myLock;			/* link to per-lockable-object information */
	PGPROC	   *myProc;			/* link to PGPROC of owning backend */
} PROCLOCKTAG;

typedef struct PROCLOCK
{
	/* tag */
	PROCLOCKTAG tag;			/* unique identifier of proclock object */

	/* data */
	PGPROC	   *groupLeader;	/* proc's lock group leader, or proc itself */
	LOCKMASK	holdMask;		/* bitmask for lock types currently held */
	LOCKMASK	releaseMask;	/* bitmask for lock types to be released */
	dlist_node	lockLink;		/* list link in LOCK's list of proclocks */
	dlist_node	procLink;		/* list link in PGPROC's list of proclocks */
} PROCLOCK;

So: one LOCK per object, one PROCLOCK per (object, backend). A table locked by fifty sessions has one LOCK and fifty PROCLOCKs.

Partitioning

The two hash tables are created with a partition count:

c
	max_table_size = NLOCKENTS();

	/*
	 * Hash table for LOCK structs.  This stores per-locked-object
	 * information.
	 */
	ShmemRequestHash(.name = "LOCK hash",
					 .nelems = max_table_size,
					 .ptr = &LockMethodLockHash,
					 .hash_info.keysize = sizeof(LOCKTAG),
					 .hash_info.entrysize = sizeof(LOCK),
					 .hash_info.num_partitions = NUM_LOCK_PARTITIONS,
					 .hash_flags = HASH_ELEM | HASH_BLOBS | HASH_PARTITION,
	);

	/* Assume an average of 2 holders per lock */
	max_table_size *= 2;

	ShmemRequestHash(.name = "PROCLOCK hash",
					 .nelems = max_table_size,
					 .ptr = &LockMethodProcLockHash,
					 .hash_info.keysize = sizeof(PROCLOCKTAG),
					 .hash_info.entrysize = sizeof(PROCLOCK),
					 .hash_info.hash = proclock_hash,
					 .hash_info.num_partitions = NUM_LOCK_PARTITIONS,
					 .hash_flags = HASH_ELEM | HASH_FUNCTION | HASH_PARTITION,
	);

and NUM_LOCK_PARTITIONS is 16:

c
/* Number of partitions the shared lock tables are divided into */
#define LOG2_NUM_LOCK_PARTITIONS  4
#define NUM_LOCK_PARTITIONS  (1 << LOG2_NUM_LOCK_PARTITIONS)

Each partition has its own LWLock, and the partition is chosen by the hash code:

c
/*
 * The lockmgr's shared hash tables are partitioned to reduce contention.
 * To determine which partition a given locktag belongs to, compute the tag's
 * hash code with LockTagHashCode(), then apply one of these macros.
 * NB: NUM_LOCK_PARTITIONS must be a power of 2!
 */
#define LockHashPartition(hashcode) \
	((hashcode) % NUM_LOCK_PARTITIONS)
#define LockHashPartitionLock(hashcode) \
	(&MainLWLockArray[LOCK_MANAGER_LWLOCK_OFFSET + \
		LockHashPartition(hashcode)].lock)

So: lock contention is spread over sixteen independent LWLocks. Two sessions locking unrelated tables have a 15-in-16 chance of not contending on the partition lock at all. This is the mechanism behind “PostgreSQL scales well to many concurrent sessions” for lock-heavy workloads.

The hash code itself comes from the hash table’s own hash function:

c
/*
 * Compute the hash code associated with a LOCKTAG.
 *
 * To avoid unnecessary recomputations of the hash code, we try to do this
 * just once per function, and then pass it around as needed.  Aside from
 * passing the hashcode to hash_search_with_hash_value(), we can extract
 * the lock partition number from the hashcode.
 */
uint32
LockTagHashCode(const LOCKTAG *locktag)
{
	return get_hash_value(LockMethodLockHash, locktag);
}

The PROCLOCK table uses a different hash function, proclock_hash(), and the reason is a lovely bit of engineering: the proclock table must use the same partition locks as the LOCK table.

c
/*
 * Compute the hash code associated with a PROCLOCKTAG.
 *
 * Because we want to use just one set of partition locks for both the
 * LOCK and PROCLOCK hash tables, we have to make sure that PROCLOCKs
 * fall into the same partition number as their associated LOCKs.
 */

Both tables are looked up while holding a single partition lock, so one LWLockAcquire covers both.

The per-backend local lock table

Every backend also keeps a local, non-shared hash table of the locks it holds. src/include/storage/lock.h explains why:

c
/*
 * Each backend also maintains a local hash table with information about each
 * lock it is currently interested in.  In particular the local table counts
 * the number of times that lock has been acquired.  This allows multiple
 * requests for the same lock to be executed without additional accesses to
 * shared memory.  We also track the number of lock acquisitions per
 * ResourceOwner, so that we can release just those locks belonging to a
 * particular ResourceOwner.
 *
 * When holding a lock taken "normally", the lock and proclock fields always
 * point to the associated objects in shared memory.  However, if we acquired
 * the lock via the fast-path mechanism, the lock and proclock fields are set
 * to NULL, since there probably aren't any such objects in shared memory.
 * (If the lock later gets promoted to normal representation, we may eventually
 * update our locallock's lock/proclock fields after finding the shared
 * objects.)
 *
 * Caution: a locallock object can be left over from a failed lock acquisition
 * attempt.  In this case its lock/proclock fields are untrustworthy, since
 * the shared lock object is neither held nor awaited, and hence is available
 * to be reclaimed.  If nLocks > 0 then these pointers must either be valid or
 * NULL, but when nLocks == 0 they should be considered garbage.
 */

The struct:

c
typedef struct LOCALLOCK
{
	/* tag */
	LOCALLOCKTAG tag;			/* unique identifier of locallock entry */

	/* data */
	uint32		hashcode;		/* copy of LOCKTAG's hash value */
	LOCK	   *lock;			/* associated LOCK object, if any */
	PROCLOCK   *proclock;		/* associated PROCLOCK object, if any */
	int64		nLocks;			/* total number of times lock is held */
	int			numLockOwners;	/* # of relevant ResourceOwners */
	int			maxLockOwners;	/* allocated size of array */
	LOCALLOCKOWNER *lockOwners; /* dynamically resizable array */
	bool		holdsStrongLockCount;	/* bumped FastPathStrongRelationLocks */
	bool		lockCleared;	/* we read all sinval msgs for lock */
} LOCALLOCK;

Note that the local table is keyed by tag and mode:

c
typedef struct LOCALLOCKTAG
{
	LOCKTAG		lock;			/* identifies the lockable object */
	LOCKMODE	mode;			/* lock mode for this table entry */
} LOCALLOCKTAG;

which is how a backend can hold AccessShareLock and RowExclusiveLock on the same table as two separate entries with separate counts.

It’s created in InitLockManagerAccess():

c
	info.keysize = sizeof(LOCALLOCKTAG);
	info.entrysize = sizeof(LOCALLOCK);

	LockMethodLocalHash = hash_create("LOCALLOCK hash",
									  16,
									  &info,
									  HASH_ELEM | HASH_BLOBS);

The payoff is in LockAcquireExtended(): the very first thing it does after finding the locallock is check whether the lock is already held, and if so, bump a counter and return without touching shared memory at all:

c
	/*
	 * If we already hold the lock, we can just increase the count locally.
	 *
	 * If lockCleared is already set, caller need not worry about absorbing
	 * sinval messages related to the lock's object.
	 */
	if (locallock->nLocks > 0)
	{
		GrantLockLocal(locallock, owner);
		if (locallock->lockCleared)
			return LOCKACQUIRE_ALREADY_CLEAR;
		else
			return LOCKACQUIRE_ALREADY_HELD;
	}

This is the single most important optimization in the whole lock manager for read-heavy workloads. A query that opens a cursor and reads a thousand rows takes AccessShareLock once, but the executor re-acquires the relation lock for every row it fetches in some code paths; with the local table, each re-acquisition is a local hash lookup and an integer increment.

The fast path

Now the fun part.

Where fast-path locks live

Fast-path locks are not in the lock table at all. They live in the backend’s own PGPROC struct, in shared memory, in two arrays:

c
	/*-- recording fast-path locks taken by this backend. --*/
	LWLock		fpInfoLock;		/* protects per-backend fast-path state */
	uint64	   *fpLockBits;		/* lock modes held for each fast-path slot */
	Oid		   *fpRelId;		/* slots for rel oids */

fpRelId[f] is the relation OID in slot f; fpLockBits[f] is a bitmask of which lock modes are held on it. The arrays are indexed by group and slot:

c
#define		FP_LOCK_GROUPS_PER_BACKEND_MAX	1024
#define		FP_LOCK_SLOTS_PER_GROUP		16	/* don't change */
#define		FastPathLockSlotsPerBackend() \
	(FP_LOCK_SLOTS_PER_GROUP * FastPathLockGroupsPerBackend)

and the group for a relation is a cheap hash:

c
/*
 * Macros to calculate the fast-path group and index for a relation.
 *
 * The formula is a simple hash function, designed to spread the OIDs a bit,
 * so that even contiguous values end up in different groups. In most cases
 * there will be gaps anyway, but the multiplication should help a bit.
 *
 * The selected constant (49157) is a prime not too close to 2^k, and it's
 * small enough to not cause overflows (in 64-bit).
 *
 * We can assume that FastPathLockGroupsPerBackend is a power-of-two per
 * InitializeFastPathLocks().
 */
#define FAST_PATH_REL_GROUP(rel) \
	(((uint64) (rel) * 49157) & (FastPathLockGroupsPerBackend - 1))

So with the default configuration there are 8 groups of 16 slots each: 128 fast-path relation slots per backend, any of which can hold any of the three weak lock modes.

The bit packing is dense. Each slot uses 3 bits of the 64-bit word, and there are 3 usable lock modes (plus the offset):

c
/* Macros for manipulating proc->fpLockBits */
#define FAST_PATH_BITS_PER_SLOT			3
#define FAST_PATH_LOCKNUMBER_OFFSET		1
#define FAST_PATH_MASK					((1 << FAST_PATH_BITS_PER_SLOT) - 1)

Which locks are eligible

c
/*
 * The fast-path lock mechanism is concerned only with relation locks on
 * unshared relations by backends bound to a database.  The fast-path
 * mechanism exists mostly to accelerate acquisition and release of locks
 * that rarely conflict.  Because ShareUpdateExclusiveLock is
 * self-conflicting, it can't use the fast-path mechanism; but it also does
 * not conflict with any of the locks that do, so we can ignore it completely.
 */
#define EligibleForRelationFastPath(locktag, mode) \
	((locktag)->locktag_lockmethodid == DEFAULT_LOCKMETHOD && \
	(locktag)->locktag_type == LOCKTAG_RELATION && \
	(locktag)->locktag_field1 == MyDatabaseId && \
	MyDatabaseId != InvalidOid && \
	(mode) < ShareUpdateExclusiveLock)
#define ConflictsWithRelationFastPath(locktag, mode) \
	((locktag)->locktag_lockmethodid == DEFAULT_LOCKMETHOD && \
	(locktag)->locktag_type == LOCKTAG_RELATION && \
	(locktag)->locktag_field1 != InvalidOid && \
	(mode) > ShareUpdateExclusiveLock)

Read the second macro carefully: it fires for modes strictly greater than ShareUpdateExclusiveLock, i.e. ShareLock, ShareRowExclusiveLock, ExclusiveLock, AccessExclusiveLock. Those are the modes that conflict with the fast-path modes, and so they are the modes that must disable the fast path.

ShareUpdateExclusiveLock itself is excluded from both: it’s self-conflicting, so it can’t go on the fast path, but it doesn’t conflict with anything that can, so it doesn’t have to disable it either. Neat.

Note also that the fast path requires locktag_field1 == MyDatabaseId — locks on shared relations (locktag_field1 == InvalidOid, e.g. pg_class catalogs) are never eligible, because they’re visible across databases and a fast-path slot in one backend’s PGPROC says nothing about another database’s sessions.

Granting a fast-path lock

The grant path is in LockAcquireExtended():

c
	if (EligibleForRelationFastPath(locktag, lockmode))
	{
		if (FastPathLocalUseCounts[FAST_PATH_REL_GROUP(locktag->locktag_field2)] <
			FP_LOCK_SLOTS_PER_GROUP)
		{
			uint32		fasthashcode = FastPathStrongLockHashPartition(hashcode);
			bool		acquired;

			/*
			 * LWLockAcquire acts as a memory sequencing point, so it's safe
			 * to assume that any strong locker whose increment to
			 * FastPathStrongRelationLocks becomes visible after we test it
			 * has yet to begin to transfer fast-path locks.
			 */
			LWLockAcquire(&MyProc->fpInfoLock, LW_EXCLUSIVE);
			if (pg_atomic_read_u32(&FastPathStrongRelationLocks[fasthashcode]) != 0)
				acquired = false;
			else
				acquired = FastPathGrantRelationLock(locktag->locktag_field2,
													 lockmode);
			LWLockRelease(&MyProc->fpInfoLock);
			if (acquired)
			{
				/*
				 * The locallock might contain stale pointers to some old
				 * shared objects; we MUST reset these to null before
				 * considering the lock to be acquired via fast-path.
				 */
				locallock->lock = NULL;
				locallock->proclock = NULL;
				GrantLockLocal(locallock, owner);
				return LOCKACQUIRE_OK;
			}
		}
		else
		{
			/*
			 * Increment the lock statistics counter if lock could not be
			 * acquired via the fast-path.
			 */
			pgstat_count_lock_fastpath_exceeded(locallock->tag.lock.locktag_type);
		}
	}

Notice what was not taken: the partition LWLock. The only lock acquired is MyProc->fpInfoLock, which protects this backend’s own fast-path state. Nobody else touches those slots — unless someone requests a strong lock, at which point they have to come and get them.

FastPathGrantRelationLock() is the actual slot allocation:

c
static bool
FastPathGrantRelationLock(Oid relid, LOCKMODE lockmode)
{
	uint32		i;
	uint32		unused_slot = FastPathLockSlotsPerBackend();

	/* fast-path group the lock belongs to */
	uint32		group = FAST_PATH_REL_GROUP(relid);

	/* Scan for existing entry for this relid, remembering empty slot. */
	for (i = 0; i < FP_LOCK_SLOTS_PER_GROUP; i++)
	{
		/* index into the whole per-backend array */
		uint32		f = FAST_PATH_SLOT(group, i);

		if (FAST_PATH_GET_BITS(MyProc, f) == 0)
			unused_slot = f;
		else if (MyProc->fpRelId[f] == relid)
		{
			Assert(!FAST_PATH_CHECK_LOCKMODE(MyProc, f, lockmode));
			FAST_PATH_SET_LOCKMODE(MyProc, f, lockmode);
			return true;
		}
	}

	/* If no existing entry, use any empty slot. */
	if (unused_slot < FastPathLockSlotsPerBackend())
	{
		MyProc->fpRelId[unused_slot] = relid;
		FAST_PATH_SET_LOCKMODE(MyProc, unused_slot, lockmode);
		++FastPathLocalUseCounts[group];
		return true;
	}

	/* No existing entry, and no empty slot. */
	return false;
}

A scan of 16 slots within one group, and — crucially — a scan of one group, not of the whole array. The hash function is what keeps this O(1): you only look at the 16 slots that could possibly hold this relation. This is a very different shape from the heavyweight path, where you must take a partition lock and do a hash table lookup.

Note that FastPathGrantRelationLock returning false (no free slot) is not an error: the caller falls through to the heavyweight path. The fast path is an optimization, and running out of slots just means you take the slow path.

Releasing a fast-path lock

Symmetrically, in LockRelease():

c
	/* Attempt fast release of any lock eligible for the fast path. */
	if (EligibleForRelationFastPath(locktag, lockmode) &&
		FastPathLocalUseCounts[FAST_PATH_REL_GROUP(locktag->locktag_field2)] > 0)
	{
		bool		released;

		/*
		 * We might not find the lock here, even if we originally entered it
		 * here.  Another backend may have moved it to the main table.
		 */
		LWLockAcquire(&MyProc->fpInfoLock, LW_EXCLUSIVE);
		released = FastPathUnGrantRelationLock(locktag->locktag_field2,
											   lockmode);
		LWLockRelease(&MyProc->fpInfoLock);
		if (released)
		{
			RemoveLocalLock(locallock);
			return true;
		}
	}

That comment — “Another backend may have moved it to the main table” — is the whole story of the next section.

Upgrading from fast path to heavyweight

Here is the subtle part. Suppose session A holds a fast-path AccessShareLock on t, and session B wants AccessExclusiveLock on t. B’s request conflicts with the fast-path mode, so B must:

  1. disable the fast path for that relation, so nobody takes a fast-path lock that B can’t see; and
  2. transfer any existing fast-path locks on that relation into the heavyweight table, so that B’s conflict check can see them.

Step 1 is a counter array:

c
/*
 * To make the fast-path lock mechanism work, we must have some way of
 * preventing the use of the fast-path when a conflicting lock might be
 * present.  We partition* the locktag space into FAST_PATH_STRONG_LOCK_HASH_PARTITIONS,
 * and maintain an integer count of the number of "strong" lockers
 * in each partition.  When any "strong" lockers are present (which is
 * hopefully not very often), the fast-path mechanism can't be used, and we
 * must fall back to the slower method of pushing matching locks directly
 * into the main lock tables.
 *
 * The deadlock detector does not know anything about the fast path mechanism,
 * so any locks that might be involved in a deadlock must be transferred from
 * the fast-path queues to the main lock table.
 */

#define FAST_PATH_STRONG_LOCK_HASH_BITS			10
#define FAST_PATH_STRONG_LOCK_HASH_PARTITIONS \
	(1 << FAST_PATH_STRONG_LOCK_HASH_BITS)
#define FastPathStrongLockHashPartition(hashcode) \
	((hashcode) % FAST_PATH_STRONG_LOCK_HASH_PARTITIONS)

static pg_atomic_uint32 *FastPathStrongRelationLocks;

1024 partitions of a counter array. The counter is checked by every fast-path acquisition, and it is the mechanism that makes the whole thing safe.

Step 2 is FastPathTransferRelationLocks(), which walks every backend:

c
static bool
FastPathTransferRelationLocks(LockMethod lockMethodTable, const LOCKTAG *locktag,
							  uint32 hashcode)
{
	LWLock	   *partitionLock = LockHashPartitionLock(hashcode);
	Oid			relid = locktag->locktag_field2;
	uint32		i;

	/* fast-path group the lock belongs to */
	uint32		group = FAST_PATH_REL_GROUP(relid);

	/*
	 * Every PGPROC that can potentially hold a fast-path lock is present in
	 * ProcGlobal->allProcs.  Prepared transactions are not, but any
	 * outstanding fast-path locks held by prepared transactions are
	 * transferred to the main lock table.
	 */
	for (i = 0; i < ProcGlobal->allProcCount; i++)
	{
		PGPROC	   *proc = GetPGProcByNumber(i);
		uint32		j;

		LWLockAcquire(&proc->fpInfoLock, LW_EXCLUSIVE);
		...
		for (j = 0; j < FP_LOCK_SLOTS_PER_GROUP; j++)
		{
			uint32		lockmode;

			/* index into the whole per-backend array */
			uint32		f = FAST_PATH_SLOT(group, j);

			/* Look for an allocated slot matching the given relid. */
			if (relid != proc->fpRelId[f] || FAST_PATH_GET_BITS(proc, f) == 0)
				continue;

			/* Find or create lock object. */
			LWLockAcquire(partitionLock, LW_EXCLUSIVE);
			for (lockmode = FAST_PATH_LOCKNUMBER_OFFSET;
				 lockmode < FAST_PATH_LOCKNUMBER_OFFSET + FAST_PATH_BITS_PER_SLOT;
				 ++lockmode)
			{
				PROCLOCK   *proclock;

				if (!FAST_PATH_CHECK_LOCKMODE(proc, f, lockmode))
					continue;
				proclock = SetupLockInTable(lockMethodTable, proc, locktag,
											hashcode, lockmode);
				...
				GrantLock(proclock->tag.myLock, proclock, lockmode);
				FAST_PATH_CLEAR_LOCKMODE(proc, f, lockmode);
			}
			LWLockRelease(partitionLock);
			...
		}
		LWLockRelease(&proc->fpInfoLock);
	}
	return true;
}

Yes: to take a strong lock on a table, you walk every backend in the cluster. That is O(MaxBackends), and it is why the fast path is a bet that strong locks are rare.

But notice the early exit:

c
		/*
		 * If the target backend isn't referencing the same database as the
		 * lock, then we needn't examine the individual relation IDs at all;
		 * none of them can be relevant.
		 *
		 * proc->databaseId is set at backend startup time and never changes
		 * thereafter, so it might be safe to perform this test before
		 * acquiring &proc->fpInfoLock. ...
		 *
		 * Also skip groups without any registered fast-path locks.
		 */
		if (proc->databaseId != locktag->locktag_field1 ||
			proc->fpLockBits[group] == 0)
		{
			LWLockRelease(&proc->fpInfoLock);
			continue;
		}

Two skips: backends in other databases (their locks can never be on this relation), and backends with nothing in this group. In practice the second skip kills almost all of the work.

And when the transferring backend is the same one that holds the fast-path lock, there’s a third path. If you hold a fast-path AccessShareLock and then ask for RowExclusiveLock on the same table, your new request is not fast-path eligible (well, RowExclusiveLock is weak, so it would take another fast-path slot)… but if you ask for AccessExclusiveLock, your own fast-path lock has to be visible to the heavyweight machinery, and the transfer loop handles that — it walks all backends including you.

The strong-lock counter

The FastPathStrongRelationLocks array is incremented by BeginStrongLockAcquire():

c
/*
 * BeginStrongLockAcquire - inhibit use of fastpath for a given LOCALLOCK,
 * and arrange for error cleanup if it fails
 */
static void
BeginStrongLockAcquire(LOCALLOCK *locallock, uint32 fasthashcode)
{
	Assert(StrongLockInProgress == NULL);
	Assert(locallock->holdsStrongLockCount == false);

	pg_atomic_fetch_add_u32(&FastPathStrongRelationLocks[fasthashcode], 1);
	locallock->holdsStrongLockCount = true;
	StrongLockInProgress = locallock;
}

and — importantly — it is not decremented when the acquisition finishes. FinishStrongLockAcquire() only clears the cleanup hook:

c
/*
 * FinishStrongLockAcquire - cancel pending cleanup for a strong lock
 * acquisition once it's no longer needed
 */
static void
FinishStrongLockAcquire(void)
{
	StrongLockInProgress = NULL;
}

The decrement happens when the locallock entry is finally removed — that is, when the strong lock is released:

c
	if (locallock->holdsStrongLockCount)
	{
		uint32		fasthashcode;

		fasthashcode = FastPathStrongLockHashPartition(locallock->hashcode);

		Assert(pg_atomic_read_u32(&FastPathStrongRelationLocks[fasthashcode]) > 0);
		pg_atomic_fetch_sub_u32(&FastPathStrongRelationLocks[fasthashcode], 1);
		locallock->holdsStrongLockCount = false;
	}

So the semantics are: while a strong lock is held on a relation, the fast path is disabled for that hash partition. That is exactly right — a strong lock conflicts with the weak modes, so while it is outstanding, nobody may take a weak lock that the strong locker would fail to see. Every fast-path acquisition in that partition now fails the atomic check and falls back to the heavyweight table.

This is a partitioned disable, not a global one. A strong lock on one table only disables the fast path for the hash partition that table falls into.

Conflicts

The conflict table

Every lock method has a table of which modes conflict with which:

c
static const LOCKMASK LockConflicts[] = {
	0,

	/* AccessShareLock */
	LOCKBIT_ON(AccessExclusiveLock),

	/* RowShareLock */
	LOCKBIT_ON(ExclusiveLock) | LOCKBIT_ON(AccessExclusiveLock),

	/* RowExclusiveLock */
	LOCKBIT_ON(ShareLock) | LOCKBIT_ON(ShareRowExclusiveLock) |
	LOCKBIT_ON(ExclusiveLock) | LOCKBIT_ON(AccessExclusiveLock),
	...

and the check is one AND against the aggregate mask:

c
	/*
	 * first check for global conflicts: If no locks conflict with my request,
	 * then I get the lock.
	 *
	 * Checking for conflict: lock->grantMask represents the types of
	 * currently held locks.  conflictTable[lockmode] has a bit set for each
	 * type of lock that conflicts with request.   Bitwise compare tells if
	 * there is a conflict.
	 */
	if (!(conflictMask & lock->grantMask))
	{
		PROCLOCK_PRINT("LockCheckConflicts: no conflict", proclock);
		return false;
	}

If that’s non-zero, there’s potentially a conflict, and the code has to look at individual proclocks, because some of the conflicting locks might be held by me:

c
	/*
	 * Rats.  Something conflicts.  But it could still be my own lock, or a
	 * lock held by another member of my locking group.  First, figure out how
	 * many conflicts remain after subtracting out any locks I hold myself.
	 */
	myLocks = proclock->holdMask;
	for (i = 1; i <= numLockModes; i++)
	{
		if ((conflictMask & LOCKBIT_ON(i)) == 0)
		{
			conflictsRemaining[i] = 0;
			continue;
		}
		conflictsRemaining[i] = lock->granted[i];
		if (myLocks & LOCKBIT_ON(i))
			--conflictsRemaining[i];
		totalConflictsRemaining += conflictsRemaining[i];
	}

	/* If no conflicts remain, we get the lock. */
	if (totalConflictsRemaining == 0)
	{
		PROCLOCK_PRINT("LockCheckConflicts: resolved (simple)", proclock);
		return false;
	}

The granted[] array — a count per mode — is what makes this work without walking the proclock list. You know how many AccessExclusiveLocks are held; you subtract the ones you hold yourself; if the count is zero for every conflicting mode, you can have the lock.

Lock groups

If group locking is in play, you also have to subtract the locks held by other members of your group:

c
	/*
	 * Locks held in conflicting modes by members of our own lock group are
	 * not real conflicts; we can subtract those out and see if we still have
	 * a conflict.  This is O(N) in the number of processes holding or
	 * awaiting locks on this object.  We could improve that by making the
	 * shared memory state more complex (and larger) but it doesn't seem worth
	 * it.
	 */
	dlist_foreach(proclock_iter, &lock->procLocks)
	{
		PROCLOCK   *otherproclock =
			dlist_container(PROCLOCK, lockLink, proclock_iter.cur);

		if (proclock != otherproclock &&
			proclock->groupLeader == otherproclock->groupLeader &&
			(otherproclock->holdMask & conflictMask) != 0)
		{
			...
		}
	}

The groupLeader field on PROCLOCK is computed once, at creation:

c
		/*
		 * It might seem unsafe to access proclock->groupLeader without a
		 * lock, but it's not really.  Either we are initializing a proclock
		 * on our own behalf, in which case our group leader isn't changing
		 * because the group leader for a process can only ever be changed by
		 * the process itself; or else we are transferring a fast-path lock to
		 * the main lock table, in which case that process can't change its
		 * lock group leader without first releasing all of its locks (and in
		 * particular the one we are currently transferring).
		 */
		proclock->groupLeader = proc->lockGroupLeader != NULL ?
			proc->lockGroupLeader : proc;

A group is a leader PGPROC plus members. BecomeLockGroupLeader() creates one:

c
/*
 * BecomeLockGroupLeader - designate process as lock group leader
 *
 * Once this function has returned, other processes can join the lock group
 * by calling BecomeLockGroupMember.
 */
void
BecomeLockGroupLeader(void)
{
	LWLock	   *leader_lwlock;

	/* If we already did it, we don't need to do it again. */
	if (MyProc->lockGroupLeader == MyProc)
		return;

	/* We had better not be a follower. */
	Assert(MyProc->lockGroupLeader == NULL);

	/* Create single-member group, containing only ourselves. */
	leader_lwlock = LockHashPartitionLockByProc(MyProc);
	LWLockAcquire(leader_lwlock, LW_EXCLUSIVE);
	MyProc->lockGroupLeader = MyProc;
	dlist_push_head(&MyProc->lockGroupMembers, &MyProc->lockGroupLink);
	LWLockRelease(leader_lwlock);
}

The group leader’s own partition lock protects the group’s membership fields:

c
/*
 * The deadlock detector needs to be able to access lockGroupLeader and
 * related fields in the PGPROC, so we arrange for those fields to be protected
 * by one of the lock hash partition locks.  Since the deadlock detector
 * acquires all such locks anyway, this makes it safe for it to access these
 * fields without doing anything extra.  To avoid contention as much as
 * possible, we map different PGPROCs to different partition locks.  The lock
 * used for a given lock group is determined by the group leader's pgprocno.
 */
#define LockHashPartitionLockByProc(leader_pgproc) \
	LockHashPartitionLock(GetNumberFromPGProc(leader_pgproc))

Groups exist so that a set of processes can hold conflicting locks on the same object without deadlocking each other — used by parallel query workers and by REPACK. The point is that a group is treated as one participant in the conflict and deadlock algorithms.

GetLockConflicts

Some callers (logical replication conflict resolution, for instance) need to know who is blocking them, not just that someone is. That’s GetLockConflicts():

c
/*
 * GetLockConflicts
 *		Get an array of VirtualTransactionIds of xacts currently holding locks
 *		that would conflict with the specified lock/lockmode.
 *		xacts merely awaiting such a lock are NOT reported.
 *
 * The result array is palloc'd and is terminated with an invalid VXID.
 * *countp, if not null, is updated to the number of items set.
 *
 * Of course, the result could be out of date by the time it's returned, so
 * use of this function has to be thought about carefully.  Similarly, a
 * PGPROC with no "lxid" will be considered non-conflicting regardless of any
 * lock it holds. ...
 */

The interesting part is that it has to check both the heavyweight table and every backend’s fast-path array:

c
	/*
	 * Fast path locks might not have been entered in the primary lock table.
	 * If the lock we're dealing with could conflict with such a lock, we must
	 * examine each backend's fast-path array for conflicts.
	 */
	if (ConflictsWithRelationFastPath(locktag, lockmode))
	{
		Oid			relid = locktag->locktag_field2;
		...
		for (uint32 i = 0; i < ProcGlobal->allProcCount; i++)
		{
			PGPROC	   *proc = GetPGProcByNumber(i);
			...
			for (j = 0; j < FP_LOCK_SLOTS_PER_GROUP; j++)
			{
				...
				/* Look for an allocated slot matching the given relid. */
				if (relid != proc->fpRelId[f])
					continue;
				lockmask = FAST_PATH_GET_BITS(proc, f);
				if (!lockmask)
					continue;
				lockmask <<= FAST_PATH_LOCKNUMBER_OFFSET;

				/*
				 * There can only be one entry per relation, so if we found it
				 * and it doesn't conflict, we can skip the rest of the slots.
				 */
				if ((lockmask & conflictMask) == 0)
					break;

				/* Conflict! */
				GET_VXID_FROM_PGPROC(vxid, *proc);

				if (VirtualTransactionIdIsValid(vxid))
					vxids[count++] = vxid;
				/* else, xact already committed or aborted */
				break;
			}
			...
		}
	}

Note that it does not hold the partition lock while scanning the fast-path arrays — it takes each backend’s fpInfoLock in shared mode. The comment acknowledges the resulting fuzziness:

This is all a bit fuzzy, because new locks could be taken after we’ve visited a particular partition, but the callers had better be prepared to deal with that anyway, since the locks could equally well be taken between the time we return the value and the time the caller does something with it.

The wait queue

When a conflict is real, the waiter joins the lock’s queue.

JoinWaitQueue() (in src/backend/storage/lmgr/proc.c) is where the priority-queue logic lives:

c
/*
 * JoinWaitQueue -- join the wait queue on the specified lock
 *
 * It's not actually guaranteed that we need to wait when this function is
 * called, because it could be that when we try to find a position at which
 * to insert ourself into the wait queue, we discover that we must be inserted
 * ahead of everyone who wants a lock that conflict with ours. In that case,
 * we get the lock immediately. Because of this, it's sensible for this function
 * to have a dontWait argument, despite the name.
 ...
 * NOTES: The process queue is now a priority queue for locking.
 */

The queue is a doubly-linked list with a “conflict mask ahead of me” accumulator. The interesting part is the reordering rule:

c
	/*
	 * Determine where to add myself in the wait queue.
	 *
	 * Normally I should go at the end of the queue.  However, if I already
	 * hold locks that conflict with the request of any previous waiter, put
	 * myself in the queue just in front of the first such waiter. This is not
	 * a necessary step, since deadlock detection would move me to before that
	 * waiter anyway; but it's relatively cheap to detect such a conflict
	 * immediately, and avoid delaying till deadlock timeout.
	 */

So the queue is not FIFO; it’s ordered by conflict relationships. A backend that would block an earlier waiter jumps ahead of it. This avoids a whole class of deadlocks that would otherwise only be detected by the (slow, one-second-granularity) deadlock timeout.

The insertion itself:

c
	/*
	 * Insert self into queue, at the position determined above.
	 */
	if (insert_before)
		dclist_insert_before(waitQueue, &insert_before->waitLink, &MyProc->waitLink);
	else
		dclist_push_tail(waitQueue, &MyProc->waitLink);

	lock->waitMask |= LOCKBIT_ON(lockmode);

then the waiter sleeps in ProcSleep(), which is where the deadlock detector’s timer lives:

c
/*
 * ProcSleep -- put process to sleep waiting on lock
 *
 * This must be called when JoinWaitQueue() returns PROC_WAIT_STATUS_WAITING.
 * Returns after the lock has been granted, or if a deadlock is detected.  Can
 * also bail out with ereport(ERROR), if some other error condition, or a
 * timeout or cancellation is triggered.
 */

When a lock is released, ProcLockWakeup() walks the queue and wakes everyone who can now proceed:

c
/*
 * ProcLockWakeup -- routine for waking up processes when a lock is
 *		released (or a prior waiter is aborted).  Scan all waiters
 *	for lock, waken any that are no longer blocked.
 *
 * The appropriate lock partition lock must be held by caller.
 */
void
ProcLockWakeup(LockMethod lockMethodTable, LOCK *lock)
{
	dclist_head *waitQueue = &lock->waitProcs;
	LOCKMASK	aheadRequests = 0;
	dlist_mutable_iter miter;

	if (dclist_is_empty(waitQueue))
		return;

	dlist_foreach_modify(miter, waitQueue)
	{
		PGPROC	   *proc = dlist_container(PGPROC, waitLink, miter.cur);
		LOCKMODE	lockmode = proc->waitLockMode;

		/*
		 * Waken if (a) doesn't conflict with requests of earlier waiters, and
		 * (b) doesn't conflict with already-held locks.
		 */
		if ((lockMethodTable->conflictTab[lockmode] & aheadRequests) == 0 &&
			!LockCheckConflicts(lockMethodTable, lockmode, lock,
								proc->waitProcLock))
		{
			/* OK to waken */
			GrantLock(lock, proc->waitProcLock, lockmode);
			/* removes proc from the lock's waiting process queue */
			ProcWakeup(proc, PROC_WAIT_STATUS_OK);
		}
		else
		{
			/*
			 * Lock conflicts: Don't wake, but remember requested mode for
			 * later checks.
			 */
			aheadRequests |= LOCKBIT_ON(lockmode);
		}
	}
}

The aheadRequests accumulator is the queue’s ordering invariant, and it’s the same trick as in JoinWaitQueue(): a waiter is only woken if it conflicts with nothing ahead of it in the queue.

Deadlock detection

The deadlock detector lives in src/backend/storage/lmgr/deadlock.c and runs when deadlock_timeout (default 1 second) expires while waiting. It takes all the partition locks:

c
	/*
	 * Acquire exclusive lock on the entire shared lock data structures. Must
	 * grab LWLocks in partition-number order to avoid LWLock deadlock.
	 *
	 * Note that the deadlock check interrupt had better not be enabled
	 * anywhere that this process itself holds lock partition locks, else this
	 * will wait forever.  Also note that LWLockAcquire creates a critical
	 * section, so that this routine cannot be interrupted by cancel/die
	 * interrupts.
	 */
	for (i = 0; i < NUM_LOCK_PARTITIONS; i++)
		LWLockAcquire(LockHashPartitionLockByIndex(i), LW_EXCLUSIVE);

which is why the deadlock check is a heavyweight, rare operation and not something you want on the hot path.

Because the deadlock detector knows nothing about the fast path (as the comment in lock.c says explicitly: “The deadlock detector does not know anything about the fast path mechanism, so any locks that might be involved in a deadlock must be transferred from the fast-path queues to the main lock table”), the fast path is safe: a lock that might be involved in a deadlock is guaranteed to be in the heavyweight table by the time the detector looks.

Sizing the lock table

The shared lock table is sized from max_locks_per_transaction:

c
#define NLOCKENTS() \
	mul_size(max_locks_per_xact, add_size(MaxBackends, max_prepared_xacts))

and the GUC:

text
{ name => 'max_locks_per_transaction', type => 'int', context => 'PGC_POSTMASTER', group => 'LOCK_MANAGEMENT',
  short_desc => 'Sets the maximum number of locks per transaction.',
  long_desc => 'The shared lock table is sized on the assumption that at most "max_locks_per_transaction" objects per server process or prepared transaction will need to be locked at any one time.',
  variable => 'max_locks_per_xact',
  boot_val => '128',
  min => '10',
  max => 'INT_MAX',
},

So the table holds 128 × (MaxBackends + max_prepared_xacts) LOCK entries, and twice that many PROCLOCK entries. Running out produces:

c
			if (reportMemoryError)
				ereport(ERROR,
						(errcode(ERRCODE_OUT_OF_MEMORY),
						 errmsg("out of shared memory"),
						 errhint("You might need to increase \"%s\".", "max_locks_per_transaction")));

The same GUC also sizes the fast path:

c
void
InitializeFastPathLocks(void)
{
	/* Should be initialized only once. */
	Assert(FastPathLockGroupsPerBackend == 0);

	/*
	 * Based on the max_locks_per_transaction GUC, as that's a good indicator
	 * of the expected number of locks, figure out the value for
	 * FastPathLockGroupsPerBackend.  This must be a power-of-two.  We cap the
	 * value at FP_LOCK_GROUPS_PER_BACKEND_MAX and insist the value is at
	 * least 1.
	 *
	 * The default max_locks_per_transaction = 128 means 8 groups by default.
	 */
	FastPathLockGroupsPerBackend =
		Max(Min(pg_nextpower2_32(max_locks_per_xact) / FP_LOCK_SLOTS_PER_GROUP,
				FP_LOCK_GROUPS_PER_BACKEND_MAX), 1);

	/* Validate we did get a power-of-two */
	Assert(FastPathLockGroupsPerBackend ==
		   pg_nextpower2_32(FastPathLockGroupsPerBackend));
}

128 / 16 = 8 groups, 8 × 16 = 128 slots per backend. Raising max_locks_per_transaction gives both a bigger heavyweight table and more fast-path slots — the two are sized from the same knob because they’re sized from the same assumption.

Caveats

The fast path is only for relation locks in the current database. Shared relations (locktag_field1 == InvalidOid), objects, advisory locks, tuple locks, transaction locks — none of them are fast-path eligible.

The fast path is only for the three weak modes. AccessShareLock, RowShareLock, RowExclusiveLock. ShareUpdateExclusiveLock is excluded because it self-conflicts; everything stronger conflicts with the fast-path modes and therefore disables the fast path.

The fast path has a per-backend slot limit. With the default configuration that’s 128 relation slots. A session touching more than that many relations in one transaction falls back to the heavyweight table, and the fallback is counted:

c
			pgstat_count_lock_fastpath_exceeded(locallock->tag.lock.locktag_type);

which is surfaced in the pg_stat_lock view, one row per lock tag type, with a fastpath_exceeded column.

Taking a strong lock walks every backend. FastPathTransferRelationLocks() iterates over ProcGlobal->allProcCount. On a cluster with hundreds of backends, a stream of ALTER TABLEs is measurably more expensive than the per-lock cost suggests.

Lock upgrades are heavyweight. Because the local lock table is keyed by (tag, mode), asking for a different mode on the same object is a different entry. If both modes are weak, the second one just sets another bit in the same fast-path slot; if the second mode is strong, the whole fast-path machinery for that relation is torn down and migrated into the heavyweight table. SELECT then UPDATE on the same table therefore takes two locks, not one.

Deadlock detection is a timeout, not a trap. deadlock_timeout defaults to 1 second. A deadlock is detected when the waiter notices the timeout and runs the detector, which then takes all sixteen partition locks. Deadlock detection is therefore not instantaneous, and it is not free.

max_locks_per_transaction is a startup parameter. It’s PGC_POSTMASTER, so changing it requires a restart, and it sizes shared memory, so raising it costs shared memory proportional to the number of backends.

Locks are not released on error mid-acquisition. Well — they are, but the locallock cleanup is subtle: the comment in lock.h warns that “a locallock object can be left over from a failed lock acquisition attempt”, in which case its lock/proclock pointers are garbage if nLocks == 0.

What it all means in practice

Most lock contention is imaginary. If you look at pg_locks and see thousands of AccessShareLock rows, that is not thousands of contended locks — those are just holders, and most of them are on the fast path. pg_locks shows both worlds, and the fastpath column tells you which is which:

c
		TupleDescInitEntry(tupdesc, (AttrNumber) 15, "fastpath",
						   BOOLOID, -1, 0);

A row with fastpath = true was never in the heavyweight table at all.

The heavyweight table’s partitioning is why PostgreSQL scales. Sixteen independently-locked hash partitions. Two sessions locking different tables rarely touch the same LWLock. This is invisible in the API and completely visible in the throughput numbers.

The local lock table is why repeated locking is free. A backend that already holds a lock on an object bumps a counter instead of touching shared memory. The cost of “the executor re-locks the relation for every tuple” is a local hash lookup.

The fast path is a bet that strong locks are rare. The bet is paid out in full whenever a LOCK TABLE ... IN ACCESS EXCLUSIVE MODE or an ALTER TABLE shows up: the fast path is disabled for that hash partition and every backend’s fast-path locks on that relation are migrated into the heavyweight table. If your workload is dominated by DDL, the fast path buys you nothing and costs you the transfer.

max_locks_per_transaction is a shared-memory sizing knob, not a performance knob. Raising it gives you more heavyweight slots and more fast-path slots. It doesn’t make locking faster; it makes “out of shared memory” go away. If you’re seeing that error, the real question is why one transaction needs more than 128 locks — usually a partition of a table being attached, or a prepared transaction holding locks forever.

Deadlocks are a queue-ordering problem, not just a timeout problem. The wait queue is a priority queue keyed on conflict relationships, and JoinWaitQueue() deliberately jumps ahead of waiters you would block. This is why PostgreSQL rarely reports a deadlock that “shouldn’t” happen.

The fast path is invisible in the API and visible in the source. The whole mechanism is about not taking a lock. Every function that looks like it should be simple — FastPathGrantRelationLock, FastPathUnGrantRelationLock — is small, and the complexity is concentrated in the interaction between the two worlds: the strong-lock counter, the transfer loop, and the locallock->lock == NULL convention that means “this lock is on the fast path”.

Further reading

  • src/include/storage/lock.h — LOCK, PROCLOCK, LOCALLOCK, the partition macros, and the local-lock-table rationale.
  • src/include/storage/locktag.h — the LOCKTAG struct, the LockTagType enum, and every SET_LOCKTAG_* macro.
  • src/include/storage/lockdefs.h — the eight lock modes and their meanings.
  • src/backend/storage/lmgr/lock.c — the heavyweight lock manager, the fast path, LockAcquireExtended(), LockCheckConflicts(), GetLockConflicts().
  • src/backend/storage/lmgr/proc.c — JoinWaitQueue(), ProcSleep(), ProcLockWakeup(), BecomeLockGroupLeader().
  • src/backend/storage/lmgr/deadlock.c — the deadlock detector.
  • src/backend/utils/adt/lockfuncs.c — pg_lock_status() (the pg_locks view), pg_blocking_pids(), and the advisory-lock functions.
  • src/backend/storage/lmgr/README — the authoritative prose description of all four lock mechanisms (spinlocks, LWLocks, heavyweight locks, predicate locks), including the “Fast Path Locking” section.
  • src/include/storage/proc.h — the PGPROC fast-path fields and the FP_LOCK_* constants.
  • src/backend/utils/init/postinit.c — InitializeFastPathLocks().
  • src/include/storage/lwlock.h — NUM_LOCK_PARTITIONS and the LWLock array layout.
  • The pg_locks view, in src/backend/catalog/system_views.sql and src/backend/utils/adt/lockfuncs.c.

PostgresPerformanceConcurrencyInternals