Make a Lost Update and a Deadlock by Hand
한국어 원문으로 표시합니다.
목표
경쟁 상태와 교착을 글로 읽는 대신 직접 만들어 보고 직접 없앱니다. 여덟 단계를 지나면 "락을 걸면 된다"가 아니라 어디까지 감싸야 하는지, 왜 순서를 통일해야 하는지, 언제는 아예 공유하지 않는 편이 나은지를 손으로 확인하게 됩니다.
왜 중요한가
동시성 버그는 대부분의 실행에서 아무 일도 일으키지 않습니다. 그래서 시험을 통과하고 운영에서 터집니다. 이 실습의 2단계는 그것을 숫자로 보여 줍니다. 같은 프로그램을 다섯 번 돌리면 다섯 번 다른 값이 나옵니다.
파이썬에는 전역 인터프리터 잠금이 있어서 스레드가 진짜로 동시에 실행되지는 않습니다. 그런데도 갱신은 사라집니다. 경쟁 상태의 원인은 병렬 실행이 아니라 읽고 고쳐 쓰는 구간이 나뉘어 있다는 사실이기 때문입니다. 인터프리터가 그 사이에서 스레드를 바꾸기만 하면 충분합니다. 이 점이 오히려 개념을 선명하게 만들어 줍니다.
교착도 같습니다. 5단계와 6단계 사이에서 바뀌는 것은 락을 잡는 순서 하나뿐입니다. 락을 더 넣지도, 기다리는 시간을 늘리지도 않습니다.
단계
/root/conc/race.py로 갱신이 사라지는 것을 재현하고/root/conc/01-race.txt에 남깁니다.- 같은 프로그램을 다섯 번 돌려
/root/conc/02-repeat.txt에 회차별 결과를 남깁니다. /root/conc/lock.py로 락을 걸어 손실을 없애고/root/conc/03-lock.txt에 남깁니다./root/conc/local.py로 아예 공유하지 않는 방법을 만들고/root/conc/04-local.txt에 남깁니다./root/conc/deadlock.py reverse로 교착을 만들고/root/conc/05-deadlock.txt에 남깁니다.- 같은 프로그램에
same갈래를 더해 교착을 없애고/root/conc/06-order.txt에 남깁니다. - 3단계와 4단계의 걸린 시간을 모아
/root/conc/07-cost.txt에 남깁니다. - 배운 것을 정리해
/root/conc/08-notes.md에 남깁니다.
참고
- 산출물은 모두
/root/conc/아래에 둡니다.mkdir -p /root/conc를 먼저 해 두세요. - 스레드 수는 4, 스레드 하나가 올리는 횟수는 50,000 으로 맞춥니다. 채점기가 이 값을 기준으로 봅니다.
- 손실이 안 보이면
sys.setswitchinterval(0.000001)을 넣으세요. 스레드가 더 자주 바뀌어 원래 있던 틈이 드러납니다. - 흔한 실수 1:
counter += 1한 줄로 쓰는 것입니다. 틈이 좁아 손실이 잘 안 보입니다. 읽기와 쓰기를 두 문장으로 나누세요. - 흔한 실수 2: 두 번째 락을 시간 제한 없이 잡는 것입니다. 프로그램이 영원히 멈추고 화면에는 아무것도 나오지 않습니다.
acquire(timeout=2)를 쓰세요. - 흔한 실수 3: 6단계에서 락을 하나로 합쳐 버리는 것입니다. 그러면 교착은 사라지지만 배울 것도 사라집니다. 락은 둘 그대로 두고 순서만 바꿉니다.
갱신이 사라지는 것을 재현한다
/root/conc/race.py 를 만듭니다. 스레드 4개가 같은 전역 변수를 각각 50,000번 올리되 v = counter 와 counter = v + 1 두 문장으로 나눠 씁니다. 프로그램은 expected 200000 과 actual <실제값> 두 줄을 출력해야 하고, 그 출력을 /root/conc/01-race.txt 에 남깁니다.
증가 한 번이 읽기와 쓰기 두 문장으로 나뉘어 있어야 그 사이에 다른 스레드가 끼어들 수 있습니다. counter += 1 한 줄로 쓰면 끼어들 틈이 좁아 손실이 잘 안 보입니다.
그래도 손실이 안 보이면 sys.setswitchinterval(0.000001) 로 스레드 전환을 자주 일으키세요. 이것은 버그를 만드는 것이 아니라 이미 있던 버그가 드러나는 조건을 만드는 것입니다.
같은 프로그램을 다섯 번 돌린다
1단계의 프로그램을 다섯 번 돌려 /root/conc/02-repeat.txt 에 첫 줄은 expected 200000, 그다음 다섯 줄은 <회차> <actual 값> 형식으로 남깁니다.
for i in 1 2 3 4 5; do ...; done 으로 돌리고 각 실행의 actual 값만 뽑아 적으면 됩니다.
값이 매번 다르다는 것이 이 단계의 요점입니다. 경쟁 상태는 있고 없고가 아니라 드러나고 안 드러나고의 문제라서, 시험을 열 번 돌려 열 번 통과해도 버그가 없다는 뜻이 아닙니다.
락을 걸어 손실을 없앤다
/root/conc/lock.py 를 만듭니다. 1단계와 같은 구조에 threading.Lock 을 걸어 손실을 없앱니다. expected 200000, actual <값>, elapsed <밀리초> 세 줄을 출력하고, 그 출력을 /root/conc/03-lock.txt 에 남깁니다.
with lock: 블록으로 읽기와 쓰기를 함께 감싸야 합니다. 읽기만 감싸거나 쓰기만 감싸면 그 사이가 그대로 열려 있어서 손실이 남습니다.
elapsed 는 스레드를 띄우기 직전부터 모두 합류한 직후까지의 시간을 밀리초로 적습니다. 7단계에서 이 값을 씁니다.
아예 공유하지 않는 방법
/root/conc/local.py 를 만듭니다. 스레드마다 자기 몫을 지역 변수로 세고 마지막에 합쳐 같은 결과를 냅니다. 락이나 세마포어는 쓰지 않습니다. 출력 형식은 3단계와 같고, 그 출력을 /root/conc/04-local.txt 에 남깁니다.
각 스레드가 자기 지역 변수만 올리다가 끝날 때 결과를 목록의 자기 자리에 넣고, 주 스레드가 모두 합류한 뒤에 더하면 됩니다. 공유가 없으니 지킬 것도 없습니다.
채점기는 이 파일에 Lock, Semaphore, Condition 이 없는지도 봅니다. 락을 쓰지 않고 같은 정확도를 얻는 것이 이 단계의 요점입니다.
락을 반대 순서로 잡아 교착을 만든다
/root/conc/deadlock.py 를 만듭니다. python3 /root/conc/deadlock.py reverse 로 부르면 스레드 둘이 락 두 개를 반대 순서로 잡아 교착을 일으켜야 합니다. 두 번째 락은 acquire(timeout=2) 로 잡아 영원히 멈추지 않게 하고, mode <갈래> 와 blocked <못 잡은 스레드 수> 두 줄을 출력합니다. 그 출력을 /root/conc/05-deadlock.txt 에 남깁니다.
첫 락을 잡은 뒤 time.sleep(0.05) 로 잠깐 쉬어 상대도 자기 첫 락을 잡게 해 주세요. 그 틈이 없으면 한쪽이 먼저 다 끝나 버려 교착이 안 생깁니다.
timeout 없이 잡으면 프로그램이 영원히 멈춥니다. 교착은 스스로 풀리지 않기 때문입니다. 시간 제한을 두는 것은 교착을 고치는 방법이 아니라 교착이 났다는 것을 관측하는 방법입니다.
순서를 통일해 교착을 없앤다
deadlock.py 에 same 갈래를 더합니다. 두 스레드가 락을 같은 순서로 잡게만 바꾸고 나머지는 그대로 둡니다. python3 /root/conc/deadlock.py same 의 출력을 /root/conc/06-order.txt 에 남깁니다. blocked 는 0 이어야 합니다.
락을 없애지도, 쉬는 시간을 빼지도, 시간 제한을 늘리지도 않습니다. 잡는 순서만 통일합니다. 그것으로 데드락 네 조건 중 순환 대기가 깨집니다.
실무에서 락 순서를 문서로 정해 두는 이유가 이것입니다. 각자 편한 순서로 잡으면 언젠가 두 경로가 엇갈립니다.
락이 얼마를 받아 가는지 잰다
3단계와 4단계 프로그램의 elapsed 를 각각 가져와 /root/conc/07-cost.txt 에 lock <밀리초>, local <밀리초> 두 줄로 남깁니다.
두 프로그램은 같은 횟수만큼 같은 수를 셉니다. 다른 것은 공유 변수를 락으로 지키느냐, 아예 공유하지 않느냐뿐입니다.
락은 공짜가 아닙니다. 그래서 실무의 방향은 락을 잘 쓰는 것보다 락이 필요 없게 만드는 것입니다. 다만 나눠 세는 방식이 언제나 가능한 것은 아니라는 점도 함께 기억해 두세요.
무엇이 무엇을 막았는지 정리한다
/root/conc/08-notes.md 에 세 줄 이상 적습니다. 갱신이 사라진 이유, 락이 막아 준 것, 교착을 없앤 방법을 각각 한 문장으로 씁니다.
본문에 임계, 교착, 순서 가 들어가야 합니다.
특히 5단계와 6단계 사이에서 바꾼 것이 무엇이었는지 정확히 적어 두세요. 락을 더 넣은 것도, 시간 제한을 늘린 것도 아니었습니다.