그냥 글을 써 봅니다/서버 개발 수기

EP.03 — 3단계 적응형 스핀락: 왜 spin하다 yield하다 sleep하는가

Tiboong 2026. 3. 5. 10:17
728x90

이 글은 『서버 개발 수기』 시리즈의 세 번째 글이다.

지난 회에서는 서버의 3계층 구조를 살펴봤다.

오늘부터 Layer 2 안으로 들어간다. 첫 번째는 락.

※ 이 시리즈에서 다루는 서버 코드는 Project-AO(Ancient Origin)라는 가명으로 부른다.

 


화장실 이야기부터 하자

스레드 8개가 돌고 있다. 패킷이 들어올 때마다 버퍼를 하나 빌려야 한다. 버퍼는 공용이다.

화장실이라고 생각하면 된다. 스레드 1이 들어가서 문 잠근다. 스레드 2가 와서 문을 당겨본다. 잠겼다. 기다려야 한다.

문제는 이 "기다림"을 어떻게 하느냐는 거다.

릭이 걸렸어!!!

내가 SI에서 쓰던 건 델파이 같은 4GL 도구였다. DB에 직접 붙여서 화면 만들면 끝. 소켓이니 스레드니 하는 세계가 아니었다. 그 후에는 웹을 했는데, 동접 1~2만도 무난했다. 프레임워크가 다 해주니까 스레드가 어떻게 도는지 신경 쓸 일이 없었다. 화장실 줄 서는 고민 같은 건 해본 적이 없었다. 근데 MMORPG는 스레드 8개가 소켓 수만 개를 나눠 처리한다. 공용 버퍼를 두고 8명이 동시에 손을 뻗는 상황이 생기는 거다.

 

그러면 줄 서는 방법이 중요해진다.


코드를 열었다

XIOSpinLock.cpp를 열었다. while 루프가 3개다. 왜 3개지?

void XIOSpinLock::Wait() {
    int count = 4000;
    // Phase 1: Spin
    while (--count >= 0) {
        if (InterlockedCompareExchange(&m_lock, 1, 0) == 0) return;
        __builtin_ia32_pause();
    }
    // Phase 2: Yield
    count = 4000;
    while (--count >= 0) {
        sched_yield();
        if (InterlockedCompareExchange(&m_lock, 1, 0) == 0) return;
    }
    // Phase 3: Sleep
    while (true) {
        usleep(1000);
        if (InterlockedCompareExchange(&m_lock, 1, 0) == 0) return;
    }
}

 

처음 봤을 때는 무슨 소리인지 하나도 몰랐다. InterlockedCompareExchange가 뭔데. __builtin_ia32_pause는 또 뭔데. sched_yield는. 그냥 영어 단어들이 나열되어 있을 뿐.

Claude한테 던졌다. "이게 뭐야?"


Phase 1 — 문 앞에서 노크하기

첫 번째 while 루프. 4000번 돈다.

화장실 문 앞에 서서 "열렸나? 열렸나? 열렸나?" 계속 확인하는 거란다. CPU를 100% 쓰면서.

 

왜 이렇게 하냐고 물었더니, 락을 잡고 있는 놈이 금방 풀 가능성이 높기 때문이란다. 버퍼 빌리는 건 몇 마이크로초면 끝난다. 그 짧은 시간에 OS한테 "나 재워줘"라고 부탁하면 오히려 재우고 깨우는 비용이 더 크단다.

4000번이면 대략 0.01ms. 이 안에 풀리면 거의 안 기다린 거나 마찬가지다.

근데 4000번이 다 돌았는데도 안 열리면?


Phase 2 — "다른 사람 먼저 하세요"

두 번째 while 루프. 또 4000번.

 

이번엔 sched_yield()를 부른다. "내 CPU 차례를 포기하고 다른 스레드한테 넘겨줘." 무한 노크 대신 잠깐 비켜주는 거다.

이게 왜 필요한지 처음에 몰랐다. Claude가 알려준 게 있는데 — 락을 잡고 있는 놈이 같은 CPU에서 돌고 있을 수 있단다. 내가 CPU를 양보하면 그 놈이 돌아가서 락을 풀 수 있다는 거다.

아. 그러니까 문 앞에서 계속 노크하는 게 아니라, 한 발 물러서서 "당신이 먼저 나와야 내가 들어가지" 하는 거였다.

 

spin보다는 느리지만, CPU를 태우지는 않는다.

 

근데 이것마저 4000번 다 돌았는데도 안 열린다면? 이건 진짜 오래 걸리는 상황이다.


Phase 3 — "나 잠깐 잘게"

세 번째 while 루프. 이번은 무한.

 

usleep(1000). 1밀리초 자고 일어나서 확인하고, 안 열렸으면 다시 1밀리초 자고.

포기한 거다. 의자에 앉아서 눈 붙이는 거다.

 

1ms가 사람한테는 찰나지만 CPU한테는 영겁이라고 한다. 명령어 수백만 개 실행할 수 있는 시간이니까. 락이 0.001ms 만에 풀렸어도 나는 1ms를 꽉 채우고 일어난다.

 

느리다. 근데 여기까지 왔다는 건 진짜 오래 걸리는 상황이라는 뜻이니까, CPU를 낭비하는 것보다는 낫다.


전체를 보면

락 잡으려는데 잠겨있다
    │
    ▼
Phase 1: Spin (4000번)
    "금방 열리겠지"
    │
    ├─ 열렸다! → 바로 진입
    │
    ▼ 4000번 지남

Phase 2: Yield (4000번)
    "좀 걸리네, 양보할게"
    │
    ├─ 열렸다! → 진입
    │
    ▼ 또 4000번 지남

Phase 3: Sleep (1ms씩)
    "한참 걸리나보다, 잘게"
    │
    ├─ 1ms 후 확인 → 열렸다! → 진입
    └─ 안 열렸으면 → 다시 1ms

 

대부분은 Phase 1에서 끝난다고 한다. 99%.

버퍼 빌리는 건 금방 끝나니까. Phase 2까지 가는 경우가 가끔, Phase 3까지 가는 건 정말 드물다.

Phase 1만 있으면? 오래 걸리는 경우에 CPU만 계속 태운다.

Phase 3만 있으면? 금방 끝나는 경우에도 1ms를 낭비한다.

3단계로 나눠서 상황에 맞게 적응하는 거다. 그래서 "적응형(Adaptive) 스핀락"이라고 부른단다.


4000이라는 숫자

왜 하필 4000일까. 1000이나 10000이 아니라.

 

유추해봤다. 2006년 CPU 기준으로 spin 1회가 대략 20~30나노초. 4000번이면 약 0.1ms. 버퍼 하나 빌려서 돌려주는 시간이 1 마이크로초 안팎이니까, 스레드 여러 개가 몰리는 최악의 경우에도 Phase 1 안에서 끝나게 잡은 숫자 같았다. "정상 범위의 천장"인 셈이다.

 

근데 알고 보니 단서가 있었다. Windows APIInitializeCriticalSectionAndSpinCount()라는 함수가 있다. 크리티컬 섹션에 spin 횟수를 지정하는 건데, 마이크로소프트 공식 문서에서 권장하는 값이 4000이었다.

IOCP 기반 서버를 만든 사람이 이 문서를 안 봤을 리가 없다. 허공에서 뽑아낸 천재적 숫자가 아니라, 마이크로소프트가 수많은 서버 환경에서 테스트해서 찾아낸 값을 가져다 쓴 거였다.

 

좋은 엔지니어는 바퀴를 재발명하지 않는다.

그런데 이거 불공평하지 않아?

여기서 하나 걸렸다.

 

Phase 3에서 자고 있는 동안 락이 풀렸다. 근데 Phase 1에서 spin 중이던 다른 스레드가 0.001ms 만에 낚아챈다. 나는 1ms 후에 일어나서 "아직 잠겨있네..." 하고 다시 잔다.

 

스핀락은 줄을 안 센다. 선착순이 아니라 운 좋은 놈이 먼저다. 문 앞에서 노크하는 놈이 의자에서 자는 놈보다 항상 빠르다.

 

이론적으로는 영원히 굶는 스레드가 생길 수 있다. 기술 용어로는 기아(starvation)라고 한다.

 

근데 실제로는 거의 안 일어난단다. 락을 잡고 있는 시간이 극도로 짧으니까. 거기다 버퍼 풀이 16슬롯으로 분산되어 있어서 같은 문 앞에 몰릴 확률 자체가 낮다.

 

설계자는 "99%의 빠른 경우를 최적화하고, 1%의 불공정은 감수한다"를 선택한 거다.

 

공정한 락(pthread_mutex, 대기표 방식)은 느리다. OS 개입이 필요하니까. 속도를 택하고 공정성을 버린 거다. 단, "락을 잡는 시간이 극도로 짧다"는 전제 위에서.

이 전제가 깨지면 스핀락은 독이 된다.

 

그 전제를 지키기 위해 만든 게 버퍼 풀이다. 다음 회에 다룬다.


지금이라면 어떻게 할까

2006년에는 이걸 직접 만들어야 했다.

 

지금은? pthread_mutexPTHREAD_MUTEX_ADAPTIVE_NP 옵션을 켜면 커널이 알아서 spin하다 sleep한다. C++20의 std::atomic::wait()도 비슷한 일을 해준다.

 

Project-AO가 유저스페이스에서 손으로 만들었던 걸, 이제는 OS가 해주는 시대가 된 거다.

근데 원리는 똑같다. "짧으면 spin, 길어지면 sleep." OS가 해주든 직접 만들든.

도구가 없던 시대에 원리를 직접 구현한 코드를 보니까, "왜 이렇게 해야 하는지"가 더 선명하게 보인다. pthread_mutex(ADAPTIVE_NP) 한 줄로 끝나는 세상에서는 얻을 수 없는 이해다.


다음 회에서는

스핀락이 빠르려면 락을 잡는 시간이 짧아야 한다. 그리고 같은 락에 몰리지 않아야 한다.

그 두 가지를 해결하는 게 16슬롯 버퍼 풀이다. 화장실을 16개로 늘린 거다.

 

→ EP.04: 16슬롯 버퍼 풀 — 화장실을 16개로 늘린 이유

728x90