LabHub
배우기 러닝패스 코스

オペレーティングシステム

並行性 — 順序を決めなければ結果も決まらない

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

한 줄 요약

여러 실행 흐름이 같은 데이터를 건드리는 순간 결과가 실행 순서에 의존하게 되고, 그 순서를 강제하는 장치가 락이며, 락을 잘못 쓰면 아무도 진행하지 못하는 상태에 빠진다.

概念マップ: 경쟁 상태(race condition)・임계 구역・상호 배제・진행

왜 이게 필요했나

counter = counter + 1 이라는 한 줄은 기계어로 최소 세 단계다. 읽고, 더하고, 쓴다. 두 스레드가 이 코드를 동시에 실행하면 둘 다 같은 값을 읽고 같은 값을 쓰는 일이 생겨 증가 한 번이 사라진다. 이것이 경쟁 상태(race condition) 다.

이 버그의 고약한 점은 재현성이다. 대부분의 실행에서는 아무 일도 일어나지 않다가, 부하가 올라가거나 코어 수가 늘어나면 나타난다. 그래서 테스트를 통과하고 운영에서 터진다.

어떻게 동작하나

공유 자원을 건드리는 코드 구간을 임계 구역이라 하고, 올바른 해법은 세 조건을 만족해야 한다.

  1. 상호 배제 — 한 번에 하나만 임계 구역에 들어간다.
  2. 진행 — 아무도 안에 없으면 들어가려는 쪽 중 하나는 반드시 들어간다.
  3. 한정 대기 — 들어가려는 쪽이 무한정 밀리지 않는다.

소프트웨어만으로도 만들 수 있지만(피터슨 알고리즘) 현대 CPU 는 원자적 명령어를 제공한다. 비교하고 값이 같으면 바꾸는 CAS(compare-and-swap)가 대표적이고, 거의 모든 락과 무잠금 자료구조가 이 위에 서 있다.

동기화 도구는 성격이 다르다.

대기 방식도 갈린다. 스핀락은 풀릴 때까지 CPU 를 돌며 기다린다. 임계 구역이 아주 짧고 코어가 여유로울 때만 이득이며, 그렇지 않으면 CPU 를 태울 뿐이다. 블로킹 락은 스레드를 재우고 다른 일을 하게 하지만 컨텍스트 스위치 비용이 든다.

데드락은 네 조건이 동시에 성립할 때만 생긴다. 상호 배제, 점유하며 대기, 비선점, 순환 대기. 넷 다 필요하다는 말은 하나만 깨면 막을 수 있다는 뜻이기도 하다. 실무에서 가장 자주 쓰이는 방법이 순환 대기를 깨는 것, 즉 모든 코드가 락을 같은 순서로 획득하게 하는 규칙이다. 데이터베이스에서 데드락이 잦다면 트랜잭션이 행을 건드리는 순서가 제각각인지부터 본다.

우선순위 역전도 알아 둘 만하다. 낮은 우선순위 스레드가 락을 쥔 채 중간 우선순위 스레드에 밀려 실행되지 못하면, 그 락을 기다리는 높은 우선순위 스레드까지 함께 막힌다. 화성 탐사선 패스파인더의 유명한 재부팅 사고가 이 문제였고, 해법은 락을 쥔 스레드의 우선순위를 일시적으로 올려 주는 우선순위 상속이다.

현장에서 만나는 모습

애플리케이션에서 "재고를 확인하고 없으면 차감"처럼 읽고 판단한 뒤 쓰는 패턴은 락 없이는 언제나 깨진다. 그런데 여기서 흔한 오해가 하나 있다. 데이터베이스의 격리 수준을 올리면 해결된다는 믿음이다. 두 트랜잭션이 같은 집합을 읽고 서로 다른 행을 갱신하면 쓰기 충돌이 없으므로 스냅샷 격리는 이를 잡지 못한다. 이 현상을 쓰기 편향(write skew)이라 하고, 직렬화 가능 수준이나 명시적 잠금, 또는 제약 조건으로만 막을 수 있다.

락을 덜 쓰는 방향

락은 정확하지만 비싸고, 잘못 쓰면 데드락을 만든다. 그래서 실무의 방향은 락을 잘 쓰는 것보다 락이 필요 없게 만드는 것이다. 방법은 대체로 셋이다.

공유하지 않는다. 각 스레드가 자기 몫의 데이터만 만지고 마지막에 합치면 락이 아예 필요 없다. 개수를 세는 일이라면 스레드마다 따로 세었다가 끝에 더하는 식이다. 합치는 단계에서만 한 번 동기화하면 되므로 경합이 사라진다.

바꾸지 않는다. 값을 고치는 대신 새 값을 만들어 참조만 갈아 끼우면, 읽는 쪽은 락 없이 안전하다. 읽기가 압도적으로 많은 설정이나 조회 표에 잘 맞는다. 대신 갱신할 때마다 사본을 만들므로 자주 바뀌는 데이터에는 맞지 않는다.

메시지로 넘긴다. 데이터를 공유하는 대신 소유권을 한 곳에 두고, 다른 흐름은 요청을 보내 그 곳이 처리하게 한다. 이 방식의 값어치는 성능이 아니라 어디서 그 데이터가 바뀌는지가 한 곳으로 모인다는 것이다. 버그를 찾을 자리가 코드 전체가 아니라 한 함수가 된다.

락을 써야 할 때의 규칙도 몇 가지만 지키면 대부분의 사고가 사라진다. 범위를 좁게 잡아 임계 구역 안에서 입출력이나 다른 락을 부르지 않고, 순서를 정해 여러 락을 언제나 같은 순서로 잡고, 락을 쥔 채 콜백을 부르지 않는다. 마지막 것이 특히 잘 잊히는데, 남의 코드를 부르는 순간 그 안에서 무슨 락을 잡을지 알 수 없어 순서 규칙이 깨진다.

마지막으로 경쟁 상태는 시험으로 잘 안 잡힌다. 대부분의 실행에서 통과하기 때문이다. 그래서 정적 분석기나 실행 시 경쟁을 감지해 주는 도구를 CI 에 넣는 편이 훨씬 효과적이고, 부하를 올려 오래 돌리는 시험을 따로 두는 것도 도움이 된다.

다음 실습에서 할 것

스레드 넷이 같은 값을 올리게 해서 갱신이 사라지는 것을 직접 만든다. 같은 프로그램을 다섯 번 돌려 값이 매번 다른 것을 확인한 뒤, 락으로 막고 아예 공유하지 않는 방식으로도 막아 본다. 마지막에는 락을 반대 순서로 잡아 교착을 만들고, 순서를 통일하는 것만으로 없앤다.