来了八次更新,只留下一次
한국어 원문으로 표시합니다.
목표
출발선을 맞춰 경쟁 조건을 재현 가능한 사실로 만든 뒤, 잃어버린 갱신을 배타 잠금으로 막고, 확인 후 사용의 틈을 원자적 생성으로 닫고, 통째로 쓰는 문서를 원자적 이름 바꾸기로 갈아 끼운다. 마지막으로 잠금을 잡기 전에 파일을 비우는 실수가 무엇을 날리는지 직접 확인한다.
왜 중요한가
경쟁 조건은 "가끔 숫자가 안 맞는다" 로 신고된다. 혼자 돌리면 언제나 맞기 때문에 조사가 재현에서 막힌다. 그런데 프로세스를 먼저 다 띄워 두고 출발 표식 하나로 동시에 보내면, 경쟁은 확률이 아니라 언제나 일어나는 일이 된다. 재현이 되는 순간부터 그것은 고칠 수 있는 결함이다.
막는 방법은 증상의 모양에 따라 다르다. 읽고 고쳐 쓰는 구간은 잠금으로 묶고, 확인 후 사용은 한 번의 호출로 바꾸고, 통째로 쓰는 파일은 이름 바꾸기로 갈아 끼운다. 이 셋을 섞어 쓰면 잠금을 걸어도 증상이 남는다.
가장 자주 걸리는 함정은 잠금의 범위다. open(path, "w") 는 여는 순간 파일을 비우므로, 그다음 줄에서 잠금을 잡아도 이미 늦었다. 이 실수는 잠금을 건 코드에서 나기 때문에 더 오래 살아남는다.
채점기는 여러분의 설명을 믿지 않는다. 채점기가 자기 하네스로 여러분의 워커를 실제로 동시에 띄워 최종 값과 승자 수를 직접 센다. 프로세스 수는 실행마다 바뀌므로 값을 외워 넣을 수 없습니다.
단계
- /root/race/gen_race.py 를 만들어 실행해 /root/race/spawn.py(출발선 하네스)를 만드세요.
- /root/race/naive_add.py 로 잠금 없는 갱신을 동시에 보내 /root/race/lost.json 에 잃어버린 갱신을 적으세요.
- /root/race/locked_add.py 로 같은 일을 배타 잠금 안에서 하고 /root/race/lock.json 에 적으세요.
- /root/race/claim.py 로 확인 후 사용의 틈을 닫고 /root/race/claim.json 에 승자와 패자를 적으세요.
- /root/race/safe_write.py 와 /root/race/read_probe.py 로 부분 기록을 막고 /root/race/atomic.json 에 적으세요.
- /root/race/append_line.py 로 잠금 전에 파일을 비우지 않고 덧붙여 /root/race/append.json 에 적으세요.
- 두 판을 여러 번 돌려 /root/race/evidence.json 에 재현 가능한 증거를 남기세요.
- /root/race/summary.json 과 /root/race/race_report.md 에 네 절로 보고하세요.
참고
- 하네스 계약:
python3 /root/race/spawn.py --worker <스크립트> --procs <수> --barrier <파일> -- <워커 인자...>는 워커를 그 수만큼 띄우고, 전부 준비된 뒤 표식 파일을 만들어 동시에 출발시킨 다음 procs·exit_codes 를 담은 JSON 을 냅니다. - 워커 계약: 모든 워커는
--barrier <파일>을 받습니다. 기다리기 전에 준비 표식<barrier>.<pid>.up을 남기고, 그다음 표식 파일이 생길 때까지 기다렸다가 일을 시작합니다. 하네스는 그 준비 표식을 세어 전부 모인 뒤에 출발시킵니다 — 몇 초 기다리는 방식은 부하가 높은 날 뒤늦게 뜬 프로세스를 놓쳐 재현이 깨집니다. - 갱신 워커:
--file <카운터 파일> --barrier <파일> [--work-ms <밀리초>]. 카운터 파일에는 정수 하나가 들어 있고, 워커는 그 값을 하나 올립니다. 기본 작업 시간은 300밀리초입니다. - 선점 워커:
--dir <디렉터리> --barrier <파일> --owner <이름>. 이기면 종료 코드 0 으로 끝나고 그 디렉터리의lease파일에 owner 를 적습니다. 지면 9 로 끝납니다. - 쓰기·읽기:
python3 /root/race/safe_write.py --file <파일> --rounds <횟수> --entries <개수>와python3 /root/race/read_probe.py --file <파일> --reads <횟수> [--out <json>]은 file·reads·bad_reads 를 담은 JSON 을 냅니다. - 덧붙이기 워커:
--file <파일> --barrier <파일> --line <문자열>. 이미 있던 줄은 그대로 두고 자기 줄을 하나 덧붙입니다. - 잠금은 권고입니다. 잠금을 잡지 않고 여는 프로그램은 막지 못하므로, 같은 파일을 만지는 모든 코드가 같은 약속을 지켜야 합니다.
- 원자적 이름 바꾸기는 임시 파일이 같은 파일 시스템에 있을 때만 성립합니다. 같은 디렉터리에 만드는 것이 가장 쉽습니다.
- 흔한 실수: 잠금 전에
"w"로 열기, 읽기를 잠금 밖에 두기, 프로세스마다 다른 파일을 잠그기,/tmp에 임시 파일을 만들고 다른 위치로 옮기기. - 부하 시험을 만들지 마세요. 파드는 2코어이고 채점 하나의 예산은 60초입니다. 프로세스는 여덟 개면 충분합니다.
출발선을 맞추는 하네스 손에 쥐기
/root/race/gen_race.py 를 만들어 실행해 /root/race/spawn.py 를 만드세요. 이 하네스는 워커를 여러 개 띄운 뒤 표식 파일 하나로 동시에 출발시킵니다.
경쟁 조건 조사가 막히는 자리는 재현입니다. 손으로 두 터미널을 번갈아 치면 같은 순간에 들어가는 일이 잘 없습니다. 이 하네스를 그대로 저장해 실행하세요. 워커는 아직 없으니 다음 단계에서 만듭니다.
여덟 건이 하나가 되는 것 보기
/root/race/naive_add.py 를 만들어(읽고, 300밀리초 일하고, 하나 올려 쓰기) 8개 프로세스로 동시에 돌리고 /root/race/lost.json 에 procs·expected·observed·lost·work_ms 를 적으세요. 카운터 파일은 0 으로 시작합니다.
워커는 기다리기 전에 준비 표식(<barrier>.<pid>.up)을 남기고, --barrier 파일이 생길 때까지 기다렸다가 시작해야 합니다. 읽기와 쓰기 사이에 --work-ms 만큼 쉬면, 그 사이 다른 프로세스가 같은 값을 읽어 갑니다. 결과를 보고 놀라지 마세요 — 그게 이 단계의 목적입니다.
배타 잠금으로 한 덩어리 만들기
/root/race/locked_add.py 를 만들어 읽기부터 쓰기까지를 배타 잠금 안에 넣고, 같은 하네스로 8개를 돌려 /root/race/lock.json 에 procs·expected·observed·lost·method 를 적으세요. observed 는 8 이어야 합니다.
fcntl.flock 으로 파일에 LOCK_EX 를 겁니다. 잠금을 잡기 전에 값을 읽으면 아무것도 막지 못하니 순서를 조심하세요. 그리고 "w" 로 열면 잠금을 얻기도 전에 파일이 비워집니다 — 읽고 쓸 자리이니 "r+" 로 엽니다.
확인 후 사용의 틈 닫기
/root/race/claim.py 를 만들어 리스를 선점하게 하고(이기면 0, 지면 9), 8개를 동시에 돌려 /root/race/claim.json 에 procs·winners·losers·owner_in_lease·method 를 적으세요. 승자는 정확히 하나여야 합니다.
os.path.exists 로 확인한 뒤 만들면 그 사이에 남이 만듭니다. os.open 에 O_CREAT 와 O_EXCL 을 함께 주면 확인과 생성이 한 번에 일어나고, 진 쪽은 FileExistsError 를 받습니다. 예외를 받는 것이 정상 동작이라는 점이 이 방식의 핵심입니다.
반쯤 쓰인 파일을 읽지 않게 하기
/root/race/safe_write.py 로 문서를 원자적으로 갈아 끼우고 /root/race/read_probe.py 로 읽으면서 세어, /root/race/atomic.json 에 rounds·entries·reads·bad_reads·method 를 적으세요. bad_reads 는 0 이어야 하고 rounds 는 50 이상, reads 는 100 이상입니다.
쓰는 도중에 읽으면 JSON 파싱이 실패하고, 그 오류는 읽는 쪽 코드의 버그처럼 보입니다. 같은 디렉터리의 임시 파일에 전부 쓰고 os.replace 로 옮기면 읽는 쪽에는 옛 파일이나 새 파일만 보입니다. 임시 파일을 다른 파일 시스템에 만들면 원자성이 깨집니다.
잠금 전에 파일을 비우는 실수
/root/race/append_line.py 를 만들어 이미 있던 줄을 지우지 않고 자기 줄을 덧붙이게 하고, 미리 줄 몇 개를 넣어 둔 파일에 8개를 동시에 돌려 /root/race/append.json 에 procs·lines_before·lines_after·kept_original·duplicate_lines 를 적으세요.
이 단계의 함정은 잠금이 아니라 여는 방식입니다. "w" 는 여는 순간 파일을 비우므로 그다음 줄에서 잠금을 잡아도 이미 늦습니다. 덧붙일 자리는 "a" 로 열고 잠금을 잡으세요. lines_after 는 lines_before 에 프로세스 수를 더한 값이어야 합니다.
재현 가능한 증거 남기기
잠금 없는 판과 잠금 있는 판을 각각 3회 이상 같은 하네스로 돌려 /root/race/evidence.json 에 trials·procs·expected·naive_finals·locked_finals 를 적으세요. 두 목록의 길이는 trials 와 같아야 합니다.
한 번의 관측은 우연일 수 있습니다. 같은 하네스로 여러 번 돌려 두 목록을 나란히 적으면, 잠금이 없는 쪽은 언제나 같은 자리에 머물고 잠금이 있는 쪽은 언제나 기대값에 닿는 것이 보입니다. 이것이 고객에게 보여 줄 증거입니다.
무엇을 잃었고 무엇으로 막았는지 보고하기
/root/race/summary.json 에 procs·expected·naive_observed·locked_observed·claim_winners·bad_reads·lines_after 를 적고, /root/race/race_report.md 에 ## 무엇을 잃었나 ## 왜 잃었나 ## 무엇으로 막았나 ## 남은 위험 네 절로 보고하세요.
고객이 사는 것은 '잠금을 걸었습니다' 가 아니라 '여덟 건 중 일곱 건이 사라졌고 지금은 여덟 건이 모두 남습니다' 입니다. 숫자를 그대로 적으세요. 권고 잠금의 한계도 남은 위험에 적습니다.