동시성 — 순서를 정하지 않으면 결과가 정해지지 않는다
한 줄 요약
여러 실행 흐름이 같은 데이터를 건드리는 순간 결과가 실행 순서에 의존하게 되고, 그 순서를 강제하는 장치가 락이며, 락을 잘못 쓰면 아무도 진행하지 못하는 상태에 빠진다.
왜 이게 필요했나
counter = counter + 1 이라는 한 줄은 기계어로 최소 세 단계다. 읽고, 더하고, 쓴다. 두 스레드가 이 코드를 동시에 실행하면 둘 다 같은 값을 읽고 같은 값을 쓰는 일이 생겨 증가 한 번이 사라진다. 이것이 경쟁 상태(race condition) 다.
이 버그의 고약한 점은 재현성이다. 대부분의 실행에서는 아무 일도 일어나지 않다가, 부하가 올라가거나 코어 수가 늘어나면 나타난다. 그래서 테스트를 통과하고 운영에서 터진다.
counter = counter + 1 은 기계어로 읽고, 더하고, 쓰는 세 단계다. 두 흐름이 겹치면 이렇게 된다.
- 둘이 같은 값을 읽는다둘 다 100 을 본다. 아직 아무 문제도 드러나지 않는다.
- 둘이 각자 더한다둘 다 101 을 손에 쥔다. 서로의 존재를 모른다.
- 둘이 차례로 쓴다결과는 102 가 아니라 101 이다. 증가 한 번이 사라졌다.
여기서 구분할 것 이 버그는 대부분의 실행에서 나타나지 않다가 부하가 오르거나 코어가 늘면 드러난다. 그래서 시험을 통과하고 운영에서 터진다.
잠깐, 예측해 보세요 부하 시험에서는 멀쩡했는데 운영에서 숫자가 어긋난다. 무엇을 의심하겠는가?
설명 확인 · 채점 없는 자가 점검
겹칠 창이 좁아서 안 걸렸을 뿐이다. 코어 수와 동시 요청이 늘면 그 창에 들어가는 빈도가 올라간다.
어떻게 동작하나
공유 자원을 건드리는 코드 구간을 임계 구역이라 하고, 올바른 해법은 세 조건을 만족해야 한다.
- 상호 배제 — 한 번에 하나만 임계 구역에 들어간다.
- 진행 — 아무도 안에 없으면 들어가려는 쪽 중 하나는 반드시 들어간다.
- 한정 대기 — 들어가려는 쪽이 무한정 밀리지 않는다.
소프트웨어만으로도 만들 수 있지만(피터슨 알고리즘) 현대 CPU 는 원자적 명령어를 제공한다. 비교하고 값이 같으면 바꾸는 CAS(compare-and-swap)가 대표적이고, 거의 모든 락과 무잠금 자료구조가 이 위에 서 있다.
동기화 도구는 성격이 다르다.
- 뮤텍스 — 소유자가 있다. 잠근 스레드만 풀 수 있다.
- 세마포어 — 카운터다. 소유자 개념이 없어 A 가 획득하고 B 가 반납해도 된다. 자원 개수를 제한할 때 맞다.
- 조건 변수 — "어떤 조건이 참이 될 때까지 기다린다"를 표현한다. 반드시 뮤텍스와 짝을 이루고, 깨어난 뒤 조건을 다시 검사해야 한다(가짜 깨어남이 있다).
대기 방식도 갈린다. 스핀락은 풀릴 때까지 CPU 를 돌며 기다린다. 임계 구역이 아주 짧고 코어가 여유로울 때만 이득이며, 그렇지 않으면 CPU 를 태울 뿐이다. 블로킹 락은 스레드를 재우고 다른 일을 하게 하지만 컨텍스트 스위치 비용이 든다.
데드락은 네 조건이 동시에 성립할 때만 생긴다. 상호 배제, 점유하며 대기, 비선점, 순환 대기. 넷 다 필요하다는 말은 하나만 깨면 막을 수 있다는 뜻이기도 하다. 실무에서 가장 자주 쓰이는 방법이 순환 대기를 깨는 것, 즉 모든 코드가 락을 같은 순서로 획득하게 하는 규칙이다. 데이터베이스에서 데드락이 잦다면 트랜잭션이 행을 건드리는 순서가 제각각인지부터 본다.
우선순위 역전도 알아 둘 만하다. 낮은 우선순위 스레드가 락을 쥔 채 중간 우선순위 스레드에 밀려 실행되지 못하면, 그 락을 기다리는 높은 우선순위 스레드까지 함께 막힌다. 화성 탐사선 패스파인더의 유명한 재부팅 사고가 이 문제였고, 해법은 락을 쥔 스레드의 우선순위를 일시적으로 올려 주는 우선순위 상속이다.
현장에서 만나는 모습
애플리케이션에서 "재고를 확인하고 없으면 차감"처럼 읽고 판단한 뒤 쓰는 패턴은 락 없이는 언제나 깨진다. 그런데 여기서 흔한 오해가 하나 있다. 데이터베이스의 격리 수준을 올리면 해결된다는 믿음이다. 두 트랜잭션이 같은 집합을 읽고 서로 다른 행을 갱신하면 쓰기 충돌이 없으므로 스냅샷 격리는 이를 잡지 못한다. 이 현상을 쓰기 편향(write skew)이라 하고, 직렬화 가능 수준이나 명시적 잠금, 또는 제약 조건으로만 막을 수 있다.
락을 덜 쓰는 방향
락은 정확하지만 비싸고, 잘못 쓰면 데드락을 만든다. 그래서 실무의 방향은 락을 잘 쓰는 것보다 락이 필요 없게 만드는 것이다. 방법은 대체로 셋이다.
공유하지 않는다. 각 스레드가 자기 몫의 데이터만 만지고 마지막에 합치면 락이 아예 필요 없다. 개수를 세는 일이라면 스레드마다 따로 세었다가 끝에 더하는 식이다. 합치는 단계에서만 한 번 동기화하면 되므로 경합이 사라진다.
바꾸지 않는다. 값을 고치는 대신 새 값을 만들어 참조만 갈아 끼우면, 읽는 쪽은 락 없이 안전하다. 읽기가 압도적으로 많은 설정이나 조회 표에 잘 맞는다. 대신 갱신할 때마다 사본을 만들므로 자주 바뀌는 데이터에는 맞지 않는다.
메시지로 넘긴다. 데이터를 공유하는 대신 소유권을 한 곳에 두고, 다른 흐름은 요청을 보내 그 곳이 처리하게 한다. 이 방식의 값어치는 성능이 아니라 어디서 그 데이터가 바뀌는지가 한 곳으로 모인다는 것이다. 버그를 찾을 자리가 코드 전체가 아니라 한 함수가 된다.
락을 써야 할 때의 규칙도 몇 가지만 지키면 대부분의 사고가 사라진다. 범위를 좁게 잡아 임계 구역 안에서 입출력이나 다른 락을 부르지 않고, 순서를 정해 여러 락을 언제나 같은 순서로 잡고, 락을 쥔 채 콜백을 부르지 않는다. 마지막 것이 특히 잘 잊히는데, 남의 코드를 부르는 순간 그 안에서 무슨 락을 잡을지 알 수 없어 순서 규칙이 깨진다.
마지막으로 경쟁 상태는 시험으로 잘 안 잡힌다. 대부분의 실행에서 통과하기 때문이다. 그래서 정적 분석기나 실행 시 경쟁을 감지해 주는 도구를 CI 에 넣는 편이 훨씬 효과적이고, 부하를 올려 오래 돌리는 시험을 따로 두는 것도 도움이 된다.
넷이 동시에 성립할 때만 생긴다. 넷 다 필요하다는 말은 하나만 깨면 막을 수 있다는 뜻이다.
- 깨기 어려운 셋상호 배제, 점유하며 대기, 비선점. 이것들은 락이라는 장치의 성질 자체라 없애기 어렵다.
- 실무에서 깨는 하나순환 대기다. 모든 코드가 락을 같은 순서로 얻게 정하면 고리가 생기지 않는다.
여기서 구분할 것 데이터베이스에서 데드락이 잦다면 트랜잭션이 행을 건드리는 순서가 제각각인지부터 본다. 락의 종류보다 순서가 먼저다.
잠깐, 예측해 보세요 두 트랜잭션이 같은 두 행을 건드리는데 서로 반대 순서다. 무엇을 바꾸겠는가?
설명 확인 · 채점 없는 자가 점검
행을 얻는 순서를 한쪽으로 통일한다. 예를 들어 기본 키 오름차순처럼 코드가 아니라 데이터로 정해진 순서를 쓴다.
다음 실습에서 할 것
스레드 넷이 같은 값을 올리게 해서 갱신이 사라지는 것을 직접 만든다. 같은 프로그램을 다섯 번 돌려 값이 매번 다른 것을 확인한 뒤, 락으로 막고 아예 공유하지 않는 방식으로도 막아 본다. 마지막에는 락을 반대 순서로 잡아 교착을 만들고, 순서를 통일하는 것만으로 없앤다.