8 분 소요

이번 포스트는 운영체제의 Process와 Process API에 대해 학습한다.

Process

CPU 가상화

  • How to provide the illusion of many CPUs?

(어떻게 하나의 실제 CPU로도, 여러 프로그램이 동시에 실행되는 ‘착각’을 만들어낼 수 있을까?)

CPU 가상화 (CPU virtualizing)

  • OS는 여러 개의 가상 CPU (Virtual CPU)가 존재하는 것처럼 착각을 만들어냄.
  • 실제로는 하나의 CPU가 존재하지만, OS가 빠르게 작업을 번갈아가면서 (time - sharing)을 실행하여 마치 동시에 돌아가는 것처럼 보이게 함.

A Process

A process is a running program

프로세스란 실행 중인 프로그램이다.

Program vs Process

구분 프로그램 (Program) 프로세스 (Process)
상태 정적인 존재 (디스크에 저장된 파일) 동적인 존재 (실행 중)
위치 보통 disk (예: 실행 파일 .exe, .out) 메모리 (RAM 안)
특징 명령어 집합(instruction set) 그 자체 OS가 CPU 위에서 실행 관리
예시 “Chrome.exe” 파일 실행 중인 여러 Chrome 탭 각각

→ 즉, 프로그램은 아직 실행되지 않은 코드의 묶음

→ 프로세스는 그 프로그램이 메모리에 올라와서 CPU에서 실행 중인 상태

프로세스의 구성 요소

1. Memory

  • 프로세스가 사용할 주소 공간을 의미
  • 세부 구성
    • Instructions : 실행할 명령어 코드
    • Data section : 전역 변수, 상수 등

2. Registers

  • CPU 내부에서 실행 중인 프로세스의 상태를 저장하는 작은 메모리
  • 주요 구성
    • Program Counter (PC) : 다음에 실행할 명령어의 주소
    • Stack Pointer (SP) : 현재 함수 호출 스택의 위치

Process API

  • OS가 제공하는 프로세스 제어 인터페이스
    • Create : 새로운 프로세스를 실행하여 프로그램을 실행시킴.
    • Destroy : 실행 중인 프로세스를 강제로 종료
    • Wait : 특정 프로세스가 종료될 때까지 기다림
    • Miscellaneous Control : 프로세스를 일시 중단했다가 나중에 재게하는 기능
    • Status : 프로세스의 상태, CPU 사용량, 실행 여부 등을 확인

Process Creation

프로세스 생성 (Process Creation)의 전 과정

전체 개요

Program -> Memory -> CPU

로딩 -> 초기화 -> 실행

요약

프로그램이 프로세스로 바뀌는 과정은 OS가 ‘주소 공간을 구성하고 실행을 시작하는’ 단계적 절차다.

Process

1. Load a program code into memory

  • 디스크에 있는 프로그램을 메모리로 불러오기
    • 프로그램은 디스크에 실행 파일로 저장되어 있음.
    • OS는 이 코드를 주소 공간 (Address Space)에 올려서 프로세스로 바꿈.
  • Lazy Loading
    • 게으르게 로드한다.
    • 천천히 가져온다는 것이 아닌 필요할 때만 로드한다는 의미
    • 프로그램 전체를 한 번에 다 메모리에 올리는 것이 아니라 실행 중 필요한 부분만 조금씩 불러옴.

2. The program’s run-time stack is allocated.

  • 런타임 스택 생성
    • 스택은 함수 호출, 지역 변수, 매개 변수, 반환 주소를 저장하는 공간
    • 프로그램이 실행되면 OS가 스택 공간을 자동으로 만들어줌.

3. Create the heap

  • 동적 메모리 영역 생성
    • Heap실행 중(runtime)에 동적으로 할당되는 메모리 공간
    • malloc( ), new등을 통해서 생성하고 free( ) or delete로 해제하지 않으면 메모리 누수 (memory leak)이 발생함.

4. Other initialization tasks

  • 기타 초기화 작업
    • OS가 하는 I/O 설정
    • 모든 프로세스는 기본적으로 세 개의 파일 디스크립터를 가진다.
      • stdin, stdout, stderr

5. Start the program

  • 프로그램의 진입점 entry point에서 실행 시작
    • 대부분의 프로그램은 main( ) 함수를 진입점으로 가짐.
    • OS는 CPU 제어권을 새로 만들어진 프로세스에 넘김.

Process State

하나의 Process 는 실행 중에 여러 상태를 오가며 변화한다.

OS는 이 상태를 관리하면서 CPU를 어떤 프로세스에게 줄지 결정한다.

1. Running

  • 현재 CPU 위에서 실제로 실행 중인 상태 (활성 상태)
  • CPU는 한 번에 단 하나의 프로세스만 실행할 수 있음.

2. Ready

  • 실행할 준비는 되었지만, CPU를 아직 받지 못한 상태
  • CPU만 배정되면 바로 실행할 수 있는 상태

3. Blocked

  • 입출력(I/O) 같은 작업 때문에 CPU가 필요 없어 잠시 멈춘 상태
  • 이 동안 CPU는 다른 프로세스에게 넘어감
    • CPU 자원 낭비 방지

Data Structures

PCB (Process Control Block)

  • 운영체제가 각 프로세스의 상태 정보를 저장하기 위한 자료구조
    • 커널 내부에 존재하는 C 구조체 형태의 데이터
  • Register Context : 현재 프로세스가 CPU에서 어떤 레지스터 값을 가지고 있었는가를 저장한 것

Process List

  • OS가 관리 중인 모든 프로세스의 PCB들을 연결해둔 리스트 구조
    • Ready List, Blocked List, Running Process

예제 코드 분석

struct context {
  int eip;  // Instruction Pointer
  int esp;  // Stack Pointer
  int ebx;  // Base Register
  int ecx;  // Counter Register
  int edx;  // Data Register
  int esi;  // Source Index Register
  int edi;  // Destination Index Register
  int ebp;  // Stack Base Pointer Register
};
  • xv6가 프로세스를 멈출 때, 위의 레지스터 값들을 저장해 두었다가 나중에 다시 복원함.
  • 이 값들이 바로 PCB 내부의 Register Context 부분
enum proc_state { UNUSED, EMBRYO, SLEEPING, RUNNABLE, RUNNING, ZOMBIE };
  • 프로세스의 상태 코드
struct proc {
  char *mem;           // 프로세스 메모리 시작 주소
  uint sz;             // 메모리 크기
  char *kstack;        // 커널 스택
  enum proc_state state; // 프로세스 상태
  int pid;             // 프로세스 ID
  struct proc *parent; // 부모 프로세스
  void *chan;          // Blocked 상태일 때 대기하는 채널
  int killed;          // 종료 요청 여부
  struct file *ofile[NOFILE]; // 열린 파일들
  struct inode *cwd;   // 현재 작업 디렉터리
  struct context context; // 레지스터 상태 저장
  struct trapframe *tf;   // 인터럽트 프레임
};
  • 현실 운영체제의 PCB 구조체와 거의 동일한 개념



Process API

여기서는 fork() wait() exec 에 대해서 자세히 학습한 것을 정리한다.

fork ()

  • 현재 프로세스를 복제해서 자식 프로세스 (child)를 만든다.
    • 메모리 (코드 / 데이터 / 힙 / 스택)의 내용은 같아 보이지만, 실제로는 Copy-On-Write페이지 매핑만 복사된다.
    • 이후 한쪽이 수정하려고 하면 그때 페이지를 복사해서 서로 독립이 된다.
  • 자식은 자신만의 PID를 갖고, 부모의 **열린 파일 디스크립터 (열어둔 파일 / 소켓 등)는 **복사된 핸들로 공유된다.
  • 레지스터 집합 (Program Counter 포함)도 복제되지만, fork의 반환값은 다르다.
    • 부모 : fork()의 반환값 => 자식 PID
      • 0보다 큰 값이 나온다.
    • 자식 : fork()의 반환값 => 0
    • 실패 : 에러 (-1) => 자식이 없다.

즉, fork() 뒤의 같은 코드가 두 번 (부모 /자식) 이 실행되는데, 분기 조건을 rc == 0 / rc > 0 으로 부모와 자식으로 나누면 된다.

코드로 보면 다음과 같다

printf("hello world (pid:%d)\n", (int)getpid());
int rc = fork();
if (rc < 0) { ... }          // 실패
else if (rc == 0) {          // 자식 경로
  printf("hello, I am child (pid:%d)\n", (int)getpid());
} else {                     // 부모 경로
  printf("hello, I am parent of %d (pid:%d)\n", rc, (int)getpid());
}
  • 코드 해석
    • hello world ... fork 전에 한 번 (부모에서) 출력된다.
    • fork() 호출 시점에서 부모와 자식 둘 다 fork( )다음 줄부터 실행을 이어간다.
  • 출력 값
prompt> ./p1
hello world (pid : 29146)
hello, I am child (pid : 29147)
hello, I am parent of 29147 (pid : 29146)
  • 여기서 OS 스케줄러에 의해 child와 parent 중 아무거나 먼저 나올 수 있다.
    • 비결정적 (Not deterministic)하다.

wait ()

  • 부모 프로세스가 자식 프로세스의 종료를 기다리고 (reap), 종료한 자식의 PID를 반환한다.
    • int wc = wait(int *status) ;
    • statusNULL이 아니면, 자식의 종료 코드 / 신호 정보를 담아 준다.
  • 자식이 종료해도 부모가 회수 (reap)를 하지 않으면 좀비(zombie)가 생긴다.
    • wait () / waited()가 시체를 수습하고, 커널 테이블에서 지워주는 역할을 한다.
  • wait()을 호출한 부모는 블로킹되어 자식이 끝날 때까지 진행하지 않는다.
  • fork()만으로는 순서가 비결정적 (nondeterministic)인데, wait()을 사용하면 결정적 (deterministic) 이다.
    • “자식 출력 -> 부모 출력” 의 순서가 보장된다.

코드에서 중요한 점은 다음과 같다. (이 부분을 제외하고는 fork()랑 같다)

#include <sys/wait.h>
  • wait()을 부르는 라이브러리
wc = wait(NULL);
  • 가장 먼저 끝난 어느 자식이든 한 개의 PID가 wc로 돌아온다.
    • 자식이 여러 개면 wait()를 반복 호출해야 모두 수거가 가능하다.
  • 반환 / 오류
    • 성공 : 종료한 자식의 PID를 반환
    • 자식이 없으면 -1 반환
    • 자식이 이미 종료된 상태라면 : 즉시 반환 (블로킹 안 함)

중요한 부분 (자주 하는 실수)

  • 부모가 wait()을 안 부르면 자식은 좀비로 남는다.
    • 부모가 먼저 종료되만 고아(orphan)가 되고 init/systemd가 맡아서 공 수거한다.
  • 스레드의 join()과 비슷하지만, 프로세스는 주소 공간이 완전히 분리되어 있고, 자원 회수도 커널이 담당한다.

출력 값

prompt> ./p2
hello world (pid : 29266)
hello, I am child (pid : 29267)
hello, I am parent of 29267 (wc : 29267) (pid : 29266)
prompt>
  • Deterministic , 즉 결정적이다.
    • wait()이 자식이 끝날 때까지 막고 있으니 항상 hello(child) -> hello(parent of ..(wc:자식 PID)) 순서로 출력된다.

exec ()

  • 현재 프로세스의 메모리 공간을 새 프로그램으로 완전히 교체한다.

    • 기존 코드 / 데이터 /스택은 사라지고, 새로운 프로그램의 코드와 데이터가 덮어씌어진다.
  • 기존 프로세스는 사라지고, PID는 그대로 유지된 채 새 프로그램이 실행된다.

    exec()가 성공하면 return하지 않는다.

    • 실패할 때는 -1을 반환한다.
  • 동작 과정

  1. 부모 프로세스가 fork()로 자식을 생성한다.
    • 자식은 부모의 메모리, 코드, 변수 등을 그대로 복제한다.
  2. 자식이 exec()를 호출한다.
    • 자신의 메모리 공간을 완전히 비우고, 새로운 **binary image (wc, ls, python 등)를 로드 한다.
  3. 이제 자식 프로세스는 완전히 다른 프로그램으로 바뀐다.

코드에서 중요한 점은 다음과 같다.

if (rc == 0) { // child
    char *myargs[3];
    myargs[0] = strdup("wc");   // 실행할 프로그램 이름
    myargs[1] = strdup("p3.c"); // 프로그램 인자 (argument)
    myargs[2] = NULL;           // 인자 배열 끝 표시

    execvp(myargs[0], myargs);  // "wc p3.c" 실행
    printf("this shouldn't print out"); // exec 실패 시에만 실행
}
  • execvp()PATH 환경변수를 따라 명령어를 찾는 버전이다.
  • 실행 후 현재 프로세스는 완전히 wc p3.c로 교체된다.
  • 부모는 wait()로 자식이 끝날 때까지 대기하고, 자식이 끝나면 PID를 반환한다.

결과값

hello world (pid:29383)
hello, I am child (pid:29384)
29 107 1030 p3.c          ← exec("wc", "p3.c") 실행 결과
hello, I am parent of 29384 (wc:29384) (pid:29383)
  • 자식 프로세스는 wc p3.c로 바뀌었고,
    • 해당 프로그램의 출력만 남긴다.
  • 부모는 자식이 종료될 때까지 wait()으로 대기한 뒤 실행이 재개된다.

(29 108 1030 : 29줄, 107단어, 1030 바이트)

중요한 점

exec()는 다음 아래의 두 개의 매개변수를 받는다.

  • 실행할 프로그램
  • 인자 배열 (arguments)

execvp(file, argv)의 의미

  • file : 실행할 바이너리 이름 또는 경로
  • argv : C 문자열 포인터 배열. argv[0]은 관례적으로 프로그램 이름 문자열, 그 뒤로 실제 문자열이 오고 마지막은 NULL로 끝난다.



Why separating fork() and exec()?

forkandexec("ls", "ls -l")과 같이 합쳐서 사용을 안하고 분류할까?

그 이유는 다음 아래와 같다.

  • forkandexec ("ls")처럼 한 번에 새 프로그램으로 바꿔버리면, 그 전에 파일디스크립터 /환경을 손댈 기회가 없다.
  • 반면 fork ()로 자식을 만든 뒤에, 자식에서 표준입출력, 파일 디스크립터, 환경변수, 작업디렉터리 등을 원하는대로 조정하고, 마지막에 exec ()로 새 프로그램으로 갈아탈 수 있다.
    • 즉, IO redirection, pipe, 백그라운드 실행, setuid, ulimt 설정 같은 전/후처리가 가능하다.

Example : IO redirection

% wc p3.c > newfile.txt
  • 쉘이 하는 일
  1. pid = fork()를 수행
  2. 자식에서:
    • close(STDOUT_FILENO); 1번 (표준 출력)을 닫는다
    • open("newfile.txt", O_CREAT|O_WRONLY|O_TRUNC, 0644);
      • 리눅스는 “가장 낮은 번호 FD부터 재사용” 하기에, 방금 닫은 FD 1이 새 파일에 배정된다.
    • execlp("wc", "wc", "p3.c", NULL);
      • 이제 wc가 표준출력= 파일인 상태로 실행되어 결과가 파일에 기록된다.
  3. 부모는 원래 표준출력 그대로 유지(터미널)
    • 보통 wait()로 자식을 수거한다.

코드로 보면 다음과 같다.

close(STDOUT_FILENO);                         // 1번 닫기
open("./p4.output", O_CREAT|O_WRONLY|O_TRUNC, S_IRWXU);
char *myargs[] = {"wc", "p4.c", NULL};
execvp(myargs[0], myargs);                    // wc 실행 -> 출력은 p4.output으로

(추가 참고 자료)

파일 디스크립터 (File Descriptor, FD)

  • OS에서 프로세스가 파일 입출력 장치에 접근할 때 사용하는 번호
  • 리눅스는 프로세스마다 열린 파일들을 배열처럼 관리하고, 각 파일에 번호를 붙인다.
번호 이름 설명
0 STDIN_FILENO 표준 입력 (키보드 입력 등)
1 STDOUT_FILENO 표준 출력 (터미널 출력)
2 STDERR_FILENO 표준 에러 (에러 메시지 출력)

프로그램이 실행될 때는 0, 1, 2는 이미 열려있는 상태이다.

추가 상세 분석

close(STDOUT_FILENO);
  • 이 코드는 표준출력 (1번 FD)를 닫는다.
    • 의미는 터미널에 연결되어 있던 출력 채널을 비워두는 것이다.
    • 이제 프로세스 입장에서는 FD 1번이 사용가능한 슬롯이 되는 것이다.
open("newfile.txt", O_CREAT|O_WRONLY|O_TRUNC, 0644);
  • 이건 새로운 파일을 열고 (없으면 만들고), 쓰기 전용으로 여는 시스템 콜이다.
  • 이때 OS는 다음과 같이 동작한다.
    • 현재 비어 있는 가장 낮은 번호의 FD부터 재사용한다.

즉,이미 0, 1, 2 중 1이 close()로 비워졌으니, 새로운 파일이 FD 1번으로 자동 할당된다.

즉, open() 이후의 상태에서

  • FD 1번은 newfile.txt로 대체된다.

이로써 printf()wc 명령 같은 모든 표준출력은 자동으로 newfile.txt에 기록된다.