LabHub
배우기 러닝패스 코스

Debugging in Practice

Eight updates arrived and one survived

LabHub 에서 이어서 보기

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

목표

출발선을 맞춰 경쟁 조건을 재현 가능한 사실로 만든 뒤, 잃어버린 갱신을 배타 잠금으로 막고, 확인 후 사용의 틈을 원자적 생성으로 닫고, 통째로 쓰는 문서를 원자적 이름 바꾸기로 갈아 끼운다. 마지막으로 잠금을 잡기 전에 파일을 비우는 실수가 무엇을 날리는지 직접 확인한다.

왜 중요한가

경쟁 조건은 "가끔 숫자가 안 맞는다" 로 신고된다. 혼자 돌리면 언제나 맞기 때문에 조사가 재현에서 막힌다. 그런데 프로세스를 먼저 다 띄워 두고 출발 표식 하나로 동시에 보내면, 경쟁은 확률이 아니라 언제나 일어나는 일이 된다. 재현이 되는 순간부터 그것은 고칠 수 있는 결함이다. 막는 방법은 증상의 모양에 따라 다르다. 읽고 고쳐 쓰는 구간은 잠금으로 묶고, 확인 후 사용은 한 번의 호출로 바꾸고, 통째로 쓰는 파일은 이름 바꾸기로 갈아 끼운다. 이 셋을 섞어 쓰면 잠금을 걸어도 증상이 남는다. 가장 자주 걸리는 함정은 잠금의 범위다. open(path, "w") 는 여는 순간 파일을 비우므로, 그다음 줄에서 잠금을 잡아도 이미 늦었다. 이 실수는 잠금을 건 코드에서 나기 때문에 더 오래 살아남는다. 채점기는 여러분의 설명을 믿지 않는다. 채점기가 자기 하네스로 여러분의 워커를 실제로 동시에 띄워 최종 값과 승자 수를 직접 센다. 프로세스 수는 실행마다 바뀌므로 값을 외워 넣을 수 없습니다.

단계

  1. /root/race/gen_race.py 를 만들어 실행해 /root/race/spawn.py(출발선 하네스)를 만드세요.
  2. /root/race/naive_add.py 로 잠금 없는 갱신을 동시에 보내 /root/race/lost.json 에 잃어버린 갱신을 적으세요.
  3. /root/race/locked_add.py 로 같은 일을 배타 잠금 안에서 하고 /root/race/lock.json 에 적으세요.
  4. /root/race/claim.py 로 확인 후 사용의 틈을 닫고 /root/race/claim.json 에 승자와 패자를 적으세요.
  5. /root/race/safe_write.py/root/race/read_probe.py 로 부분 기록을 막고 /root/race/atomic.json 에 적으세요.
  6. /root/race/append_line.py 로 잠금 전에 파일을 비우지 않고 덧붙여 /root/race/append.json 에 적으세요.
  7. 두 판을 여러 번 돌려 /root/race/evidence.json 에 재현 가능한 증거를 남기세요.
  8. /root/race/summary.json/root/race/race_report.md 에 네 절로 보고하세요.

참고

출발선을 맞추는 하네스 손에 쥐기

/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## 무엇을 잃었나 ## 왜 잃었나 ## 무엇으로 막았나 ## 남은 위험 네 절로 보고하세요.

고객이 사는 것은 '잠금을 걸었습니다' 가 아니라 '여덟 건 중 일곱 건이 사라졌고 지금은 여덟 건이 모두 남습니다' 입니다. 숫자를 그대로 적으세요. 권고 잠금의 한계도 남은 위험에 적습니다.