pthread_mutex 하나로도 충분히 락을 걸 수 있는데 왜 futex를 직접 만져야 할까. 사실 glibc의 pthread_mutex_lock 자체가 내부적으로 futex(2) syscall 위에 구현되어 있다 — 경쟁이 없을 때는 유저스페이스의 atomic CAS(compare-and-swap) 한 번으로 락을 잡고, 경쟁이 발생했을 때만 커널에 진입해 스레드를 재운다. 이 “빠른 경로는 유저스페이스, 느린 경로만 커널”이라는 설계가 futex(fast userspace mutex)의 핵심이고, 이걸 직접 만들어보면 왜 스핀락이 아닌데도 뮤텍스가 대부분의 경우 syscall 없이 동작하는지 감이 온다. 이 글에서는 futex(2)를 직접 호출해 최소 뮤텍스를 구현하고, 실제로 컴파일·실행해 락 유무에 따른 레이스 컨디션 차이와 strace로 관찰한 FUTEX_WAIT/FUTEX_WAKE 동작을 정리한다.
핵심 개념
futex는 “값 하나”에 대한 조건부 대기/깨우기 프리미티브다. 커널은 락의 의미를 전혀 모르고, 유저 메모리의 4바이트 정수를 두고 두 가지만 제공한다.
| 연산 | 동작 |
|---|---|
| FUTEX_WAIT(addr, val) | *addr == val이면 스레드를 재운다(atomic 비교 후 대기 큐 등록). 값이 다르면 즉시 리턴(EAGAIN) |
| FUTEX_WAKE(addr, n) | addr에 대기 중인 스레드 중 최대 n개를 깨운다 |
Ulrich Drepper의 “Futexes Are Tricky”에서 소개된 3-state 뮤텍스 설계를 그대로 따랐다. 락 변수 하나에 3가지 상태만 둔다.
| state 값 | 의미 |
|---|---|
| 0 | unlocked |
| 1 | locked, 대기자 없음 (경쟁 없이 잡힘) |
| 2 | locked, 대기자 있음(contended) — unlock 시 반드시 FUTEX_WAKE 필요 |
lock()은 0→1 CAS를 먼저 시도해 성공하면 syscall 없이 끝난다. 실패하면(이미 잠겨 있으면) state를 2로 바꾸고 FUTEX_WAIT로 잔다. unlock()은 값을 1 감소시키고, 감소 전 값이 2(=대기자가 있었음)였을 때만 FUTEX_WAKE를 호출한다. 대기자가 없는 대부분의 경우 unlock도 syscall 없이 끝난다는 게 이 설계의 핵심이다.
실전 코드
WSL2(Ubuntu 24.04, glibc)에서 syscall(SYS_futex, ...)로 직접 호출해 구현했다. glibc는 futex를 별도 wrapper 함수로 노출하지 않는다.
#define _GNU_SOURCE
#include <stdio.h>
#include <stdint.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/syscall.h>
#include <linux/futex.h>
#include <stdatomic.h>
/*
* futex 기반 최소 뮤텍스 구현.
* state: 0 = unlocked, 1 = locked(no waiters), 2 = locked(contended)
*/
typedef struct {
atomic_int state;
} futex_mutex_t;
static long futex(atomic_int *uaddr, int futex_op, int val,
const struct timespec *timeout, int *uaddr2, int val3) {
return syscall(SYS_futex, uaddr, futex_op, val, timeout, uaddr2, val3);
}
static void futex_mutex_init(futex_mutex_t *m) {
atomic_store(&m->state, 0);
}
static void futex_mutex_lock(futex_mutex_t *m) {
int expected = 0;
/* fast path: uncontended CAS 0 -> 1 */
if (atomic_compare_exchange_strong(&m->state, &expected, 1))
return;
/* slow path: contended. state must become 2 so unlock() knows to wake. */
do {
expected = 1;
if (atomic_compare_exchange_strong(&m->state, &expected, 2) || expected == 2) {
/* state가 2로 바뀌었거나 이미 2였다: 값이 바뀔 때까지 잔다 */
futex(&m->state, FUTEX_WAIT, 2, NULL, NULL, 0);
}
expected = 0;
} while (!atomic_compare_exchange_strong(&m->state, &expected, 2));
}
static void futex_mutex_unlock(futex_mutex_t *m) {
int old = atomic_fetch_sub(&m->state, 1); /* 1->0 or 2->1 */
if (old == 2) {
atomic_store(&m->state, 0);
futex(&m->state, FUTEX_WAKE, 1, NULL, NULL, 0);
}
}8개 스레드가 공유 카운터를 각 20만 번씩 증가시키는 테스트로 검증했다.
gcc -O2 -Wall -pthread futex_mutex.c -o futex_mutex
./futex_mutexexpected=1600000 actual=1600000 OK
expected=1600000 actual=1600000 OK
expected=1600000 actual=1600000 OK
3회 반복 실행 모두 정확히 일치했다. 이제 락을 완전히 제거하고 g_counter++만 남긴 버전으로 비교한다.
static void *worker(void *arg) {
for (int i = 0; i < ITERS_PER_THREAD; i++)
g_counter++; /* 잠금 없음: race condition */
return NULL;
}| 빌드 | 결과 (3회 실행) |
|---|---|
| futex 뮤텍스 (-O2) | 1600000 / 1600000 / 1600000 — 전부 OK |
| 락 없음, -O0 | 541674 / 448089 / 447981 — 전부 MISMATCH |
| 락 없음, -O2 | 1600000 / 1600000 / 1600000 — 전부 OK (!) |
-O2에서 락 없는 버전도 우연히 값이 맞았는데, objdump로 확인해보니 이유가 있었다. 컴파일러가 다른 스레드의 개입 가능성을 전혀 모르는 상태에서 for(200000) g_counter++ 루프를 단일 메모리 add 명령 하나로 강도 축소(strength reduction)해버린 것이다.
0000000000001270 <worker>:
1270: f3 0f 1e fa endbr64
1274: 48 81 05 99 2d 00 00 addq $0x30d40,0x2d99(%rip) # g_counter
127b: 40 0d 03 00
127f: 31 c0 xor %eax,%eax
1281: c3 ret
레이스 윈도우가 20만 번의 개별 read-modify-write에서 명령어 1개짜리 add로 줄어드니 충돌 확률이 극히 낮아진 것뿐, 여전히 정의되지 않은 동작(UB)이다. -O0는 최적화 없이 load-inc-store 3단계를 그대로 남겨서 레이스가 쉽게 드러난다. “최적화 레벨을 바꾸니 버그가 사라졌다”는 상황을 실제로 재현한 셈인데, 동시성 버그를 컴파일러 최적화로 판단하면 안 되는 이유이기도 하다.
마지막으로 두 스레드가 실제로 경합하도록 락을 잡은 채 50ms씩 대기시키고 strace -f -e trace=futex로 syscall을 관찰했다.
strace -f -e trace=futex ./futex_trace_demo[pid 25819] futex(0x5f6f11d1b014, FUTEX_WAIT, 2, NULL <unfinished ...>
[pid 25818] futex(0x5f6f11d1b014, FUTEX_WAKE, 1 <unfinished ...>
[pid 25819] <... futex resumed>) = 0
[pid 25818] <... futex resumed>) = 1
[pid 25818] futex(0x5f6f11d1b014, FUTEX_WAIT, 2, NULL <unfinished ...>
[pid 25819] futex(0x5f6f11d1b014, FUTEX_WAKE, 1) = 1
[pid 25818] <... futex resumed>) = 0
...
[pid 25819] futex(0x5f6f11d1b014, FUTEX_WAKE, 1) = 0
0x5f6f11d1b014 주소가 g_lock.state이고, 뒤 3개 인자(NULL, NULL, 0)는 우리 코드가 그대로 넘긴 값이다. 락을 못 잡은 스레드가 FUTEX_WAIT로 블록되고(PID 25819), 락을 놓은 스레드가 FUTEX_WAKE로 깨우는(PID 25818) 흐름이 그대로 보인다. 맨 끝의 FUTEX_WAKE, 1) = 0은 반환값이 “실제로 깨운 대기자 수”라서, 마침 아무도 안 자고 있을 때 unlock이 호출되면 0이 찍힌다는 것도 확인할 수 있었다.
주의사항
- 이 코드는 학습용 최소 구현이다. 실제 프로덕션에서는 검증된 pthread_mutex를 쓸 것 — 우선순위 역전(priority inversion) 방지를 위한 FUTEX_LOCK_PI, 강건성(robust mutex), 재귀 락 등을 직접 다 챙기려면 훨씬 복잡해진다.
- state는 반드시 4바이트 정수여야 한다 (futex(2)는 32비트 값만 취급).
- 프로세스 간 공유 락으로 쓰려면 state를
mmap(MAP_SHARED)나shm_open으로 할당한 공유 메모리에 둬야 한다 — 스택/힙의 일반 메모리는 fork 이후 각자의 주소 공간으로 분리된다. - FUTEX_WAIT는 spurious wakeup(가짜 깨어남)이 있을 수 있어 반환 후에도 반드시 while 루프로 조건(CAS)을 재확인해야 한다. 이 구현은 do-while로 이미 그 구조를 따르고 있다.
- lock()의 CAS 재시도 루프는 공정성(fairness)을 보장하지 않는다 — 특정 스레드가 계속 새치기해서 락을 잡을 수 있다(락 기아, lock starvation).
마무리
futex는 “항상 커널에 진입하는 무거운 락”이 아니라 “경쟁이 없으면 유저스페이스에서 끝나고, 경쟁이 생겼을 때만 커널이 개입하는” 하이브리드 프리미티브다. 직접 3-state 뮤텍스를 구현해보면 pthread_mutex_lock 한 줄 뒤에 숨어 있는 CAS-then-syscall 패턴이 명확해지고, -O2 강도 축소 사례처럼 동시성 버그가 컴파일러 최적화 여부에 따라 겉으로 드러나거나 숨을 수 있다는 것도 실감할 수 있다.