사전 임계 구역
개념

임계 구역

gabury1고친 사람 github-actions[bot]

임계 구역은 한 번에 하나만 지나가게 막아야 하는 코드 구간입니다. 여럿이 같은 값을 동시에 고치면 그 값이 꼬이기 때문입니다. 이 구간을 찾아 한 번에 하나만 들어가게 지키는 일이 동시성 프로그래밍의 기본입니다. 그 일은 흔히 락이 맡습니다.

쉽고 빠른 이해

임계 구역은 「여기는 한 명씩」이라고 표시해 둔 코드 구간입니다. 재고가 하나 남은 상품을 두 요청이 동시에 사는 코드가 그 예입니다.

이 표시가 없으면 두 요청이 둘 다 「재고 있음」을 보고 둘 다 팔아 버립니다. 이런 버그는 두 요청이 딱 맞게 겹칠 때만 터져서 테스트에서는 잘 안 보입니다.

도는 방식은 이렇습니다.

  1. 구간에 들어가기 전에 문을 잠그는 장치인 락을 잡습니다
  2. 락을 잡은 요청만 구간 안의 코드를 실행합니다
  3. 다 끝나면 락을 놓습니다. 기다리던 다음 요청이 들어갑니다

대가는 기다림입니다. 구간 안은 한 줄로 서서 지나갑니다. 그래서 구간이 길수록 요청을 동시에 더 받아도 처리량이 덜 늘어납니다. 락을 잘못 걸면 요청끼리 서로 기다리다 전부 멈추기도 합니다.

상세

공용 화장실 한 칸은 한 사람씩만 씁니다. 들어간 사람은 문을 잠급니다. 볼일을 마치면 문을 열고 나옵니다. 그동안 다른 사람은 문 앞에 줄을 섭니다.

코드를 한 줄씩 따라가는 실행 하나를 실행 흐름이라고 합니다. 한 프로그램 안에서 여러 흐름을 동시에 돌리는 단위가 스레드입니다. 웹 서버는 흔히 요청마다 스레드를 하나씩 붙여 처리합니다. 그래서 같은 코드를 여러 흐름이 동시에 지나갑니다.

여러 흐름이 함께 쓰는 값을 공유 자원이라고 합니다. 메모리 위의 변수, 파일, 데이터베이스의 한 행이 모두 공유 자원이 될 수 있습니다.

임계 구역(critical section)은 공유 자원을 읽고 고치는 코드 가운데 한 번에 한 흐름만 지나가야 하는 구간입니다. 한국어로는 임계 영역이라고도 부릅니다.

이 절은 재고를 줄이는 코드 한 조각으로 임계 구역을 봅니다.

재고를 줄이는 코드의 임계 구역

아래 메서드는 재고가 남아 있으면 하나를 줄이고 판매 성공을 돌려줍니다. 요청마다 다른 스레드가 이 메서드를 부른다고 해 봅시다.

Java
int stock = 1;

boolean buy() {
    if (stock > 0) {       // 확인
        stock = stock - 1; // 차감
        return true;
    }
    return false;
}

흐름이 하나뿐이면 이 코드는 맞게 돕니다. 재고가 1이면 첫 호출은 성공하고 둘째 호출은 실패합니다.

흐름 둘이 동시에 부르면 사정이 달라집니다. 두 흐름이 stock > 0 을 확인하는 때에 재고는 아직 1입니다. 둘 다 확인을 통과하고 둘 다 차감합니다.

sequenceDiagram
    participant A as 스레드 A
    participant S as stock
    participant B as 스레드 B
    A->>S: 확인
    S-->>A: 1 이라 통과
    B->>S: 확인
    S-->>B: 1 이라 통과
    A->>S: 차감
    B->>S: 차감
    Note over S: 하나를 두 번 팔았다

상품 하나를 두 번 판 셈입니다. 재고 값은 0이 되기도 하고 -1이 되기도 합니다. 두 흐름이 차감하려고 둘 다 1을 읽으면 각자 0을 써 넣어 0이 남습니다. 한 흐름이 0을 써 넣은 뒤에 다른 흐름이 읽으면 -1이 됩니다.

어느 쪽이 될지는 두 흐름이 어떤 순서로 겹쳤느냐에 달렸습니다. 이렇게 결과가 실행 순서에 따라 달라지는 상태를 경쟁 상태라고 부릅니다.

문제는 확인과 차감 사이의 틈입니다. 이 코드는 확인한 값이 차감할 때까지 안 바뀐다고 믿고 있습니다. 그 믿음이 맞으려면 if 로 확인하는 줄부터 차감하는 줄까지가 한 덩어리로 지나가야 합니다. 이 덩어리가 이 메서드의 임계 구역입니다.

return false 는 공유 자원을 건드리지 않습니다. 그래서 구간 밖에 두어도 됩니다. 임계 구역을 찾는 기준은 둘입니다. 공유 자원을 건드리는가, 그리고 도중에 남이 끼어들면 뜻이 깨지는가입니다.

이 버그는 두 흐름이 확인과 차감 사이에서 딱 맞게 겹쳐야 터집니다. 그런 겹침은 드물고 요청이 몰릴 때 늘어납니다. 그래서 테스트에서는 잘 안 보이다가 운영에서 가끔 터집니다. 터진 뒤에 다시 재현하기도 어렵습니다.

진입 구역과 퇴장 구역

흐름이 임계 구역을 한 번 지나는 동안의 코드는 넷으로 나눠 부릅니다. 임계 구역 자신, 그 앞에 붙는 코드, 뒤에 붙는 코드, 나머지 전부입니다.

flowchart TD
    R["나머지 구역 · 공유 자원을 안 건드린다"] --> E["진입 구역 · 들어가도 되나 묻는다"]
    E --> C["임계 구역 · 한 번에 하나"]
    C --> X["퇴장 구역 · 나간다고 알린다"]
    X --> R

진입 구역은 임계 구역 바로 앞의 코드입니다. 들어가도 되는지 묻습니다. 안 되면 기다립니다. 락을 잡는 줄이 여기에 섭니다.

퇴장 구역은 임계 구역 바로 뒤의 코드입니다. 다 썼다고 알려서 기다리던 흐름 하나가 들어오게 합니다. 락을 놓는 줄이 여기에 섭니다.

나머지 구역은 공유 자원을 건드리지 않는 코드 전부입니다. 이 구간은 여러 흐름이 동시에 지나가도 됩니다. 그림처럼 흐름은 이 넷을 돌고 또 돕니다.

아래는 앞의 메서드에 진입 구역과 퇴장 구역을 붙인 것입니다. 자바의 synchronized 블록은 둘을 한 번에 적는 문법입니다.

Java
final Object lock = new Object();

boolean buy() {
    synchronized (lock) {     // 진입
        if (stock > 0) {
            stock = stock - 1;
            return true;
        }
    }                         // 퇴장
    return false;
}

블록에 들어갈 때 lock 객체의 락을 잡습니다. 블록을 나갈 때 놓습니다. 블록 안에서 return 으로 빠져나가도 놓습니다. 이제 재고가 1일 때 두 요청이 겹쳐도 한쪽만 성공합니다. return false 는 앞에서 본 대로 블록 밖에 두었습니다.

락 객체의 lock() 과 unlock() 을 직접 부르는 코드에서는 퇴장 구역이 빠지는 실수가 흔합니다. 구간 안에서 예외가 나서 놓는 줄을 못 지나면 락이 잡힌 채 남습니다. 그러면 기다리던 흐름은 영영 못 들어갑니다. 그래서 놓는 줄은 finally 블록에 둡니다.

지키는 방법이 갖춰야 할 세 조건

임계 구역을 지키는 방법은 여럿입니다. 어떤 방법이든 제대로 지킨다고 말하려면 세 조건을 채워야 합니다. 이 세 조건을 모두 채우는 방법을 찾는 문제를 임계 구역 문제(critical section problem)라고 부릅니다.

조건 뜻 깨지면
상호 배제 한 흐름이 구간 안에 있으면 다른 흐름은 못 들어간다 경쟁 상태
진행 구간이 비어 있고 들어가려는 흐름이 있으면 그중 하나는 결국 들어간다 데드락(서로 기다리다 멈춤) · 라이브락(서로 양보만 하다 못 들어감)
한정 대기 들어가겠다고 한 흐름은 남에게 정해진 횟수까지만 순서를 내준다 기아(한 흐름만 계속 밀림)

상호 배제는 임계 구역이라는 말의 뜻 그 자체입니다. 이것만 지키기는 어렵지 않습니다. 아무도 안 들여보내면 되기 때문입니다. 그래서 나머지 두 조건이 붙습니다.

진행은 「아무도 못 들어가는 상태」를 막습니다. 두 흐름이 서로 상대가 쥔 락을 기다리며 멈추는 데드락이 이 조건을 깹니다. 서로 양보만 하다가 둘 다 못 들어가는 라이브락도 이 조건을 깹니다.

한정 대기는 「나만 계속 못 들어가는 상태」를 막습니다. 다른 흐름은 잘 드나드는데 한 흐름만 계속 순서에서 밀리면 이 조건이 깨집니다. 그 상태를 기아라고 부릅니다.

운영체제의 도움 없이 메모리 읽기와 쓰기만으로 세 조건을 채우는 풀이도 있습니다. 두 흐름을 위한 풀이로 피터슨 알고리즘이 잘 알려져 있습니다.

요즘 프로세서는 속도를 내려고 메모리 읽기와 쓰기의 순서를 바꾸기도 합니다. 그러면 한 흐름이 상대가 남긴 표시를 늦게 봐서 둘이 함께 들어갈 수 있습니다. 이 풀이가 맞게 돌려면 순서를 고정하는 명령인 메모리 장벽을 끼워야 합니다. 그래서 실무에서는 이런 풀이 대신 아래 장치를 씁니다.

구간을 지키는 장치

실무에서는 운영체제나 언어가 주는 장치로 진입 구역과 퇴장 구역을 만듭니다. 장치는 주로 못 들어간 흐름이 어떻게 기다리는지로 갈립니다.

장치 못 들어간 흐름은 주로 쓰는 곳
뮤텍스 잠들어 기다린다 애플리케이션 코드 전반
스핀락 잠들지 않고 계속 다시 확인한다 구간이 아주 짧은 커널 코드
세마포어 들여보낼 수를 센다. 1로 두면 한 흐름만 들이고 나머지는 잠든다 들여보낼 수를 세어야 할 때
모니터 잠들어 기다린다. 조건이 설 때까지 기다리는 기능이 붙는다 자바 synchronized 같은 언어 문법
인터럽트(하드웨어가 프로세서에 보내는 신호) 끄기 애초에 다른 흐름으로 안 넘어간다 프로세서가 하나인 기계의 커널 코드

잠드는 방식은 기다리는 동안 프로세서를 다른 흐름에 내줍니다. 대신 깨어날 때 문맥 교환이 듭니다. 문맥 교환은 운영체제가 돌던 흐름을 멈추고 다른 흐름으로 갈아타는 일입니다.

계속 다시 확인하는 방식은 문맥 교환이 없습니다. 대신 기다리는 동안 프로세서를 헛돌립니다. 그래서 구간이 아주 짧아 곧 들어갈 수 있을 때만 씁니다.

인터럽트는 하드웨어가 프로세서에 보내는 신호입니다. 운영체제는 이 신호를 받고 다른 흐름으로 갈아탑니다. 프로세서가 하나뿐이면 신호를 잠시 꺼 두는 것만으로 아무도 끼어들지 못합니다. 프로세서가 여럿이면 다른 프로세서가 동시에 돌기 때문에 이 방법이 안 통합니다.

임계 구역을 없애는 방법

임계 구역은 아래 세 가지가 모일 때 생깁니다.

  • 여러 흐름이 같은 값을 같이 씁니다
  • 그 값을 고칩니다
  • 고치는 도중에 남이 끼어들면 뜻이 깨집니다

셋 중 하나를 없애면 지킬 구간도 없어집니다.

없애는 것 방법 한 줄 풀이
도중의 틈 원자적 연산 확인과 고치기를 프로세서 명령 하나로 끝낸다
같이 쓰기 스레드 로컬 · 메시지 전달 흐름마다 제 몫을 따로 두거나, 값을 같이 쓰지 않고 주고받는다
고치기 불변 객체 만든 뒤 안 바뀌는 값은 여럿이 동시에 읽어도 안 꼬인다

원자적 연산의 대표는 CAS(Compare-And-Swap, 비교 후 교체)입니다. 「지금 값이 내가 읽은 값과 같을 때만 새 값으로 바꿔라」를 명령 하나로 처리합니다. 그 사이 누가 값을 바꿨으면 교체가 실패합니다. 그러면 코드가 다시 읽고 다시 시도합니다. 재고 차감처럼 값 하나를 바꾸는 짧은 구간은 락 없이 이렇게 바꿔 쓸 수 있습니다.

구간의 길이와 처리량

임계 구역 안은 한 번에 하나만 지나갑니다. 흐름을 넷으로 늘려도 그 구간을 지나는 속도는 그대로입니다. 늘어난 흐름은 구간 앞에서 줄을 섭니다. 여럿이 한 락을 두고 기다리는 상태를 락 경합이라고 부릅니다.

전체 일 가운데 임계 구역이 차지하는 몫이 클수록, 흐름을 늘려 얻는 이득에 천장이 생깁니다. 그 천장을 계산하는 식이 암달의 법칙입니다.

그래서 구간은 짧게 둡니다. 특히 오래 걸리는 입출력을 구간 안에 두지 않습니다. 아래 코드는 다른 서비스를 부르는 동안 락을 쥐고 있습니다.

Java
synchronized (lock) {
    p = client.fetchPrice(id); // 원격 호출
    cache.put(id, p);
}

원격 호출이 끝날 때까지 다른 흐름은 전부 기다립니다. 원격 호출은 공유 자원인 cache 를 건드리지 않으므로 구간 밖으로 뺄 수 있습니다.

Java
Price p = client.fetchPrice(id); // 락 밖
synchronized (lock) {
    cache.put(id, p);
}

이제 락을 쥐는 동안 하는 일은 캐시에 넣는 일 하나뿐입니다.

공유 자원이 여럿이면 자원마다 락을 따로 두기도 합니다. 서로 다른 자원을 고치는 흐름끼리는 안 기다리게 됩니다. 이 방법을 락 세분화라고 합니다. 대가도 있습니다. 락이 여럿이 되면 흐름마다 잡는 순서가 엇갈려 데드락이 날 수 있습니다.

서버 여러 대에 걸친 임계 구역

같은 애플리케이션을 서버 여러 대에 띄우면 synchronized 로는 못 지킵니다. 이 락은 프로세스 하나의 메모리 안에만 있습니다. 프로세스는 실행 중인 프로그램 하나입니다. 다른 서버의 프로세스는 이 락이 있는지도 모릅니다.

그래서 모든 서버가 같이 보는 곳에 락을 둡니다. 흔한 방법은 데이터베이스의 행 잠금입니다.

데이터베이스는 여러 읽기와 쓰기를 한 묶음으로 처리합니다. 그 묶음을 트랜잭션이라고 합니다. 행을 읽으면서 잠그면 그 트랜잭션이 끝날 때까지 다른 트랜잭션은 그 행을 고치지 못합니다.

SQL
BEGIN;
SELECT stock FROM item WHERE id = 7 FOR UPDATE;
UPDATE item SET stock = stock - 1 WHERE id = 7;
COMMIT;

SELECT ... FOR UPDATE 가 진입 구역입니다. COMMIT 이 퇴장 구역입니다. 그 사이가 이 경우의 임계 구역입니다. 확인과 차감을 한 문장에 담는 방법도 있습니다. UPDATE item SET stock = stock - 1 WHERE id = 7 AND stock > 0 처럼 쓰면 확인과 차감 사이에 틈이 안 생깁니다.

데이터베이스 밖에 공용 락을 두는 방법은 분산 락이라고 부릅니다. 잠그지 않고 고친 뒤에 충돌을 확인하는 낙관적 잠금도 있습니다. 이쪽은 충돌이 나면 다시 시도합니다.

임계 구역이 생기는 코드와 안 생기는 코드

모든 코드에 임계 구역이 있는 것은 아닙니다. 아래 표는 백엔드 코드에서 자주 보는 경우를 가른 것입니다.

코드 임계 구역 까닭
애플리케이션에 하나만 있고 모든 요청이 같이 쓰는 객체의 필드를 고친다 생긴다 모든 요청 흐름이 같은 필드를 고친다
메서드 안의 지역 변수를 고친다 안 생긴다 흐름마다 따로 가진 값이라 같이 쓰지 않는다
설정값처럼 한 번 정하고 읽기만 한다 안 생긴다 아무도 고치지 않는다
읽기가 대부분이고 가끔 고친다 고치는 쪽만 생긴다 읽기끼리는 같이 들여보내도 안 꼬인다

마지막 줄의 경우에는 읽기-쓰기 락을 씁니다. 읽는 흐름은 여럿이 함께 들어갑니다. 고치는 흐름은 혼자만 들어갑니다.

관련 항목

임계 구역을 지키는 장치

락 · 뮤텍스 · 스핀락 · 세마포어 · 모니터 · 읽기-쓰기 락 · 조건 변수 · 인터럽트

임계 구역을 지키는 방법이 채워야 할 조건

상호 배제 · 진행 조건 · 한정 대기 · 임계 구역 문제

임계 구역 문제를 소프트웨어로 푼 알고리즘

피터슨 알고리즘 · 데커 알고리즘 · 빵집 알고리즘

임계 구역이 깨질 때 나는 오류

경쟁 상태 · 데이터 경쟁 · 갱신 손실 · 데드락 · 라이브락 · 기아 · 우선순위 역전

임계 구역을 없애는 수단

원자성 · 원자적 연산 · CAS · 락 프리 · 스레드 로컬 · 불변 객체 · 메시지 전달 · 액터 모델

임계 구역이 길 때 드는 비용

락 경합 · 암달의 법칙 · 문맥 교환 · 바쁜 대기 · 락 세분화

서버 여러 대에 걸친 임계 구역을 지키는 수단

행 잠금 · 비관적 잠금 · 낙관적 잠금 · 분산 락 · 트랜잭션

임계 구역이 놓이는 실행 환경

동시성 · 병렬성 · 스레드 · 프로세스 · 공유 자원 · 운영체제 · 커널

다른 이름: critical section · 임계 영역 · 크리티컬 섹션