Process (API)
이번 포스트는 운영체제의 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가 ‘주소 공간을 구성하고 실행을 시작하는’ 단계적 절차다.

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) ;status가NULL이 아니면, 자식의 종료 코드 / 신호 정보를 담아 준다.
- 자식이 종료해도 부모가 회수 (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을 반환한다.
-
동작 과정
- 부모 프로세스가
fork()로 자식을 생성한다.- 자식은 부모의 메모리, 코드, 변수 등을 그대로 복제한다.
- 자식이
exec()를 호출한다.- 자신의 메모리 공간을 완전히 비우고, 새로운 **binary image (wc, ls, python 등)를 로드 한다.
- 이제 자식 프로세스는 완전히 다른 프로그램으로 바뀐다.
코드에서 중요한 점은 다음과 같다.
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
- 쉘이 하는 일
pid = fork()를 수행- 자식에서:
close(STDOUT_FILENO);1번 (표준 출력)을 닫는다open("newfile.txt", O_CREAT|O_WRONLY|O_TRUNC, 0644);- 리눅스는 “가장 낮은 번호 FD부터 재사용” 하기에, 방금 닫은 FD 1이 새 파일에 배정된다.
execlp("wc", "wc", "p3.c", NULL);- 이제 wc가 표준출력= 파일인 상태로 실행되어 결과가 파일에 기록된다.
- 부모는 원래 표준출력 그대로 유지(터미널)
- 보통
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에 기록된다.