LabHub
배우기 러닝패스 코스

オペレーティングシステム

失われる更新とデッドロックを手で作る

LabHub 에서 이어서 보기

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

목표

경쟁 상태와 교착을 글로 읽는 대신 직접 만들어 보고 직접 없앱니다. 여덟 단계를 지나면 "락을 걸면 된다"가 아니라 어디까지 감싸야 하는지, 왜 순서를 통일해야 하는지, 언제는 아예 공유하지 않는 편이 나은지를 손으로 확인하게 됩니다.

왜 중요한가

동시성 버그는 대부분의 실행에서 아무 일도 일으키지 않습니다. 그래서 시험을 통과하고 운영에서 터집니다. 이 실습의 2단계는 그것을 숫자로 보여 줍니다. 같은 프로그램을 다섯 번 돌리면 다섯 번 다른 값이 나옵니다.

파이썬에는 전역 인터프리터 잠금이 있어서 스레드가 진짜로 동시에 실행되지는 않습니다. 그런데도 갱신은 사라집니다. 경쟁 상태의 원인은 병렬 실행이 아니라 읽고 고쳐 쓰는 구간이 나뉘어 있다는 사실이기 때문입니다. 인터프리터가 그 사이에서 스레드를 바꾸기만 하면 충분합니다. 이 점이 오히려 개념을 선명하게 만들어 줍니다.

교착도 같습니다. 5단계와 6단계 사이에서 바뀌는 것은 락을 잡는 순서 하나뿐입니다. 락을 더 넣지도, 기다리는 시간을 늘리지도 않습니다.

단계

  1. /root/conc/race.py 로 갱신이 사라지는 것을 재현하고 /root/conc/01-race.txt 에 남깁니다.
  2. 같은 프로그램을 다섯 번 돌려 /root/conc/02-repeat.txt 에 회차별 결과를 남깁니다.
  3. /root/conc/lock.py 로 락을 걸어 손실을 없애고 /root/conc/03-lock.txt 에 남깁니다.
  4. /root/conc/local.py 로 아예 공유하지 않는 방법을 만들고 /root/conc/04-local.txt 에 남깁니다.
  5. /root/conc/deadlock.py reverse 로 교착을 만들고 /root/conc/05-deadlock.txt 에 남깁니다.
  6. 같은 프로그램에 same 갈래를 더해 교착을 없애고 /root/conc/06-order.txt 에 남깁니다.
  7. 3단계와 4단계의 걸린 시간을 모아 /root/conc/07-cost.txt 에 남깁니다.
  8. 배운 것을 정리해 /root/conc/08-notes.md 에 남깁니다.

참고

갱신이 사라지는 것을 재현한다

/root/conc/race.py 를 만듭니다. 스레드 4개가 같은 전역 변수를 각각 50,000번 올리되 v = countercounter = v + 1 두 문장으로 나눠 씁니다. 프로그램은 expected 200000actual <실제값> 두 줄을 출력해야 하고, 그 출력을 /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.pysame 갈래를 더합니다. 두 스레드가 락을 같은 순서로 잡게만 바꾸고 나머지는 그대로 둡니다. python3 /root/conc/deadlock.py same 의 출력을 /root/conc/06-order.txt 에 남깁니다. blocked 는 0 이어야 합니다.

락을 없애지도, 쉬는 시간을 빼지도, 시간 제한을 늘리지도 않습니다. 잡는 순서만 통일합니다. 그것으로 데드락 네 조건 중 순환 대기가 깨집니다.

실무에서 락 순서를 문서로 정해 두는 이유가 이것입니다. 각자 편한 순서로 잡으면 언젠가 두 경로가 엇갈립니다.

락이 얼마를 받아 가는지 잰다

3단계와 4단계 프로그램의 elapsed 를 각각 가져와 /root/conc/07-cost.txtlock <밀리초>, local <밀리초> 두 줄로 남깁니다.

두 프로그램은 같은 횟수만큼 같은 수를 셉니다. 다른 것은 공유 변수를 락으로 지키느냐, 아예 공유하지 않느냐뿐입니다.

락은 공짜가 아닙니다. 그래서 실무의 방향은 락을 잘 쓰는 것보다 락이 필요 없게 만드는 것입니다. 다만 나눠 세는 방식이 언제나 가능한 것은 아니라는 점도 함께 기억해 두세요.

무엇이 무엇을 막았는지 정리한다

/root/conc/08-notes.md 에 세 줄 이상 적습니다. 갱신이 사라진 이유, 락이 막아 준 것, 교착을 없앤 방법을 각각 한 문장으로 씁니다.

본문에 임계, 교착, 순서 가 들어가야 합니다.

특히 5단계와 6단계 사이에서 바꾼 것이 무엇이었는지 정확히 적어 두세요. 락을 더 넣은 것도, 시간 제한을 늘린 것도 아니었습니다.