Devin.KR

CS · 기본

개발자를 위한 컴퓨터 사이언스 - 내 코드가 실제로 도는 곳

프로세스와 스레드 - 주소 공간의 공유와 분리, 생성과 실행 전환 비용 (CS 기초 5장)

프로세스와 스레드의 공유 범위, 프로그램 적재와 실행 전환을 구분하고, 독립 실행 예제와 작업 전달 비용을 통해 장애 격리와 작업 풀 선택의 근거를 익힌다.

개발자 · 원고 갱신

이 장에서 배우는 것

4장에서는 자료구조를 메모리 접근 방식으로 골랐다. 이번에는 그 메모리를 누가 소유하고 어떤 실행 흐름이 함께 쓰는지 살핀다. 선수지식은 변수, 함수 호출, 앞 장의 캐시 지역성이다. 프로세스 목록에 같은 프로그램 이름이 여러 번 나와도 그것만으로 중복 실행 장애라고 결론 내릴 수 없다는 데서 출발한다.

  • 프로세스와 스레드의 공유 영역과 독립 상태를 구분한다.
  • 프로세스 생성, 프로그램 적재, 실행 전환의 차이를 설명한다.
  • 장애 격리와 데이터 전달 비용을 근거로 작업 풀의 형태를 고른다.

개념

이미지 변환 요청이 몰리는 서비스에서 작업자를 네 개로 늘렸다고 가정한다. 처리량은 거의 그대로인데 메모리 사용량만 커졌다. 각 작업자가 별도 프로세스로 큰 자료를 따로 읽었다면 가능한 결과다. 같은 주소 공간을 공유하는 스레드로 바꾸면 복제 비용을 줄일 수 있지만, 이번에는 공유 자료의 수정과 오류 전파를 함께 고려해야 한다.

주소 공간은 소유 경계다

프로세스는 실행 중인 프로그램의 자원과 주소 공간을 관리하는 단위다. 스레드는 그 안에서 명령을 이어 가는 실행 흐름이다. 서로 다른 프로세스의 같은 숫자 주소가 같은 객체를 뜻하지 않는다. 반대로 한 프로세스의 스레드들은 일반적인 힙 객체와 전역 데이터를 함께 볼 수 있으므로 공유 가능성과 안전한 공유를 구별해야 한다.

프로세스 A의 두 스레드는 힙과 전역 영역을 공유하고 각자 스택과 레지스터를 가지며, 프로세스 B는 별도 주소 공간을 가진다.

프로세스 A의 두 스레드는 힙과 전역 영역을 공유하고 각자 스택과 레지스터를 가지며, 프로세스 B는 별도 주소 공간을 가진다. 그림의 경계는 소유 범위를, 화살표는 실행 또는 참조 방향을 뜻한다.

대상같은 프로세스의 스레드다른 프로세스
힙·전역 데이터같은 주소 공간에서 접근기본적으로 분리
스택·레지스터 상태실행 흐름마다 별도각 실행 흐름마다 별도
열린 파일 자원일반적으로 공유생성 방식에 따라 상속·공유 가능
오류 영향프로세스 전체에 퍼질 수 있음주소 공간 격리가 경계가 됨

스레드별 스택은 함수 호출의 이어 갈 위치와 지역 데이터를 유지한다. 별도 스택이라는 말은 다른 스레드가 절대로 그 주소를 읽을 수 없다는 뜻은 아니다. 주소를 공유하면 접근할 수도 있고, 해당 함수가 끝나 수명이 종료된 데이터를 참조하면 문제가 된다. 표의 구분은 소유와 수명의 기본 구조이지 언어의 접근 제한 규칙을 대신하지 않는다.

생성과 프로그램 적재는 다른 사건이다

POSIX 계열에서 fork는 부모를 바탕으로 자식 프로세스를 만드는 동작이고, exec 계열은 현재 프로세스의 프로그램 이미지를 새 프로그램으로 바꾸는 동작이다. 새 작업자를 만든 뒤 다른 프로그램을 적재하는 흐름을 개념적으로 나누어 볼 수 있다. 다만 고수준 실행 도구가 실제로 항상 fork 다음 exec를 호출한다고 단정하면 안 된다. 운영체제와 실행 옵션에 따라 다른 생성 경로를 사용할 수 있다.

부모 메모리를 바탕으로 자식을 만들어도 시작하자마자 모든 물리 메모리를 그대로 복사해야 하는 것은 아니다. 복사 시 쓰기 방식은 쓰기 전까지 페이지를 공유할 수 있게 하되, 쓰기가 발생하면 사적 복사본을 마련한다. 따라서 시작 직후 메모리 절약과 작업 중 메모리 절약은 같은 약속이 아니다. 많은 페이지를 수정하는 작업은 시작 시의 이점을 상당 부분 잃을 수 있다.

생성: 새 실행 주체와 자원 경계를 만든다
적재: 실행할 프로그램 이미지를 준비한다
전환: 실행 중이던 흐름 대신 다른 흐름을 이어 간다

전환 비용에는 다시 데우는 시간도 있다

실행을 멈춘 흐름이 나중에 돌아오려면 레지스터와 실행 위치 같은 문맥을 보존해야 한다. 운영체제가 다음 실행 대상을 고르는 일도 필요하다. 다른 주소 공간으로 옮기면 주소 변환과 관련된 작업이 추가될 수 있다. 그러나 매 전환마다 캐시 전체가 반드시 지워진다고 설명하는 것은 틀리다.

실제 손해에는 다른 작업이 캐시를 사용해 이전 작업의 데이터가 밀려난 영향이 포함될 수 있다. 코어를 옮겨 실행되면 이전 코어에 있던 데이터의 지역성을 잃기도 한다. 이런 간접 비용은 작업 크기와 메모리 접근에 따라 달라진다. 그래서 스레드 전환이 언제나 일정한 시간이고 프로세스 전환은 언제나 몇 배라는 수치는 이 장에서 사용하지 않는다.

직접 확인하기

별도 프로세스에 전달한 값은 돌아와 합쳐지지 않는다

다음 예제들은 Python 3 표준 라이브러리만 쓴다. 각 코드 블록은 새 파일에 저장해 독립 실행할 수 있다. 첫 실험은 표준 입력으로 부모의 값을 직렬화해 자식에게 보내는 방식이다. 자식이 값을 바꾸어도 부모 객체가 자동으로 바뀌지 않는다는 계약을 확인하며, 특정 생성 시스템 호출을 입증하는 실험은 아니다.

import json, subprocess, sys
parent = {"jobs": 1}
program = "import json,sys; x=json.loads(sys.stdin.read()); x['jobs']+=1; print(x['jobs'])"
child = subprocess.run([sys.executable, "-c", program],
    input=json.dumps(parent), text=True, capture_output=True, check=True)
print("parent", parent["jobs"])
print("child", child.stdout.strip())
결과
----
parent 1
child 2

자식의 결과는 출력 통로로 받은 별도 문자열이다. 부모가 결과를 자신의 상태에 반영하려면 읽고 해석하고 적용하는 단계가 필요하다. 네트워크가 없어도 프로세스 경계를 건너는 데이터 전달에는 표현 변환과 버퍼 사용이 생길 수 있다. 작업이 아주 작다면 계산보다 이 준비가 더 큰 비중을 차지할 수 있다.

공유와 스케줄링은 다른 질문이다

두 이름이 같은 객체를 가리키는 다음 코드는 공유의 최소 모형이다. 실제 스레드를 시작하지 않으므로 실행 순서의 우연에 기대지 않는다. 값이 함께 바뀐다는 사실은 확인하지만 동시에 수정해도 안전하다는 결론은 내리지 않는다. 공유 범위를 먼저 이해한 뒤 8장에서 수정 순서의 문제를 따로 다룬다.

shared = {"jobs": 1}
worker_a = shared
worker_b = shared
worker_a["jobs"] += 1
print("same object", worker_a is worker_b)
print("worker B sees", worker_b["jobs"])
결과
----
same object True
worker B sees 2

다음 상태 모형에서는 기다리는 작업과 실행할 수 있는 작업을 나눈다. 기다림에서 깨어났다고 곧바로 CPU를 얻는 것은 아니다. 실행 가능한 상태로 돌아온 뒤 스케줄러가 선택해야 명령이 진행된다. 모형의 두 번 배정은 실제 커널 전환 횟수의 측정값이 아니다.

states = ["ready", "running", "waiting", "ready", "running", "done"]
for before, after in zip(states, states[1:]):
    print(before, "->", after)
print("dispatches", states.count("running"))
결과
----
ready -> running
running -> waiting
waiting -> ready
ready -> running
running -> done
dispatches 2

작업 전달 크기는 시간을 재지 않아도 확인할 수 있다. 다음 코드는 동일한 포장 규칙으로 항목 수만 바꾸어 메시지 바이트 수를 센다. 이 값은 직렬화된 본문 길이이며, 운영체제 내부 복사량이나 전체 메모리 사용량을 뜻하지 않는다. 덩어리로 묶어 보내는 선택은 이런 포장 비용과 기다리는 시간을 함께 바꾸므로 실제 요청 크기 분포로 판단한다.

import json
for count in (1, 100):
    message = json.dumps({"items": list(range(count))}).encode("utf-8")
    print("items", count, "bytes", len(message))
결과
----
items 1 bytes 14
items 100 bytes 401

환경별 차이

환경확인할 경계해석할 때의 주의
Linux프로세스 식별자와 스레드 식별자도구가 스레드를 행별로 표시하는지 확인
macOS같은 POSIX 개념과 다른 관찰 도구Linux의 /proc 경로를 그대로 쓰지 않음
컨테이너PID 이름 공간과 자원 제한안팎에서 보이는 식별자가 다를 수 있음

실행 파일 이름은 프로세스의 고유 식별자가 아니다. 같은 실행 파일로 여러 작업자를 만들 수 있고 한 프로세스 안에도 많은 스레드가 존재한다. 관찰 도구가 무엇을 한 행으로 표시하는지 알아야 개수를 세는 일이 의미가 있다. 재시작으로 식별자가 바뀌는 상황에서는 숫자만 보존하지 말고 시작 시각과 관찰 구간도 함께 남기는 편이 낫다.

컨테이너는 곧바로 별도의 물리 컴퓨터가 되는 장치가 아니다. 프로세스가 보게 되는 이름 공간과 사용할 수 있는 자원에 경계가 더해진다. 따라서 컨테이너 안에서 CPU 수가 보인다는 사실만으로 그 수만큼 항상 계산할 수 있다고 생각하면 안 된다. 허용되는 CPU 시간은 다음 장의 제한 정보와 함께 읽는다.

실무의 풀 선택에서는 복제할 데이터 양, 작업 하나의 크기, 작업 실패 시 격리 범위를 먼저 적는다. 이어서 작업 수를 고정한 채 요청당 대기 시간과 최대 메모리를 비교한다. 스레드 수를 늘려 처리량이 오른 경우에도 꼬리 지연이나 공유 자원 경합이 함께 나빠질 수 있다. 평균 처리량 하나만으로 구조를 결정하지 않는 이유다.

실무에서 자주 틀리는 것

1. 같은 이름의 프로세스를 중복 장애로 판단한다

프로세스 목록에 같은 이름이 여러 줄 보이는 증상으로 시작한다. 흔한 오진은 프로그램이 잘못 재실행되었다는 것이다. 실제로는 의도적으로 만든 작업자이거나 스레드를 개별 표시한 화면일 수 있다. 부모 관계, 식별자 종류, 설정된 작업자 수를 맞추어 확인하면 정상 병렬 구조와 불필요한 재실행을 구별할 수 있다.

2. 스레드는 메모리를 안 쓴다고 계산한다

동시 요청 수를 올린 뒤 메모리가 증가하는데 힙의 큰 자료는 하나뿐인 증상이다. 흔한 오진은 반드시 공유 객체 누수가 있다는 것이다. 실제 원인에는 스레드별 스택과 요청별 버퍼, 대기 중인 작업이 보유한 객체가 포함된다. 작업자 수와 진행 중인 요청 수를 나누어 기록하고 최대 동시성에 따른 메모리 변화를 비교한다.

3. 프로세스로 나누면 모든 오류가 격리된다고 믿는다

작업자 하나가 실패한 뒤 다른 작업까지 응답하지 않는 증상이다. 주소 공간이 따로이므로 관계없다는 판단은 흔한 오진이다. 작업자들은 같은 파일, 외부 서비스, 부모의 응답 통로를 사용할 수 있으므로 기다림이 연결될 수 있다. 실패한 작업의 결과 통로를 닫고 기다림에 종료 조건을 두었는지 확인해야 격리가 실제 동작으로 이어진다.

풀의 개수보다 경계를 먼저 정한다. 작업자에게 전달하는 입력과 돌아오는 결과, 작업자가 보유하는 자원과 종료 책임을 문서화한다. 프로세스를 고르는 결정은 통신 계약을 만드는 결정이기도 하다. 이 계약이 분명해야 장애를 재현했을 때 어느 쪽이 끝나야 하고 어느 쪽이 살아 있어야 하는지 검사할 수 있다.

스스로 확인하기

  1. 부모 값 1, 자식 출력 2가 관찰되었다. 부모가 출력 문자열을 받았으므로 부모 값도 2여야 한다는 주장을 판단하라.
  2. 작업자 8개가 각각 사적 버퍼 12 MiB를 보유한다. 버퍼만의 합을 계산하고 전체 메모리와 같지 않은 이유를 설명하라.
  3. 깨어난 작업이 곧바로 실행되지 않을 수 있는 이유와, 전환마다 캐시가 모두 사라진다는 설명의 문제를 설명하라.

첫 문제의 답은 아니다. 문자열을 받은 일과 기존 객체를 수정한 일은 별도다. 두 번째는 96 MiB이며 공유 영역, 스택, 런타임 등의 비용이 더해질 수 있다. 세 번째는 실행 가능한 다른 작업과 CPU를 경쟁하기 때문이고, 캐시의 영향은 보존 여부와 접근 패턴에 따라 달라지므로 전체 삭제를 일반 법칙으로 말할 수 없다.

참고와 출처: Python subprocess 공식 문서, Linux man-pages fork, Linux man-pages execve를 사실 확인에 사용했다. 본문의 상황, 설명, 코드와 도식은 이 책을 위해 작성했으며 외부 문장이나 코드를 발췌하지 않았다.

다음 6장에서는 실행 가능한 작업 중 누가 CPU를 받는지 살핀다. 작업자 수가 늘었는데 처리량이 그대로인 현상을 실행 큐, 부하 평균, CPU 시간 제한으로 나누어 읽는다.

READER FEEDBACK

질문·오탈자·의견

내용에 관한 질문이나 오탈자, 더 나은 설명을 위한 의견을 남겨 주세요. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

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

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