Devin.KR

동시성의 정확성 - 경쟁 조건과 원자성, 가시성과 순서, 데드락 예방 (CS 기초 8장)

개발자 조회 1

이 장에서 배우는 것

7장에서는 가상 주소와 실제 페이지의 관계를 살폈다. 이번에는 같은 메모리를 함께 사용하는 실행 흐름들이 결과를 어떻게 깨뜨리는지 다룬다. 선수지식은 5장의 공유 범위와 6장의 실행 중단 및 재개다. 언어별 동기화 문법 대신 필요한 보장의 이름과 재현 가능한 실행 순서를 익힌다.

  • 공유 값을 읽고 고치는 동작 사이에 끼어드는 실행을 재현한다.
  • 원자성, 가시성, 순서 보장을 서로 다른 질문으로 설명한다.
  • 경쟁 조건과 데드락을 재현 조건으로 기록하고 자원 획득 순서를 설계한다.

개념

완료한 작업 수를 한 개씩 올렸는데 마지막 합계가 실제 완료 수보다 작다고 가정한다. 로그를 더 찍으면 사라지고 부하가 큰 때에만 나타나기도 한다. 단순히 실행이 느려서 생긴 일로 보면 잠깐 기다리는 코드를 붙이고 끝낼 수 있다. 그러나 두 작업이 같은 이전 값을 읽고 자기 결과를 덮어쓰는 순서가 가능하다면 기다림의 길이를 바꾸어도 올바름은 보장되지 않는다.

한 줄과 하나의 동작은 다르다

값을 하나 증가시키는 표현은 개념적으로 읽기, 계산, 쓰기로 나누어 볼 수 있다. A와 B가 둘 다 10을 읽은 다음 각각 11을 쓰면 두 작업을 마쳤는데 최종 값은 11이다. 각자 계산은 맞았지만 공유 상태에 반영한 전체 결과가 기대와 다르다. 이처럼 여러 실행 흐름의 상대적 순서가 결과의 올바름을 좌우하는 상황을 경쟁 조건으로 다룬다.

A와 B가 같은 값 10을 읽고 각각 11을 써서 갱신 하나가 사라진다. 아래 대기 그래프는 A가 B를, B가 A를 기다리는 순환을 보인다.

A와 B가 같은 값 10을 읽고 각각 11을 써서 갱신 하나가 사라진다. 아래 대기 그래프는 A가 B를, B가 A를 기다리는 순환을 보인다. 그림의 경계는 소유 범위를, 화살표는 실행 또는 참조 방향을 뜻한다.

원자성은 정해진 연산을 다른 참여자에게 쪼개진 중간 상태로 보이지 않게 다루는 보장이다. 보장 범위를 한 변수 읽기로 잡을지, 여러 값의 관계를 함께 고치는 구간으로 잡을지 먼저 결정해야 한다. 단일 읽기와 쓰기가 각각 원자적이어도 읽고 판단하고 쓰는 전체 묶음은 별도 문제다. 보장이 필요한 단위는 지켜야 할 조건에서 정한다.

질문필요한 보장대표적인 실패
중간 상태가 끼어들어도 되는가원자성갱신 소실·검사 후 상태 변경
쓴 값을 다른 흐름이 언제 볼 수 있는가가시성오래된 상태 관찰
앞선 쓰기와 뒤의 신호가 어떤 관계인가순서준비 신호와 데이터의 불일치
언젠가 진행할 수 있는가진행 가능성서로 자원을 기다리며 정지

보인다와 올바른 순서다는 다르다

생산자가 데이터를 채운 뒤 준비 표시를 바꾸고 소비자가 그 표시를 보고 데이터를 읽는다고 가정한다. 소스 코드에서 그 순서로 적었다는 것만으로 다른 실행 흐름에도 필요한 관찰 관계가 생긴다고 단정하지 않는다. 언어의 메모리 모델과 사용하는 동기화 장치가 보장하는 관계를 확인해야 한다. 이 설명을 모든 하드웨어가 항상 쓰기를 거꾸로 수행한다는 뜻으로 읽어서도 안 된다.

가시성은 변경이 다른 실행 흐름에 관찰되는 문제이고, 순서는 어떤 동작이 다른 동작보다 먼저 관찰되어야 하는지의 문제다. 원자적으로 신호 하나를 바꾸는 기능이 있다고 해서 신호 앞의 여러 데이터 쓰기까지 모두 원하는 관계로 공개된다고 가정하면 안 된다. 필요한 계약은 데이터를 완성한 뒤 신호를 공개하고, 소비자가 그 공개를 확인한 뒤 데이터를 읽는 관계다. 어떤 장치가 이 계약을 제공하는지는 사용하는 언어와 도구에서 확인한다.

필요한 관계
생산자: 데이터 준비 → 공개 신호
소비자: 공개 확인 → 데이터 읽기
소스 코드의 배열만으로 스레드 간 보장을 추정하지 않는다

상호 배제와 데드락은 함께 설계한다

상호 배제는 같은 보호 구간에 여러 실행 흐름이 동시에 들어오지 못하게 하는 방식이다. 보호하려는 상태에 접근하는 모든 참여자가 같은 규칙을 따라야 효과가 있다. 일부 쓰기만 보호하고 다른 경로가 그대로 수정한다면 조건은 여전히 깨질 수 있다. 읽는 쪽도 여러 값의 관계를 일관되게 보아야 한다면 해당 규칙에 참여해야 한다.

두 작업이 서로 가진 자원이 풀리기를 기다리면 아무도 진행하지 못할 수 있다. 데드락의 고전적인 네 조건은 상호 배제, 보유하며 대기, 강제 회수 불가, 순환 대기다. 자원을 배타적으로 갖고, 이미 가진 것을 유지한 채 다른 것을 기다리며, 강제로 빼앗을 수 없고, 기다림이 고리를 이룬다는 뜻이다. 네 조건 중 하나를 구조적으로 없애면 이 모형의 데드락을 예방할 수 있다.

실무에서는 서로 다른 경로에서도 자원을 같은 전역 순서로 얻도록 해 순환 대기를 없애는 선택이 흔하다. 다만 화면에 보이는 두 자원만 정렬하고 내부 호출이 다른 자원을 잡으면 규칙이 완성되지 않는다. 호출 경계를 넘는 전체 획득 순서를 확인해야 한다. 기다림을 중단하는 시간 제한은 실패를 감지하는 데 도움을 주지만 이미 가진 자원의 정리와 재시도 규칙이 없으면 예방책이 되지 않는다.

직접 확인하기

우연을 기다리지 않고 실패 순서를 고정한다

다음 Python 3 예제는 실제 스레드를 경쟁시키지 않는다. 두 작업의 읽기와 쓰기를 의도한 순서로 배열하여 어떤 누락된 보장이 결과를 깨뜨리는지 결정적으로 재현한다. 실행 환경의 스레드 구현 때문에 우연히 성공하거나 실패하는 실험을 피하고 실행 순서 자체를 검증한다. 출력은 실제 응용 프로그램의 모든 가능한 실행 결과를 대신하지 않는다.

value = 10
a = value
b = value
a += 1
b += 1
value = a
value = b
print("expected", 12)
print("observed", value)
결과
----
expected 12
observed 11

최종 값 11은 두 번 더하기의 산술 오류가 아니다. 두 참여자가 출발점 10을 공유한 채 자신의 결과를 덮어쓴 것이다. 오류 기록에는 입력값과 기대값뿐 아니라 A 읽기, B 읽기, A 쓰기, B 쓰기라는 관계를 남긴다. 이렇게 하면 느리게 실행했을 때 문제가 사라져도 무엇을 막아야 하는지 유지된다.

가능한 끼어들기를 열거해 보장을 비교한다

각 작업 안의 읽기, 계산, 쓰기 순서를 유지하면서 두 작업을 섞으면 이 작은 모형에서는 스무 가지 순서가 나온다. 표준 라이브러리의 조합을 이용해 A가 들어갈 세 자리를 고르면 나머지는 B다. 각 순서의 결과를 모아 11과 12가 모두 가능함을 확인한다. 전체 경우를 작은 모형에서 확인한 것이지 무제한의 실제 프로그램을 증명한 것은 아니다.

from itertools import combinations
results = set()
for a_slots in combinations(range(6), 3):
    steps = {"A": 0, "B": 0}
    local = {}
    value = 10
    for slot in range(6):
        who = "A" if slot in a_slots else "B"
        step = steps[who]
        if step == 0:
            local[who] = value
        elif step == 1:
            local[who] += 1
        else:
            value = local[who]
        steps[who] += 1
    results.add(value)
print("schedules", 20)
print("possible results", sorted(results))
결과
----
schedules 20
possible results [11, 12]

다음 코드는 한 작업의 전체 갱신을 마친 뒤 다른 작업을 진행시키는 모형이다. 실제 락 API를 설명하거나 특정 런타임의 보장을 추정하지 않는다. 보호 구간이 읽기부터 쓰기까지 이어져야 한다는 점만 드러낸다. 계산 뒤 마지막 대입만 보호한다면 이미 같은 이전 값을 읽은 두 결과를 다시 쓰는 문제는 남는다.

value = 10
for worker in ("A", "B"):
    local = value
    local += 1
    value = local
    print(worker, value)
print("final", value)
결과
----
A 11
B 12
final 12

자원 순서도 작은 입력으로 검사할 수 있다. 다음은 요청에 적힌 순서가 달라도 같은 우선순위로 정렬해 획득하도록 만드는 모형이다. 실제 자원을 잠그거나 교착 상태를 만들어 프로세스를 멈추지 않는다. 두 경로가 동일한 순서를 따른다는 전제와 결과를 확인하는 실험이다.

rank = {"left": 0, "right": 1}
requests = [("left", "right"), ("right", "left")]
for request in requests:
    order = sorted(request, key=rank.get)
    print("request", "/".join(request), "acquire", "/".join(order))
결과
----
request left/right acquire left/right
request right/left acquire left/right

환경별 차이

환경달라지는 부분그대로 필요한 판단
Python·Java 등 런타임언어의 메모리 모델과 제공 장치한 표현을 전체 원자 연산으로 단정하지 않음
단일 CPU같은 순간 병렬 실행이 없음실행 중간에 다른 작업이 끼어들 수 있음
여러 CPU동시에 실행할 수 있음공유 상태와 관찰 순서의 계약 필요
여러 프로세스기본 주소 공간은 분리공유 메모리·파일 등 공동 자원 규칙 필요

단일 CPU에서도 한 작업이 읽은 뒤 멈추고 다른 작업이 값을 바꾸면 경쟁 조건이 생길 수 있다. 따라서 코어를 하나로 제한해 오류가 줄었다고 올바름이 증명되지는 않는다. 여러 프로세스를 사용해도 공유 파일이나 명시적 공유 메모리에서 비슷한 문제가 생길 수 있다. 핵심은 실행 주체의 이름이 아니라 공동으로 변경하는 자원의 존재다.

언어별로 어떤 표현이 어느 수준까지 보장되는지는 다르며 버전과 실행 방식의 영향을 받을 수 있다. 이 장의 인터리빙은 개념 모형이므로 특정 런타임에서 단 한 줄이 반드시 세 단계로 끊긴다는 주장으로 사용하지 않는다. 실제 구현에서는 공식 메모리 모델과 동기화 계약을 읽고 필요한 보장을 대응시킨다. API 사용법은 언어별 학습 영역이며 여기서는 조건의 정의에 집중한다.

정확성을 검증할 때는 최종 합계뿐 아니라 중간 상태의 조건도 살핀다. 완료 수가 총 작업 수를 넘지 않는지, 반환한 자원을 다시 쓰지 않는지, 실패 경로에서도 자원을 풀었는지 같은 조건이 필요하다. 모든 실행을 반복 테스트만으로 조사할 수 없으므로 작은 상태 모형과 실제 구현 검증을 함께 사용한다. 테스트에서 오류를 못 보았다는 사실과 오류가 불가능하다는 주장은 구별한다.

실무에서 자주 틀리는 것

1. 한 줄 증가식은 원자적이라고 믿는다

완료 수가 간헐적으로 작아지는 증상에서 정수 계산이 잘못됐다는 오진이 나온다. 실제 원인 후보는 읽기와 갱신을 하나의 보호 단위로 다루지 않은 것이다. 갱신을 단계로 나누고 둘이 같은 값을 읽는 순서를 구성해 확인한다. 수정 뒤에는 모든 갱신 경로가 동일한 보호 규칙을 따르는지도 검토한다.

2. 잠깐 기다리면 가시성이 보장된다고 본다

준비 표시를 본 뒤에도 데이터가 기대와 다르게 보이는 증상이다. 흔한 오진은 컴퓨터가 아직 느리니 기다림만 늘리면 된다는 것이다. 실제 원인에는 공개와 관찰 사이의 필요한 동기화 관계가 빠진 경우가 있다. 시간 지연 대신 어떤 계약이 데이터 공개 순서를 보장하는지 확인하며 재현 빈도 감소를 보장 확보로 착각하지 않는다.

3. 락을 추가하면 언제나 더 안전하다고 믿는다

값의 오류는 줄었지만 요청 둘이 끝없이 멈추는 증상이다. 흔한 오진은 서버 용량이 부족하다는 것이다. 실제로 서로 반대 순서로 자원을 얻어 순환 대기가 생겼을 수 있다. 각 실행 흐름이 가진 자원과 기다리는 자원을 그려 고리를 찾고, 모든 호출 경로가 같은 획득 순서를 따르는지 확인한다.

정확성의 조건과 진행 조건을 함께 적는다. 공유 상태가 깨지지 않아도 아무 작업도 끝나지 않으면 시스템은 목적을 달성하지 못한다. 데드락, 특정 작업이 계속 밀리는 상황, 재시도만 반복하는 상황은 원인이 다를 수 있다. 멈췄다는 증상에 이름만 붙이기 전에 실제 대기 관계와 상태 변화를 조사한다.

스스로 확인하기

  1. 값 10을 두 작업이 각각 한 번 올렸는데 11이 되었다. 이를 만드는 읽기와 쓰기 순서를 적고 보호할 구간을 설명하라.
  2. A가 왼쪽 자원을 가진 채 오른쪽을 기다리고 B는 반대다. 데드락 네 조건을 대응시키고 하나를 깨는 규칙을 제안하라.
  3. 스무 가지 모형 순서에서 결과가 11과 12였다. 실험을 백 번 반복해 모두 12가 나왔으므로 안전하다는 주장을 판단하라.

첫 답은 A와 B가 10을 읽고 각각 11을 쓴 순서이며 읽기부터 결과 반영까지 조건을 보호해야 한다. 두 번째는 배타적 소유, 보유하며 대기, 회수 불가, 순환 대기이고 같은 전역 순서로 자원을 얻으면 이 고리를 막을 수 있다. 세 번째는 거짓이다. 성공한 표본만으로 이미 모형에서 확인된 실패 순서가 실제 구현에서 불가능하다고 증명할 수 없다.

참고와 출처: Java 언어 명세의 스레드와 메모리 모델을 원자성·관찰 순서의 구분을 확인하는 자료로 사용했다. 해당 언어의 규칙을 모든 런타임의 규칙으로 일반화하지 않았다. 예제, 문제, 대기 그래프와 본문은 자체 작성했으며 외부 문장과 코드를 발췌하지 않았다.

다음 9장에서는 올바르게 만든 데이터를 파일로 내보낼 때 지나가는 경로를 살핀다. 응용 프로그램에서 쓰기를 마쳤다는 사실과 저장장치까지 보장되었다는 사실의 차이가 새 판단 기준이 된다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.