콘텐츠로 이동

(KO) PostgreSQL LWLock과 스핀락 — 원자 상태 워드, 대기 큐 프로토콜, 플랫폼 TAS

목차

공유 메모리에 작업 상태를 저장하는 데이터베이스 엔진은 반드시 한 가지 조율 문제를 풀어야 한다. 여러 프로세스가 동일한 메모리 위치를 동시에 읽고 쓸 때 쪼개진 읽기(torn read)와 덮어쓰기 손실(lost write)을 어떻게 막는가의 문제다. 그 답이 상호 배제(mutual exclusion) — 쓰기는 한 번에 하나, 읽기 최적화 변형에서는 동시 읽기를 허용하되 동시 쓰기를 금지하는 보장 — 이다.

상호 배제는 고전 동시성 이론에서 두 가지 관점으로 연구된다.

하드웨어 기본 연산. 핵심은 test-and-set(TAS) 명령어다. 메모리 워드를 원자적으로 읽고 1을 쓰며(또는 센티넬 값으로 교환) 이전 값을 반환한다. 이전 값이 0이면 호출자가 잠금을 획득한 것이고, 이미 1이면 다른 쪽이 보유 중임을 의미한다. 현대 CPU는 TAS를 CAS(compare-and-swap) 와 FAA(fetch-and-add)로 일반화했으며, 이들은 표현력이 더 높다. Database Internals(Petrov, 8장 “Introduction to Distributed Transactions”)는 하드웨어 원자 연산이 모든 상위 수준 동기화의 토대라고 설명한다. PostgreSQL처럼 단일 호스트 공유 메모리 엔진에서는 32비트 워드에 대한 네이티브 CAS가 핵심 기본 연산이다.

스피닝 vs. 블로킹. 잠금이 보유 중임을 발견한 호출자는 두 가지를 선택할 수 있다. 타이트한 루프에서 폴링(CPU 소비)하거나, CPU를 OS 스케줄러 에 반납하고 깨어나기를 기다리는 것이다. 아주 짧은 대기에는 스피닝이 유리한데, 커널 진입·복귀 왕복 비용이 몇 사이클 낭비보다 훨씬 크기 때문이다. 반면 한 타임슬라이스보다 길어질 수 있는 대기에는 블로킹이 필수다. 실용적인 구현 대부분은 두 방식을 결합한다 — 짧게 스핀한 뒤 블록한다. 이 두 계층 전략이 PostgreSQL의 두 잠금 계층 모두에 깔려 있다.

읽기-쓰기 잠금. 공유 자원이 쓰기보다 읽기가 훨씬 잦을 때, 순수한 상호 배제는 병렬로 진행해도 되는 읽기까지 직렬화해 동시성을 낭비한다. 읽기-쓰기 잠금(reader-writer lock) 은 공유 모드(여러 읽기가 동시에 가능, 쓰기 없음)와 배타 모드(쓰기 하나, 읽기 없음)를 구분한다. 교과서적 설계는 상태를 별도의 공유·배타 카운터로 인코딩하고 내부 스핀락으로 보호한다. 문제는 언컨텐디드(uncontended) 상황에서의 비용이다 — 공유 외부 잠금이 자유로운데도 내부 스핀락을 잡아야 한다면, 서로 충돌하지 않는 읽기들이 내부 스핀락을 놓고 싸우게 된다.

바로 이 내부 스핀락 병목이 현재 LWLock 구현이 해결하려 한 문제다. lwlock.c의 파일 헤더 주석이 이를 설명한다:

// lwlock.c (file header comment)
// This used to be a pretty straight forward reader-writer lock
// implementation, in which the internal state was protected by a
// spinlock. Unfortunately the overhead of taking the spinlock proved to be
// too high for workloads/locks that were taken in shared mode very
// frequently. Often we were spinning in the (obviously exclusive) spinlock,
// while trying to acquire a shared lock that was actually free.

재설계의 핵심은 모든 상태를 하나의 원자 워드에 인코딩해 언컨텐디드 빠른 경로에서 내부 스핀락을 완전히 제거한 것이다. 읽기는 단일 CAS로 공유 잠금을 획득하며 스핀락을 전혀 건드리지 않는다.

공유 메모리 데이터베이스는 보편적으로 최소 두 잠금 계층을 필요로 한다.

1계층 — 스핀락(수 마이크로초). 카운터 증가, 플래그 갱신, 원자적으로 읽어야 하는 값 쌍 같이 수십 명령어에 걸친 구간에만 사용된다. 시스템 콜이나 비사소한(non-trivial) 서브루틴에 걸쳐 보유해서는 안 된다. PostgreSQL의 storage/lmgr/README는 “몇 십 명령어를 넘거나 어떤 형태의 커널 호출에 걸쳐 잠금을 보유해야 한다면 스핀락을 쓰지 말라”고 명시한다. 데드락 탐지, 오류 시 자동 해제, 타임아웃(~1분 후 PANIC)은 없다.

2계층 — 경량 읽기-쓰기 잠금(마이크로초~밀리초). 버퍼 매핑 해시 버킷 순회, 카탈로그 캐시 항목 읽기, WAL 레코드 쓰기처럼 작은 연산 동안 접근하는 공유 자료 구조를 보호한다. 공유·배타 모드를 지원하고, 프로세스 대기 메커니즘(세마포어)과 통합되어 대기자가 CPU를 소비하지 않으며, 오류 복구 시 자동으로 해제된다.

3계층 — 헤비웨이트 잠금(밀리초~초). 정규 잠금 관리자(lock.c)가 SQL 가시적 동시성 — 행 잠금, 테이블 잠금, LOCK TABLE, 데드락 탐지 — 을 처리한다. 이 관리자는 자신의 자료 구조를 보호하기 위해 2계층 LWLock에 의존한다.

2계층 잠금 설계의 핵심 긴장은 언컨텐디드 빠른 경로다. 부하 아래 동작하는 데이터베이스 서버에서 동일한 LWLock이 여러 백엔드에 의해 초당 수천 번 획득·해제될 수 있고, 대다수는 잠금이 자유로운 상태다. 가장 흔한 경우(언컨텐디드 공유·배타 획득)를 최대한 저렴하게 만들어야 한다. 공유 잠금이 자유로운 경우에도 스핀락이 필요한 고전 읽기-쓰기 잠금은 이 요건을 충족하지 못한다.

현대적 접근(PostgreSQL, Linux rwlock, Java StampedLock 등)은 모든 상태를 단일 원자 워드에 인코딩하고 빠른 경로에 CAS를 사용한다. 워드에는 (a) 배타 보유 여부(센티넬 비트 또는 카운터 영역), (b) 공유 보유자 수(카운터 영역), (c) 대기 큐 상태 플래그가 담긴다. 언컨텐디드 공유 획득은 단일 fetch-and-add; 배타 획득은 센티넬을 CAS로 교환한다. 이 경로에서 내부 스핀락은 불필요하다.

PostgreSQL은 두 계층을 명확히 분리한다:

  • 스핀락(slock_t, SpinLockAcquire/SpinLockRelease) — 바쁜-대기(busy-wait)만 사용, 수 명령어, 단일 바이트 또는 int에 대한 하드웨어 TAS로 구현. API는 spin.h의 매크로이며 s_lock.h의 플랫폼별 어셈블리에 위임한다.
  • LWLock(LWLock, LWLockAcquire/LWLockRelease) — 공유·배타 모드, 컨텐션 시 PGSemaphore를 통한 OS 블록, 오류 시 자동 해제, 대기 이벤트 리포팅 시스템과 통합.

스핀락은 LWLock 구현 내부(LW_FLAG_LOCKED 대기 목록 뮤텍스)와 가장 좁은 임계 구간(예: LWLock 트랜치 카운터를 보호하는 ShmemLock, 전역 PGPROC 상태를 보호하는 ProcGlobal->lock) 전반에 걸쳐 사용된다. LWLock은 공유 모드가 필요하거나 수십 명령어 이상 블록될 수 있는 모든 곳에 사용된다.

LWLock 설계의 핵심은 단일 pg_atomic_uint32 필드인 lock->state로, 잠금의 현재 상태를 모두 인코딩한다:

// state bit layout — lwlock.c
#define LW_FLAG_HAS_WAITERS ((uint32) 1 << 31) // waiters in queue
#define LW_FLAG_RELEASE_OK ((uint32) 1 << 30) // ok to wake waiters now
#define LW_FLAG_LOCKED ((uint32) 1 << 29) // wait-list spinlock
// LW_VAL_EXCLUSIVE = MAX_BACKENDS + 1 (a power-of-2; exclusive sentinel)
// LW_VAL_SHARED = 1 (each shared holder adds 1)
// LW_SHARED_MASK = MAX_BACKENDS (bits 0..N covering shared count)
// LW_LOCK_MASK = MAX_BACKENDS | LW_VAL_EXCLUSIVE

상위 3비트(29–31)는 플래그 비트이고, 0번 비트부터 공유 카운터 영역이다. 배타 센티넬 LW_VAL_EXCLUSIVEMAX_BACKENDS + 1이다. MAX_BACKENDS가 항상 2의 거듭제곱에서 1을 뺀 값이므로, LW_VAL_EXCLUSIVE = MAX_BACKENDS + 1은 2의 거듭제곱이 되어 공유 카운터 범위 위의 한 비트만 차지한다. 덕분에 배타 센티넬과 공유 보유자 수 사이의 혼동이 없다.

bit 31: HAS_WAITERS — 이 잠금에 잠든 대기자가 있음
bit 30: RELEASE_OK — 해제 시 대기자를 깨워도 됨
bit 29: LOCKED — 대기 목록 스핀락 (내부용)
bits 0..27: 잠금 보유자 영역
(state & LW_VAL_EXCLUSIVE) != 0 → 배타적으로 보유됨
else (state & LW_SHARED_MASK) → 공유 보유자 수

lwlock.cStaticAssertDecl 검사(약 109번 줄)는 컴파일 타임에 MAX_BACKENDS + 1이 2의 거듭제곱임을, MAX_BACKENDSLW_FLAG_MASK가 겹치지 않음을, LW_VAL_EXCLUSIVELW_FLAG_MASK가 겹치지 않음을 보장한다.

// LWLock — src/include/storage/lwlock.h
typedef struct LWLock
{
uint16 tranche; /* tranche ID for wait event naming */
pg_atomic_uint32 state; /* atomic state word */
proclist_head waiters; /* list of waiting PGPROCs */
} LWLock;

구조체는 작다 — uint16 하나, 원자 uint32 하나, 2워드 리스트 헤드. 각 잠금은 LWLockPadded 유니온 안에서 LWLOCK_PADDED_SIZE(캐시 라인 하나)로 패딩되어 거짓 공유(false sharing)를 방지한다.

트랜치(tranche) 란 대기 이벤트 리포팅을 위한 이름 그룹이다. 128개의 BufferContent 잠금은 모두 LWTRANCHE_BUFFER_CONTENT 트랜치를 공유하고, 16개의 LockManager 파티션 잠금은 LWTRANCHE_LOCK_MANAGER를 공유한다. 명명된 트랜치(RequestNamedLWLockTranche / GetNamedLWLockTranche) 확장 모듈은 포스트마스터 시작 시 사용자 가시적 대기 이벤트 이름이 붙은 LWLock 배열을 할당할 수 있다. 트랜치 ID는 pg_stat_activity에 리포트되는 대기 이벤트 ID와 동일하다.

메인 잠금 배열 MainLWLockArrayCreateLWLocks에 의해 공유 메모리에 한 번 할당되며, 고정(개별 이름) 잠금과 파티션별 그룹을 모두 담는다. 크기는 LWLockShmemSize로 계산한다.

CreateLWLocks / InitializeLWLocks가 만드는 공유 메모리 레이아웃은 단일 연속 블록이다. 동적 트랜치 ID 카운터(4바이트)가 MainLWLockArray 바로 앞에 위치하며(LWLockNewTrancheId가 읽는다), 배열 본체는 LWLOCK_PADDED_SIZE(캐시 라인) 경계에서 시작한다. 고정 영역은 lwlock.h의 오프셋 매크로(BUFFER_MAPPING_LWLOCK_OFFSET, LOCK_MANAGER_LWLOCK_OFFSET, PREDICATELOCK_MANAGER_LWLOCK_OFFSET)로 배치되어 어느 백엔드든 파티션 그룹을 상수 오프셋으로 인덱싱할 수 있다. 명명된 트랜치 잠금(RequestNamedLWLockTranche)은 고정 NUM_FIXED_LWLOCKS 영역 다음에 이어지고, NamedLWLockTranche 디스크립터 배열과 트랜치 이름 문자열이 끝부분에 패킹된다.

flowchart TD
    subgraph SHM["ShmemAlloc으로 할당된 공유 메모리 블록 (CreateLWLocks)"]
        CTR["int LWLockCounter<br/>(동적 트랜치 ID 카운터,<br/>초기값 = LWTRANCHE_FIRST_USER_DEFINED)"]
        subgraph ARR["MainLWLockArray : LWLockPadded[] (캐시 라인 정렬)"]
            IND["[0 .. NUM_INDIVIDUAL_LWLOCKS)<br/>개별 명명 잠금<br/>(각각 고유 트랜치)"]
            BUF["BUFFER_MAPPING_LWLOCK_OFFSET<br/>128개 잠금, LWTRANCHE_BUFFER_MAPPING"]
            LCK["LOCK_MANAGER_LWLOCK_OFFSET<br/>16개 잠금, LWTRANCHE_LOCK_MANAGER"]
            PRD["PREDICATELOCK_MANAGER_LWLOCK_OFFSET<br/>16개 잠금, LWTRANCHE_PREDICATE_LOCK_MANAGER"]
            NAMED["[NUM_FIXED_LWLOCKS ..)<br/>명명된 트랜치 잠금<br/>(RequestNamedLWLockTranche)"]
        end
        DESC["NamedLWLockTranche[]<br/>(trancheId, trancheName)"]
        STR["트랜치 이름 문자열 (char)"]
    end
    CTR --> IND
    IND --> BUF --> LCK --> PRD --> NAMED
    NAMED --> DESC --> STR

    L["LWLock { uint16 tranche;<br/>pg_atomic_uint32 state;<br/>proclist_head waiters }"] -.패딩.-> PAD["LWLockPadded (= PG_CACHE_LINE_SIZE)<br/>잠금 하나당 캐시 라인 하나, 거짓 공유 없음"]
    NAMED -.각 원소는.-> L

LWLockPadded 원소는 베어 LWLockchar pad[LWLOCK_PADDED_SIZE]의 유니온이므로, 모든 잠금이 캐시 라인 하나를 완전히 차지한다. 각 LWLock 내부의 tranche 필드는 BuiltinTrancheNames[](또는 동적 LWLockTrancheNames[])의 인덱스로, LWLockReportWaitStart가 대기 이벤트를 pg_stat_activity의 사용자 가시적 이름으로 변환할 때 이를 사용한다.

획득 프로토콜 — 두 번 시도 경쟁 조건 차단

섹션 제목: “획득 프로토콜 — 두 번 시도 경쟁 조건 차단”

언컨텐디드 공유 경로는 단일 원자 CAS 하나로 끝난다. 컨텐디드 경우에 단 한 번의 시도로는 부족한 이유를 lwlock.c 주석이 설명한다:

// LWLockAcquire — lwlock.c (simplified narrative)
// Phase 1: Try to do it atomically, if we succeed, nice
// Phase 2: Add ourselves to the waitqueue of the lock
// Phase 3: Try to grab the lock again; if we succeed, remove ourselves
// Phase 4: Sleep till wake-up, goto Phase 1

차단하려는 경쟁 조건은 이렇다. 단순하게 한 번만 시도한 뒤 큐에 진입하면, 진입이 완료되기 전에 이미 잠금이 해제됐을 수 있다 — 그러면 신규 대기자는 영원히 깨어나지 못한다. 두 번 시도 프로토콜은 이를 막는다. LWLockQueueSelf 로 대기자 목록에 이름을 올린 뒤 두 번째 LWLockAttemptLock을 실행한다. 두 번째 시도가 성공하면 LWLockDequeueSelf로 깨끗하게 큐에서 빠져나온다. 실패하면, 해제자는 큐를 처리할 때 이미 진입된 대기자를 반드시 보게 되므로 깨움이 보장된다.

// LWLockAcquire — lwlock.c
bool
LWLockAcquire(LWLock *lock, LWLockMode mode)
{
// ...
HOLD_INTERRUPTS();
for (;;)
{
mustwait = LWLockAttemptLock(lock, mode); /* Phase 1 */
if (!mustwait)
break;
LWLockQueueSelf(lock, mode); /* Phase 2 */
mustwait = LWLockAttemptLock(lock, mode); /* Phase 3 */
if (!mustwait)
{
LWLockDequeueSelf(lock);
break;
}
/* Phase 4 */
PGSemaphoreLock(proc->sem); /* block in OS */
// ... loop back
}
held_lwlocks[num_held_lwlocks++] = {lock, mode};
return result; /* true = acquired without waiting */
}

HOLD_INTERRUPTS()는 첫 번째 시도 전에 호출되고, RESUME_INTERRUPTS()LWLockRelease에서 호출된다. 덕분에 cancel/die 시그널이 LWLock을 보유한 백엔드를 중단시킬 수 없다.

아래 흐름은 LWLockAcquire 호출 하나를 추적한다. 왼쪽 줄기(빠른 경로)는 첫 번째 LWLockAttemptLock이 성공하는 경우로, 부하 아래 압도적으로 흔한 경로다. 오른쪽 줄기(느린 경로)는 큐 진입 후 재시도·블록 루프로, 큐 진입 후 잠금을 놓치는 경쟁 조건을 차단한다. 블록 지점(PGSemaphoreLock)은 두 번째 LWLockAttemptLock도 실패한 뒤, 즉 호출자가 이미 큐에 진입된 상태에서만 도달한다 — 이 조건이 해제자가 대기자를 보고 LWLockWakeup을 호출함을 보장한다.

flowchart TD
    A["LWLockAcquire(lock, mode)"] --> B["HOLD_INTERRUPTS()"]
    B --> C{"num_held_lwlocks &gt;= MAX_SIMUL_LWLOCKS?"}
    C -->|예| Cerr["elog(ERROR, LWLock 초과)"]
    C -->|아니오| D["LWLockAttemptLock(lock, mode)<br/>Phase 1: lock-&gt;state에 단일 CAS"]
    D --> E{"mustwait?"}
    E -->|아니오| Z["held_lwlocks[]에 기록<br/>result 반환"]
    E -->|예| F["LWLockQueueSelf(lock, mode)<br/>Phase 2: LW_FLAG_LOCKED 아래 MyProc 진입"]
    F --> G["LWLockAttemptLock(lock, mode)<br/>Phase 3: CAS 재시도, 이제 가시성 보장"]
    G --> H{"mustwait?"}
    H -->|아니오| I["LWLockDequeueSelf(lock)<br/>큐 진입 취소"]
    I --> Z
    H -->|예| J["LWLockReportWaitStart()<br/>Phase 4: 블록"]
    J --> K["PGSemaphoreLock(proc-&gt;sem)"]
    K --> L{"proc-&gt;lwWaiting == LW_WS_NOT_WAITING?"}
    L -->|아니오, 허위 깨움| K
    L -->|예, 정상 깨움| M["pg_atomic_fetch_or(state, LW_FLAG_RELEASE_OK)<br/>result = false"]
    M --> D
    Z --> Y["호출자 임계 구간 실행 ... 이후 LWLockRelease"]
    Y --> R["LWLockReleaseInternal:<br/>pg_atomic_sub_fetch_u32(state, LW_VAL_*)"]
    R --> S{"HAS_WAITERS &amp; RELEASE_OK 설정<br/>AND LW_LOCK_MASK == 0?"}
    S -->|아니오| T["RESUME_INTERRUPTS(); 완료"]
    S -->|예| U["LWLockWakeup(lock):<br/>대기자 순회, LW_WS_PENDING_WAKEUP 표시,<br/>각각 PGSemaphoreUnlock"]
    U --> T

LWLockAttemptLock — 원자 빠른 경로

섹션 제목: “LWLockAttemptLock — 원자 빠른 경로”
// LWLockAttemptLock — lwlock.c
static bool
LWLockAttemptLock(LWLock *lock, LWLockMode mode)
{
uint32 old_state = pg_atomic_read_u32(&lock->state);
while (true)
{
uint32 desired_state = old_state;
bool lock_free;
if (mode == LW_EXCLUSIVE)
{
lock_free = (old_state & LW_LOCK_MASK) == 0;
if (lock_free) desired_state += LW_VAL_EXCLUSIVE;
}
else
{
lock_free = (old_state & LW_VAL_EXCLUSIVE) == 0;
if (lock_free) desired_state += LW_VAL_SHARED;
}
// CAS는 실패 시에도 항상 실행됨 (메모리 배리어 역할)
if (pg_atomic_compare_exchange_u32(&lock->state,
&old_state, desired_state))
return !lock_free; /* false = 획득 성공; true = 대기 필요 */
}
}

공유 획득의 경우: 배타 보유자가 없으면 공유 카운트를 1 증가시킨다. 배타 획득의 경우: 어떤 보유자도 없을 때(배타 센티넬도, 공유 카운터도 0) LW_VAL_EXCLUSIVE를 교환한다. CAS는 lock_free가 false여도 항상 실행된다 — 이는 전체 메모리 배리어 역할을 하며, 구현 주석은 명시적으로 이를 생략해도 벤치마크에서 이점이 없었다고 밝힌다.

대기 목록(lock->waiters, proclist)은 그 자체로 잠금 없는 자료 구조가 아니다. state의 비트 29인 LW_FLAG_LOCKED가 작은 스핀락 비트로서 진입과 이탈을 직렬화한다. LWLockWaitListLock은 fetch-or로 이 비트를 획득하고, 이미 잡혀 있으면 perform_spin_delay로 스피닝하며, LWLockWaitListUnlock은 fetch-and로 이 비트를 지운다. 스핀락 의미론이 LWLock 코드 내부에서 나타나는 유일한 지점이다.

// LWLockWaitListLock — lwlock.c
static void
LWLockWaitListLock(LWLock *lock)
{
while (true)
{
old_state = pg_atomic_fetch_or_u32(&lock->state, LW_FLAG_LOCKED);
if (!(old_state & LW_FLAG_LOCKED))
break; /* 획득 성공 */
// LW_FLAG_LOCKED가 지워질 때까지 perform_spin_delay로 스핀
}
}

LWLockRelease는 보유 잠금 배열에서 해당 항목을 제거한 뒤 LWLockReleaseInternal을 호출한다. 해제는 상태 워드를 원자적으로 감소시킨다:

// LWLockReleaseInternal — lwlock.c
static void
LWLockReleaseInternal(LWLock *lock, LWLockMode mode)
{
if (mode == LW_EXCLUSIVE)
oldstate = pg_atomic_sub_fetch_u32(&lock->state, LW_VAL_EXCLUSIVE);
else
oldstate = pg_atomic_sub_fetch_u32(&lock->state, LW_VAL_SHARED);
// 대기자를 깨우는 조건:
// HAS_WAITERS 설정 AND RELEASE_OK 설정 AND 보유자 없음
if ((oldstate & (LW_FLAG_HAS_WAITERS | LW_FLAG_RELEASE_OK)) ==
(LW_FLAG_HAS_WAITERS | LW_FLAG_RELEASE_OK) &&
(oldstate & LW_LOCK_MASK) == 0)
LWLockWakeup(lock);
}

LWLockWakeup은 대기 목록 스핀락을 획득한 뒤 lock->waiters를 순회하고, 선택된 대기자를 LW_WS_PENDING_WAKEUP으로 표시하고, 대기 목록 스핀락을 해제한 다음, 각 대기자의 세마포어에 PGSemaphoreUnlock을 호출한다. 공유 대기자는 여럿을 한꺼번에 깨울 수 있다. 첫 번째 배타 대기자를 만나면 순회가 멈춘다.

RELEASE_OK 플래그는 이중 깨움을 막는다. LWLockWakeup이 대기자를 LW_WS_PENDING_WAKEUP으로 옮길 때 LW_FLAG_RELEASE_OK를 지워서, 깨어난 프로세스가 LWLockAcquire의 재시도 루프 상단에서 다시 설정하기 전까지는 추가 깨움 라운드가 발생하지 않는다.

표준 획득/해제 쌍을 넘어, LWLock은 세 번째 모드인 LWLockWaitForVar / LWLockUpdateVar를 제공한다. 배타 모드로 잠금을 보유한 호출자가 변수 갱신을 브로드캐스트하면, 여러 대기자가 LW_WAIT_UNTIL_FREE로 잠금에 블록하고 변수가 변하면 깨어난다. WAL 삽입이 백엔드들로 하여금 WAL 플러시 LSN이 진행될 때까지 기다리되 WAL 삽입 잠금을 획득하지 않고 대기할 수 있게 해주는 메커니즘이 바로 이것이다.

// LWLockUpdateVar — lwlock.c
// 호출자는 배타 모드로 잠금을 보유 중이어야 함.
void
LWLockUpdateVar(LWLock *lock, pg_atomic_uint64 *valptr, uint64 val)
{
pg_atomic_exchange_u64(valptr, val); /* full barrier */
// LW_WAIT_UNTIL_FREE 대기자 모두를 깨움
LWLockWaitListLock(lock);
// ... 큐 앞쪽 대기자를 wakeup 리스트로 이동
LWLockWaitListUnlock(lock);
// ... 각각 PGSemaphoreUnlock
}

LW_WAIT_UNTIL_FREE 대기자는 대기 큐의 앞쪽에 배치된다. 변수 변경 알림이 일반 잠금 대기자에 의해 가로막히지 않고 즉각 전달되도록 하기 위해서다.

PostgreSQL의 스핀락 API는 플랫폼별 TAS 위의 얇은 매크로 껍데기다:

spin.h
#define SpinLockInit(lock) S_INIT_LOCK(lock)
#define SpinLockAcquire(lock) S_LOCK(lock)
#define SpinLockRelease(lock) S_UNLOCK(lock)
#define SpinLockFree(lock) S_LOCK_FREE(lock)

x86-64에서 S_LOCKTAS_SPIN을 루프하는 s_lock()으로 전개된다. TAS_SPIN은 먼저 비잠금 읽기 검사(*(lock) ? 1 : TAS(lock))를 수행해 잠금이 분명히 보유 중일 때 버스 잠금을 쓸데없이 단언하지 않는다. 이 기법은 인텔의 “Implementing Scalable Atomic Locks” 백서에 문서화된 것이다. TAS 매크로 자체는 lock; xchgb 명령어를 발행한다:

// tas() — src/include/storage/s_lock.h (x86_64 section)
static __inline__ int
tas(volatile slock_t *lock)
{
slock_t _res = 1;
__asm__ __volatile__(
" lock \n"
" xchgb %0,%1 \n"
: "+q"(_res), "+m"(*lock)
: /* no inputs */
: "memory", "cc");
return (int) _res;
}

ARM64에서는 __sync_lock_test_and_set(GCC 빌트인, LDXR/STXR)에 ISB를 스핀 딜레이로 사용한다. 하드웨어 TAS가 없는 플랫폼에서는 semop 기반 스핀락으로 폴백한다.

s_lock.c의 이식 가능한 대기 루프는 스핀 횟수를 스스로 조절한다:

// s_lock / perform_spin_delay — s_lock.c
int
s_lock(volatile slock_t *lock, const char *file, int line, const char *func)
{
SpinDelayStatus delayStatus;
init_spin_delay(&delayStatus, file, line, func);
while (TAS_SPIN(lock))
perform_spin_delay(&delayStatus);
finish_spin_delay(&delayStatus);
return delayStatus.delays;
}

perform_spin_delay는 매 반복마다 SPIN_DELAY()(x86의 rep; nop)를 호출한다. spins_per_delay번 스핀 후에는 무작위로 증가하는 pg_usleep 딜레이(1 ms~1 s)로 에스컬레이션해 심한 컨텐션 아래서 스탈베이션을 방지한다. spins_per_delay는 프로세스별 휴리스틱이다 — 잠금이 슬리프 없이 획득되면 빠르게 늘리고(멀티프로세서 환경을 나타냄), 슬리프가 필요했다면 천천히 줄인다(유니프로세서 또는 과부하 환경을 나타냄). set_spins_per_delay / update_spins_per_delay는 시작과 종료 시 공유 메모리에 추정값을 써서 백엔드 간에 전파한다.

SpinLockAcquireLWLockAcquire 모두 잠금 보유 기간 동안 cancel/die 인터럽트를 보류하도록 문서화되어 있다. lmgr/README는 “스핀락이나 경량 잠금을 획득하면 모든 잠금이 해제될 때까지 쿼리 취소 및 die() 인터럽트가 보류된다”고 명시한다. 이는 LWLockAcquire / LWLockReleaseHOLD_INTERRUPTS() / RESUME_INTERRUPTS()와 스핀락 매크로 자체로 구현된다. 결과적으로 스핀락을 보유하는 동안은 CHECK_FOR_INTERRUPTS()가 발생할 수 없으며, 임계 구간을 극히 짧게 유지해야 한다는 의미다.

LWLockReleaseAllereport(ERROR)가 촉발하는 오류 복구 경로에서 호출된다. 이 함수는 프로세스 로컬 held_lwlocks[] 배열을 순회해 보유 중인 모든 잠금을 역순으로 해제한다. held_lwlocks 배열이 유지되는 이유가 바로 이것이다 — 디버깅 보조 수단이 아니라 오류 복구 해제 목록이다. 스핀락은 자동 해제가 없어서, longjmp에 걸쳐 스핀락을 보유하면 서버가 데드락 상태가 된다.

심벌종류파일역할
slock_ttypedefs_lock.h플랫폼별 스핀락 워드 (x86에서는 보통 unsigned char, ARM에서는 int)
TAS매크로s_lock.h단일 원자 test-and-set; 성공 시 0 반환
TAS_SPIN매크로s_lock.hTAS와 유사하지만 버스 잠금 전에 비잠금 검사 수행 (x86_64 / ARM64)
SPIN_DELAY매크로s_lock.h스핀 루프 내부 CPU 힌트 (rep; nop / isb)
SpinLockAcquire매크로spin.h공개 API — S_LOCK에 위임
SpinLockRelease매크로spin.h공개 API — S_UNLOCK에 위임
s_lock함수s_lock.c플랫폼 독립 백오프 스핀 루프
perform_spin_delay함수s_lock.c스핀 1회 반복: CPU 힌트, 횟수 세기, pg_usleep 에스컬레이션
finish_spin_delay함수s_lock.c획득 후 spins_per_delay 휴리스틱 조정
set_spins_per_delay함수s_lock.c시작 시 공유 추정값을 프로세스 로컬 spins_per_delay에 복사
update_spins_per_delay함수s_lock.c종료 시 프로세스 로컬 추정값을 공유 값에 혼합
심벌종류파일역할
LWLock구조체lwlock.h잠금 객체: tranche(uint16), state(원자 uint32), waiters(proclist)
LWLockPadded유니온lwlock.h거짓 공유 방지를 위해 캐시 라인 1개로 패딩된 LWLock
LWLockMode열거형lwlock.hLW_EXCLUSIVE, LW_SHARED, LW_WAIT_UNTIL_FREE
LWLockWaitState열거형lwlock.hLW_WS_NOT_WAITING, LW_WS_WAITING, LW_WS_PENDING_WAKEUP
NamedLWLockTranche구조체lwlock.h확장 모듈 할당 잠금 배열을 위한 이름 + 트랜치 ID
LWLockHandle구조체lwlock.c보유 잠금의 프로세스 로컬 기록: {lock *, mode}
MainLWLockArray전역lwlock.c모든 고정 잠금과 명명된 트랜치 LWLock의 공유 메모리 기반
held_lwlocks[]정적 배열lwlock.c프로세스별 보유 잠금 목록 (최대 200개 동시)
LW_FLAG_HAS_WAITERS매크로lwlock.cstate의 비트 31
LW_FLAG_RELEASE_OK매크로lwlock.cstate의 비트 30
LW_FLAG_LOCKED매크로lwlock.cstate의 비트 29 (대기 목록 스핀락)
LW_VAL_EXCLUSIVE매크로lwlock.c배타 센티넬 = MAX_BACKENDS + 1
LW_VAL_SHARED매크로lwlock.c공유 증가분 = 1
심벌종류파일역할
LWLockShmemSize함수lwlock.c모든 잠금 + 트랜치 이름에 필요한 공유 메모리 바이트 계산
CreateLWLocks함수lwlock.c포스트마스터: MainLWLockArray와 명명된 트랜치 배열을 공유 메모리에 할당
InitializeLWLocks함수lwlock.cLWLockInitialize를 호출해 모든 고정 및 명명된 트랜치 잠금 초기화
LWLockInitialize함수lwlock.cstate = LW_FLAG_RELEASE_OK 설정, 트랜치 ID 바인딩
RequestNamedLWLockTranche함수lwlock.c확장 훅: 포스트마스터 시작 전에 이름으로 N개 잠금 요청
GetNamedLWLockTranche함수lwlock.c명명된 트랜치 배열의 기반 포인터 반환
LWLockRegisterTranche함수lwlock.c프로세스별: 대기 이벤트 조회를 위해 트랜치 ID → 이름 등록
LWLockNewTrancheId함수lwlock.cMainLWLockArray[-1] 카운터에서 새 트랜치 ID 할당 (ShmemLock으로 보호)
LWLockAttemptLock함수lwlock.c단일 CAS 시도; true 반환 = 대기 필요
LWLockQueueSelf함수lwlock.cLW_FLAG_LOCKED 아래 MyProc를 대기 목록에 추가
LWLockDequeueSelf함수lwlock.c아직 남아 있다면 MyProc를 대기 목록에서 제거
LWLockAcquire함수lwlock.c완전 블로킹 획득; 두 번 시도 프로토콜
LWLockConditionalAcquire함수lwlock.c비블로킹 획득; 잠금이 바쁘면 즉시 false 반환
LWLockAcquireOrWait함수lwlock.c자유로우면 획득, 바쁘면 획득 없이 대기 — WALWriteLock에 사용
LWLockWaitListLock함수lwlock.c대기 목록 스핀락(비트 29) 획득
LWLockWakeup함수lwlock.c대기자 순회, 해당 대기자 이동 후 PGSemaphoreUnlock 호출
LWLockReleaseInternal함수lwlock.c원자 감소 + 조건부 LWLockWakeup
LWLockRelease함수lwlock.c공개 해제: 소유권 반납 + LWLockReleaseInternal + RESUME_INTERRUPTS
LWLockReleaseAll함수lwlock.c오류 복구: held_lwlocks[]의 모든 항목 해제
LWLockWaitForVar함수lwlock.c*valptr != oldval이 되거나 잠금이 해제될 때까지 블록
LWLockUpdateVar함수lwlock.c*valptr를 원자적으로 갱신하고 LW_WAIT_UNTIL_FREE 대기자 깨움

위치 힌트 (커밋 273fe94, 2026-06-05)

섹션 제목: “위치 힌트 (커밋 273fe94, 2026-06-05)”
심벌파일
LW_FLAG_* / LW_VAL_* 매크로src/backend/storage/lmgr/lwlock.c94–106
LWLock 구조체src/include/storage/lwlock.h41–50
LWLockPadded / LWLOCK_PADDED_SIZEsrc/include/storage/lwlock.h62–72
LWLockMode 열거형src/include/storage/lwlock.h112–119
BuiltinTrancheIds 열거형src/include/storage/lwlock.h181–225
*_LWLOCK_OFFSET / NUM_FIXED_LWLOCKS 매크로src/include/storage/lwlock.h104–110
LWLockShmemSizesrc/backend/storage/lmgr/lwlock.c432
CreateLWLockssrc/backend/storage/lmgr/lwlock.c462
InitializeLWLockssrc/backend/storage/lmgr/lwlock.c502
GetNamedLWLockTranchesrc/backend/storage/lmgr/lwlock.c585
LWLockNewTrancheIdsrc/backend/storage/lmgr/lwlock.c615
LWLockRegisterTranchesrc/backend/storage/lmgr/lwlock.c640
RequestNamedLWLockTranchesrc/backend/storage/lmgr/lwlock.c682
LWLockInitializesrc/backend/storage/lmgr/lwlock.c719
LWLockReportWaitStartsrc/backend/storage/lmgr/lwlock.c737
LWLockAttemptLocksrc/backend/storage/lmgr/lwlock.c796
LWLockWaitListLocksrc/backend/storage/lmgr/lwlock.c867
LWLockWakeupsrc/backend/storage/lmgr/lwlock.c932
LWLockQueueSelfsrc/backend/storage/lmgr/lwlock.c1048
LWLockDequeueSelfsrc/backend/storage/lmgr/lwlock.c1091
LWLockAcquiresrc/backend/storage/lmgr/lwlock.c1180
LWLockConditionalAcquiresrc/backend/storage/lmgr/lwlock.c1351
LWLockAcquireOrWaitsrc/backend/storage/lmgr/lwlock.c1408
LWLockWaitForVarsrc/backend/storage/lmgr/lwlock.c1596
LWLockUpdateVarsrc/backend/storage/lmgr/lwlock.c1732
LWLockReleaseInternalsrc/backend/storage/lmgr/lwlock.c1836
LWLockReleasesrc/backend/storage/lmgr/lwlock.c1900
s_locksrc/backend/storage/lmgr/s_lock.c98
perform_spin_delaysrc/backend/storage/lmgr/s_lock.c126
finish_spin_delaysrc/backend/storage/lmgr/s_lock.c186
set_spins_per_delaysrc/backend/storage/lmgr/s_lock.c207
update_spins_per_delaysrc/backend/storage/lmgr/s_lock.c218
tas (x86_64)src/include/storage/s_lock.h214
SpinLockAcquire 매크로src/include/storage/spin.h59

/data/hgryoo/references/postgres, 브랜치 REL_18_STABLE, 커밋 273fe94를 검증 기준으로 삼았다.

확인됨:

  • LWLock 구조체 레이아웃(tranche + state + waiters), lwlock.h:41–50에서 확인.
  • 상태 비트 매크로 및 StaticAssertDecl 검사, lwlock.c:94–116에서 확인.
  • LWLockAttemptLock CAS 루프(공유·배타 경로), lwlock.c:796–856에서 확인.
  • 두 번 시도 프로토콜(LWLockAcquireLWLockQueueSelf → 두 번째 LWLockAttemptLock), lwlock.c:1180–1341에서 확인.
  • LWLockReleaseInternal 원자 감소 + LWLockWakeup 가드 조건, lwlock.c:1836–1877에서 확인.
  • LWLockWakeup 대기 목록 순회, LW_WS_PENDING_WAKEUP 전이, pg_write_barrier(), PGSemaphoreUnlock, lwlock.c:932–1040에서 확인.
  • LWLockWaitForVar / LWLockUpdateVar 메커니즘(큐 앞쪽 LW_WAIT_UNTIL_FREE, pg_atomic_exchange_u64 배리어), lwlock.c:1596–1795에서 확인.
  • LWLockAcquireOrWait 의미론(자유로우면 획득, 바쁘면 획득 없이 대기; WALWriteLock용으로 문서화), lwlock.c:1393–1408 주석 및 1408–1594 본문에서 확인.
  • s_lock 적응형 스핀 루프 및 spins_per_delay 휴리스틱, s_lock.c에서 확인.
  • x86_64 lock; xchgb TAS 및 TAS_SPIN 비잠금 사전 검사, s_lock.h:196–241에서 확인.
  • ARM64 __sync_lock_test_and_set + ISB 딜레이, s_lock.h:250–290에서 확인.
  • LWTRANCHE_AIO_URING_COMPLETIONBuiltinTrancheNames에 존재(PG18 비동기 I/O), lwlock.c:180에서 확인.

미해결 / 이번 패스에서 미검증:

  • 약한 순서 아키텍처에서 pg_atomic_compare_exchange_u32의 정확한 메모리 순서 보장(arch별 port/atomics/ 구현을 끝까지 추적하지 않음).
  • 프로세스 경계를 넘어 잠금 소유권을 이전하는 병렬 워커와 LWLockDisown / LWLockReleaseDisowned의 상호작용 — 완전히 추적하지 않음.

PostgreSQL 너머 — 비교 설계와 연구 프론티어

섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 프론티어”

Linux 커널 rwsemqspinlock. Linux도 읽기-쓰기 세마포어에 유사한 원자 워드 설계를 사용한다 — 단일 long에 리더 수, 쓰기 소유 비트, 대기자 존재 비트를 인코딩한다. 최적화된 빠른 경로(단일 LOCK XADD)는 PostgreSQL의 공유 모드 빠른 경로와 거의 동일하다. Linux의 qspinlock(MCS 기반)은 PostgreSQL의 단순한 TAS 스핀락보다 정교한데, 심한 컨텐션에서 나이브 TAS의 O(n) 캐시 라인 바운싱을 없애는 큐 기반 방식을 채택한다. PostgreSQL은 스핀락을 수십 명령어만 보유하고 컨텐션 수준도 낮아서 MCS 스핀락을 채택하지 않았다.

Java StampedLock (JDK 8+). Java의 StampedLock은 유사한 레이아웃의 AtomicLong 상태 워드를 사용한다 — 배타 비트, 리더 수, 낙관적 읽기용 스탬프. 낙관적 읽기 모드(tryOptimisticRead / validate)는 원자 연산 없이 데이터를 읽은 뒤 쓰기가 개입했는지 확인하는 잠금 없는 변형으로, 잠금보다 MVCC에 더 가깝다. PostgreSQL은 낙관적 LWLock 읽기를 구현하지 않지만, LWLockConditionalAcquire가 논블로킹 획득 사용 사례를 담당한다.

확장 가능한 잠금 관리자와 NUMA. 단일 공유 잠금 배열은 NUMA 환경에서 캐시 일관성 트래픽을 유발해 성능이 저하된다는 관찰이 있다. 논문 “A Scalable Lock Manager for Multicores”(Johnson et al., SIGMOD 2010, knowledge/research/dbms-papers/scalable-lock-manager.md에 캡처됨)는 이 트래픽을 줄이기 위해 CPU 코어별 잠금 테이블을 제안한다. PostgreSQL은 LWLockPadded(잠금 하나당 캐시 라인 하나)와, 고컨텐션 잠금 그룹의 파티셔닝(NUM_BUFFER_PARTITIONS = 128, NUM_LOCK_PARTITIONS = 16)으로 NUMA 효과를 부분적으로 해소한다 — 각 파티션이 독립 LWLock을 가져 핫 잠금이 여러 캐시 라인에 분산된다.

낙관적 동시성과 잠금 없는 접근. 읽기 위주 데이터의 잠금 대안으로 seqlock(시퀀스 잠금)이 있다 — 쓰기 전후에 버전 카운터를 증가시키고, 읽기는 카운터가 짝수이고 변하지 않았는지 확인한다. PostgreSQL은 WAL 삽입 로직(XLogCtl->Insert seqlock 유사 LSN 검사)과 LWLockWaitForVar 에서 구조적으로 유사한 메커니즘을 사용한다. 완전한 잠금 없는 해시 테이블·B-트리(Masstree, MICA 등)는 조회에서 잠금을 완전히 제거하지만, 훨씬 복잡한 메모리 회수(hazard pointer 또는 epoch 기반)가 필요하다. PostgreSQL은 이런 인프라가 없고 해당 워크로드에서 성능 이익도 미미해서 채택하지 않는다.

PG18 비동기 I/O 통합. LWTRANCHE_AIO_URING_COMPLETION 트랜치는 PG18의 신규 항목이다. storage/aio/의 비동기 I/O 서브시스템에서 io_uring 완료 링을 보호한다. 비동기 I/O 도입으로 기존 트랜치를 재사용하는 대신 새 트랜치가 필요했는데, 완료 처리가 I/O를 발행하지 않은 워커 프로세스에서 일어날 수 있기 때문이다 — 트랜치 이름 덕분에 pg_stat_activity에서 이 대기 이벤트를 사용자가 진단할 수 있다.

  • src/backend/storage/lmgr/lwlock.c — 전체 LWLock 구현
  • src/backend/storage/lmgr/s_lock.c — 이식 가능한 스핀락 대기 루프
  • src/include/storage/lwlock.hLWLock 구조체, LWLockMode, BuiltinTrancheIds
  • src/include/storage/spin.h — 공개 스핀락 API 매크로
  • src/include/storage/s_lock.h — 플랫폼별 TAS 구현 (x86, x86_64, ARM64, s390, …)
  • src/backend/storage/lmgr/README — PostgreSQL 4계층 잠금의 공식 개요
  • knowledge/research/dbms-papers/scalable-lock-manager.md — Johnson et al. 2010, 확장 가능한 잠금 관리자 (NUMA)
  • knowledge/research/dbms-general/database-internals.md — Petrov 8장 (원자 연산, 동시성 기초)