CS · 기본
비전공자를 위한 컴퓨터공학 지도 - 전공 4년을 개발자 눈으로 따라잡기
운영체제 - 프로세스·스레드·메모리·파일
프로세스와 스레드 차이, 동기화와 경쟁 조건, 가상 메모리 개념, 완성 코드는 threading 으로 경쟁 조건 재현과 Lock 으로 해결
개발자KR · 원고 갱신
이 장에서 배우는 것
앞 장에서 CPU가 명령을 실행하고 메모리에 데이터를 보관하는 모습을 살펴보았다. 실제 개발 환경에서는 여러 프로그램이 동시에 실행되고, 한 프로그램 안에서도 여러 작업이 진행된다. 운영체제는 이 작업들에 CPU를 사용할 차례와 메모리 공간을 나누어 주고, 파일 같은 자원에 접근하는 규칙을 제공하는 시스템 소프트웨어다. 전공 수업에서는 이를 자원 관리와 실행 환경 제공이라는 관점으로 설명한다.
이 장에서는 학습 진도 관리 프로그램을 작은 실험실로 삼는다. 두 작업이 같은 완료 횟수를 수정할 때 기록이 사라지는 상황을 재현하고, 접근 순서를 조정해 해결한다. 이어서 프로그램이 사용하는 주소와 실제 메모리의 관계, 파일을 열고 닫는 책임을 연결한다. 운영체제 전체를 구현하는 대신 실무에서 문제를 설명할 때 필요한 핵심 네 가지를 확인한다.
- 실행 중인 프로그램인 프로세스와 그 안의 실행 흐름인 스레드를 구분한다.
- 공유 데이터를 수정하는 순서 때문에 결과가 달라지는 경쟁 조건을 재현한다.
- Lock으로 보호할 작업 범위를 정하고, 동기화가 필요한 이유를 설명한다.
- 가상 메모리의 주소 공간과 파일 자원의 수명을 코드로 확인한다.
문제 상황
다른 전공을 공부하다 개발 일을 시작한 사람이 운영체제와 네트워크 과목의 학습 기록을 관리한다고 하자. 화면에서 완료 버튼을 누르는 작업과 저장된 진도를 반영하는 작업이 각각 실행된다. 두 작업은 같은 완료 횟수를 하나씩 늘린다. 초기 값이 0이라면 두 번 처리한 뒤에는 2가 되어야 한다.
그러나 두 작업이 모두 0을 읽고 각자 1을 저장하면 최종 값은 1이다. 예외도 발생하지 않고 프로그램도 종료된다. 화면에는 그럴듯한 숫자가 나오지만 한 번의 갱신이 사라졌다. 전공 수업에서는 이런 현상을 갱신 손실이라고 부른다. 오류 메시지가 없는 만큼 결과가 맞다는 가정을 따로 검증해야 한다.
같은 문제는 재고 수량, 처리한 파일 수, 작업 진행률에서도 나타난다. 여기서 중요한 질문은 어느 줄이 먼저 실행되었느냐만이 아니다. 어떤 데이터가 공유되는지, 읽은 값이 저장될 때까지 유효한지, 여러 동작을 하나의 보호 구간으로 묶어야 하는지를 묻는 일이 더 중요하다.
이 장의 실험은 운영체제가 우연히 특정 실행 순서를 선택하기를 기다리지 않는다. 두 스레드가 같은 값을 읽도록 대기 지점을 배치한다. 따라서 실행할 때마다 같은 잘못된 결과를 얻고, 같은 수정으로 기대한 결과를 얻는다. 실제 서비스의 발생 빈도를 측정하는 실험이 아니라, 문제가 생길 수 있는 실행 순서 하나를 눈앞에 고정하는 실험이다.
프로세스와 스레드: 무엇을 함께 사용하는가
프로그램은 디스크에 저장된 코드와 데이터다. 프로세스(process)는 그 프로그램을 실행하는 동안 운영체제가 관리하는 실행 단위다. 같은 프로그램을 두 번 실행하면 보통 서로 다른 프로세스가 생긴다. 각 프로세스에는 실행 상태와 자원 정보가 있으며, 메모리 주소를 해석하는 공간도 구분된다.
스레드(thread)는 프로세스 안에서 명령을 따라 진행하는 실행 흐름이다. 한 프로세스에 여러 스레드가 있으면 같은 프로세스의 객체와 열린 파일 같은 자원에 함께 접근할 수 있다. 각 스레드는 자신이 어느 함수를 실행하고 있는지, 다음에 어느 명령을 실행할지에 관한 상태를 가진다. 함수 호출과 지역 변수의 실행 상태를 보관하는 영역은 전공 수업에서 스택이라고 부른다.
스레드마다 함수의 지역 변수 이름이 따로 있다고 해서 그 변수가 가리키는 객체까지 복제되는 것은 아니다. 완성 코드의 두 스레드는 각자 지역 변수 old를 사용하지만, 둘 다 같은 리스트 counter를 참조한다. 공유 여부는 변수 이름보다 실제로 어느 객체에 접근하는지를 기준으로 판단해야 한다.
| 기준 | 프로세스 | 같은 프로세스의 스레드 |
|---|---|---|
| 메모리 주소 공간 | 기본적으로 분리된다 | 함께 사용한다 |
| 데이터 전달 | 별도의 통신이나 공유 수단이 필요하다 | 같은 객체에 접근할 수 있다 |
| 실행 상태 | 하나 이상의 스레드를 포함한다 | 각자 실행 위치와 호출 상태를 가진다 |
| 실험에서의 관찰 | 두 스레드의 프로세스 번호가 같다 | 같은 counter를 수정한다 |
운영체제가 실행할 작업에 CPU를 사용할 차례를 배정하는 일을 스케줄링이라고 부른다. 실행 중인 작업의 상태를 보관하고 다른 작업의 상태로 바꾸는 것은 문맥 교환이다. CPU를 사용할 수 있는 실행 단위가 번갈아 진행되면 사용자에게는 여러 일이 동시에 처리되는 것처럼 보일 수 있다.
여러 작업의 진행 시간이 겹치는 성질을 동시성(concurrency), 실제로 같은 순간에 여러 작업을 실행하는 성질을 병렬성(parallelism)이라고 부른다. 경쟁 조건에는 여러 CPU 코어가 필수적이지 않다. 하나의 코어에서도 읽기와 쓰기 사이에 다른 스레드가 진행하면 갱신 손실이 발생할 수 있다. 완성 코드에서는 대기 지점으로 이 교차 진행을 직접 만든다.
스레드를 선택할 때는 작업의 목적도 살펴야 한다. 파일이나 통신 응답을 기다리는 동안 다른 일을 진행하려는 목적과, 계산 자체를 여러 코어에 나누려는 목적은 다르다. 일반적인 GIL 사용 CPython에서 파이썬 계산 코드는 스레드 수만 늘린다고 여러 코어에서 함께 실행되지는 않는다. 실행 환경과 작업 특성을 확인한 뒤 방식을 선택해야 한다.
동기화: 읽기부터 쓰기까지 보호한다
경쟁 조건(race condition)은 여러 실행 흐름이 공유 상태에 접근하는 순서에 따라 결과가 달라지는 상황이다. 완료 횟수를 늘리는 동작은 기존 값을 읽고, 1을 더하고, 새 값을 저장하는 과정으로 생각할 수 있다. 소스 코드에서 짧게 표현된다는 이유만으로 이 전체 과정이 다른 실행 흐름과 섞이지 않는다고 판단할 수는 없다.
다른 작업이 중간 상태를 관찰하거나 끼어들지 못하도록 하나의 동작처럼 취급하는 성질을 원자성이라고 부른다. 전공 수업에서 원자적 연산이라는 말을 만나면 소스 코드의 줄 수가 아니라, 어떤 범위에 이런 보장이 주어지는지를 확인해야 한다. 이 실험에서는 읽기와 쓰기를 의도적으로 나누어 보호되지 않은 중간 상태를 드러낸다.
| 순서 | 스레드 A | 스레드 B | 공유 완료 횟수 |
|---|---|---|---|
| 1 | 0을 읽는다 | 대기한다 | 0 |
| 2 | 읽은 값을 보관한다 | 0을 읽는다 | 0 |
| 3 | 1을 저장한다 | 읽은 값을 보관한다 | 1 |
| 4 | 처리를 마친다 | 1을 저장한다 | 1 |
여러 실행 흐름의 진행 순서를 조정하는 일을 동기화(synchronization)라고 부른다. 완성 코드에서는 두 종류의 도구를 쓴다. Barrier는 정해진 수의 스레드가 모두 도착할 때까지 기다리는 합류 지점이다. Lock은 한 번에 하나의 실행 흐름만 보호 구간에 들어가도록 하는 잠금이다. 두 도구 모두 대기를 만들지만 보장하는 내용이 다르다.
경쟁 조건을 재현하는 함수에는 합류 지점이 두 개 있다. 첫 번째는 두 스레드가 모두 기존 값을 읽을 때까지 쓰기를 늦춘다. 두 번째는 두 스레드가 모두 쓰기를 끝낼 때까지 다음 회차의 읽기를 늦춘다. 이 배치 덕분에 한 회차에서 두 번의 갱신 요청이 들어와도 값은 정확히 1만 증가한다.
Lock으로 해결할 때는 읽기와 계산과 쓰기를 모두 같은 보호 구간에 넣는다. 이렇게 공유 상태를 다루며 한 번에 하나만 들어가도록 정한 코드 구간을 임계 구역이라고 부른다. 여러 실행 흐름이 임계 구역에 동시에 들어가지 못하게 하는 성질은 상호 배제다. Lock을 공유한다고 해서 주변 코드까지 자동으로 보호되는 것은 아니다.
GIL은 파이썬 실행기 내부에서 파이썬 코드를 실행하는 스레드를 제한하는 전역 인터프리터 잠금이다. 일반적인 GIL 사용 CPython에서도 여러 단계로 이루어진 업무 처리가 통째로 보호되는 것은 아니다. 이 실험은 읽기와 쓰기 사이에 기다림을 넣으므로 GIL의 존재와 별개로 갱신 손실을 재현한다. 실행기의 내부 보호와 프로그램 데이터의 일관성을 구분해야 한다.
완성 코드의 올바른 처리에서는 각 회차가 시작할 때만 합류한 뒤 Lock을 얻는다. Lock을 가진 채 두 스레드가 모두 도착해야 하는 지점에서 기다리면, 다른 스레드가 Lock을 얻지 못해 도착할 수 없는 상황이 생길 수 있다. 서로의 진행을 기다리느라 더 진행하지 못하는 상태를 교착 상태라고 부른다.
가상 메모리와 파일: 주소와 자원의 수명
가상 메모리(virtual memory)는 프로그램이 사용하는 주소를 실제 메모리의 위치와 구분해 관리하는 체계다. 프로세스가 사용하는 주소들의 범위를 주소 공간이라고 부른다. 서로 다른 프로세스에서 같은 숫자의 주소를 사용하더라도 같은 데이터를 가리킨다고 볼 수 없다. 운영체제와 하드웨어가 각 주소 공간의 주소를 실제 메모리와 연결하기 때문이다.
전공 수업에서는 주소 공간을 일정 크기로 나눈 단위를 페이지, 실제 메모리에서 페이지를 담는 단위를 페이지 프레임이라고 부른다. 이 둘의 연결 정보를 관리하는 구조가 페이지 테이블이다. 이 장에서는 그 구조를 구현하지 않고, 주소 숫자만으로 데이터를 식별할 수 없다는 핵심을 사전 두 개로 확인한다.
완성 코드에서 두 사전은 모두 4096이라는 키를 갖지만 저장된 과목 이름은 다르다. 이것은 주소 공간 분리를 설명하는 모형이다. 실제로 운영체제의 주소 매핑을 만들거나 프로세스 두 개를 실행하는 코드는 아니다. 실제 스레드 실험과 설명용 모형의 경계를 구분해야 관찰 결과를 과하게 해석하지 않는다.
가상 메모리는 프로세스 사이의 격리와 메모리 사용 관리에 쓰인다. 프로그램이 접근한 페이지에 대해 즉시 사용할 수 있는 매핑이 없거나 접근 권한 문제가 발생하면 페이지 폴트라는 예외가 발생할 수 있다. 운영체제가 이를 처리해 실행을 이어 가기도 하고, 허용되지 않은 접근이면 실행을 중단시키기도 한다. 페이지 폴트가 발생했다는 말만으로 디스크에서 데이터를 읽었다고 단정할 수는 없다.
프로그램에서 큰 객체를 계속 보관하면 가상 메모리가 있으니 괜찮다는 결론도 성립하지 않는다. 실제 메모리와 보조 저장장치에는 한계가 있으며, 어떤 페이지가 실제 메모리에 있는지에 따라 처리 시간이 달라질 수 있다. 이 장의 모형은 주소 공간의 분리만 보여 주며, 페이지 교체나 메모리 부족을 재현하지 않는다.
파일은 이름을 통해 다시 찾아갈 수 있도록 저장장치에 보관한 데이터다. 프로세스가 파일을 열면 운영체제는 열린 파일을 다루는 자원을 관리하고, 파이썬은 이를 파일 객체로 제공한다. 운영체제가 열린 파일을 식별할 때 사용하는 정수 값을 파일 디스크립터라고 부른다. 완성 코드에서는 그 숫자 대신 파일 객체의 열린 상태와 닫힌 상태를 확인한다.
with 문을 벗어나면 파일이 닫히도록 작성하면 성공 경로뿐 아니라 예외가 발생하는 경로에서도 정리 책임을 표현할 수 있다. 파일을 닫는 일과 여러 스레드가 같은 파일 내용을 안전하게 갱신하는 일은 별개의 문제다. 또 파일을 닫았다는 사실만으로 전원이 끊겨도 최근 데이터가 보존된다는 보장까지 얻는 것은 아니다.
완성 코드는 임시 디렉터리에 학습 기록을 저장하고, 쓰기 파일을 닫은 다음 다시 열어 읽는다. 마지막에는 임시 디렉터리도 정리한다. 임의로 정해지는 경로와 운영체제가 부여하는 번호는 출력하지 않는다. 따라서 자원을 실제로 사용하면서도 실행 결과는 일정하게 유지한다.
완성 코드
다음 내용을 main.py로 저장한다. 외부 패키지와 네트워크는 사용하지 않는다. 두 스레드가 각각 세 번 완료 횟수를 늘리므로 기대 값은 6이다. 난수를 사용하지 않으며, 실행 순서에 따라 바뀔 수 있는 중간 출력도 만들지 않는다.
import os
import threading
from pathlib import Path
from tempfile import TemporaryDirectory
WORKERS = 2
ROUNDS = 3
def run_counter(protected):
counter = [0]
process_ids = [None] * WORKERS
gate = threading.Barrier(WORKERS)
lock = threading.Lock()
def worker(index):
process_ids[index] = os.getpid()
for _ in range(ROUNDS):
if protected:
gate.wait()
with lock:
old = counter[0]
counter[0] = old + 1
else:
old = counter[0]
gate.wait()
counter[0] = old + 1
gate.wait()
threads = [
threading.Thread(target=worker, args=(index,))
for index in range(WORKERS)
]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
same_process = all(
pid == os.getpid() for pid in process_ids
)
return counter[0], same_process
def address_space_demo():
space_a = {4096: "운영체제"}
space_b = {4096: "네트워크"}
return space_a[4096], space_b[4096]
def file_demo():
with TemporaryDirectory() as directory:
path = Path(directory) / "progress.txt"
with path.open("w", encoding="utf-8") as stream:
stream.write("운영체제: 완료\n")
inside = not stream.closed
outside = stream.closed
with path.open("r", encoding="utf-8") as reader:
saved = reader.read().strip()
return inside, outside, saved
def main():
expected = WORKERS * ROUNDS
lost, same_process = run_counter(False)
fixed, _ = run_counter(True)
subject_a, subject_b = address_space_demo()
inside, outside, saved = file_demo()
assert same_process
assert lost == ROUNDS
assert fixed == expected
assert inside and outside
assert saved == "운영체제: 완료"
print(f"같은 프로세스의 스레드: {same_process}")
print(f"기대 완료 횟수: {expected}")
print(f"보호 없음: {lost}")
print(f"Lock 사용: {fixed}")
print(f"주소 공간 모형 A[4096]: {subject_a}")
print(f"주소 공간 모형 B[4096]: {subject_b}")
print(f"파일을 쓰는 동안 열림: {inside}")
print(f"쓰기 with 종료 후 닫힘: {outside}")
print(f"다시 읽은 기록: {saved}")
if __name__ == "__main__":
main()
줄별 해설
import os와 import threading은 프로세스 번호 확인과 스레드 실행에 필요한 표준 라이브러리를 가져온다. Path는 파일 경로를 다루고, TemporaryDirectory는 사용이 끝난 임시 디렉터리를 정리한다. WORKERS와 ROUNDS는 각각 스레드 수와 스레드마다 반복할 횟수다.
run_counter의 protected는 보호 여부를 선택한다. 호출할 때마다 counter, gate, lock을 새로 만들기 때문에 보호하지 않은 실험의 상태가 다음 실험으로 이어지지 않는다. counter를 원소 하나인 리스트로 만든 이유는 내부 함수에서 같은 객체의 값을 바꾸기 위해서다. 실제 프로그램에서는 이 자리에 진도 정보를 담은 객체가 올 수 있다.
process_ids는 각 스레드가 확인한 프로세스 번호를 저장한다. 각 스레드는 자신의 index에 해당하는 칸만 쓴다. 이 기록은 완료 횟수처럼 여러 스레드가 같은 칸을 읽고 다시 수정하는 작업이 아니다. 주 실행 흐름은 모든 스레드를 기다린 뒤 기록을 읽는다.
worker는 스레드가 실행할 함수다. 바깥 함수에서 만든 counter와 lock을 사용하므로 두 스레드가 같은 객체를 공유한다. protected가 참이면 gate.wait()에서 회차 시작을 맞추고, with lock 안에서 기존 값을 읽고 증가시켜 저장한다. 한 스레드가 보호 구간을 나간 뒤 다른 스레드가 들어오므로 두 번째 스레드는 앞선 변경을 반영한다.
protected가 거짓인 분기에서는 old를 먼저 읽는다. 첫 gate.wait()가 풀리려면 두 스레드가 모두 읽기를 끝내야 한다. 따라서 둘은 같은 기존 값에 1을 더해 저장한다. 두 번째 gate.wait()는 양쪽 저장이 끝난 다음 회차를 시작하게 한다. Barrier는 다시 사용할 수 있으므로 같은 객체로 세 회차를 진행한다.
threads를 만드는 부분의 target은 스레드에서 실행할 함수다. args=(index,)는 그 함수에 전달할 인수 하나를 담은 튜플이다. 괄호 안 쉼표는 인수가 하나여도 튜플을 만드는 데 필요하다. 스레드 객체를 만드는 것만으로 worker가 실행되지는 않는다.
첫 반복문의 start()가 실행을 시작한다. 다음 반복문의 join()은 각 스레드가 끝날 때까지 주 실행 흐름을 기다리게 한다. 모든 스레드를 먼저 시작하고 나중에 기다리는 순서가 중요하다. 하나를 시작하자마자 그 스레드의 종료를 기다리면, 첫 스레드가 Barrier에서 두 번째 스레드의 시작을 기다리는 상태에 머물 수 있다.
same_process는 수집한 번호가 주 실행 흐름의 프로세스 번호와 모두 같은지 확인한다. 실제 번호는 실행마다 달라질 수 있으므로 번호 자체를 출력하지 않는다. 반환하는 불리언 값은 이 실험의 스레드들이 같은 프로세스에 속한다는 관찰을 전달한다.
address_space_demo는 주소 숫자가 같아도 주소 공간에 따라 데이터가 달라질 수 있음을 표현한다. 여기서 4096은 설명을 위해 고른 키다. 파이썬 객체의 실제 주소나 페이지 크기를 측정한 값이 아니다. 두 사전은 실제로 하나의 프로세스 안에 있으므로 운영체제의 메모리 격리를 검증한 결과로 읽어서는 안 된다.
file_demo는 쓰기용 with 안에서 stream.closed가 거짓인지 확인한다. 그 블록을 나온 뒤에는 참인지 확인한다. 이어 읽기용 파일을 열어 저장 내용을 가져온다. strip()은 기록 마지막의 줄바꿈을 없애 출력 한 줄로 만든다. 반환된 문자열은 이미 메모리에 있으므로 임시 파일이 정리된 뒤에도 사용할 수 있다.
main의 assert는 관찰한 값이 기대와 맞는지 검사한다. 조건이 거짓이면 검증 실패를 알린다. 실제 서비스의 사용자 입력 검증을 assert에 맡겨서는 안 되지만, 여기서는 실험의 전제가 성립했는지 확인하는 용도다. 마지막 조건문은 이 파일을 직접 실행했을 때 main을 호출한다.
실행 결과
macOS 또는 Linux의 Python 3.12 이상 환경에서 다음 명령으로 실행한다. 프로그램은 스레드 작업과 임시 파일 정리를 마친 뒤 종료된다.
python3 main.py
예상 출력은 다음과 같다.
같은 프로세스의 스레드: True
기대 완료 횟수: 6
보호 없음: 3
Lock 사용: 6
주소 공간 모형 A[4096]: 운영체제
주소 공간 모형 B[4096]: 네트워크
파일을 쓰는 동안 열림: True
쓰기 with 종료 후 닫힘: True
다시 읽은 기록: 운영체제: 완료
보호하지 않은 결과가 3인 이유는 각 회차에서 두 스레드가 같은 값을 읽고 같은 새 값을 저장하기 때문이다. 세 회차의 시작 값은 각각 0, 1, 2가 된다. 보호한 경우에는 매 회차 두 번의 증가가 모두 반영되어 6이 된다. 어느 스레드가 먼저 Lock을 얻는지는 달라도 마지막 합계는 같다.
이 결과가 모든 경쟁 조건이 항상 절반의 값을 만든다는 뜻은 아니다. 여기서는 합류 지점으로 특정 순서를 구성했다. 실제 프로그램에서는 대기 시간과 실행 순서가 달라져 증상이 드물게 나타나거나 다른 값이 나올 수 있다. 작은 재현 코드가 유용한 이유는 발생 빈도와 분리해 원인 자체를 확인할 수 있기 때문이다.
실무에서 자주 틀리는 것
저장하는 줄만 잠근다
다음 코드는 읽기를 보호 구간 밖에서 수행한다. 두 스레드가 같은 값을 읽었다면 쓰기를 차례대로 하더라도 갱신 손실은 남는다. 잠금은 저장 명령 하나가 아니라, 그 저장 값을 결정하는 읽기까지 보호해야 한다.
# 틀린 코드: run_counter의 worker 안이라고 가정한다.
old = counter[0]
with lock:
counter[0] = old + 1
# 고친 코드
with lock:
old = counter[0]
counter[0] = old + 1
완성 코드의 보호 분기에는 고친 형태가 들어 있다. 업무 규칙이 더 복잡해지면 검사와 갱신도 함께 살펴야 한다. 예를 들어 남은 횟수를 확인한 뒤 차감한다면, 확인 시점의 조건이 차감 시점까지 유지되어야 한다.
스레드마다 새 Lock을 만든다
Lock은 같은 잠금을 사용하는 실행 흐름 사이에서 접근을 조정한다. 각 스레드가 자기 잠금을 만들면 서로를 기다리지 않는다. 보호하려는 데이터와 그 데이터를 지키는 잠금을 함께 관리해야 한다.
# 틀린 코드: worker가 호출될 때마다 잠금이 생긴다.
def worker(index):
local_lock = threading.Lock()
with local_lock:
old = counter[0]
counter[0] = old + 1
# 고친 코드: worker를 만들기 전에 한 번 생성한다.
lock = threading.Lock()
def worker(index):
with lock:
old = counter[0]
counter[0] = old + 1
이 조각은 run_counter 내부의 정의를 비교한 것이다. 완성 코드에서는 worker 바깥에 Lock이 하나 있으므로 두 스레드가 같은 보호 규칙을 따른다. 공유 데이터를 바꾸는 다른 함수가 추가되면 그 함수도 같은 규칙을 따라야 한다.
join을 호출하면 경쟁 조건이 해결된다고 생각한다
join은 종료 시점을 기다리는 도구다. 스레드 내부의 읽기와 쓰기를 묶어 주지 않는다. 다음처럼 join까지 호출해도 increase가 보호되지 않았다면 갱신 손실이 가능하다.
# 틀린 코드: 기다림만으로 갱신을 보호할 수 없다.
def increase():
old = counter[0]
counter[0] = old + 1
thread = threading.Thread(target=increase)
thread.start()
thread.join()
# 고친 코드: 갱신을 보호하고 종료도 기다린다.
def increase():
with lock:
old = counter[0]
counter[0] = old + 1
thread = threading.Thread(target=increase)
thread.start()
thread.join()
위 조각의 함수가 다른 스레드에서도 실행되는 상황을 가정한다. 한 스레드만 접근한다면 이 경쟁 조건은 없다. 여러 스레드를 다룰 때는 데이터 접근을 보호하는 책임과 작업 완료를 기다리는 책임을 나누어 확인한다.
파일을 닫는 줄에 도달할 것이라고 가정한다
파일을 연 뒤 중간 작업에서 예외가 발생하면 마지막 close()에 도달하지 못할 수 있다. 정상 실행만 보며 정리 코드를 배치하면 파일을 여는 책임과 닫는 책임이 멀어진다.
# 틀린 코드: write에서 예외가 나면 close를 건너뛴다.
stream = path.open("w", encoding="utf-8")
stream.write("운영체제: 완료\n")
stream.close()
# 고친 코드
with path.open("w", encoding="utf-8") as stream:
stream.write("운영체제: 완료\n")
완성 코드의 closed 출력으로 with 전후의 수명을 확인할 수 있다. 다만 with는 여러 작성자의 동시 접근을 조정하지 않는다. 하나의 파일을 여러 작업이 수정한다면 내용 갱신 규칙과 자원 정리 규칙을 각각 설계해야 한다.
한눈에 보기
| 핵심 | 실무에서 쓰이는 곳 | 코드로 확인한 내용 | 면접에서 묻는 방식 |
|---|---|---|---|
| 프로세스와 스레드 | 작업 실행 구조 선택 | 두 스레드의 프로세스 번호가 같다 | 스레드끼리 무엇을 공유하는가 |
| 동기화 | 진도와 수량의 일관성 유지 | 보호 전 3, 보호 후 6이다 | 어디부터 어디까지 잠가야 하는가 |
| 가상 메모리 | 주소 해석과 프로세스 격리 이해 | 모형에서 같은 키가 다른 값을 가리킨다 | 주소 숫자가 같으면 같은 데이터인가 |
| 파일 자원 | 기록 저장과 자원 정리 | 쓰기 블록을 벗어나면 파일이 닫힌다 | 예외가 나도 닫히게 하려면 어떻게 하는가 |
면접에서는 용어 정의 뒤에 작은 실행 순서를 붙이면 설명이 구체적이 된다. “두 스레드가 모두 0을 읽고 각각 1을 쓰면 한 번의 갱신이 사라진다. 따라서 읽기부터 쓰기까지 같은 Lock으로 보호한다”라고 설명할 수 있다. 프로세스와 스레드의 차이도 자원 공유 여부와 이 실험의 counter를 연결하면 된다.
더 깊게 볼 서재에는 운영체제 원리서와 시스템 프로그래밍 실습서를 구분해 두면 좋다. 운영체제 원리서는 CPU 실행 순서 배정, 주소 변환, 파일 관리가 어떤 문제를 해결하는지 설명하는 책을 고른다. 시스템 프로그래밍 실습서는 작은 프로그램으로 프로세스 생성, 스레드 대기, 파일 열기와 닫기를 확인하는 책을 고른다. 제목보다 목차와 예제의 설명 방식을 기준으로 선택한다.
원리서의 가상 메모리 부분을 읽을 때는 주소 공간, 페이지 테이블, 페이지 폴트의 관계를 먼저 찾는다. 동기화 부분에서는 상호 배제와 교착 상태의 조건을 찾는다. 실습서에서는 API 호출 이름만 외우기보다 실패했을 때 정리 책임이 누구에게 있는지 확인한다. 이 장의 실험을 설명할 수 있게 된 뒤 이런 질문으로 독서를 넓히면 된다.
파이썬 도구의 세부 동작은 threading 공식 문서에서, 임시 자원의 정리 방식은 tempfile 공식 문서에서 확인할 수 있다. 문서는 함수의 계약을 확인하는 근거로 사용하고, 운영체제 개념의 전체 설명은 원리서와 연결해 읽는다.
다음 장에서 요청이 서버에 도착하는 흐름을 볼 때도 이 장의 구분이 이어진다. 요청을 처리하는 실행 흐름은 무엇인지, 여러 요청이 함께 사용하는 데이터는 무엇인지, 기다리는 동안 어떤 자원이 유지되는지를 질문할 수 있다.
연습 문제
- ROUNDS를 4로 바꾸고 실행하라. 기대 완료 횟수, 보호 없는 결과, Lock을 사용한 결과를 먼저 적은 다음 출력과 비교하라.
- 보호하지 않은 분기의 두 번째 gate.wait()를 제거한다고 하자. 결과가 계속 3이라고 보장할 수 없는 이유를 다음 회차의 읽기 시점과 연결해 설명하라.
- 보호한 분기의 gate.wait()를 with lock 안쪽 첫 줄로 옮기면 어떤 일이 생길 수 있는가. 첫 스레드와 두 번째 스레드가 각각 무엇을 기다리는지 설명하라. 이 코드는 직접 실행하지 말고 실행 순서를 적어 판단하라.
- 주소 공간 모형에서 space_a[4096]을 "복습 필요"로 바꾼 뒤 두 사전의 값을 반환하라. 결과를 예상하고 실행으로 확인하라. 이 결과만으로 실제 프로세스 격리를 검증했다고 말할 수 있는지도 설명하라.
정답과 해설
- 기대 완료 횟수는 8, 보호 없는 결과는 4, Lock을 사용한 결과는 8이다. 보호하지 않은 경우 회차마다 두 스레드가 같은 값을 읽고 같은 새 값을 쓰므로 1씩 증가한다. 보호한 경우 두 번의 증가가 모두 반영된다. 완성 코드의 검증식은 상수를 사용하므로 이 변경에도 그대로 적용된다.
- 한 스레드가 저장 직후 다음 회차의 값을 읽을 수 있다. 그때 다른 스레드가 이전 회차의 쓰기를 마쳤는지는 보장되지 않는다. 첫 합류 지점은 두 읽기를 모두 기다리지만, 두 번째 합류 지점이 없으면 회차 사이의 쓰기 완료를 맞추지 못한다. 따라서 기존의 결정적인 결과를 보장할 근거가 사라진다.
- 첫 스레드는 Lock을 얻은 채 Barrier에서 두 번째 스레드를 기다린다. 두 번째 스레드는 같은 Lock을 얻지 못해 Barrier에 도착하지 못한다. 첫 스레드가 잠금을 놓아야 두 번째가 도착할 수 있고, 두 번째가 도착해야 첫 스레드가 진행할 수 있으므로 교착 상태가 된다. 합류 지점을 잠금 밖에 두어야 한다.
- A의 값은 "복습 필요", B의 값은 "네트워크"다. 별도로 만든 사전의 같은 키를 수정해도 다른 사전은 바뀌지 않는다. 이는 주소 공간별 해석이 다를 수 있다는 설명용 모형이다. 실제로는 두 사전 모두 같은 파이썬 프로세스 안에 있으므로, 운영체제가 제공하는 프로세스 사이의 격리를 검증한 실험은 아니다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.