LODY/정리

운영체제 / 동시성과 락

동시성과 락

🟣 주소 공간을 공유하는 실행 흐름

앞 세 장은 프로세스를 서로 격리하는 이야기였다. 각자 CPU를 가진 듯, 각자 메모리를 가진 듯 착시를 줬다. 그런데 하나의 프로그램 안에서 일을 병렬로 나누고 싶을 때가 있다. 여러 코어를 동시에 쓰거나, 한 스레드가 입출력을 기다리는 동안 다른 스레드가 계산을 이어 가려는 경우다. 이럴 때 쓰는 것이 스레드다.

스레드는 프로세스 안의 독립된 실행 흐름이다. 프로세스와 결정적으로 다른 점은 같은 주소 공간을 공유한다는 것이다. 한 프로세스의 여러 스레드는 코드도, 힙도, 전역 변수도 같이 본다. 각자 자기 스택과 레지스터만 따로 가진다. 격리가 안전을 주던 앞 장과 정반대로, 여기서는 공유가 문제의 씨앗이 된다. 여러 스레드가 같은 데이터를 동시에 건드리면 무슨 일이 생기는지, 그리고 그걸 어떻게 막는지가 이 장의 주제다.

이 문제는 리눅스 시스템 프로그래밍에서 프로세스 간 공유 메모리와 세마포어로 한 번 다뤘던 것과 뿌리가 같다. 그쪽이 프로세스 사이의 공유였다면, 여기서는 한 프로세스 안 스레드 사이의 공유다. 이름과 무대만 다를 뿐 경쟁의 구조는 똑같다.


🟣 경쟁 상태: 한 줄이 원자적이지 않다

문제를 눈으로 보는 게 빠르다. 두 스레드가 공유된 카운터를 각각 백만 번씩 1 증가시킨다. 결과는 이백만이어야 한다. 그런데 실제로 돌려 보면 매번 그보다 작은, 실행할 때마다 다른 값이 나온다.

원인은 counter++가 한 번에 끝나는 연산이 아니라는 데 있다. 이 한 줄은 기계어로 세 단계다. 메모리에서 값을 읽고, 레지스터에서 1을 더하고, 다시 메모리에 쓴다. 스레드 A가 값을 읽은 직후 타이머 인터럽트로 문맥 교환이 일어나 B가 끼어들면, B도 같은 옛날 값을 읽는다. 둘 다 같은 숫자에 1을 더해 쓰니, 두 번 올랐어야 할 값이 한 번만 오른다. 이렇게 여러 실행 흐름이 공유 자원에 끼어들어 결과가 실행 순서에 따라 달라지는 상황이 경쟁 상태다.

값이 틀어지는 이 코드 구간을 임계 구역(critical section)이라 부른다. 임계 구역에는 한 번에 한 스레드만 들어가야 한다. 이 성질이 상호 배제(mutual exclusion)다. 결국 필요한 건 "이 구간을 실행하는 동안은 아무도 끼어들지 못하게" 만드는 도구다. 그 도구가 락이다.


🟣 락은 하드웨어의 도움 없이는 못 만든다

락의 목표는 단순하다. 임계 구역에 들어가기 전에 잠그고, 나올 때 푼다. 잠겨 있으면 다른 스레드는 풀릴 때까지 기다린다. 문제는 이 락 자체를 소프트웨어만으로 올바르게 만들 수 없다는 것이다.

"락이 풀렸는지 확인하고, 풀렸으면 잠근다"를 두 줄로 짜면, 그 두 줄 사이에 또 문맥 교환이 끼어들 수 있다. A가 풀린 것을 확인한 직후 멈추고, B도 풀린 것을 확인하면, 둘 다 잠그고 둘 다 임계 구역에 들어간다. 락을 만들려다 똑같은 경쟁 상태에 다시 빠진다. 확인과 잠금이 쪼개지지 않고 한 번에 일어나야 하는데, 소프트웨어로는 이 원자성을 보장할 수 없다.

그래서 하드웨어가 원자적 명령을 제공한다. 대표가 테스트-앤-셋(test-and-set)이다. 이 명령은 메모리의 값을 읽어 반환하는 동시에 새 값을 써넣는 두 동작을 쪼개지지 않게 한 번에 한다. 이걸로 락을 짜면, 값을 읽어 잠겨 있었는지 확인하는 것과 잠그는 것이 원자적으로 함께 일어나 앞의 빈틈이 사라진다. 비슷하게 "기대한 값이면 바꾼다"를 원자적으로 하는 컴페어-앤-스왑(compare-and-swap)도 널리 쓰인다. 락은 이 하드웨어 원자 명령 위에 세워진다.

하드웨어 원자 명령(test-and-set, CAS)(상호 배제)임계 구역안전하게 보호됨원자성 제공한 번에 하나만 입장

🟣 기다리는 방식: 돌 것인가, 잠들 것인가

락이 잠겨 있을 때 기다리는 스레드는 무엇을 할까. 두 가지 길이 있다. 하나는 락이 풀렸는지 계속 반복해서 확인하는 스핀락(spinlock)이다. 짧게 기다릴 거라면 문맥 교환 비용 없이 바로 진입하니 유리하다. 하지만 오래 기다리면 CPU를 태우며 아무 일도 안 하는 낭비가 된다.

다른 길은 락을 못 얻으면 스레드를 재우고(대기 상태로 보내고) CPU를 양보하는 것이다. 락이 풀리면 커널이 그 스레드를 깨운다. 오래 기다릴 때 유리하지만, 재우고 깨우는 문맥 교환 비용이 든다. 실전 락(리눅스의 futex 같은)은 이 둘을 섞는다. 짧게 스핀하며 기다려 보고, 그래도 안 풀리면 그제야 잠든다. 앞 장들에서 반복된 그 균형 감각이 여기서도 나온다. 짧은 대기엔 스핀이, 긴 대기엔 잠듦이 싸다.

성능에는 공정성도 얽힌다. 단순한 스핀락은 운 나쁜 스레드가 계속 락을 못 얻는 기아에 빠질 수 있다. 도착 순서대로 번호표를 나눠 주고 자기 차례에 들어가게 하는 티켓 락(ticket lock)이 순서를 보장해 이를 막는다.


🟣 자료구조에 락을 거는 법: 정확성과 성능 사이

락 하나로 데이터를 지키는 법을 알았으니, 실제 자료구조에 적용해 본다. OSTEP은 이 지점을 따로 다룬다. 가장 단순한 방법은 자료구조 전체를 락 하나로 감싸는 것이다. 공유 카운터라면 증가 연산 전체를 락으로 두른다. 정확하다. 그런데 모든 연산이 그 락 하나를 두고 줄을 서니, 코어를 아무리 늘려도 빨라지지 않는다. 정확하지만 확장성이 없다.

성능을 되찾으려면 락을 잘게 나눈다. 카운터라면 코어마다 지역 카운터를 따로 두고 각자 자기 것만 올리다가, 가끔 전역 카운터에 합친다. 이런 확장 가능한 근사 카운터는 전역 락 경합을 줄여 코어 수에 맞춰 성능이 오른다. 대신 전역 값이 잠깐 실제와 어긋날 수 있다. 정확성을 조금 양보하고 확장성을 얻는 거래다.

연결 리스트에서도 같은 고민이 이어진다. 리스트 전체를 락 하나로 잠그는 대신, 노드마다 락을 두고 순회하면서 다음 노드의 락을 잡고 나서 현재 노드의 락을 푸는 방식이 있다. 이렇게 하면 서로 다른 구간을 동시에 손볼 수 있다. 다만 노드마다 락을 잡고 푸는 비용이 만만치 않아, 실제로는 단순한 전체 락이 더 빠른 경우도 많다. 락을 잘게 나눌수록 병렬성은 오르지만 락 관리 비용도 오른다는 트레이드오프가 동시 자료구조 설계의 핵심이다.


🟣 조건 변수, 세마포어, 그리고 데드락

락은 "한 번에 하나만"을 해결하지만, "어떤 조건이 만족될 때까지 기다림"은 다른 도구가 필요하다. 생산자가 데이터를 채울 때까지 소비자가 기다리는 상황이 그렇다. 스핀으로 조건을 계속 확인하는 건 낭비다. 조건 변수(condition variable)는 스레드를 조건이 만족될 때까지 재우고, 다른 스레드가 조건을 바꾸면 깨운다. 이 조건 변수와 카운터를 합쳐, 대기와 신호를 정수 하나로 다루는 것이 세마포어다. 세마포어의 상세한 동작(P·V 연산, 이진·계수 세마포어)은 리눅스 시스템 프로그래밍 쪽에서 프로세스 동기화로 정리했으니, 여기서는 스레드 동기화에도 그대로 쓰인다는 점만 짚는다.

락을 여러 개 쓰기 시작하면 새 위험이 생긴다. 스레드 A가 락 1을 쥔 채 락 2를 기다리고, B가 락 2를 쥔 채 락 1을 기다리면 둘 다 영원히 멈춘다. 이 데드락(deadlock)은 네 가지 조건이 동시에 성립할 때만 생긴다. 상호 배제, 자원을 쥔 채 기다림(hold-and-wait), 뺏을 수 없음(no preemption), 그리고 순환 대기(circular wait)다.

스레드 A락 1 보유락 2스레드 B락 2 보유락 1락 2 대기락 1 대기

가장 흔한 대응은 순환 대기를 깨는 것이다. 모든 스레드가 락을 항상 같은 순서로만 잡게 강제하면 순환이 생길 수 없다. 네 조건 중 하나만 무너뜨려도 데드락은 성립하지 않는다.


🟣 데이터 레이스를 언어가 막는다: Swift Concurrency

이 경쟁 상태 문제가 최신 iOS 개발의 한복판에 있다. Swift는 5.5부터 동시성을 언어 차원으로 끌어들였고, 그 목표 중 하나가 데이터 레이스를 컴파일 시점에 잡아내는 것이다. 개발자가 락을 손으로 걸다 실수하는 대신, 언어가 공유 상태 접근을 검사한다.

핵심 도구가 actor다. 액터는 자기 내부 상태를 감싸서 한 번에 한 작업만 접근하게 만든다. 이 장 초반에 본 임계 구역과 상호 배제를 언어 문법으로 표현한 것이다. 락을 직접 잡고 푸는 대신, 액터에 상태를 넣으면 컴파일러가 그 접근을 직렬화해 준다. 메인 스레드에서만 UI를 건드리게 강제하는 @MainActor도 같은 원리다. UI 상태라는 공유 자원을 하나의 실행 흐름으로 몰아 경쟁 자체를 없앤다. 락과 임계 구역의 이론을 알면, Swift가 왜 액터라는 추상을 도입했고 컴파일러가 무엇을 검사하는지가 선명해진다. 결국 언어는 개발자가 손으로 걸던 상호 배제를 타입 시스템으로 옮긴 것이다.


🟣 정리

  • 스레드는 같은 주소 공간을 공유하는 실행 흐름이다. 공유가 병렬성을 주는 대신 경쟁 상태라는 문제를 낳는다.
  • counter++가 원자적이지 않아 여러 스레드가 끼어들면 결과가 순서에 따라 달라진다. 임계 구역을 상호 배제로 지켜야 한다.
  • 락은 소프트웨어만으로 못 만든다. test-and-set, compare-and-swap 같은 하드웨어 원자 명령 위에 세운다.
  • 기다림은 스핀과 잠듦으로 갈리고, 실전 락은 짧게 스핀하다 잠드는 식으로 둘을 섞는다. 티켓 락은 순서를 보장해 기아를 막는다.
  • 동시 자료구조는 락을 잘게 나눌수록 병렬성이 오르지만 관리 비용도 오른다. 데드락은 네 조건이 모두 성립할 때만 생기고, 락 순서를 고정해 순환 대기를 깨면 막을 수 있다.

면접에서 "액터가 무엇을 해결하나"나 "데이터 레이스를 어떻게 막나"를 물으면, 임계 구역과 상호 배제라는 이론에서 출발해 Swift가 그걸 언어로 옮긴 과정까지 이어 답할 수 있다. 이것으로 가상화와 동시성, OSTEP의 두 조각을 정리했다. 남은 조각인 지속성(파일 시스템과 디스크)은 다음 책으로 미룬다. 자원을 나눠 쓰고, 그 경쟁을 다루는 원리가 CPU에서 메모리로, 다시 스레드로 같은 얼굴을 하고 반복된다는 것이 이 책 전체를 관통한 관찰이었다.