Devin.KR

경쟁 조건을 재현하는 순서

90분 안팎

학습 목표

읽기·수정·쓰기 이벤트 교차 실행으로 유실된 증가와 직렬화 결과를 비교합니다.

개념

완료 횟수와 공유 합계가 어긋나는 이유를 찾습니다

수집 태스크와 전송 태스크가 각자 완료 통계를 하나 올렸는데 공유 합계는 하나만 증가했다고 가정합니다. 로그의 완료 메시지 두 개만으로 합계가 올바르게 반영되었다고 판단할 수 없습니다. 이번 레슨은 공유 값의 읽기·계산·쓰기를 서로 다른 이벤트로 분해하고 실행 순서를 입력으로 지정합니다. 문제를 우연히 기다리는 대신 어느 순서가 갱신을 지우는지 재현하고, 보호해야 할 구간을 정합니다.

모델에는 태스크 A와 B, 공유 정수 shared와 태스크별 임시 값 local이 있습니다. shared는 0에서 시작하고 두 태스크는 각각 한 번 증가합니다. A의 지역 값은 B가 직접 바꾸지 않습니다. 각 태스크의 올바른 내부 순서는 읽기 R, 지역 증가 M, 공유 쓰기 W입니다. A의 읽기를 AR처럼 두 글자로 표현하며 이벤트는 AR·AM·AW·BR·BM·BW 여섯 개입니다. 이것은 컴파일러 명령을 분해한 실측 결과가 아니라 유실 갱신을 설명하는 결정적 모델입니다.

각 이벤트의 효과를 따로 기록합니다

AR은 shared를 local의 A 항목에 복사합니다. AM은 local의 A 항목에 1을 더합니다. AW는 그 값을 shared에 씁니다. B에도 같은 규칙을 적용합니다. AR 뒤에 BR이 실행되면 둘 다 0을 읽습니다. AM과 BM은 각자 1을 계산하고 AW와 BW는 모두 1을 씁니다. 두 태스크가 자신의 작업을 끝냈지만 최종 shared는 1입니다. 나중 쓰기가 앞선 쓰기의 값을 덮어쓰므로 증가 하나가 합계에 남지 않습니다.

AR·AM·AW를 모두 마친 뒤 BR·BM·BW가 실행되면 B가 읽는 이전 값은 1입니다. B는 2를 쓰고 최종 합계가 기대와 같습니다. 태스크 A가 먼저라는 사실 자체가 정답을 보장한 것은 아닙니다. B를 먼저 끝내고 A를 실행해도 2입니다. 중요한 차이는 한 태스크의 읽기부터 쓰기까지 사이에 다른 태스크가 같은 공유 값을 바꿀 수 있는지입니다. 시작 로그의 순서와 실제 공유 접근 순서를 혼동하지 않습니다.

공유 값 쓰기 직전에만 잠그는 정책도 부족합니다. A와 B가 이미 같은 0을 읽었다면 각자 1을 들고 대기하다 차례로 잠금을 얻어도 결과는 1입니다. 잠금의 범위는 위험한 문법 한 줄이 아니라 지켜야 할 조건에서 결정합니다. 여기의 조건은 성공한 증가 두 번이 합계에 두 번 반영되는 것입니다. 읽기·계산·쓰기를 같은 보호 구간에 넣어야 이 조건을 만족합니다. 계산이 길다면 공유 상태와 무관한 준비는 잠금 밖에서 합니다.

보호된 실행을 별도로 구성합니다

실습의 protected 결과는 주어진 순서에서 처음 읽기를 요청한 태스크의 순서를 구한 뒤, 그 순서로 각 태스크의 R·M·W 세 동작을 연속 실행해 계산합니다. AR 다음 BR이 와도 B의 읽기를 그대로 실행하지 않습니다. 실제 mutex를 모델에 넣었다면 B는 A가 잠금을 풀 때까지 대기할 것입니다. 입력에 적힌 불가능한 내부 동작을 억지로 진행시키지 않고 별도의 직렬화된 실행으로 비교합니다.

이 모델은 잠금 요청 순서와 소유권을 설명하지만 실제 스레드를 생성하거나 우선순위 상속을 구현하지 않습니다. protected=2는 모델에서 정한 원자적 구간의 결과입니다. Python 실행이 순차적이라는 사실만으로 C 코드도 안전하다고 주장하지 않습니다. 이어지는 C 실습에서 실제 pthread가 함께 사용하는 큐에는 각 접근이 같은 mutex 규칙을 지키도록 구현합니다. 언어 실행기의 내부 정책은 장치 코드의 동기화 계약을 대신하지 않습니다.

원자성은 중간 상태가 다른 참여자에게 끼어들어 관찰되지 않도록 필요한 묶음을 지키는 성질입니다. 가시성은 한 태스크의 변경을 다른 태스크가 언제 볼 수 있는지, 순서는 메시지 본문을 채운 뒤 준비 표시를 공개하는 관계가 지켜지는지의 질문입니다. 증가 모델은 원자성 실패를 중심으로 보여 줍니다. 다음 큐에서는 데이터와 인덱스가 일치하도록 같은 보호 구간을 사용합니다. 숫자 증가의 성공을 전체 메시지 공개의 성공으로 확대 해석하지 않습니다.

정상적인 태스크 내부 순서만 허용합니다

표준 입력은 공백으로 구분한 이벤트 여섯 개입니다. 줄바꿈은 공백과 같게 취급합니다. 여섯 이름이 각 한 번씩 있어야 하고 A와 B 각각 R보다 M이 뒤에, M보다 W가 뒤에 있어야 합니다. AW가 AR 앞에 있는 입력은 경쟁 상황이 아니라 초기화되지 않은 지역 값을 쓰는 잘못된 시나리오입니다. 이 경우 INVALID를 출력합니다. 이벤트 수·허용 집합·중복 여부·태스크 내부 순서를 실행 전에 검사합니다.

정상 출력은 unprotected=N protected=2입니다. 비보호 결과는 유효한 입력에 따라 1 또는 2가 됩니다. shared와 local을 각각 초기화하여 두 모델을 독립 실행합니다. 비보호 결과 1을 다음 모델의 초기 값으로 재사용하면 protected가 3이 되어 비교가 깨집니다. 또 protected를 상수 2로 출력하는 대신 실제 직렬화 흐름을 실행합니다. 이 과정은 나중에 태스크 수나 초기 값을 바꿀 때 검사할 근거를 남깁니다.

비보호 구현에서는 이벤트 문자열을 태스크와 연산으로 나누고 R이면 복사, M이면 지역 증가, W이면 공유 대입을 수행합니다. 입력 검증을 통과했다면 M과 W 시점에 해당 local이 존재합니다. 검증 순서가 빠지면 KeyError가 나거나 존재하지 않는 지역 값을 0으로 채워 오류 입력을 정상처럼 처리할 수 있습니다. 딕셔너리의 기본값으로 덮기보다 사전 조건을 검사하는 편이 잘못된 실행 순서를 정확히 드러냅니다.

실제 C 코드로 옮길 때 남는 질문입니다

일반 C 공유 객체를 동기화 없이 여러 스레드가 충돌 접근하면 데이터 레이스에 따른 정의되지 않은 동작이 생길 수 있습니다. 그래서 실제 C 비보호 프로그램을 반복 실행해서 결과 1을 얻는 것을 이 레슨의 재현 방법으로 삼지 않습니다. 이곳의 Python 이벤트 모델은 정의된 연산만 실행하여 유실 경로를 안정적으로 보여 줍니다. 실제 스레드 검사는 보호된 큐를 대상으로 수행하고 결과의 순번·건수·본문을 검증합니다.

volatile을 붙여도 읽기·계산·쓰기 묶음이 하나가 되지 않습니다. 장치 레지스터 접근의 목적과 태스크 간 통계를 보호하는 목적은 다릅니다. 단일 원자 카운터는 증가를 보호할 수 있어도 메시지 본문 여러 필드와 큐 인덱스의 관계를 저절로 지키지 않습니다. 어떤 공유 객체를 누가 읽고 쓰는지 목록으로 만든 다음, 소유권 분리·메시지 전달·적합한 동기화 중 필요한 방법을 고릅니다.

실험 결과가 2였다는 이유로 비보호 설계를 승인하지 않습니다. 그 입력은 안전하게 보이는 한 순서일 뿐, 다른 유효한 교차 실행에서 1이 나옵니다. 반복 횟수를 늘리는 검사와 실패 경로를 정한 검사는 목적이 다릅니다. 잠깐 대기하는 sleep을 붙이거나 로그를 더 찍는 방법은 타이밍을 바꾸어 증상을 숨길 수 있습니다. 같은 이벤트 순서를 다시 실행할 수 있도록 입력 여섯 개와 단계별 shared·local 값을 증거로 남깁니다.

미션에서는 이 증가 모델을 task_model.py의 검사로 유지합니다. 보호 전 1·보호 후 2를 보이는 입력 하나와 이미 직렬화된 입력 하나를 함께 확인합니다. 경쟁 조건·데드락의 이론 전체는 연결한 서재 장으로 이어 읽고, 여기서는 읽기를 보호 범위에서 제외하면 왜 유실이 남는지 실행 흔적으로 설명할 수 있으면 목표를 달성합니다.

따라하기

유실을 만드는 여섯 단계

각 쓰기는 맞아 보여도 마지막 shared는1입니다. 중간 지역값을 같이 관찰합니다.

EVENTS=['AR','BR','AM','BM','AW','BW']
shared=0; local={}
for event in EVENTS:
    task,op=event
    if op=='R': local[task]=shared
    elif op=='M': local[task]+=1
    else: shared=local[task]
    print(event,f'shared={shared}',f'local={local[task]}')

실행 결과

AR shared=0 local=0
BR shared=0 local=0
AM shared=0 local=1
BM shared=0 local=1
AW shared=1 local=1
BW shared=1 local=1

보호 구간 전체 직렬화

A의 읽기부터 쓰기까지 마친 다음 B가 읽습니다. shared는2가 됩니다.

EVENTS=['AR','AM','AW','BR','BM','BW']
shared=0; local={}
for event in EVENTS:
    task,op=event
    if op=='R': local[task]=shared
    elif op=='M': local[task]+=1
    else: shared=local[task]
    print(event,f'shared={shared}',f'local={local[task]}')

실행 결과

AR shared=0 local=0
AM shared=0 local=1
AW shared=1 local=1
BR shared=1 local=1
BM shared=1 local=2
BW shared=2 local=2

불가능한 내부 순서 거절

표준 입력을 받는 완성 예시로 검증합니다. AW가 AM보다 앞선 조합은 실행하지 않습니다. 표준 입력은 다음과 같습니다.

AR AW AM BR BM BW

표준 입력을 받는 완성 예시로 검증합니다. AW가 AM보다 앞선 조합은 실행하지 않습니다.

import sys
v=sys.stdin.read().split()
expected={'AR','AM','AW','BR','BM','BW'}
valid=len(v)==6 and set(v)==expected
if valid:
    valid=all(v.index(t+'R')<v.index(t+'M')<v.index(t+'W') for t in 'AB')
if not valid:
    print('INVALID')
else:
    shared=0; local={}
    for event in v:
        task,op=event
        if op=='R': local[task]=shared
        elif op=='M': local[task]+=1
        else: shared=local[task]
    unprotected=shared
    shared=0
    order=[e[0] for e in v if e[1]=='R']
    for task in order:
        local[task]=shared
        local[task]+=1
        shared=local[task]
    print(f'unprotected={unprotected} protected={shared}')

실행 결과

INVALID

확인 문제

실습

입력 이벤트 AR·AM·AW·BR·BM·BW 각 한 번과 태스크별 R→M→W 순서를 검사합니다. 정상 입력은 주어진 교차 순서의 읽기·지역증가·쓰기 결과를 unprotected=N으로 계산합니다. 처음 읽기 요청 순서로 태스크마다 세 동작을 연속 실행한 독립 결과를 protected=N으로 계산하여 같은 줄에 출력합니다. 초기값은 각각0입니다. 중복·누락·미정의 이벤트·내부 순서 위반은 INVALID입니다. 스레드나 sleep을 사용하지 않습니다.

모범 답안
import sys
v=sys.stdin.read().split()
expected={'AR','AM','AW','BR','BM','BW'}
valid=len(v)==6 and set(v)==expected
if valid:
    valid=all(v.index(t+'R')<v.index(t+'M')<v.index(t+'W') for t in 'AB')
if not valid:
    print('INVALID')
else:
    shared=0; local={}
    for event in v:
        task,op=event
        if op=='R': local[task]=shared
        elif op=='M': local[task]+=1
        else: shared=local[task]
    unprotected=shared
    shared=0
    order=[e[0] for e in v if e[1]=='R']
    for task in order:
        local[task]=shared
        local[task]+=1
        shared=local[task]
    print(f'unprotected={unprotected} protected={shared}')

더 읽기

면접 질문

  • 공유 통계 증가에서 읽기부터 쓰기까지 보호해야 하는 이유를 실행 순서로 설명해 주세요.
  • volatile이 태스크 간 동기화를 대신하지 못하는 이유를 설명해 주세요.