Search

프로세스 생명주기와 스케줄링

프로세스의 정적 구조를 이해했다면, 이제 동적 관리의 복잡성을 탐구할 차례입니다. 현대 시스템에서는 수백 개의 프로세스가 동시에 실행되며, 운영체제는 이들을 효율적으로 조율해야 합니다. 단일 CPU로 어떻게 여러 프로세스가 병행 실행되는 것처럼 보이게 할 수 있을까요? 프로세스는 어떤 과정을 통해 생성되고 종료될까요? 이러한 질문들은 운영체제의 핵심 메커니즘인 스케줄링과 프로세스 생명주기 관리로 이어집니다.

프로세스 스케줄링의 기본 원리

멀티태스킹의 근본적 목표

프로세스 스케줄링은 제한된 CPU 자원을 여러 프로세스가 효율적으로 공유할 수 있도록 하는 메커니즘입니다. 이는 두 가지 근본적인 목표를 달성하기 위해 설계됩니다.
멀티프로그래밍의 목적은 CPU 이용률을 최대화하는 것입니다. 항상 어떤 프로세스가 실행되도록 하여 CPU가 유휴 상태에 있는 시간을 최소화합니다. 메모리에 동시에 존재하는 프로세스의 수를 다중 프로그래밍 정도라고 하며, 이는 시스템 성능에 직접적인 영향을 미칩니다.
시분할의 목적은 각 프로그램이 실행되는 동안 사용자가 상호작용할 수 있도록 프로세스들 사이에서 CPU를 빈번하게 교체하는 것입니다. 이를 통해 여러 사용자가 동시에 시스템을 사용하는 것처럼 느낄 수 있습니다.

프로세스 특성에 따른 분류

프로세스는 실행 패턴에 따라 두 가지 유형으로 구분됩니다. 이 구분은 스케줄링 정책을 결정하는 중요한 기준이 됩니다.
I/O 바운드 프로세스는 계산에 소비하는 것보다 I/O에 더 많은 시간을 소비합니다. 이들은 빈번한 입출력 요청으로 인해 짧은 CPU 버스트와 긴 대기 시간을 특징으로 합니다. 텍스트 에디터, 웹 브라우저, 데이터베이스 서버 등이 대표적인 예입니다.
CPU 바운드 프로세스는 계산에 더 많은 시간을 사용하여 I/O 요청을 자주 생성하지 않습니다. 과학 계산, 이미지 처리, 암호화 작업 등이 이에 해당합니다.
// I/O 바운드 프로세스의 전형적인 패턴 while ((line = read_from_file()) != NULL) { process_line(line); // 짧은 CPU 사용 write_to_output(result); // 다시 I/O 대기 } // CPU 바운드 프로세스의 전형적인 패턴 for (int i = 0; i < 1000000000; i++) { result += complex_calculation(i); // 긴 CPU 사용 }
C
복사
스케줄러는 이러한 특성을 고려하여 I/O 바운드 프로세스에게 높은 우선순위를 부여합니다. 이들이 CPU를 짧게 사용한 후 곧바로 I/O 대기 상태로 전환되므로, 다른 프로세스의 실행을 크게 방해하지 않으면서도 시스템의 응답성을 크게 개선할 수 있기 때문입니다.
실제로 스케줄러는 프로세스의 이러한 특성을 어떻게 판단할까요? 정적인 분류가 아닌 동적 관찰을 통해 이루어집니다. 과거 실행 이력을 바탕으로 CPU 버스트 길이의 지수 가중 이동 평균을 계산하여 다음 실행 패턴을 예측합니다. I/O 작업을 완료하고 준비 상태로 돌아온 프로세스는 일시적으로 우선순위가 상승하는데, 이는 사용자와의 상호작용 가능성이 높다는 가정에 기반합니다.

스케줄링 큐 시스템

프로세스가 시스템에 들어가면 준비 큐에 들어가서 준비 상태가 되어 CPU에서 실행되기를 기다립니다. 이 큐는 일반적으로 연결 리스트로 저장되며, 준비 큐 헤더에는 리스트의 첫 번째 PCB에 대한 포인터가 저장되고 각 PCB에는 준비 큐의 다음 PCB를 가리키는 포인터 필드가 포함됩니다.
시스템에는 준비 큐 외에도 다른 큐들이 존재합니다. 프로세스에 CPU가 할당되면 프로세스는 잠시 동안 실행되어 결국 종료되거나 인터럽트되거나 I/O 요청의 완료와 같은 특정 이벤트가 발생할 때까지 기다립니다.
3.4 준비 큐와 대양한 입출력 장치 대기 큐
특정 이벤트가 발생하기를 기다리는 프로세스는 대기 큐에 삽입됩니다. 각 I/O 장치마다 별도의 대기 큐가 존재하는데, 이는 각 장치의 물리적 특성과 최적화 전략이 다르기 때문입니다.
디스크 I/O 큐에서는 SCAN 알고리즘을 사용하여 헤드 이동 거리를 최소화합니다. 네트워크 I/O 큐에서는 패킷의 우선순위나 프로토콜 특성에 따른 차등 처리가 필요합니다. 키보드나 마우스 같은 인터랙티브 장치는 즉각적인 응답이 필요하므로 높은 우선순위를 가집니다.
이러한 분리된 큐 시스템은 단순한 구현상의 편의가 아닙니다. 서로 다른 자원에 대해 최적화된 관리 전략을 적용하고, 우선순위 역전 문제를 구조적으로 방지하며, 공정성과 효율성 사이의 적절한 균형을 찾을 수 있게 해줍니다.
3.5 프로세스 스케줄링을 나타내는 큐잉 다이어그램

CPU 스케줄러와 스와핑

CPU 스케줄러의 역할은 준비 큐에 있는 프로세스 중에서 선택된 하나의 프로세스에 CPU를 할당하는 것입니다. 스케줄러는 새 프로세스를 선택하는 빈도가 매우 높습니다. I/O 바운드 프로세스는 I/O 요청을 대기하기 전에 몇 밀리초 동안만 실행할 수 있기 때문입니다.
일부 운영체제는 스와핑으로 알려진 중간 형태의 스케줄링을 가지고 있습니다. 핵심 아이디어는 때로는 메모리에서 프로세스를 제거하여 다중 프로그래밍의 정도를 감소시키는 것이 유리할 수 있다는 것입니다. 나중에 프로세스를 메모리에 다시 적재할 수 있으며 중단된 위치에서 실행을 계속할 수 있습니다.
이 기법을 스와핑이라고 하는 이유는 프로세스를 메모리에서 디스크로 "스왑 아웃"하고 현재 상태를 저장하고, 이후 디스크에서 메모리로 "스왑 인"하여 상태를 복원할 수 있기 때문입니다.

프로세스 생성의 체계적 메커니즘

3.7 보편적인 Linux 시스템의 프로세스 트리

프로세스 생성의 기본 고려사항

프로세스가 새로운 프로세스를 생성할 때는 여러 가지 측면을 고려해야 합니다. 첫째, 자식 프로세스는 자신의 임무를 달성하기 위하여 어떤 자원이 필요한지 결정해야 합니다. 자식 프로세스는 이 자원을 운영체제로부터 직접 얻거나, 부모 프로세스가 가진 자원의 부분 집합만을 사용하도록 제한될 수 있습니다.
둘째, 프로세스가 새로운 프로세스를 생성할 때 두 프로세스를 실행시키는 데 두 가지 가능한 방법이 존재합니다. 부모는 자식과 병행하게 실행을 계속하거나, 부모는 일부 또는 모든 자식이 실행을 종료할 때까지 기다릴 수 있습니다.
셋째, 새로운 프로세스들의 주소 공간 측면에서 볼 때 다음과 같은 두 가지 가능성이 있습니다. 자식 프로세스는 부모 프로세스의 복사본이거나, 자식 프로세스가 자신에게 적재될 새로운 프로그램을 가지고 있습니다.

UNIX의 fork() 시스템 콜

UNIX에서 새로운 프로세스는 fork() 시스템 콜로 생성됩니다. 새로운 프로세스는 원래 프로세스의 주소 공간의 복사본으로 구성됩니다. 이 기법은 부모 프로세스가 쉽게 자식 프로세스와 통신할 수 있게 합니다.
#include <sys/types.h> #include <stdio.h> #include <unistd.h> int main() { pid_t pid; /* 새 프로세스를 생성한다 (fork) */ pid = fork(); if (pid < 0) { /* 오류가 발생했음 */ fprintf(stderr, "Fork Failed"); return 1; } else if (pid == 0) { /* 자식 프로세스 */ execlp("/bin/ls", "ls", NULL); } else { /* 부모 프로세스 */ /* 부모가 자식이 완료되기를 기다릴 것임 */ wait(NULL); printf("Child Complete"); } return 0; }
C
복사
fork()의 가장 특이한 특징은 하나의 함수 호출이 두 번의 반환을 생성한다는 점입니다. 자식 프로세스에는 0이 반환되고, 부모 프로세스에는 자식 프로세스의 PID가 반환됩니다. 이 설계 덕분에 단일 코드에서 두 프로세스의 서로 다른 동작을 구현할 수 있습니다.
자식 프로세스는 열린 파일과 같은 자원뿐 아니라 특권과 스케줄링 속성을 부모 프로세스로부터 상속받습니다. 또한 현대 시스템에서는 Copy-on-Write 최적화가 적용되어, 실제로 메모리 내용이 수정될 때까지는 부모와 자식이 동일한 물리적 메모리 페이지를 공유합니다.
fork() 후에 두 프로세스 중 한 프로세스가 exec() 시스템 콜을 사용하여 자신의 메모리 공간을 새로운 프로그램으로 교체하는 것이 일반적입니다. exec() 시스템 콜은 이진 파일을 메모리로 적재하고 그 프로그램의 실행을 시작합니다.
이러한 두 단계 분리가 흥미로운 점은 중간에 프로세스 속성을 세밀하게 조정할 수 있다는 것입니다. 파일 디스크립터를 재지정하거나, 환경 변수를 설정하거나, 신호 처리기를 변경하는 등의 작업이 모두 fork()와 exec() 사이에서 가능합니다. 이는 UNIX 설계 철학의 핵심인 조합성과 단순성을 보여주는 대표적인 사례입니다.

exec() 계열 함수들

exec() 시스템 콜은 현재 프로세스의 메모리 이미지를 완전히 새로운 프로그램으로 교체합니다. 중요한 특징은 성공하면 반환되지 않는다는 점입니다. 기존 프로그램의 메모리 이미지가 파괴되기 때문에 오직 오류가 발생했을 때만 원래 프로세스로 반환됩니다.
// 다양한 exec() 함수들 execl("/bin/ls", "ls", "-l", NULL); // 인자 리스트 execv("/bin/ls", args_array); // 인자 배열 execlp("ls", "ls", "-l", NULL); // PATH 검색 execvp("ls", args_array); // PATH 검색 + 배열
C
복사
각 변형은 인자 전달 방식과 프로그램 검색 방법에서 차이를 보입니다. 'l'이 포함된 함수는 인자를 리스트로, 'v'가 포함된 함수는 배열로 전달받습니다. 'p'가 포함된 함수는 PATH 환경 변수를 검색하여 실행 파일을 찾습니다.

Windows CreateProcess()와의 비교

Windows에서의 프로세스 생성은 CreateProcess() 함수를 이용하여 이루어집니다. 이는 fork()와는 근본적으로 다른 접근 방식을 보여줍니다.
#include <stdio.h> #include <windows.h> int main(VOID) { STARTUPINFO si; PROCESS_INFORMATION pi; /* 메모리 할당 */ ZeroMemory(&si, sizeof(si)); si.cb = sizeof(si); ZeroMemory(&pi, sizeof(pi)); /* 자식 프로세스 생성 */ if (!CreateProcess( NULL, /* 명령어 라인 사용 */ "C:\\WINDOWS\\system32\\mspaint.exe", /* 명령어 라인 */ NULL, /* 프로세스를 상속하지 말 것 */ NULL, /* 스레드 핸들을 상속하지 말 것 */ FALSE, /* 핸들상속 디제이블 */ 0, /* 생성 플래그 없음 */ NULL, /* 부모 환경 블록 사용 */ NULL, /* 부모 디렉터리 사용 */ &si, &pi)) { fprintf(stderr, "Create Process Failed\n"); return -1; } /* 부모가 자식이 끝나기를 기다림 */ WaitForSingleObject(pi.hProcess, INFINITE); printf("Child Complete\n"); /* 핸들 닫기 */ CloseHandle(pi.hProcess); CloseHandle(pi.hThread); }
C
복사
CreateProcess()는 한 번의 호출로 새로운 프로세스를 생성하고 지정된 프로그램을 로드합니다. fork() + exec()의 두 단계 과정과 대조적으로, Windows 방식은 더 직관적이지만 UNIX 방식의 유연성은 제공하지 않습니다. 이는 통합 vs 분리, 편의성 vs 유연성이라는 서로 다른 설계 철학을 반영합니다.

프로세스 종료와 자원 관리

정상적인 프로세스 종료

프로세스가 마지막 문장의 실행을 끝내고, exit() 시스템 콜을 사용하여 운영체제에 자신의 삭제를 요청하면 종료됩니다. 이 시점에서 프로세스는 자신을 기다리고 있는 부모 프로세스에 상태 값을 반환할 수 있습니다.
물리 메모리와 가상 메모리, 열린 파일, I/O 버퍼를 포함한 프로세스의 모든 자원이 할당 해제되고 운영체제로 반납됩니다. 그러나 프로세스의 종료 상태 정보는 부모 프로세스가 이를 읽을 때까지 프로세스 테이블에 보존됩니다.

프로세스 테이블과 좀비 프로세스

프로세스 테이블은 운영체제가 모든 프로세스를 관리하기 위한 중앙 집중식 자료구조입니다. 각 항목은 하나의 프로세스를 나타내며, PID와 프로세스 상태, 부모-자식 관계 등의 핵심 정보를 포함합니다.
종료되었지만 부모 프로세스가 아직 wait()를 호출하지 않은 프로세스를 좀비 프로세스라고 합니다. 좀비 상태에서 프로세스는 거의 모든 자원을 해제했지만, PID와 종료 상태 정보는 유지됩니다.
# 좀비 프로세스 확인 $ ps aux | grep Z user 1234 0.0 0.0 0 0 ? Z 10:30 0:00 [program] <defunct>
Shell
복사
모든 프로세스는 종료하게 되면 좀비 상태가 되지만 아주 짧은 시간 동안만 머무릅니다. 부모가 wait()를 호출하면 좀비 프로세스의 프로세스 식별자와 프로세스 테이블의 해당 항목이 운영체제에 반환됩니다.
좀비 프로세스의 존재 이유는 시스템 일관성과 관련이 있습니다. 프로세스가 종료 상태를 "생산"하고 부모가 이를 "소비"하는 생산자-소비자 패턴으로, 분산된 생성과 소비 과정에서 일시적인 저장소 역할을 합니다. 이는 즉시성과 안전성 사이의 절충안이라고 할 수 있습니다.

wait() 시스템 콜의 역할

부모 프로세스는 wait() 시스템 콜을 사용해서 자식 프로세스가 종료할 때를 기다릴 수 있습니다. wait()는 부모가 자식의 종료 상태를 얻어낼 수 있도록 하나의 인자를 전달받고, 부모가 어느 자식이 종료되었는지 구별할 수 있도록 종료된 자식의 프로세스 식별자를 반환합니다.
#include <sys/wait.h> // 동기적 실행 패턴 pid_t pid = fork(); if (pid == 0) { // 자식: 시간이 오래 걸리는 작업 execl("/usr/bin/sort", "sort", "large_file.txt", NULL); } else { // 부모: 자식 완료까지 대기 int status; wait(&status); if (WIFEXITED(status)) { printf("정렬 완료, 종료 코드: %d\n", WEXITSTATUS(status)); } printf("다음 작업 진행\n"); } // 비동기적 실행 패턴 pid_t pid = fork(); if (pid == 0) { // 자식: 백그라운드 작업 sleep(10); exit(42); } else { // 부모: 즉시 다른 작업 수행 printf("부모가 다른 작업을 계속 수행\n"); perform_other_tasks(); // 나중에 정리 (좀비 방지) int status; waitpid(pid, &status, 0); }
C
복사
wait()는 동기화와 자원 정리라는 두 가지 중요한 기능을 수행합니다. 동기화 측면에서는 프로세스 간 협력의 기본 도구로, 부모가 자식의 작업 완료를 기다리는 순차적 프로그래밍 모델을 분산 환경으로 확장합니다. 자원 정리 측면에서는 좀비 프로세스를 완전히 제거하여 프로세스 테이블과 PID 공간을 관리합니다.
wait()를 호출하지 않으면 자식 프로세스는 좀비 상태로 남게 됩니다. 이는 반드시 문제가 되는 것은 아니지만, 장기 실행되는 서버 프로그램에서는 심각한 자원 누수를 야기할 수 있습니다.

고아 프로세스와 계층적 관리

부모 프로세스가 wait()를 호출하는 대신 종료한다면 자식 프로세스는 고아 프로세스가 됩니다. 모든 프로세스는 반드시 부모를 가져야 한다는 원칙에 따라, 운영체제는 고아 프로세스의 새로운 부모 프로세스로 init 프로세스를 지정합니다.
// 고아 프로세스 생성 예시 int main() { pid_t pid = fork(); if (pid == 0) { // 자식: 부모보다 오래 생존 sleep(15); printf("자식: 부모 PID = %d\n", getppid()); // 1 (init)이 됨 } else { // 부모: 자식을 고아로 만들고 종료 printf("부모: 자식 PID = %d\n", pid); exit(0); // wait() 호출 없이 종료 } return 0; }
C
복사
init 프로세스는 주기적으로 wait()를 호출하여 고아 프로세스들을 정리합니다. 이러한 메커니즘은 시스템적 안전망의 구현으로, 개별 구성 요소의 실패가 전체 시스템의 붕괴로 이어지지 않도록 합니다.
프로세스 계층 구조는 단순한 구조적 관계가 아닌 책임과 의무의 네트워크입니다. 부모 프로세스가 자식을 생성한다는 것은 지속적인 관리 계약을 의미하며, 특히 종료 시점에서 그 중요성이 극대화됩니다.

강제 종료와 연쇄적 종료

일부 상황에서는 프로세스를 강제로 종료해야 합니다. 적당한 시스템 콜을 통해서 한 프로세스는 다른 프로세스의 종료를 유발할 수 있습니다. 통상적으로 그런 시스템 콜은 단지 종료될 프로세스의 부모만이 호출할 수 있습니다.
부모는 다음과 같은 여러 가지 이유로 인하여 자식 중 하나의 실행을 종료할 수 있습니다:
자식이 자신에게 할당된 자원을 초과하여 사용할 때
자식에게 할당된 태스크가 더 이상 필요 없을 때
부모가 exit를 하는데, 운영체제는 부모가 exit 한 후에 자식이 실행을 계속하는 것을 허용하지 않는 경우
몇몇 시스템에서는 부모 프로세스가 종료한 이후에 자식 프로세스가 존재할 수 없습니다. 그러한 시스템에서는 프로세스가 종료되면 그로부터 비롯된 모든 자식 프로세스들도 종료되어야 합니다. 이것을 연쇄식 종료라고 부르며 이 작업은 운영체제가 시행합니다.

실제 시스템에서의 프로세스 관리

Linux systemd의 현대적 접근

현대 Linux 시스템에서 systemd는 전통적인 init을 대체하여 더 정교한 프로세스 관리를 제공합니다. systemd는 모든 사용자 프로세스의 최상위 부모 역할을 수행하며, 단순한 프로세스 생성을 넘어서 서비스 의존성 관리, 자동 재시작, 자원 제한 등의 고급 기능을 제공합니다.
systemd (PID 1) ├── 시스템 서비스들 ├── 사용자 세션 │ ├── login shell │ │ ├── 사용자가 실행한 프로그램들 │ │ └── 각 프로그램의 자식 프로세스들 │ └── 데스크톱 환경 프로세스들 └── 하드웨어 관리 데몬들
Plain Text
복사
각 서비스는 cgroup을 통해 CPU, 메모리, I/O 등의 자원 사용량을 제한받을 수 있으며, systemd는 이를 통해 시스템 전체의 안정성과 성능을 보장합니다.

Android의 프로세스 계층과 메모리 관리

Android는 제한된 메모리 환경에서 프로세스를 효율적으로 관리하기 위한 독특한 전략을 사용합니다. 제한된 시스템 자원을 회수하기 위해 기존 프로세스를 종료해야 할 수도 있는데, Android는 임의의 프로세스를 종료하지 않고 프로세스의 중요도 계층을 식별했습니다.
중요도가 낮은 프로세스부터 종료하는 정책을 통해 사용자 경험을 최대화합니다:
1.
Foreground Process: 현재 사용자와 상호작용하는 프로세스
2.
Visible Process: 화면에 보이지만 포커스가 없는 프로세스
3.
Service Process: 백그라운드 서비스를 실행하는 프로세스
4.
Background Process: 사용자에게 보이지 않는 프로세스
5.
Empty Process: 캐시 목적으로만 유지되는 프로세스
Android 개발 관행은 프로세스 수명주기 지침을 따르는 것을 권장합니다. 이 지침을 준수하면 프로세스 상태는 종료 전에 저장되고 사용자가 응용 프로그램으로 다시 전환하면 저장된 상태에서부터 재개됩니다.

프로세스 우선순위와 스케줄링 정책

Linux에서는 nice 값과 스케줄링 정책을 통해 프로세스 우선순위를 세밀하게 조정할 수 있습니다. nice 값은 -20부터 19까지의 범위를 가지며, 낮은 값일수록 높은 우선순위를 의미합니다.
# 프로세스 우선순위 조정 nice -n 10 ./cpu_intensive_program # 낮은 우선순위로 실행 renice -5 -p 1234 # 실행 중인 프로세스 우선순위 변경 # CPU 친화성 설정 taskset -c 0,1 ./parallel_program # 특정 CPU 코어에 바인딩
Shell
복사
CFS(Completely Fair Scheduler)는 각 프로세스의 가상 런타임을 추적하여 공정한 CPU 배분을 보장합니다. 실시간 스케줄링 클래스는 일반 프로세스보다 높은 우선순위를 가지며, 데드라인을 보장해야 하는 시스템에서 사용됩니다.

결론

프로세스 생명주기와 스케줄링은 운영체제의 핵심 메커니즘입니다. 스케줄링은 제한된 CPU 자원을 효율적으로 분배하는 정교한 시스템이며, 프로세스 생성과 종료는 시스템 자원을 체계적으로 관리하는 근본적인 과정입니다. fork()와 exec()의 분리 설계, wait()의 이중적 역할, 부모-자식 관계를 통한 계층적 관리는 모두 견고하고 유연한 시스템 구축의 기초가 됩니다. 이러한 기본 원리들이 현대 시스템에서 어떻게 확장되고 적용되는지 이해하는 것은 시스템 프로그래밍과 운영체제 설계의 핵심 역량입니다.

참고