락
여럿이 같이 쓰는 것을 한 번에 하나만 건드리게 막는 장치입니다. 먼저 잡은 쪽이 놓을 때까지 나머지는 기다립니다. 기다리게 만드는 것 말고는 하는 일이 없습니다.
상세
회의실 앞에 예약판이 하나 걸려 있다고 해 봅시다. 쓰려는 사람이 판에 이름을 적으면 나머지는 그 이름이 지워질 때까지 복도에서 기다립니다. 회의실 문 자체는 잠기지 않습니다.
락은 충돌하는 방식으로 같은 것을 잡으려는 다른 흐름을 막는 접근권입니다. 자원을 만지려는 실행 흐름은 먼저 락을 잡습니다. 잡은 쪽이 놓기 전까지, 충돌하는 방식으로 같은 락을 잡으려는 다른 흐름은 그 자원에 못 들어갑니다. 충돌하지 않는 방식끼리는 여럿이 동시에 쥡니다. 배타 락처럼 한 번에 하나만 쥘 수 있는 락에서는 잡은 흐름을 그 락의 소유자라고 부릅니다. 이 진입 차단이 락이 주는 보장의 전부입니다.
동작은 셋뿐입니다. 잡기와 놓기, 그리고 못 잡았을 때의 처리입니다. 잡기가 성공하면 그 흐름이 락을 쥔 쪽이 됩니다. 놓기는 쥔 것을 반납해 기다리던 흐름이 들어갈 수 있게 만듭니다. 못 잡았을 때는 놓일 때까지 기다리거나, 기다리지 않고 실패한 채로 돌아옵니다.
sequenceDiagram
participant 흐름A
participant 락
participant 흐름B
흐름A->>락: 잡기
락-->>흐름A: 획득
흐름B->>락: 잡기
Note over 흐름B: 기다림
흐름A->>락: 놓기
락-->>흐름B: 획득
흐름 A 가 락을 잡아 소유자가 되고, 그동안 흐름 B 의 잡기는 돌아오지 않습니다. A 가 놓는 순간 B 의 잡기가 성공합니다. 자원을 실제로 읽고 쓰는 일은 그림에 없습니다. 그것이 락의 성질입니다.
락과 자원을 잇는 것은 대개 약속입니다. 락이 아는 것은 지금 누가 자기를 쥐고 있느냐뿐입니다. 어느 자원을 어느 락이 지키는지는 그 코드를 쓰는 쪽이 정합니다. 약속을 어기고 락을 안 잡은 채 자원을 만지는 흐름이 하나라도 있으면 보장은 깨집니다. 안 잡은 쪽까지 막아 주는 락도 있습니다. 그 갈림은 갈래에서 짚습니다.
잡은 락은 반드시 놓아야 합니다. 놓기가 빠지면 기다리던 흐름은 계속 기다립니다. 서로 다른 흐름이 각자 락을 쥔 채 상대가 쥔 락을 기다리면 아무도 못 나아갑니다. 이 상태가 데드락입니다.
배경
실행 흐름이 하나뿐이면 락이 필요 없습니다. 순서가 하나로 정해져 있어 같은 입력에 같은 결과가 나옵니다. 흐름이 둘 이상이면 사정이 달라집니다. 값을 하나 올리는 일은 대개 세 단계로 나뉩니다. 읽고, 고치고, 다시 씁니다. 두 흐름이 이 세 단계를 겹쳐 실행하면 둘 다 같은 옛 값을 읽고 둘 다 같은 새 값을 씁니다. 한쪽의 갱신이 사라집니다. 결과가 흐름이 끼어드는 지점에 따라 달라지는 상태를 경쟁 상태라고 부릅니다. 결과가 매번 같지 않으니 재현도 어렵습니다.
필요한 것은 그 세 단계를 통째로 한 흐름만 지나가게 만드는 일입니다. 한 번에 하나만 들여보내야 하는 코드 구간을 임계 구역이라고 부릅니다. 임계 구역에 하나만 들어가게 한다는 요구가 상호배제입니다. 상호배제는 요구고, 락은 그 요구를 이루는 장치입니다. 요구와 장치를 갈라 두는 이유는 장치가 락 하나가 아니기 때문입니다.
이름은 자물쇠에서 왔습니다. 영어 lock 을 그대로 옮겼고, 한국어로는 잠금이라고도 씁니다. 잡는 일을 잠근다고, 놓는 일을 푼다고 말하는 것도 같은 비유에서 이어집니다.
갈래
락은 다섯 축으로 갈립니다. 몇이 동시에 쥘 수 있나, 안 잡고 지나가는 쪽까지 막나, 못 잡았을 때 기다리나 돌아오나, 기다리는 동안 잠드나 도나, 무엇 하나를 잠그나.
공유와 배타
같은 대상에 여럿이 동시에 쥘 수 있는 락을 공유 락, 하나만 쥘 수 있는 락을 배타 락이라고
합니다. flock(2) 은 이 둘을 LOCK_SH 와 LOCK_EX 로 나눕니다. 공유 락은 한 파일에 대해
여러 프로세스가 동시에 쥘 수 있습니다. 배타 락은 한 시점에 한 프로세스만 쥘 수 있습니다.
한 파일이 공유 락과 배타 락을 동시에 가질 수는 없습니다.
같은 축이 데이터베이스에서는 여러 단계로 벌어집니다. PostgreSQL 공식 문서는 테이블 수준 락을
ACCESS SHARE 부터 ACCESS EXCLUSIVE 까지 여러 모드로 둡니다. 두 트랜잭션은 같은 테이블에
충돌하는 모드의 락을 동시에 쥘 수 없습니다. 다만 트랜잭션은 자기 자신과는 충돌하지
않습니다. 충돌하지 않는 모드끼리는 여러 트랜잭션이 동시에 쥡니다. ACCESS SHARE 는
ACCESS EXCLUSIVE 하고만 충돌합니다. ACCESS EXCLUSIVE 는 모든 모드와 충돌합니다. 그래서
쥔 쪽이 그 테이블에 접근하는 유일한 트랜잭션임을 보장합니다.
어드바이저리 락
flock(2) 은 자기가 거는 것을 열린 파일에 붙이는 어드바이저리 락이라고 적습니다. 어드바이저리는
같은 락을 걸기로 한 쪽들 사이에서만 성립한다는 뜻입니다. 락을 안 잡고 그냥 파일을 여는 쪽은
막지 못합니다. 반대로 잡지 않은 쪽까지 강제로 막는 방식을 맨더토리 락이라고 부릅니다.
기다리기와 즉시 돌아오기
못 잡았을 때 무엇을 하느냐가 갈립니다. flock() 은 다른 프로세스가 맞지 않는 락을 쥐고 있으면
블록될 수 있습니다. 블록되지 않게 하려면 LOCK_NB 를 함께 넘기라고 적혀 있습니다.
POSIX(Portable Operating System Interface, 이식 가능 운영체제 인터페이스) 규격도 같은 짝을
둡니다. pthread_mutex_lock() 은 이미 다른 스레드가 잠근 뮤텍스면 호출한 스레드가
블록된다고 적혀 있습니다. 그 짝이 즉시 돌아오는 pthread_mutex_trylock() 입니다.
flowchart TD
A[잡기 시도] --> B{이미 잡혀 있나}
B -->|아니오| C[잡고 진입]
B -->|예| D{기다리는 방식인가}
D -->|예| E[놓일 때까지 대기]
D -->|아니오| F[실패로 돌아옴]
비어 있으면 어느 쪽이든 잡고 들어갑니다. 이미 잡혀 있을 때만 갈립니다. 기다리는 쪽은 놓일 때까지 호출이 돌아오지 않고, 즉시 돌아오는 쪽은 실패를 값으로 받아 다른 일을 합니다.
잠드는 락과 도는 락
기다리는 동안 무엇을 하느냐도 갈립니다. Linux 커널 문서는 커널의 잠금 장치를 잠드는 락, CPU(Central
Processing Unit, 중앙처리장치) 지역 락, 도는 락 셋으로 나눕니다. 잠드는 락은 선점 가능한 태스크
문맥에서만 잡을 수 있습니다. 같은 문서는 다른 선택지가 없는 게 아니라면 다른 문맥에서 잠드는
락을 잡지 말라고 적습니다. 잠드는 락으로는 mutex · rt_mutex · semaphore ·
rw_semaphore · ww_mutex · percpu_rw_semaphore 를 듭니다.
도는 락으로는 raw_spinlock_t 와 비트 스핀락을 듭니다. PREEMPT_RT 가 아닌 커널에서는
spinlock_t 와 rwlock_t 도 도는 락이라고 적습니다. 도는 락은 암묵적으로 선점을 끕니다.
잡기와 놓기 함수에는 접미사가 붙어 보호 범위를 더 넓힙니다. _bh() 는 소프트 인터럽트를 끄고
켭니다. _irq() 는 인터럽트를 끄고 켭니다. _irqsave/restore() 는 인터럽트가 꺼진 상태를
저장하며 인터럽트를 끄고, 저장해 둔 상태로 되돌립니다.
잠그는 단위
무엇 하나를 잠그느냐가 갈립니다. PostgreSQL 공식 문서는 테이블 수준 락 외에 행 수준 락을 둡니다. 행 수준 락은 데이터 조회에는 영향을 주지 않습니다. 같은 행에 쓰려는 쪽과 잠그려는 쪽만 막습니다. 행 수준 락도 테이블 수준 락과 마찬가지로 트랜잭션이 끝나거나 세이브포인트가 되돌려질 때 풀립니다.
그 아래에 페이지 수준 락이 또 있습니다. 공유 버퍼 풀 안의 테이블 페이지에 대한 읽기와 쓰기를 제어하는 공유 락과 배타 락입니다. 행 하나를 가져오거나 갱신한 직후 바로 풀립니다. 같은 문서는 애플리케이션 개발자가 페이지 수준 락을 보통은 신경 쓰지 않아도 된다고 적습니다.
예시
pthread_mutex_lock
POSIX 규격이 스레드 사이의 상호배제를 위해 두는 호출 세 벌입니다.
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
pthread_mutex_lock() 이 0 또는 [EOWNERDEAD] 를 반환하면 그 뮤텍스는 잠긴 것입니다. 이미
다른 스레드가 잠근 뮤텍스면 호출한 스레드는 쓸 수 있게 될 때까지 블록됩니다. 성공하면
호출한 스레드를 소유자로 하는 잠긴 상태로 돌아옵니다. pthread_mutex_trylock() 은 여기까지
같습니다. 이미 잠겨 있으면 즉시 돌아온다는 점만 다릅니다. 자기 자신이 이미 잠근 경우도
포함입니다.
실패는 값으로 옵니다. 뮤텍스 타입이 PTHREAD_MUTEX_ERRORCHECK 이고 지금 스레드가 이미 그
뮤텍스를 소유하고 있으면 pthread_mutex_lock() 은 [EDEADLK] 로 실패합니다.
pthread_mutex_trylock() 은 이미 잠겨 있어 잡지 못하면 [EBUSY] 로 실패합니다.
SELECT ... FOR UPDATE
PostgreSQL 에서 행 수준 락을 명시적으로 잡는 구문입니다.
SELECT ... FOR UPDATE
FOR UPDATE 는 그 SELECT 가 가져온 행들을 갱신할 것처럼 잠급니다. 현재 트랜잭션이 끝날
때까지 다른 트랜잭션이 그 행을 잠그거나 수정하거나 삭제하지 못합니다. 그 행에 UPDATE ·
DELETE · SELECT FOR UPDATE · SELECT FOR NO KEY UPDATE · SELECT FOR SHARE ·
SELECT FOR KEY SHARE 를 시도하는 다른 트랜잭션은 현재 트랜잭션이 끝날 때까지 블록됩니다.
같은 문서는 FOR UPDATE 나 FOR SHARE 가 붙지 않은 평범한 SELECT 를 막는 것은
ACCESS EXCLUSIVE 락뿐이라고 적습니다. 그리고 모드를 명시하지 않은 LOCK TABLE 은
ACCESS EXCLUSIVE 를 기본값으로 잡습니다.
proxy_cache_lock
nginx 가 캐시 채우기를 하나로 묶는 설정 한 줄입니다.
proxy_cache_lock on;
기본값은 off 입니다. http · server · location 문맥에서 쓰며 1.1.12 판에 나왔습니다.
켜면 proxy_cache_key 로 식별되는 새 캐시 항목 하나를 채우기 위해 한 번에 하나의 요청만 뒤쪽
서버로 넘어갑니다. 같은 캐시 항목을 찾는 다른 요청들은 응답이 캐시에 나타날 때까지 기다립니다.
그 항목의 캐시 락이 풀릴 때까지 기다리기도 합니다. 기다리는 시간은
proxy_cache_lock_timeout 이 정한 만큼까지입니다. 여기서는 잠그는 대상이 자료구조도 파일도
아니라 캐시 항목 하나입니다.
관련 항목
락이 비롯된 배경
경쟁 상태 · 임계 구역 · 상호배제 · 원자성 · 동시성 · 병렬성
락의 하위 종류
뮤텍스 · 세마포어 · 스핀락 · 읽기-쓰기 락 · 어드바이저리 락 · 맨더토리 락 · 행 잠금 · 테이블 잠금 · 파일 락 · 분산 락 · 2단계 잠금 · 낙관적 잠금 · 비관적 잠금
락에서 자주 나는 오류·장애
데드락 · 라이브락 · 기아 · 우선순위 역전 · 락 경합 · 락 호위 · 갱신 손실 · 캐시 스탬피드
락을 대신할 수 있는 다른 수단
MVCC(Multi-Version Concurrency Control, 다중 버전 동시성 제어) · 무잠금 자료구조 · 원자적 연산 · 비교 후 교환 · 불변 데이터 · 단일 스레드 실행
락에 관여하는 역할·참여자
락과 맞물리는 데이터베이스 개념
락을 정의·구현한 표준과 제품
POSIX · PostgreSQL · nginx · Linux
락이 쓰이는 시스템
도는 락이 손대는 커널 자원
다른 이름: lock · 잠금