Devin.KR

운영체제 질문 - 프로세스·스레드·메모리

개발자KR 조회 1

이 장에서 배우는 것

앞 장에서 생각을 말하며 문제를 푸는 연습을 했다면, 이번에는 프로그램이 실행되는 동안 자원을 어떻게 나누고 보호하는지 설명한다. 중고 거래 서비스의 기술 면접에서 면접관이 “스레드를 늘리면 더 빨라지나요?”라고 물었다고 하자. 용어 정의만으로는 답이 끝나지 않는다. 어떤 작업인지, 무엇을 공유하는지, 어디에서 기다리는지를 연결해야 한다.

운영체제 질문에서 신입 지원자에게 기대하는 것은 커널 구현을 재현하는 능력이 아니다. 프로세스와 스레드의 경계를 구분하고, 실행 순서에 따라 결과가 바뀌는 이유를 설명하며, 메모리를 효율적으로 사용하는 근거를 제시하는 능력이다. 이 장에서는 네 가지 질문을 깊게 살펴보고, 실행 순서를 직접 정한 작은 실험으로 답변을 확인한다.

  • 프로세스와 스레드가 분리하거나 공유하는 자원을 구분한다.
  • 컨텍스트 전환의 비용과 작업 종류에 따른 동시 실행의 효과를 설명한다.
  • 경쟁 상태를 찾아 뮤텍스와 세마포어의 역할, 교착 상태의 조건을 연결한다.
  • 가상 메모리와 페이지를 설명하고 캐시 지역성이 적중률에 미치는 영향을 확인한다.
  • 결론, 근거, 프로젝트 예시, 한계와 대안의 순서로 답변한다.

문제 상황

가상의 중고 거래 서비스 팀이 판매글 조회 수를 계산하는 작은 프로그램을 제시한다. 두 작업자가 같은 카운터를 세 번씩 증가시킨다. 기대한 값은 6인데, 실행 순서에 따라 더 작은 값이 나온다. 지원자는 “동시에 실행해서 그렇다”라고 말하지만, 면접관은 다시 묻는다.

면접관: 동시에 실행된다는 말만으로는 원인이 충분하지 않다. 값을 읽는 시점과 저장하는 시점을 나누어 설명해 보라. 잠금을 걸면 무엇이 달라지는지도 말해 보라.

이어 면접관은 판매글의 이미지 정보를 메모리에 보관한다고 가정한다. 저장 공간은 세 항목뿐이다. 어떤 접근 순서에서는 같은 항목을 여러 번 재사용하지만, 다른 순서에서는 직전에 보관한 항목이 계속 밀려난다. 이번에는 “메모리가 부족해서 느리다”라는 말보다 구체적인 설명이 필요하다.

다음 표는 이 상황에서 이어질 수 있는 질문을 정리한다. 질문은 정의에서 시작하지만, 꼬리 질문에서는 정의를 선택의 근거로 바꾸어야 한다.

운영체제 면접에서 확인하는 열 가지 질문
질문의도답변 핵심꼬리 질문
프로세스와 스레드는 어떻게 다른가?자원과 실행 단위를 구분하는가주소 공간의 경계와 공유 범위를 설명한다.스레드끼리 스택도 공유하는가?
스레드를 늘리면 빨라지는가?작업 특성을 고려하는가연산, 입출력 대기, 공유 자원 경합을 나눈다.Python에서도 여러 코어를 쓰는가?
컨텍스트 전환은 무엇인가?동시 실행의 비용을 이해하는가실행 상태를 저장하고 다른 실행 상태를 복원한다.프로세스 전환이 늘 더 비싼가?
카운터 증가가 왜 누락되는가?연산을 단계로 분해하는가읽기, 계산, 쓰기 사이의 끼어들기로 갱신이 유실된다.정수라서 안전하지 않은가?
뮤텍스와 세마포어는 어떻게 다른가?보호 목적에 맞게 도구를 고르는가상호 배제와 허용 개수 제한을 구분한다.세마포어 값을 1로 두면 같은가?
교착 상태의 조건은 무엇인가?잠금 도입의 부작용을 아는가상호 배제, 점유 대기, 비선점, 순환 대기를 설명한다.잠금 순서를 통일하면 무엇이 깨지는가?
가상 메모리는 무엇인가?주소와 실제 저장 위치를 구분하는가가상 주소를 물리 메모리 등에 매핑하는 체계다.프로세스마다 같은 주소를 써도 되는가?
페이지 폴트가 나면 디스크를 읽는가?예외 경로를 지나치게 단순화하지 않는가원인에 따라 새 페이지 할당, 파일 읽기, 접근 오류 처리가 다르다.메모리가 남아 있어도 발생하는가?
지역성은 성능과 어떤 관계인가?접근 패턴을 설명하는가최근 데이터의 재사용과 인접 데이터 접근을 구분한다.배열 순회는 왜 유리한가?
LRU의 약점은 무엇인가?교체 정책의 가정을 이해하는가최근 사용 이력이 미래 재사용을 보장하지 않는다.용량보다 큰 순환 접근에서는 어떤가?

프로세스와 스레드: 무엇을 나누고 무엇을 공유하는가

프로세스(process)는 실행 중인 프로그램을 운영체제가 관리하는 자원과 보호의 단위다. 각 프로세스는 일반적으로 독립된 가상 주소 공간을 갖는다. 스레드(thread)는 프로세스 안에서 명령을 실행하는 흐름이다. 같은 프로세스의 스레드는 코드, 전역 데이터, 힙 등의 주소 공간을 공유하지만, 각각의 레지스터 상태와 스택을 가진다.

여기서 “스택이 따로 있다”는 말은 다른 스레드가 그 메모리에 접근할 수 없다는 뜻이 아니다. 같은 주소 공간 안에 있으므로 참조가 전달되면 접근할 수 있다. 반대로 프로세스도 공유 메모리 같은 별도 수단을 사용하면 일부 메모리를 공유할 수 있다. 면접에서는 기본 경계를 먼저 말하고 예외를 덧붙이는 편이 명확하다.

한 프로세스의 스레드는 주소 공간을 공유하지만 실행 상태와 스택은 각각 가진다

깊게 답하기: 프로세스와 스레드는 어떻게 다른가?

모범 답안
프로세스는 자원과 주소 공간을 구분하는 단위이고, 스레드는 그 안에서 실행되는 흐름이다. 같은 프로세스의 스레드는 힙과 전역 데이터를 공유하므로 데이터를 주고받기 쉽지만, 공유 값에 접근할 때 동기화가 필요하다. 제가 만든 중고 거래 과제의 이미지 처리 작업을 나눈다면, 실패를 격리해야 하는 작업은 별도 프로세스로 분리하는 방안을 검토하겠다. 다만 프로세스 사이에는 데이터 전달 비용이 있고, 스레드도 작업 특성과 실행 환경에 따라 효과가 달라진다. 처리량만 보고 한쪽을 고르지는 않겠다.

이 답변은 경계를 먼저 설명하고, 데이터 공유라는 근거를 붙인 뒤, 과제에 적용할 선택과 그 비용을 말한다. 실제로 프로세스를 분리한 경험이 없다면 “분리했다”라고 바꾸면 안 된다. 구현한 범위와 앞으로 적용할 판단을 구분하는 것도 답변의 일부다.

면접관: 스레드가 하나 실패하면 다른 스레드도 종료되는가?

지원자: 실패의 종류에 따라 다르다. Python에서 처리하지 않은 일반 예외로 한 작업 스레드가 끝나는 것과, 프로세스 전체를 종료시키는 오류는 구분해야 한다. 스레드는 주소 공간을 공유하므로 프로세스 수준의 장애에서 서로 강하게 격리되지는 않는다.

컨텍스트 전환과 Python 실행 환경

컨텍스트 전환(context switch)은 실행 중인 작업의 레지스터, 다음 실행 위치 같은 상태를 저장하고 다른 작업의 상태를 복원하는 과정이다. 프로세스가 바뀌면 주소 공간 관련 상태도 다루어야 할 수 있다. 저장과 복원의 직접 비용뿐 아니라, 바뀐 작업이 사용하는 데이터 때문에 캐시 효율이 떨어지는 간접 비용도 있다. 같은 프로세스의 스레드 전환은 주소 공간을 유지할 수 있지만, 모든 상황에서 일정한 비용 차이가 나는 것은 아니다.

함수를 호출하거나 사용자 코드에서 작업 순서를 바꾸는 것 자체가 운영체제의 컨텍스트 전환은 아니다. 뒤의 코드는 제너레이터로 실행을 멈추고 재개한다. 실제 커널 전환 시간을 재는 대신, 전환 지점에 따라 공유 값이 달라지는 논리를 드러내는 실험이다.

Python이라는 언어와 특정 구현의 동작도 구분해야 한다. 일반적인 CPython 3.12 빌드에는 전역 인터프리터 잠금(GIL)이 있어 한 프로세스의 여러 스레드가 Python 코드를 동시에 실행하는 데 제약이 있다. 입출력을 기다리는 작업은 스레드로 대기 시간을 겹칠 수 있지만, Python 코드 중심의 연산 작업은 스레드 수만큼 빨라진다고 기대하기 어렵다. 프로세스 분리나 잠금을 해제하고 실행하는 확장 모듈 등을 검토할 수 있다. 이후 버전의 선택적 자유 스레딩 빌드처럼 실행 조건이 다르면 판단도 달라지므로, 면접에서는 구현과 빌드 조건을 함께 말한다.

공유 상태: 경쟁 상태를 막으면 무엇이 남는가

경쟁 상태(race condition)는 여러 실행 흐름의 상대적인 진행 순서에 따라 결과가 달라지는 상황이다. 카운터 증가를 “읽기 → 1 더하기 → 저장”으로 나누어 보자. 두 작업자가 모두 0을 읽은 뒤 각각 1을 저장하면, 두 번 증가시켰는데 최종 값은 1이다. 앞선 갱신을 뒤의 쓰기가 덮어쓴 것이다. 이런 문제는 여러 코어에서 실제로 같은 순간에 실행되지 않아도, 단계 사이에 실행이 교차하면 발생할 수 있다.

임계 구역(critical section)은 공유 상태의 일관성을 위해 함께 보호해야 하는 코드 구간이다. 뮤텍스(mutex)는 한 번에 하나의 실행 흐름만 그 구간에 들어가도록 하는 데 사용한다. 읽기와 쓰기 전체를 같은 잠금으로 보호해야 한다. 쓰기 한 줄만 보호하면 이미 오래된 값을 읽은 작업자가 잘못된 결과를 저장할 수 있다.

깊게 답하기: 카운터 증가에는 왜 잠금이 필요한가?

모범 답안
카운터 증가가 하나의 분리 불가능한 연산으로 보장되지 않는다면, 값을 읽고 갱신하는 전체 구간을 보호해야 한다. 두 작업자가 같은 이전 값을 읽으면 증가 결과 하나가 덮어써지기 때문이다. 중고 거래 과제에서 메모리의 조회 수를 여러 작업자가 바꾼다고 가정하면, 같은 잠금을 사용해 읽기부터 저장까지 묶겠다. 다만 이 잠금은 같은 프로세스에서 해당 잠금을 따르는 작업자 사이에만 효과가 있다. 여러 프로세스가 값을 바꾼다면 공유 위치에 맞는 원자적 갱신 수단이나 프로세스 간 동기화를 검토하겠다.

면접관: GIL이 있으면 잠금이 없어도 되는가?

지원자: GIL을 애플리케이션 상태의 일관성을 보장하는 잠금으로 사용하지 않겠다. 여러 단계로 이루어진 업무 연산 전체를 보호해 주지는 않기 때문이다. 특정 코드가 한 환경에서 반복 실행해도 같은 값을 냈다는 사실만으로 안전성을 증명할 수는 없다.

세마포어(semaphore)는 사용 가능한 허가의 개수를 관리한다. 예를 들어 이미지 변환 작업을 동시에 세 개까지만 수행하도록 제한할 수 있다. 허가가 세 개라고 해서 공유 카운터의 갱신이 안전해지는 것은 아니다. 작업 개수 제한과 공유 값 보호는 목적이 다르다.

초기값이 1인 세마포어도 상호 배제에 사용할 수 있지만, 뮤텍스와 모든 의미가 같지는 않다. 소유권과 해제 규칙은 도구마다 다르다. Python의 기본 Lock은 잠근 스레드만 해제해야 한다는 소유권을 강제하지 않는다. 어떤 구현을 사용하든 획득과 해제가 짝을 이루도록 하고, 실제 도구의 규칙을 확인해야 한다.

깊게 답하기: 교착 상태는 어떤 조건에서 생기는가?

교착 상태(deadlock)는 작업들이 서로가 보유한 자원을 기다려 진행하지 못하는 상태다. 작업 A가 판매글 잠금을 가진 채 사용자 잠금을 기다리고, 작업 B가 사용자 잠금을 가진 채 판매글 잠금을 기다리는 상황을 생각할 수 있다. 기다리는 시간이 길다는 사실만으로 교착 상태라고 부르지는 않는다. 누가 무엇을 보유하고 무엇을 기다리는지 확인해야 한다.

모범 답안
전형적인 자원 교착 상태는 상호 배제, 점유 대기, 비선점, 순환 대기라는 네 조건이 함께 성립할 때 발생할 수 있다. 자원을 독점하고, 가진 자원을 놓지 않은 채 추가 자원을 기다리며, 다른 작업이 이를 강제로 회수하지 못하고, 대기가 고리를 이루는 경우다. 중고 거래 과제에서 판매글 잠금과 사용자 잠금을 함께 쓴다면 모든 경로에서 같은 순서로 획득하도록 하겠다. 그러면 잠금 순서로 생기는 순환 대기를 막을 수 있다. 다만 외부 호출이나 다른 자원까지 얽히면 그 대기도 살펴야 하며, 시간 제한을 둔다면 실패 후 이미 잡은 자원을 해제하는 절차가 필요하다.

면접관: 시간 제한을 주면 교착 상태가 해결되는가?

지원자: 대기에서 빠져나올 계기는 만들지만, 일관된 복구까지 자동으로 해결하지는 않는다. 획득한 자원을 해제하고 중간 변경을 정리해야 한다. 모두 같은 간격으로 재시도하면 서로 양보만 반복할 수도 있어, 재시도 정책도 함께 정해야 한다.

메모리: 주소의 추상화와 재사용의 효과

가상 메모리(virtual memory)는 프로그램이 사용하는 주소를 실제 물리 메모리 등의 저장 위치에 대응시키는 체계다. 각 프로세스는 자기 주소 공간을 기준으로 실행하므로 같은 숫자의 가상 주소가 서로 다른 물리 위치를 가리킬 수 있다. 이를 “부족한 메모리를 디스크로 늘리는 기능”으로만 설명하면 주소 공간의 분리와 보호라는 핵심을 빠뜨린다.

페이지(page)는 가상 메모리를 일정한 크기로 나누어 관리하는 단위다. 물리 메모리의 대응 단위는 페이지 프레임이라고 부른다. 페이지 테이블에는 매핑과 접근 권한 등의 정보가 담기며, 주소 변환 정보를 재사용하는 TLB가 변환 비용을 줄인다. 페이지 크기는 환경에 따라 달라지므로 특정 값을 정의처럼 외우지 않는다.

페이지 폴트(page fault)는 접근에 필요한 매핑이나 권한 조건이 충족되지 않아 운영체제의 처리가 필요한 상황이다. 아직 물리 메모리를 붙이지 않은 영역에 처음 접근해 새 페이지를 제공할 수도 있고, 파일이나 교체 공간에서 데이터를 읽어야 할 수도 있다. 허용되지 않은 접근이라면 오류 처리가 이어질 수 있다. 따라서 페이지 폴트를 곧바로 디스크 읽기와 같은 뜻으로 사용하면 안 된다.

깊게 답하기: 지역성과 LRU는 어떤 관계인가?

시간 지역성(temporal locality)은 최근 사용한 데이터를 가까운 미래에 다시 사용하는 경향이다. 공간 지역성(spatial locality)은 접근한 위치 근처의 데이터를 사용하는 경향이다. 연속 메모리에 놓인 데이터를 순서대로 읽으면 한 번 가져온 캐시 라인의 주변 데이터도 활용할 수 있다. 다만 Python 리스트는 객체 참조를 연속으로 보관하므로, 리스트가 가리키는 객체 내용까지 모두 연속이라고 말해서는 안 된다.

모범 답안
지역성이 좋으면 이미 가져온 데이터를 다시 활용할 가능성이 높아진다. LRU는 가장 오랫동안 사용하지 않은 항목을 내보내므로, 최근 사용한 항목이 다시 쓰일 것이라는 시간 지역성에 기대는 정책이다. 중고 거래 서비스에서 소수의 인기 판매글 정보를 반복해서 읽는 상황을 예로 들 수 있다. 다만 용량보다 많은 항목을 차례로 순환하면 곧 다시 쓸 항목을 계속 내보낼 수 있다. 접근 기록과 적중률을 확인하고, 용량이나 보관 대상, 교체 정책을 함께 검토하겠다.

최근 최소 사용(LRU) 정책은 접근할 때마다 사용 순서를 갱신한다. 아래 그림에서는 세 칸이 가득 찬 상태에서 1을 다시 사용한 뒤 4가 들어온다. 가장 먼저 들어온 1이 아니라 가장 오랫동안 사용하지 않은 2가 나간다. 삽입 순서와 최근 사용 순서는 다르다.

LRU는 적중한 항목을 최근 사용 위치로 옮기고 가장 오래 사용하지 않은 항목을 교체한다

면접관: 실제 운영체제도 매번 모든 페이지의 정확한 순서를 유지하는가?

지원자: 실제 구현은 접근 정보와 관리 비용을 고려해 근사 정책 등을 사용한다. 여기서 만드는 정확한 LRU는 교체 원리를 확인하기 위한 모델이다. 이 모델의 적중률을 실제 시스템의 처리 시간이나 페이지 폴트 횟수로 곧바로 해석하지는 않겠다.

같은 질문에 답할 때 설명의 깊이가 달라지는 지점
질문나쁜 답보통 답좋은 답
프로세스와 스레드의 차이스레드가 가벼워서 좋다.프로세스는 메모리가 분리되고 스레드는 공유한다.주소 공간과 실행 상태를 구분하고, 공유 비용과 실패 격리까지 선택 근거로 연결한다.
카운터 증가의 안전성정수는 간단해서 안전하다.동시에 바꾸므로 잠금을 쓴다.갱신 유실 순서를 설명하고 읽기부터 쓰기까지 같은 잠금으로 보호하며 적용 범위를 밝힌다.
교착 상태 대응오래 기다리면 다시 실행한다.네 조건을 나열한다.실제 자원 대기를 예로 들고 잠금 순서로 순환 대기를 끊는 방법과 복구 한계를 설명한다.
LRU의 효과최근 것을 남기면 빠르다.오랫동안 안 쓴 것을 지운다.시간 지역성을 근거로 설명하고 용량을 넘는 순환 접근이라는 반례를 제시한다.

완성 코드

다음 코드를 main.py에 저장한다. 표준 라이브러리만 사용하며 입력이나 대기 없이 끝난다. 두 작업자를 실제 스레드로 실행하는 대신, 제너레이터를 한 번씩 번갈아 진행한다. 잠금이 없으면 두 작업자가 같은 값을 읽도록 실행 순서가 고정된다. 잠금이 있으면 획득에 실패한 작업자는 다음 차례까지 양보한다. 이 방식으로 운영체제 스케줄링의 우연에 기대지 않고 갱신 유실을 재현한다.

페이지 실험도 고정된 참조열을 사용한다. 난수와 현재 시각을 사용하지 않으므로 실행할 때마다 같은 결과가 나온다. 여기서 페이지 번호는 모델의 식별자이며, 실제 메모리를 할당하거나 운영체제의 페이지 교체 정책을 변경하지 않는다.

from collections import OrderedDict
from threading import Lock


def counter_worker(shared, repeats, lock):
    for _ in range(repeats):
        if lock is not None:
            while not lock.acquire(blocking=False):
                yield
        try:
            previous = shared["value"]
            yield
            shared["value"] = previous + 1
        finally:
            if lock is not None:
                lock.release()
        yield


def simulate_counter(use_lock):
    shared = {"value": 0}
    lock = Lock() if use_lock else None
    workers = [
        counter_worker(shared, 3, lock)
        for _ in range(2)
    ]
    while workers:
        remaining = []
        for worker in workers:
            try:
                next(worker)
            except StopIteration:
                continue
            remaining.append(worker)
        workers = remaining
    return shared["value"]


def simulate_lru(references, capacity):
    if capacity <= 0:
        raise ValueError("capacity must be positive")
    cache = OrderedDict()
    hits = 0
    for page in references:
        if page in cache:
            hits += 1
            cache.move_to_end(page)
        else:
            if len(cache) == capacity:
                cache.popitem(last=False)
            cache[page] = None
    total = len(references)
    misses = total - hits
    rate = hits / total if total else 0.0
    return hits, misses, rate


def main():
    print("공유 카운터: 작업자 2명, 각 3회 증가")
    print("기대값: 6")
    print(f"잠금 없음: {simulate_counter(False)}")
    print(f"잠금 있음: {simulate_counter(True)}")

    cases = [
        ("재사용", [1, 2, 3, 1, 2, 4, 1, 2, 3, 4]),
        ("순환", [1, 2, 3, 4] * 3),
    ]
    print("LRU 페이지 교체: 용량 3")
    for name, references in cases:
        hits, misses, rate = simulate_lru(references, 3)
        total = len(references)
        print(
            f"{name}: 적중 {hits}/{total}, "
            f"실패 {misses}, 적중률 {rate:.1%}"
        )


if __name__ == "__main__":
    main()

줄별 해설

OrderedDict는 키의 순서를 관리하고, 사용한 키를 끝으로 옮길 수 있게 한다. Lock은 카운터의 읽기와 쓰기를 묶는 데 사용한다. 프로그램의 동시 실행은 제너레이터로 모형화하지만, 잠금의 획득과 해제에는 표준 라이브러리 도구를 사용한다.

counter_worker의 shared는 두 작업자가 함께 보는 딕셔너리다. 정수 인자를 각각 전달하는 대신 같은 저장 위치를 읽고 쓰게 한다. repeats는 증가 횟수이고, lock이 None이면 보호 없이 실행한다.

for _ in range(repeats)는 증가 작업 하나를 반복한다. 잠금이 있을 때 acquire(blocking=False)를 호출하면 즉시 성공 여부를 받는다. 실패한 경우의 yield는 작업자를 멈추고 바깥 진행 루프에 제어를 돌려준다. 여기서 차단 방식으로 잠금을 기다리면 같은 실행 스레드에서 잠금을 해제할 작업자도 진행하지 못하므로 실험이 멈출 수 있다.

previous = shared["value"]는 현재 값을 지역 변수에 복사한다. 바로 다음 yield가 핵심이다. 저장하기 전에 다른 작업자가 같은 값을 읽을 기회를 준다. 잠금이 없다면 첫 순회에서 두 작업자 모두 0을 읽고, 다음 순회에서 둘 다 1을 저장한다.

shared["value"] = previous + 1은 읽어 둔 값을 기준으로 저장한다. finally는 잠금을 획득한 작업자가 해제하도록 한다. 마지막 yield는 한 번의 갱신을 끝내고 다음 작업자에게 차례를 넘긴다. 실제 업무 코드에서는 보통 with lock:으로 획득과 해제를 묶는다. 여기서는 획득 실패 시 양보하는 동작을 드러내기 위해 직접 호출한다.

simulate_counter는 매 실험마다 공유 값과 잠금을 새로 만든다. 두 제너레이터는 같은 딕셔너리와 같은 잠금을 받는다. next(worker)가 각 작업자를 다음 양보 지점까지 진행시키며, 종료된 작업자는 remaining에 넣지 않는다. 남은 작업자가 없으면 반복을 끝낸다.

simulate_lru는 양수 용량만 허용한다. cache의 앞쪽은 가장 오래 사용하지 않은 항목이고, 뒤쪽은 가장 최근에 사용한 항목이다. 적중하면 hits를 늘리고 move_to_end로 최근 사용 위치를 갱신한다.

실패한 경우 공간이 가득 찼다면 popitem(last=False)로 앞 항목을 제거한다. 새 페이지는 뒤에 들어간다. 값으로 저장한 None에는 의미가 없고 키와 순서만 사용한다. total - hits로 실패 횟수를 구하며, 빈 참조열의 적중률은 0.0으로 정해 0으로 나누지 않는다.

main은 같은 조건의 카운터 실험을 두 번 실행하고 서로 다른 참조열에 LRU를 적용한다. rate:.1%는 비율을 소수 첫째 자리까지 백분율로 표시한다. 마지막 조건문은 이 파일을 직접 실행했을 때만 실험을 시작하게 한다.

실행 결과

macOS 또는 Linux에서 Python 3.12 이상으로 실행한다.

python3 main.py

예상 출력은 다음과 같다.

공유 카운터: 작업자 2명, 각 3회 증가
기대값: 6
잠금 없음: 3
잠금 있음: 6
LRU 페이지 교체: 용량 3
재사용: 적중 4/10, 실패 6, 적중률 40.0%
순환: 적중 0/12, 실패 12, 적중률 0.0%

잠금 없는 실험은 매 증가 단계에서 두 작업자가 같은 이전 값을 읽는다. 따라서 세 번의 증가 결과만 남는다. 이는 모든 실제 실행이 3을 만든다는 예측이 아니라, 올바르지 않은 결과를 만드는 실행 순서 하나를 구성한 것이다. 잠금 있는 실험에서는 한 작업자가 읽고 쓰는 동안 다른 작업자가 읽기에 들어가지 못해 여섯 번의 갱신이 모두 반영된다.

재사용 참조열에서는 네 번째와 다섯 번째 접근의 1, 2가 적중하고, 일곱 번째와 여덟 번째 접근에서도 1, 2가 적중한다. 순환 참조열에서는 네 항목이 세 칸을 돌아가며 밀어낸다. 다음에 필요한 항목이 이미 제거되어 열두 번 모두 실패한다. 이 수치는 참조열과 용량에 대한 결과이며 LRU의 보편적인 성능 수치가 아니다.

실무에서 자주 틀리는 것

저장하는 부분만 잠그기

다음 코드는 쓰기 충돌만 피하면 된다고 생각한 경우다. 읽은 뒤 다른 작업자가 값을 바꾸면, 잠금 안에서도 오래된 값을 기준으로 저장하게 된다.

previous = shared["value"]
with lock:
    shared["value"] = previous + 1

읽기와 갱신을 같은 임계 구역에 둔다. 공유 상태에 접근하는 다른 경로도 같은 잠금을 따라야 한다.

with lock:
    shared["value"] = shared["value"] + 1

보호 범위가 넓으면 대기가 늘지만, 범위를 줄이기 전에 어떤 값들이 함께 일관성을 유지해야 하는지 정해야 한다.

서로 다른 순서로 두 잠금 획득하기

각 함수만 보면 잠금을 잘 사용한 것처럼 보여도 두 작업이 다른 순서로 획득하면 서로 기다릴 수 있다.

def task_a():
    with item_lock:
        with user_lock:
            update_state()


def task_b():
    with user_lock:
        with item_lock:
            update_state()

관련된 모든 경로에서 판매글 잠금 다음 사용자 잠금이라는 순서를 지킨다. 아래 함수들이 호출하는 코드에서도 역순 획득을 만들지 않아야 한다.

def task_a():
    with item_lock:
        with user_lock:
            update_state()


def task_b():
    with item_lock:
        with user_lock:
            update_state()

자원이 실행 중에 동적으로 정해진다면 안정적인 식별자에 따른 정렬 등 공통 순서 규칙을 설계한다.

적중한 페이지의 사용 순서를 갱신하지 않기

다음처럼 적중 횟수만 늘리면 순서는 삽입 시점에 머문다. 그 결과 최근에 사용한 항목이 앞에 남아 교체될 수 있다.

if page in cache:
    hits += 1

적중할 때마다 해당 항목을 최근 사용 위치로 옮긴다.

if page in cache:
    hits += 1
    cache.move_to_end(page)

교체 정책은 이름보다 상태 갱신 규칙으로 설명하는 편이 정확하다. LRU라면 삽입뿐 아니라 모든 접근이 사용 순서에 반영되어야 한다.

잠금을 잡은 채 완료 여부를 바쁘게 확인하기

아래 반복은 대기 중에도 잠금을 놓지 않는다. 완료 상태를 기록할 작업자도 같은 잠금이 필요하다면 진행할 수 없다.

with lock:
    while not shared["done"]:
        pass

상태를 기다려야 한다면 조건 변수(condition variable)처럼 대기 중 잠금을 놓고, 깨어날 때 다시 획득하는 도구를 검토한다.

from threading import Condition

condition = Condition()
shared = {"done": False}


def wait_until_done():
    with condition:
        condition.wait_for(lambda: shared["done"])


def mark_done():
    with condition:
        shared["done"] = True
        condition.notify_all()

이 예시는 실제 스레드에서 사용하는 패턴이다. 완성 코드의 제너레이터 진행 루프에 그대로 넣는 방식은 아니다. 대기하는 쪽은 상태를 다시 확인하고, 변경하는 쪽은 같은 조건 변수의 잠금 아래에서 상태를 바꾸고 알린다.

한눈에 보기

운영체제 답변에 포함할 근거와 피할 단정
주제설명의 중심예시 또는 대응피할 단정
프로세스·스레드주소 공간과 실행 상태의 경계공유 편의와 실패 격리를 비교한다.스레드는 언제나 빠르다.
컨텍스트 전환실행 상태 저장·복원과 간접 비용대기와 경합, 캐시 영향을 살핀다.함수 호출도 모두 같은 전환이다.
경쟁 상태순서에 따라 일관성이 달라진다.읽기와 쓰기를 함께 보호한다.한 번 정답이 나왔으니 안전하다.
뮤텍스·세마포어상호 배제와 허용 개수 제한목적과 소유권 규칙을 확인한다.동시 작업 수만 줄이면 값도 안전하다.
교착 상태네 조건과 자원 대기의 고리잠금 순서를 통일하고 복구를 설계한다.시간 제한만 있으면 정리가 끝난다.
가상 메모리주소 변환과 보호페이지 매핑과 폴트 원인을 구분한다.페이지 폴트는 모두 디스크 읽기다.
지역성·LRU재사용 패턴과 보관 용량의 관계적중률과 순환 접근을 확인한다.최근 사용 이력이 미래를 보장한다.

면접관이 답변 뒤에 “그렇다면 이 경우는 어떤가?”라고 묻는 것은 첫 답을 취소하라는 뜻이 아니다. 처음 답한 원리가 어떤 조건에서 유지되는지 확인하는 과정이다. “한 프로세스 안에서는”, “일반적인 CPython 빌드에서는”, “이 참조열에서는”처럼 적용 범위를 붙이면 다음 질문에도 설명을 이어 갈 수 있다.

연습 문제

  1. 완성 코드에서 작업자 두 명은 유지하고 각 작업자의 증가 횟수를 5로 바꾼다. 기대값, 잠금 없는 결과, 잠금 있는 결과를 실행 전에 예상하고 실행 순서로 설명하라.
  2. 잠금 없는 작업자에서 값을 읽은 직후의 yield만 제거한다. 결과가 기대값과 같아졌다면 실제 스레드 환경에서도 안전하다고 말할 수 있는지 설명하라.
  3. 용량 3으로 참조열 [1, 2, 3, 1, 4, 1, 2]를 처리한다. LRU의 적중 횟수와 최종 보관 순서를 구하라. 적중할 때 순서를 갱신하지 않는 정책과도 비교하라.
  4. 작업 A는 판매글 잠금을 보유하고 사용자 잠금을 기다린다. 작업 B는 사용자 잠금을 보유하고 판매글 잠금을 기다린다. 교착 상태의 네 조건을 각각 대응시키고, 획득 순서 통일과 시간 제한의 차이를 설명하라.

정답과 해설

  1. 기대값은 10, 잠금 없는 결과는 5, 잠금 있는 결과는 10이다. 잠금이 없으면 매번 두 작업자가 같은 이전 값을 읽어 같은 다음 값을 저장한다. 잠금이 있으면 한 작업자의 읽기와 저장 사이에 다른 작업자의 읽기가 들어오지 않는다. 출력의 횟수 안내와 기대값 문구도 함께 바꾸어야 한다.

  2. 이 진행 루프에서는 읽기와 저장 사이에 작업자가 양보하지 않으므로 결과가 6이 된다. 하지만 이는 모델이 허용하는 교차 지점을 제거한 결과다. 실제 실행 환경의 연산 보장이나 다른 공유 상태 접근까지 증명하지 않는다. 안전성을 주장하려면 필요한 원자성, 동기화 수단, 모든 접근 경로를 근거로 설명해야 한다.

  3. LRU는 네 번째와 여섯 번째의 1에서 적중하므로 2회 적중, 5회 실패다. 최종 순서는 오래된 것부터 [4, 1, 2]다. 적중 시 순서를 갱신하지 않으면 네 번째의 1만 적중한다. 4를 넣을 때 삽입이 가장 오래된 1을 제거하고, 다음 1도 실패한다. 최종 순서는 [4, 1, 2]로 같지만 적중 횟수는 다르다. 마지막 상태만 비교하면 정책 차이를 놓칠 수 있다.

  4. 각 잠금은 동시에 하나의 작업만 보유하므로 상호 배제가 성립한다. 두 작업 모두 하나를 가진 채 다른 하나를 기다리므로 점유 대기다. 보유한 잠금을 다른 작업이 강제로 빼앗지 못하므로 비선점이다. A와 B가 서로를 기다리는 고리가 순환 대기다. 모든 작업이 판매글 잠금 다음 사용자 잠금으로 획득하면 이 두 잠금 사이의 순환 대기를 막는다. 시간 제한은 대기에서 빠져나오게 할 뿐이므로, 획득한 잠금의 해제와 중간 상태 복구가 추가로 필요하다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.