[JUNGLE] TIL_41. CSAPP 8.2 ~ 8.4

모깅·2025년 10월 23일

JUNGLE

목록 보기
42/56
post-thumbnail

8.2 프로세스 (Processes)

예외(Exception)는 운영체제 커널이 프로세스(process)라는 개념을 제공할 수 있게 하는 기본적인 구성 요소입니다. 프로세스는 컴퓨터 과학에서 가장 심오하고 성공적인 아이디어 중 하나입니다.

현대 시스템에서 프로그램을 실행할 때, 우리는 마치 우리 프로그램이 시스템에서 유일하게 실행되는 것처럼 느끼는 환상(illusion)을 경험합니다. 우리 프로그램은 프로세서와 메모리 모두를 독점적으로 사용하는 것처럼 보입니다. 프로세서는 프로그램의 명령어들을 중단 없이 순서대로 실행하는 것처럼 보이며, 프로그램의 코드와 데이터는 시스템 메모리에 있는 유일한 객체인 것처럼 보입니다. 이러한 환상들은 바로 프로세스라는 개념을 통해 제공됩니다.

프로세스의 정의와 컨텍스트 (Context)

  • 정의: 프로세스의 고전적인 정의는 실행 중인 프로그램의 인스턴스(an instance of a program in execution)입니다.
  • 컨텍스트 (Context): 시스템의 모든 프로그램은 특정 프로세스의 컨텍스트 내에서 실행됩니다. 컨텍스트란 프로그램이 올바르게 실행되기 위해 필요한 모든 상태 정보(state)를 의미하며, 다음을 포함합니다.
    • 메모리에 저장된 프로그램의 코드와 데이터
    • 스택 (Stack)
    • 범용 레지스터 (General-purpose registers)의 값
    • 프로그램 카운터 (Program Counter, PC)
    • 환경 변수 (Environment variables)
    • 열려있는 파일 디스크립터 (File descriptors) 목록

프로세스 생성

사용자가 셸(shell)에 실행 파일 이름을 입력하여 프로그램을 실행할 때마다, 셸은 새로운 프로세스를 생성하고 이 새로운 프로세스의 컨텍스트 안에서 해당 실행 파일을 실행시킵니다. 응용 프로그램 역시 새로운 프로세스를 생성하여 자신의 코드를 실행하거나 다른 응용 프로그램을 실행할 수 있습니다.

프로세스의 핵심 추상화

운영체제가 프로세스를 어떻게 구현하는지에 대한 상세한 설명 대신, 프로세스가 응용 프로그램에 제공하는 핵심적인 추상화(abstractions)에 초점을 맞춥니다.

  • 독립적인 논리적 제어 흐름 (Independent logical control flow)
    • 개념: 우리 프로그램이 프로세서를 독점적으로 사용하는 것과 같은 환상을 제공합니다. CPU가 여러 프로세스를 전환하며 실행하더라도, 각 프로세스는 자신만의 제어 흐름을 독립적으로 가지고 있는 것처럼 동작합니다.
  • 사적인 주소 공간 (Private address space)
    • 개념: 우리 프로그램이 메모리 시스템을 독점적으로 사용하는 것과 같은 환상을 제공합니다. 각 프로세스는 다른 프로세스와 격리된 자신만의 가상 주소 공간을 가지므로, 다른 프로세스의 메모리에 직접 접근할 수 없습니다.

8.2.1 논리적 제어 흐름 (Logical Control Flow)

프로세스는 시스템에서 (실제로는) 다른 많은 프로그램이 동시에 실행(concurrently)되고 있음에도 불구하고, 각 프로그램에게 마치 프로세서를 독점적으로 사용하는 듯한 환상(illusion)을 제공합니다.

만약 디버거를 사용해 우리 프로그램의 실행을 한 단계씩(single-step) 추적해 본다면, 우리는 일련의 프로그램 카운터(PC, Program Counter) 값들을 관찰하게 될 것입니다. 이 PC 값들은 오로지 우리 프로그램의 실행 파일 또는 런타임에 동적으로 연결된 공유 객체(shared objects)에 포함된 명령어들에만 해당하는 값들입니다.

이러한 PC 값의 순서(sequence)를 논리적 제어 흐름(logical control flow) 또는 간단히 논리적 흐름(logical flow)이라고 합니다.


동시 실행과 인터리빙 (Concurrency and Interleaving)

(CSAPP 그림 8.12 참조) 세 개의 프로세스를 실행하는 시스템을 생각해 봅시다. 프로세서의 단일한 물리적 제어 흐름(physical control flow)은 각 프로세스당 하나씩, 세 개의 논리적 흐름으로 분할됩니다. (그림에서) 각 수직선은 한 프로세스의 논리적 흐름의 일부를 나타냅니다.

이 예시에서 세 논리적 흐름의 실행은 인터리브(interleaved)되어 있습니다. (즉, 실행이 겹쳐져서 일어납니다.)

  1. 프로세스 A가 잠시 실행됩니다.
  2. 이어서 B가 실행되어 완료(completion)됩니다.
  3. 그다음 C가 잠시 실행됩니다.
  4. 이어서 A가 실행되어 완료됩니다.
  5. 마지막으로 C가 실행을 완료합니다.

선점 (Preemption) 과 프로세서 사용의 환상

여기서 핵심은 프로세스들이 차례를 바꿔가며(take turns) 프로세서를 사용한다는 것입니다. 각 프로세스는 자신의 흐름의 일부를 실행한 뒤 선점(preempted)(일시적으로 중단)되며, 그동안 다른 프로세스들이 자신의 차례를 가집니다.

이 프로세스 중 하나의 컨텍스트에서 실행되는 프로그램 입장에서는, 자신이 프로세서를 독점적으로 사용하는 것처럼 보입니다.

그 반대라는 유일한 증거는, 만약 우리가 각 명령어의 경과 시간(elapsed time)을 정밀하게 측정한다면, 우리 프로그램의 일부 명령어들 실행 사이에 CPU가 주기적으로 멈추는(stall) 것처럼 보인다는 점입니다. 하지만 프로세서가 멈출 때마다, 이후 프로그램의 메모리 위치나 레지스터의 내용에는 어떠한 변경도 없이 우리 프로그램의 실행을 재개합니다.

8.2.2 동시성 흐름 (Concurrent Flows)

논리적 흐름(Logical flow)은 컴퓨터 시스템에서 다양한 형태로 나타납니다. 예외 핸들러(Exception handlers), 프로세스(processes), 시그널 핸들러(signal handlers), 스레드(threads), 그리고 자바 프로세스(Java processes)가 모두 논리적 흐름의 예입니다.


동시성 흐름의 정의

실행이 다른 흐름과 시간상으로 겹치는(overlaps in time) 논리적 흐름을 동시성 흐름(concurrent flow)이라고 부르며, 이 두 흐름은 동시적으로 실행된다(run concurrently)고 말합니다.

더 정확하게 정의하자면, 두 흐름 X와 Y는 다음과 같은 경우에만 서로에 대해 동시적입니다:

  • X가 Y의 시작 이후, 그리고 Y의 종료 이전에 시작하는 경우
  • 또는 Y가 X의 시작 이후, 그리고 X의 종료 이전에 시작하는 경우

예를 들어, (앞의) 그림 8.12에서 프로세스 A와 B는 동시적으로 실행되며, A와 C 또한 동시적으로 실행됩니다. 반면에 B와 C는 동시적으로 실행되지 않는데, 이는 B의 마지막 명령어가 C의 첫 번째 명령어가 실행되기 전에 실행되기 때문입니다. (즉, 실행 시간이 겹치지 않습니다.)


동시성, 멀티태스킹, 타임 슬라이스

여러 흐름이 동시적으로 실행되는 일반적인 현상을 동시성(concurrency)이라고 합니다. 하나의 프로세스가 다른 프로세스들과 차례를 바꿔가며(taking turns) 실행되는 개념은 멀티태스킹(multitasking)이라고도 알려져 있습니다.

프로세스가 자신의 흐름의 일부를 실행하는 각각의 기간(time period)을 타임 슬라이스(time slice)라고 부릅니다. 따라서 멀티태스킹은 타임 슬라이싱(time slicing)이라고도 합니다. 예를 들어, 그림 8.12에서 프로세스 A의 흐름은 2개의 타임 슬라이스로 구성됩니다.


동시성 vs. 병렬성 (Concurrency vs. Parallelism)

주목할 점은, 동시성 흐름이라는 개념은 그 흐름들이 실행되는 프로세서 코어(processor cores)의 수나 컴퓨터의 수와 무관하다는 것입니다. 두 흐름이 시간상 겹치기만 한다면, 설령 같은 프로세서(단일 코어)에서 실행되더라도 이들은 동시적입니다.

하지만, 동시성 흐름의 진부분집합(proper subset)병렬 흐름(parallel flows)을 식별하는 것이 유용할 때가 있습니다. 만약 두 흐름이 서로 다른 프로세서 코어나 컴퓨터에서 동시적으로 실행된다면, 우리는 이 흐름들을 병렬 흐름이라 부르며, 병렬적으로 실행된다(running in parallel) 또는 병렬 실행(parallel execution)을 갖는다고 말합니다.

8.2.3 사적인 주소 공간 (Private Address Space)

프로세스는 각 프로그램에게 마치 시스템의 주소 공간(address space)을 독점적으로 사용하는 듯한 환상(illusion)을 제공합니다.

nn-bit 주소를 가진 기기에서, 주소 공간은 0,1,,2n10, 1, \dots, 2^n - 1 까지, 총 2n2^n개의 가능한 주소 집합입니다. 프로세스는 각 프로그램에게 자신만의 사적인 주소 공간(private address space)을 제공합니다. 이 공간이 '사적(private)'이라는 의미는, 이 공간의 특정 주소와 연결된 메모리 바이트는 일반적으로 다른 어떤 프로세스에 의해서도 읽히거나 쓰일 수 없다는 것입니다. (즉, 프로세스 간 메모리 침범이 불가능합니다.)


주소 공간의 구조 (Organization)

비록 각 사적인 주소 공간과 연결된 메모리의 내용은 일반적으로 서로 다르지만, 모든 공간은 동일한 일반적인 구조(organization)를 가집니다. 예를 들어, (CSAPP) 그림 8.13은 x86-64 리눅스 프로세스의 주소 공간 구조를 보여줍니다.

  • 사용자 공간 (User Space): 주소 공간의 아래쪽 부분(bottom portion)은 사용자 프로그램을 위해 예약되어 있으며, 일반적인 코드(code), 데이터(data), 힙(heap), 스택(stack) 세그먼트를 포함합니다. 코드 세그먼트는 항상 0x400000 주소에서 시작합니다.
  • 커널 공간 (Kernel Space): 주소 공간의 위쪽 부분(top portion)커널(kernel)(운영체제 중 메모리에 상주하는 부분)을 위해 예약되어 있습니다. 주소 공간의 이 부분은 커널이 (예: 응용 프로그램이 시스템 콜을 실행할 때) 프로세스를 대신하여 명령어를 실행할 때 사용하는 코드, 데이터, 스택을 포함합니다.

8.2.4 사용자 모드와 커널 모드 (User and Kernel Modes)

운영체제 커널이 완벽한 프로세스 추상화를 제공하기 위해서는, 프로세서가 응용 프로그램이 실행할 수 있는 명령어와 접근할 수 있는 주소 공간 영역을 제한하는 메커니즘을 제공해야 합니다.


모드 비트 (Mode Bit)

프로세서는 일반적으로 제어 레지스터(control register) 내의 모드 비트(mode bit)를 통해 이 기능을 제공합니다. 모드 비트는 프로세스가 현재 누리고 있는 권한(privileges)을 나타냅니다.

  • 커널 모드 (Kernel Mode / Supervisor Mode)
    • 조건: 모드 비트가 설정됨(set).
    • 권한: 커널 모드에서 실행되는 프로세스는 명령어 세트의 모든 명령어를 실행할 수 있으며, 시스템의 모든 메모리 위치에 접근할 수 있습니다.
  • 사용자 모드 (User Mode)
    • 조건: 모드 비트가 설정되지 않음(not set).
    • 제한: 사용자 모드의 프로세스는 특권 명령어(privileged instructions) (예: 프로세서 중단, 모드 비트 변경, I/O 작업 시작)를 실행할 수 없습니다. 또한 주소 공간의 커널 영역 코드나 데이터에 직접 접근할 수 없습니다.
    • 결과: 이러한 시도는 치명적인 보호 오류(protection fault)를 발생시킵니다. 사용자 프로그램은 시스템 콜 인터페이스(system call interface)를 통해서만 커널 코드와 데이터에 간접적으로 접근해야 합니다.

모드 전환 (Mode Switching)

  1. 시작: 응용 프로그램 코드를 실행하는 프로세스는 처음에 사용자 모드에 있습니다.
  2. 사용자 \rightarrow 커널: 프로세스가 사용자 모드에서 커널 모드로 전환되는 유일한 방법은 예외(exception) (인터럽트, 폴트, 트랩 방식의 시스템 콜)를 통해서입니다. 예외가 발생하여 제어권이 예외 핸들러로 넘어가면, 프로세서는 모드를 사용자에서 커널로 변경합니다.
  3. 핸들러 실행: 예외 핸들러는 커널 모드에서 실행됩니다.
  4. 커널 \rightarrow 사용자: 핸들러가 응용 프로그램 코드로 복귀할 때, 프로세서는 모드를 커널에서 사용자로 다시 변경합니다.

리눅스의 커널 데이터 접근: /proc, /sys 파일 시스템

리눅스는 /proc 파일 시스템이라는 영리한 메커니즘을 제공하여, 사용자 모드 프로세스커널 자료구조의 내용에 접근할 수 있도록 합니다.

  • /proc: 많은 커널 자료구조의 내용을 텍스트 파일 계층 구조로 내보냅니다(exports). 사용자 프로그램은 이 파일들을 읽을 수 있습니다.
    • 예: CPU 타입 (/proc/cpuinfo), 특정 프로세스의 메모리 세그먼트 (/proc/<pid>/maps)
  • /sys: (리눅스 커널 2.6부터 도입) 시스템 버스 및 장치에 대한 추가적인 저수준 정보를 내보냅니다.

8.2.5 문맥 교환 (Context Switches)

운영체제 커널은 문맥 교환(context switch)이라고 알려진 더 높은 수준의 예외 제어 흐름(exceptional control flow) 형태를 사용하여 멀티태스킹(multitasking)을 구현합니다. 문맥 교환 메커니즘은 (8.1절에서 논의한) 더 낮은 수준의 예외 메커니즘 위에 구축됩니다.


문맥 (Context)

커널은 각 프로세스에 대한 문맥(context)을 유지 관리합니다. 문맥이란 커널이 선점된(preempted) 프로세스를 다시 시작하기 위해 필요한 상태(state)입니다. 이는 다음과 같은 객체들의 값을 포함합니다.

  • 범용 레지스터 (General-purpose registers)
  • 부동 소수점 레지스터 (Floating-point registers)
  • 프로그램 카운터 (Program Counter, PC)
  • 사용자 스택 (User's stack)
  • 상태 레지스터 (Status registers)
  • 커널 스택 (Kernel's stack)
  • 다양한 커널 자료구조들:
    • 주소 공간을 기술하는 페이지 테이블 (Page table)
    • 현재 프로세스 정보를 담는 프로세스 테이블 (Process table)
    • 프로세스가 열어둔 파일 정보를 담는 파일 테이블 (File table)

스케줄링과 문맥 교환

프로세스 실행 중 특정 시점에, 커널은 현재 프로세스를 선점(preempt)하고 이전에 선점되었던 다른 프로세스를 다시 시작하기로 결정할 수 있습니다. 이 결정을 스케줄링(scheduling)이라고 하며, 커널 내의 스케줄러(scheduler)라고 불리는 코드에 의해 처리됩니다. 커널이 실행할 새 프로세스를 선택하면, 커널이 그 프로세스를 스케줄했다고 말합니다.

커널이 새 프로세스를 스케줄한 후에는, 문맥 교환(context switch)이라는 메커니즘을 사용하여 현재 프로세스를 선점하고 제어권을 새 프로세스로 이전합니다. 문맥 교환은 다음 3단계를 수행합니다.

  1. 현재 프로세스의 문맥을 저장합니다.
  2. 이전에 선점되었던 어떤 프로세스의 저장된 문맥을 복원합니다.
  3. 이 새롭게 복원된 프로세스에게 제어권을 넘겨줍니다.

문맥 교환이 발생하는 시점

  1. 시스템 콜 (System Call)
    • 커널이 사용자를 대신하여 시스템 콜을 실행하는 동안 발생할 수 있습니다.
    • 만약 시스템 콜이 어떤 이벤트 발생을 기다리며 블록(block)된다면, 커널은 현재 프로세스를 잠자기(sleep) 상태로 만들고 다른 프로세스로 전환할 수 있습니다.
      • 예: read 시스템 콜이 디스크 접근을 필요로 할 때, 커널은 디스크 데이터 도착을 기다리는 대신 다른 프로세스를 실행할 수 있습니다.
      • 예: sleep 시스템 콜은 명시적으로 현재 프로세스를 잠재우도록 요청합니다.
    • 일반적으로 시스템 콜이 블록되지 않더라도, 커널은 호출 프로세스에게 제어권을 반환하는 대신 문맥 교환을 수행하기로 결정할 수 있습니다.
  2. 인터럽트 (Interrupt)
    • 인터럽트의 결과로 발생할 수 있습니다.
    • 예: 모든 시스템에는 주기적인 타이머 인터럽트(보통 1ms 또는 10ms마다)를 생성하는 메커니즘이 있습니다. 타이머 인터럽트가 발생할 때마다, 커널은 현재 프로세스가 충분히 오래 실행되었다고 판단하고 새 프로세스로 전환할 수 있습니다.

문맥 교환 예시 (그림 8.14)

  1. 프로세스 A가 사용자 모드에서 실행되다가 read 시스템 콜을 실행하여 커널로 트랩(trap)합니다.
  2. 커널의 트랩 핸들러는 디스크 컨트롤러에 DMA 전송을 요청하고, 데이터 전송 완료 시 디스크가 프로세서에 인터럽트를 발생시키도록 설정합니다.
  3. 디스크 작업은 오래 걸리므로, 커널은 기다리는 대신 프로세스 A에서 프로세스 B로 문맥 교환을 수행합니다.
  4. 프로세스 B가 사용자 모드에서 실행되다가, 디스크가 데이터 전송 완료를 알리는 인터럽트를 보냅니다.
  5. 커널은 프로세스 B가 충분히 실행되었다고 판단하고, 프로세스 B에서 프로세스 A로 문맥 교환을 수행합니다.
  6. 제어권은 프로세스 A의 read 시스템 콜 바로 다음 명령어로 복귀합니다.
  7. 프로세스 A는 다음 예외가 발생할 때까지 계속 실행됩니다.

8.3 시스템 콜 오류 처리 (System Call Error Handling)

Unix 시스템 수준 함수들은 오류가 발생하면 일반적으로 -1을 반환하고, 전역 정수 변수 errno에 오류의 원인을 나타내는 값을 설정합니다.

프로그래머는 항상 오류를 확인해야 하지만, 안타깝게도 오류 확인 코드가 코드를 복잡하게 만들고 가독성을 떨어뜨리기 때문에 생략하는 경우가 많습니다.


기본 오류 확인 (Basic Error Checking)

예를 들어, fork 함수 호출 시 기본적인 오류 확인 방법은 다음과 같습니다.

1 if ((pid = fork()) < 0) { // fork 실패 시 -1 반환
2     // strerror(errno): errno 값에 해당하는 오류 메시지 문자열 반환
3     fprintf(stderr, "fork error: %s\\n", strerror(errno));
4     exit(0); // 프로그램 종료
5 }

오류 보고 함수 (Error-Reporting Function)

이 반복적인 코드를 단순화하기 위해 unix_error와 같은 사용자 정의 오류 보고 함수를 만들 수 있습니다.

void unix_error(char *msg) /* Unix-style error */
{
    fprintf(stderr, "%s: %s\n", msg, strerror(errno));
    exit(0);
}

이 함수를 사용하면 fork 호출 코드가 더 간결해집니다.

if ((pid = fork()) < 0)
    unix_error("fork error");

오류 처리 래퍼 (Error-Handling Wrappers)

코드를 더욱 단순화하기 위해, 오류 처리 래퍼(error-handling wrappers)를 사용할 수 있습니다 (W. Richard Stevens가 개척).

  • 규칙: 기본 함수 foo에 대해, 이름의 첫 글자만 대문자로 바꾼 래퍼 함수 Foo를 정의합니다.
  • 동작: 래퍼 함수는 기본 함수를 호출하고, 오류를 확인하며, 문제가 있으면 프로그램을 종료시킵니다.

예를 들어, fork 함수의 래퍼 Fork는 다음과 같습니다.

pid_t Fork(void)
{
    pid_t pid;
    if ((pid = fork()) < 0)
        unix_error("Fork error"); // 오류 시 종료
    return pid;
}

이 래퍼를 사용하면, fork 호출은 매우 간결한 한 줄이 됩니다.

pid = Fork();

8.4 프로세스 제어 (Process Control)

Unix는 C 프로그램에서 프로세스를 조작하기 위한 여러 시스템 콜을 제공합니다. 이 섹션에서는 중요한 함수들과 그 사용 예를 설명합니다.


8.4.1 프로세스 ID 얻기 (Obtaining Process IDs)

모든 프로세스는 고유한 양수(0이 아닌) 프로세스 ID (PID)를 가집니다.

  • getpid() 함수: 호출한 프로세스 자기 자신의 PID를 반환합니다.
  • getppid() 함수: 호출한 프로세스의 부모 프로세스 (즉, 이 프로세스를 생성한 프로세스)의 PID를 반환합니다.
#include <sys/types.h>
#include <unistd.h>

pid_t getpid(void);
pid_t getppid(void);
// 호출자 또는 부모의 PID를 반환

8.4.2 프로세스 생성 및 종료 (Creating and Terminating Processes)

프로그래머 관점에서 프로세스는 다음 세 가지 상태 중 하나에 있다고 생각할 수 있습니다.

  • 실행 (Running): 프로세스가 CPU에서 실행 중이거나, 실행되기를 기다리며 커널에 의해 스케줄될 상태입니다.
  • 중단 (Stopped): 프로세스 실행이 일시 중단되어 스케줄되지 않습니다. SIGSTOP, SIGTSTP, SIGTTIN, SIGTTOU 시그널을 받으면 중단되며, SIGCONT 시그널을 받을 때까지 이 상태를 유지하다가 다시 실행 상태가 됩니다. (시그널은 8.5절에서 자세히 설명)
  • 종료 (Terminated): 프로세스가 영구적으로 중단됩니다. 프로세스는 다음 세 가지 이유 중 하나로 종료됩니다: (1) 기본 동작이 프로세스 종료인 시그널을 받음, (2) main 루틴에서 반환함, (3) exit 함수를 호출함.

프로세스 종료: exit 함수

#include <stdlib.h>
void exit(int status);
// 이 함수는 반환하지 않음 (Does not return)

exit 함수는 status 종료 상태 값과 함께 프로세스를 종료합니다. (main 루틴에서 정수 값을 반환하는 것도 종료 상태를 설정하는 다른 방법입니다.)


프로세스 생성: fork 함수

부모 프로세스는 fork 함수를 호출하여 새로운 실행 중인 자식 프로세스를 생성합니다.

#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);
// 자식에게는 0, 부모에게는 자식의 PID, 오류 시 -1 반환

새로 생성된 자식 프로세스는 부모와 거의 동일하지만, 완전히 같지는 않습니다.

  • 상속받는 것 (동일한 복사본):
    • 부모의 사용자 수준 가상 주소 공간 (코드, 데이터, 힙, 공유 라이브러리, 사용자 스택)의 동일한 (하지만 분리된) 복사본.
    • 부모가 fork 호출 시 열어둔 파일 디스크립터(File descriptors)의 동일한 복사본. (즉, 자식도 부모가 열었던 파일을 읽고 쓸 수 있음)
  • 가장 중요한 차이점:
    • 부모와 자식은 서로 다른 PID를 가집니다.

fork의 특징: "한 번 호출, 두 번 반환"

fork 함수는 흥미로우며 종종 혼란스럽습니다. 한 번 호출되지만 두 번 반환하기 때문입니다.

  • 부모 프로세스: fork자식 프로세스의 PID를 반환합니다.
  • 자식 프로세스: fork0을 반환합니다.

자식의 PID는 항상 0이 아니므로, 반환 값을 통해 프로그램이 현재 부모에서 실행 중인지 자식에서 실행 중인지 명확하게 구분할 수 있습니다.


fork 예제 (그림 8.15)

  • 부모 프로세스가 fork를 호출하여 자식 프로세스를 만듭니다.
  • fork 반환 시, 부모와 자식 모두 지역 변수 x의 값은 1입니다.
  • 자식은 x를 증가시켜 2를 출력하고 (if (pid == 0)), 부모는 x를 감소시켜 0을 출력합니다.
  • 출력 순서는 실행할 때마다 다를 수 있습니다 (여기서는 부모가 먼저).

fork의 미묘한 측면들

  1. 한 번 호출, 두 번 반환 (Call once, return twice): fork는 부모에서 한 번 호출되지만, 부모와 자식 각각에게 한 번씩, 총 두 번 반환됩니다.
  2. 동시 실행 (Concurrent execution): 부모와 자식은 별개의 프로세스이며 동시에(concurrently) 실행됩니다. 커널은 이들의 명령어 흐름을 임의의 방식으로 인터리빙(interleaving)할 수 있으므로, 실행 순서를 가정해서는 안 됩니다.
  3. 복제되지만 분리된 주소 공간 (Duplicate but separate address spaces): fork 직후 부모와 자식의 주소 공간(스택, 변수 값, 힙, 코드 등)은 동일합니다. 하지만 이들은 별개의 프로세스이므로 각자의 사적인(private) 주소 공간을 가집니다. fork 이후 한쪽에서 변수(x)를 변경해도 다른 쪽에는 영향을 주지 않습니다. (이는 Copy-on-Write 메커니즘으로 효율적으로 구현됩니다.)
  4. 공유 파일 (Shared files): 자식은 부모의 열린 파일 디스크립터를 상속받습니다. 예제에서 부모의 stdout이 화면에 연결되어 있었으므로, 자식의 printf 출력도 화면으로 갑니다.
  • 프로세스 그래프의 구조
    • 프로그램 실행은 정점(vertex)들의 시퀀스로 모델링됩니다.
    • 시작 정점 (main): 부모 프로세스가 main을 호출하는 것에 해당하며, 진입 간선(in-edge)이 없고 진출 간선(out-edge)이 하나입니다.
    • 종료 정점 (exit): 각 프로세스의 정점 시퀀스는 exit 호출에 해당하는 정점으로 끝나며, 진입 간선이 하나이고 진출 간선(out-edge)이 없습니다.
  • 예시 (Fig 8.15 & 8.16)


-> 부모가 변수 x를 1로 설정합니다.
-> 부모가 fork를 호출하면, 자식 프로세스가 생성됩니다.
-> 자식 프로세스는 부모와 동시에(concurrently) 그리고 자신만의 사적인(private) 주소 공간에서 실행됩니다.

  • 위상 정렬 (Topological Sort)과 실행 순서
    • 단일 프로세서에서 실행되는 프로그램의 경우, 해당 프로세스 그래프에 있는 정점들의 모든 위상 정렬(topological sort)은 프로그램 문장들의 실행 가능한 전체 순서(feasible total ordering)를 나타냅니다.
    • 위상 정렬의 개념: 그래프의 정점들을 왼쪽에서 오른쪽으로 일렬로 나열했을 때, 모든 방향성 간선(directed edge)이 왼쪽에서 오른쪽을 향하도록 하는 순열(permutation)입니다.
    • 적용: Fig 8.15 예제에서 부모와 자식의 printf 문은 어느 순서로든 발생할 수 있습니다. 이는 두 실행 순서(부모-자식, 자식-부모)가 각각 그래프의 유효한 위상 정렬에 해당하기 때문입니다.
  • 프로세스 그래프의 유용성

  • 프로세스 그래프는 특히 중첩된(nested) fork 호출을 이해하는 데 매우 유용합니다.
  • 예시 (Fig 8.17): 소스 코드에 fork 호출이 두 번 있는 프로그램은 (프로세스 그래프를 통해 알 수 있듯이) 총 4개의 프로세스를 실행하며, 각 프로세스의 printf 호출은 어떤 순서로도 실행될 수 있습니다.

8.4.3 자식 프로세스의 청소

  • 프로세스 종료와 Reaping
    • 프로세스가 어떤 이유로든 종료되면, 커널은 즉시 시스템에서 제거하지 않습니다.
    • 대신, 프로세스는 부모에 의해 거둬들여질(reaped) 때까지 종료된(terminated) 상태로 유지됩니다.
    • 부모가 종료된 자식을 거둬들일 때, 커널은 자식의 종료 상태(exit status)를 부모에게 전달하고, 그 후에야 종료된 프로세스를 폐기(discard)합니다. (이 시점에 프로세스는 완전히 사라집니다.)
  • 좀비 (Zombie)
    • 종료되었지만 아직 부모에 의해 거둬들여지지 않은 프로세스를 좀비(zombie)라고 부릅니다.
  • 부모 프로세스의 종료 (고아 프로세스 처리)
    • 부모 프로세스가 먼저 종료되면, 커널은 init 프로세스가 그 부모의 고아(orphaned)가 된 자식들양부모(adopted parent)가 되도록 처리합니다.
    • init 프로세스 (PID 1): 시스템 시작 시 커널에 의해 생성되며, 절대 종료되지 않고 모든 프로세스의 조상입니다.
    • 만약 부모가 좀비 자식들을 거둬들이지 않고 종료하면, init 프로세스가 이들을 거둬들입니다.
  • Reaping의 중요성
    • 셸(shell)이나 서버와 같이 오래 실행되는(long-running) 프로그램은 항상 자신의 좀비 자식들을 거둬들여야 합니다.
    • 좀비 프로세스는 실행 중이지는 않지만, 여전히 시스템 메모리 자원을 소비하기 때문입니다.
  • waitpid 함수
    • 프로세스는 waitpid 함수를 호출하여 자신의 자식 프로세스가 종료되거나 멈추기를 기다립니다.
    • 함수 원형:
      #include <sys/types.h>
      #include <sys/wait.h>
      
      pid_t waitpid(pid_t pid, int *statusp, int options);
      
    • 반환 값: 성공 시 자식의 PID, WNOHANG 옵션 사용 시 0, 오류 시 -1

대기 집합(Wait Set) 결정 (pid 인자)

waitpid가 기다릴 자식 프로세스(대기 집합)는 pid 인자에 의해 결정됩니다.

  • pid > 0 : 대기 집합은 프로세스 ID가 pid와 일치하는 단일 자식 프로세스입니다.
  • pid = -1 : 대기 집합은 부모의 모든 자식 프로세스입니다.

(참고: waitpid는 유닉스 프로세스 그룹과 관련된 다른 종류의 대기 집합도 지원하지만, 여기서는 다루지 않습니다.)


기본 행동 수정 (options 인자)

waitpid의 기본 행동(옵션 0)은 대기 집합의 자식 중 하나가 종료(terminated)될 때까지 호출 프로세스를 중단(suspend)시키는 것입니다.

options 인자에 WNOHANG, WUNTRACED, WCONTINUED 상수를 조합하여 이 기본 행동을 수정할 수 있습니다.

  • WNOHANG (기다리지 않음)
    • 대기 집합의 자식 프로세스 중 아무도 아직 종료(terminated)되지 않았다면, (반환 값 0과 함께) 즉시 반환합니다.
    • (기본 동작은 자식이 종료될 때까지 중단됩니다.)
    • 이 옵션은 부모 프로세스가 자식을 기다리는 동안 다른 유용한 작업을 계속 수행해야 할 때 유용합니다.
  • WUNTRACED (멈춤 상태도 감지)
    • 호출 프로세스의 실행을 중단시키고, 대기 집합의 프로세스가 종료(terminated)되거나 멈출(stopped) 때까지 기다립니다.
    • 반환을 유발한 (종료되거나 멈춘) 자식의 PID를 반환합니다.
    • (기본 동작은 종료된 자식에 대해서만 반환합니다.)
    • 이 옵션은 종료된 자식과 멈춘 자식 모두를 확인하고 싶을 때 유용합니다.
  • WCONTINUED (재개 상태 감지)
    • 호출 프로세스의 실행을 중단시키고, 대기 집합의 실행 중인 프로세스가 종료되거나, 멈춰있던 프로세스가 SIGCONT 시그널을 받아 재개(resumed)될 때까지 기다립니다. (시그널은 8.5절에서 설명)

3. 옵션 조합하기

옵션들은 비트 OR (|) 연산자를 사용하여 함께 조합할 수 있습니다.

  • 예: WNOHANG | WUNTRACED
    • 대기 집합의 자식 중 멈추거나(stopped) 또는 종료된(terminated) 자식이 없다면, (반환 값 0과 함께) 즉시 반환합니다.
    • (멈추거나 종료된 자식이 있다면), 그 자식 중 하나의 PID를 반환합니다.

청소된(Reaped) 자식의 exit 상태 확인하기

statusp 인자가 NULL이 아닐 경우, waitpid는 반환을 유발한 자식에 대한 상태 정보를 statusp가 가리키는 status 변수에 인코딩합니다. wait.h 헤더 파일은 이 status 인자를 해석하기 위한 여러 매크로를 정의합니다:

  • WIFEXITED(status): 자식이 exit 호출이나 return을 통해 정상적으로 종료했다면 참(true)을 반환합니다.
  • WEXITSTATUS(status): 정상적으로 종료된 자식의 종료 상태(exit status)를 반환합니다. 이 상태는 WIFEXITED()가 참을 반환했을 때만 정의됩니다.
  • WIFSIGNALED(status): 자식 프로세스가 잡히지 않은(not caught) 시그널에 의해 종료되었다면 참을 반환합니다.
  • WTERMSIG(status): 자식 프로세스를 종료시킨 시그널의 번호를 반환합니다. 이 상태는 WIFSIGNALED()가 참을 반환했을 때만 정의됩니다.
  • WIFSTOPPED(status): 반환을 유발한 자식이 현재 멈춘(stopped) 상태라면 참을 반환합니다.
  • WSTOPSIG(status): 자식을 멈추게 한 시그널의 번호를 반환합니다. 이 상태는 WIFSTOPPED()가 참을 반환했을 때만 정의됩니다.
  • WIFCONTINUED(status): 자식 프로세스가 SIGCONT 시그널을 받아 재시작(restarted)되었다면 참을 반환합니다.

오류 조건 (Error Conditions)

  • 만약 호출한 프로세스에 자식이 없다면, waitpid는 -1을 반환하고 errnoECHILD로 설정합니다.
  • 만약 waitpid 함수가 시그널에 의해 중단(interrupted)되었다면, -1을 반환하고 errnoEINTR로 설정합니다.

wait 함수

wait 함수는 waitpid의 더 간단한 버전입니다.

#include <sys/types.h>
#include <sys/wait.h>

pid_t wait(int *statusp);

waitpid 사용 예제

waitpid 함수는 다소 복잡하므로, 몇 가지 예제를 살펴보는 것이 유용합니다.

예제 1: 임의의 순서로 기다리기 (Fig 8.18)

  • 이 프로그램은 waitpid를 사용하여 N개의 모든 자식이 종료될 때까지 특정한 순서 없이(in no particular order) 기다립니다.
  • 11행: 부모가 N개의 자식을 생성합니다.
  • 12행: 각 자식은 고유한 종료 상태(exit status)로 종료합니다. (이 라인은 부모가 아닌 자식들만 실행합니다.)
  • 15행: 부모는 while 루프의 테스트 조건으로 waitpid를 사용하여 모든 자식이 종료되기를 기다립니다.
    • 첫 번째 인자가 1이므로, waitpid 호출은 임의의(arbitrary) 자식 하나가 종료될 때까지 중단(block)됩니다.
    • 자식이 종료될 때마다, waitpid는 해당 자식의 0이 아닌 PID를 반환합니다.
  • 16행: 부모는 반환된 자식의 종료 상태를 확인합니다. 자식이 (exit 함수 호출을 통해) 정상적으로 종료되었다면, 부모는 종료 상태를 추출하여 stdout에 출력합니다.
  • 루프 종료: 모든 자식이 거둬들여지면(reaped), 다음 waitpid 호출은 1을 반환하고 errnoECHILD로 설정합니다.
  • 24행: waitpid가 오류가 아닌 ECHILD로 정상 종료되었는지 확인합니다.

예제 1의 비결정성 (Nondeterminism)

linux> ./waitpid1
child 22966 terminated normally with exit status=100
child 22967 terminated normally with exit status=101
  • 위 출력에서 보듯이, 프로그램은 자식들을 특정한 순서 없이 거둬들입니다.
  • 거둬들여지는 순서는 이 특정 컴퓨터 시스템의 속성입니다. 다른 시스템이나, 심지어 같은 시스템에서 다시 실행하더라도, 두 자식은 반대 순서로 거둬들여질 수 있습니다.
  • 이것은 동시성(concurrency)에 대한 추론을 매우 어렵게 만드는 비결정적(nondeterministic) 행동의 예입니다.
  • 가능한 두 가지 결과(순서) 모두 똑같이 올바르며, 프로그래머는 다른 결과가 아무리 드물어 보일지라도 하나의 결과가 항상 발생할 것이라고 가정해서는 안 됩니다.
  • 유일하게 올바른 가정은 각 가능한 결과(순서)가 동등하게 발생할 가능성이 있다는 것입니다.

예제 2: 생성된 순서대로 기다리기 (Fig 8.19)

  • Fig 8.19는 출력 순서의 비결정성을 제거하는 간단한 변경을 보여줍니다.
  • 11행: 부모는 자식들의 PID를 배열 등에 순서대로 저장합니다.
  • 그 후, waitpid를 호출할 때 첫 번째 인자로 1 대신 저장해둔 특정 PID를 순서대로 전달합니다.
  • 이렇게 함으로써 부모는 자식이 생성된 순서와 동일한 순서로 자식들을 기다리고 거둬들입니다.

8.4.4 프로세스 재우기 (Putting Processes to Sleep)

sleep 함수

sleep 함수는 프로세스를 지정된 시간(초) 동안 일시 중단(suspend)시킵니다.

  • 헤더: <unistd.h>
  • 원형: unsigned int sleep(unsigned int secs);
  • 반환 값:
    • 요청된 시간이 모두 경과하면 0을 반환합니다.
    • sleep 함수가 시그널(Signal)에 의해 중단되어 일찍 반환된 경우, 남은 시간(초)을 반환합니다. (시그널은 8.5절에서 자세히 다룹니다.)

pause 함수

pause 함수는 해당 프로세스가 시그널을 수신할 때까지 호출 함수를 재웁니다.

  • 헤더: <unistd.h>
  • 원형: int pause(void);
  • 반환 값: 항상 1을 반환합니다.

8.4.5 프로그램 적재와 실행 (Loading and Running Programs)

execve 함수

execve 함수는 현재 프로세스의 문맥(context)에서 새 프로그램을 적재(load)하고 실행(run)합니다.

#include <unistd.h>
int execve(const char *filename, const char *argv[],
           const char *envp[]);
  • 반환: 성공 시 반환하지 않음 (Does not return); 오류 시 -1 반환

execve 함수는 실행 가능한 객체 파일 filenameargv 인수 목록 및 envp 환경 변수 목록과 함께 적재하고 실행합니다.

execvefilename을 찾을 수 없는 경우와 같이 오류가 있을 때만 호출 프로그램으로 반환합니다. 따라서 fork가 한 번 호출되어 두 번 반환되는 것과 달리, execve는 한 번 호출되면 (성공 시) 절대 반환하지 않습니다.


인수 및 환경 변수 목록 구조

  • 인수 목록 (argv): argv 변수는 NULL로 끝나는(null-terminated) 포인터 배열을 가리키며, 이 배열의 각 포인터는 인수 문자열(argument string)을 가리킵니다 (그림 8.20). 관례적으로 argv[0]는 실행 파일의 이름입니다.

  • 환경 변수 목록 (envp): 유사한 자료 구조(그림 8.21)로 표현됩니다. envp 변수는 NULL로 끝나는 포인터 배열을 가리키며, 각 포인터는 name=value 형식의 환경 변수 문자열을 가리킵니다.

새 프로그램의 main 함수와 스택 구조

execvefilename을 적재한 후, (7.9절에서 설명한) 시작 코드(start-up code)를 호출합니다. 이 시작 코드는 스택을 설정하고 새 프로그램의 main 루틴으로 제어를 넘깁니다. main 루틴의 프로토타입은 다음과 같습니다:

int main(int argc, char **argv, char **envp);
// 또는
int main(int argc, char *argv[], char *envp[]);

main이 실행을 시작할 때, 사용자 스택은 그림 8.22와 같은 구조를 가집니다 (스택의 맨 아래(높은 주소)에서 맨 위(낮은 주소) 순서로):

  1. 인수 및 환경 변수 문자열들
  2. envp[] 배열: NULL로 끝나는 포인터 배열. 각 포인터는 스택에 있는 환경 변수 문자열을 가리킴.
    • 전역 변수 environ은 이 포인터들의 첫 번째(envp[0])를 가리킵니다.
  3. argv[] 배열: NULL로 끝나는 포인터 배열. 각 포인터는 스택에 있는 인수 문자열을 가리킴.
  4. 시스템 시작 함수(__libc_start_main)의 스택 프레임 (스택의 최상단)

main 함수는 x86-64 스택 규율에 따라 레지스터에 저장된 세 개의 인수를 받습니다:

  1. argc: argv[] 배열 내의 NULL이 아닌 포인터의 수.
  2. argv: argv[] 배열의 첫 번째 항목(argv[0])을 가리킴.
  3. envp: envp[] 배열의 첫 번째 항목(envp[0])을 가리킴.

환경 배열 조작 함수

리눅스는 환경 배열을 조작하기 위한 여러 함수를 제공합니다.

  • getenv 함수
#include <stdlib.h>
char *getenv(const char *name);
  • 반환: name이 존재하면 해당 포인터 반환, 없으면 NULL 반환
  • getenv 함수는 환경 배열에서 name=value 형태의 문자열을 검색합니다. 찾으면 value에 대한 포인터를 반환하고, 그렇지 않으면 NULL을 반환합니다.
  • setenvunsetenv 함수
    #include <stdlib.h>
    int setenv(const char *name, const char *newvalue, int overwrite);
    void unsetenv(const char *name);
    • setenv 반환: 성공 시 0, 오류 시 -1
    • unsetenv 반환: 없음
    • unsetenv: 만약 환경 배열에 name=oldvalue 형태의 문자열이 있다면, 이를 삭제합니다.
    • setenv:
      • name이 이미 존재(name=oldvalue)한다면: overwrite가 0이 아닌(nonzero) 경우에만 oldvaluenewvalue로 교체합니다.
      • name이 존재하지 않는다면: name=newvalue 문자열을 배열에 추가합니다.

8.4.6 fork와 execve를 사용한 프로그램 실행

유닉스 셸(Shell)이나 웹 서버와 같은 프로그램들은 forkexecve 함수를 매우 빈번하게 사용합니다.

  • 셸(Shell)이란?: 사용자를 대신하여 다른 프로그램들을 실행하는 대화형(interactive) 응용 프로그램입니다. (예: sh, csh, bash 등)
  • 셸의 동작: 셸은 (종료 전까지) 일련의 읽기/평가(read/evaluate) 단계를 수행합니다.
    1. 읽기 단계: 사용자로부터 커맨드 라인을 읽어 들입니다.
    2. 평가 단계: 커맨드 라인을 파싱(parsing)하고, 사용자를 대신하여 프로그램을 실행합니다.

셸의 구현 (예제)

(그림 8.23) 간단한 셸의 main 루틴은 다음과 같이 동작합니다.

  1. 커맨드 라인 프롬프트를 출력합니다.
  2. 사용자가 stdin으로 커맨드 라인을 입력하기를 기다립니다.
  3. 커맨드 라인을 평가합니다.

(그림 8.24) 커맨드 라인을 평가하는 eval 함수의 동작입니다.

  1. parseline 함수 호출 (파싱)

- (그림 8.25) 공백으로 구분된 커맨드 라인 인수들을 파싱하여 `execve`에 전달될 `argv` 벡터를 구축합니다.
- 첫 번째 인수는 **내장 셸 명령어(built-in command)**이거나, 새 자식 프로세스의 문맥에서 적재되어 실행될 **실행 파일**로 간주됩니다.
  1. 백그라운드/포그라운드 결정
    • 만약 마지막 인수가 '&' 문자이면, parseline은 1을 반환합니다. 이는 프로그램이 백그라운드(background)에서 실행되어야 함을 의미합니다 (셸은 자식의 종료를 기다리지 않습니다).
    • 그렇지 않으면 0을 반환하며, 프로그램이 포그라운드(foreground)에서 실행되어야 함을 의미합니다 (셸이 자식의 종료를 기다립니다).
  2. builtin_command 함수 호출 (내장 명령어 확인)
    • eval 함수는 builtin_command를 호출하여 첫 번째 인수가 내장 셸 명령어인지 확인합니다.
    • 만약 내장 명령어라면 (예: quit): 즉시 해당 명령을 해석하고 1을 반환합니다. (실제 셸은 pwd, jobs, fg 등 수많은 내장 명령을 가집니다.)
    • 내장 명령어가 아니라면 (0 반환):
      1. 셸은 fork를 호출하여 자식 프로세스를 생성합니다.
      2. (자식 프로세스 내부에서) execve를 호출하여 요청된 프로그램을 실행합니다.
  3. 자식 프로세스 기다리기 (Wait)
    • 백그라운드 실행 시: 셸은 eval 함수를 즉시 반환하고 루프의 맨 위로 돌아가 다음 커맨드 라인을 기다립니다.
    • 포그라운드 실행 시: 셸은 waitpid 함수를 사용하여 해당 작업(자식 프로세스)이 종료될 때까지 기다립니다. 작업이 종료되면 셸은 다음 반복으로 넘어갑니다.

간단한 셸의 결함

이 간단한 셸에는 결함(flaw)이 있습니다. 백그라운드로 실행된 자식들을 전혀 거둬들이지(reap) 않습니다. (이로 인해 좀비 프로세스가 발생합니다.)

이 결함을 올바르게 수정하기 위해서는 시그널(Signal)의 사용이 필요하며, 이는 다음 섹션에서 설명합니다.

profile
멈추지 않기

0개의 댓글