파이프, 포크, 그리고 좀비: Unix 프로세스 관리의 이해

파이프, 포크, 그리고 좀비: Unix 프로세스 관리의 이해

현대 운영 체제의 아키텍처는 여러 프로세스를 관리하고 프로세스 간의 통신을 용이하게 하는 능력에 크게 의존합니다. Unix 철학의 핵심은 복잡한 작업을 수행하기 위해 서로 연결할 수 있는 작고 전문화된 도구들을 만드는 아이디어입니다. 이러한 모듈성은 파이프, 포크, 그리고 프로세스 종료 상태 관리라는 세 가지 핵심 메커니즘을 통해 가능해집니다.

이 기사는 셸 구현에 관한 교육 자료와 프로세스 생명 주기의 미묘한 차이점을 바탕으로 이러한 근본적인 시스템 프로그래밍 개념을 탐구합니다.

파이프의 철학과 진화

파이프가 운영 체제의 표준 기능이 되기 훨씬 전부터, Doug McIlroy는 프로그램들을 "garden hoses"처럼 결합하여 데이터가 한 세그먼트에서 다른 세그먼트로 전달되도록 하는 방법을 구상했습니다. 이러한 개념적 전환은 단일 구조의 프로그램 설계에서 중간 출력을 서로 연결하는 시스템 지향적 접근 방식으로 초점을 옮겼습니다.

이 철학은 "Literate Programming"의 창시자인 Donald Knuth의 접근 방식과 극명한 대조를 이룹니다. Knuth가 알고리즘의 복잡성과 코드의 산문 같은 문서화에 집중한 반면, McIlroy는 Literate Programming 환경에서 상당한 오버헤드가 필요할 수 있는 복잡한 텍스트 파싱 작업이 파이프를 사용하면 단 몇 줄의 셸 코드로 달성될 수 있음을 보여주었습니다. 단순한 도구들을 체인으로 연결함으로써 시스템은 데이터 흐름을 처리하고, 개발자는 파싱 로직의 구현보다는 도구의 구성에 집중할 수 있게 됩니다.

파이프의 역학 및 SIGPIPE

압박 상황에서 파이프가 어떻게 작동하는지 이해하면 Unix가 리소스를 어떻게 관리하는지 알 수 있습니다. 시퀀스 생성기(seq)가 페이저(less)로 파이프로 연결되는 시나리오를 생각해 봅시다:

$ seq 2 100000000 | less

이 설정에서 seq는 거대한 숫자 스트림을 생성하는 반면, less는 현재 화면을 채울 만큼만 읽습니다. 흔히 혼동하는 부분은 seq가 백그라운드에서 계속 실행되는지 여부입니다.

기술적으로, less가 열려 있고 읽기 역할을 수행하는 한 파이프는 열려 있는 상태로 유지됩니다. seq 프로세스는 파이프의 내부 버퍼가 가득 차면 less가 더 많은 데이터를 소비할 때까지 대기(block)하게 됩니다. 그러나 읽기 프로세스(less)가 종료되거나 닫히면(예를 들어, q를 눌러 종료), 파이프는 끊어집니다.

프로세스가 읽는 프로세스가 없는 파이프에 쓰기를 시도할 때, 커널은 쓰는 프로세스에 SIGPIPE 시그널을 보냅니다. SIGPIPE의 기본 동작은 프로그램을 종료하는 것입니다. 이 메커니즘은 생산자가 아무도 듣고 있지 않은 데이터를 생성하는 데 CPU 사이클을 낭비하지 않도록 보장합니다.

파이프를 이용한 블로킹 호출 구현

파이프는 단순한 데이터 전송 이상의 역할을 할 수 있습니다. 동기화 프리미티브로도 사용될 수 있습니다. 예를 들어, 파이프를 사용하여 waitpid()와 유사한 블로킹 메커니즘을 구현할 수 있습니다.

부모 프로세스가 파이프를 생성하고 자식 프로세스를 포크(fork)하면, 부모는 파이프의 쓰기 끝단을 닫고 읽기 끝단에서 읽기를 시도할 수 있습니다. 자식 프로세스는 파이프의 파일 디스크립터를 상속받기 때문에, 자식(또는 다른 프로세스)이 쓰기 끝단을 보유하고 있는 한 파이프는 열려 있는 상태로 유지됩니다.

int main() {
   int pipefd[2];
   pipe(pipefd);
   pid_t p = fork();
   if (p == 0) {
     exec(); // 자식이 자신의 이미지를 교체함
   }
   close(pipefd[1]); // 부모는 자신의 쓰기 끝단을 닫음
   char buf;
   read(pipefd[0], &buf, 1); // 자식이 종료되고 쓰기 끝단을 닫을 때까지 블로킹됨
   close(pipefd[0]);
}

자식 프로세스가 종료되면 커널은 파이프의 쓰기 끝단을 포함하여 자식의 모든 열린 파일 디스크립터를 자동으로 닫습니다. 이 시점에서 부모 프로세스의 read 호출은 0을 반환하며, 이는 자식이 종료되었음을 알리는 신호입니다.

프로세스 계층 구조와 좀비 상태

Unix에서 프로세스는 엄격한 계층 구조로 조직됩니다. 모든 프로세스는 부모를 가지며, 이 트리의 루트는 init 프로세스(PID 1)로, 이는 종료할 수 없는 유일한 프로세스입니다.

waitpid의 역할

프로세스가 종료되면 시스템에서 즉시 사라지지 않습니다. 커널은 프로세스 구조 내에 종료 상태와 몇 가지 기본적인 리소스 사용 통계 정보를 보유합니다. 이 정보는 부모 프로세스가 waitpid() 시스템 호출을 사용하여 이를 회수할 때까지 유지됩니다. waitpid()가 호출되면 커널은 마침내 프로세스 구조와 관련 프로세스 ID(PID)를를 재활용할 수 있습니다.

좀비 프로세스 이해하기

자식 프로세스가 종료되었지만 부모가 waitpid()를 호출하지 못하면, 자식은 좀비 프로세스가 됩니다. 좀비는 실행을 마쳤지만 여전히 프로세스 테이블의 슬롯을 차지하고 있는 프로세스입니다. 좀비는 CPU나 메모리를 소비하지 않지만, PID를 소비합니다. 고립된 프로세스나 waitpid로 회수수되지 않은 프로세스가 많은 시스템에서는 결국 PID가 부족해져 새로운 프로세스를 시작할 수 없게 될 수 있습니다.

ps 명령어를 사용하면, 좀비는 Z+ 상태 코드로 쉽게 식별할 수 있습니다:

user 78623 0.0 0.0 0 pts/0 Z+ 15:44 0:00 [manyfork]

고아 프로세스와 init

부모 프로세스가 자식보다 먼저 종료되면, 자식들은 "고아"가 됩니다. 이러한 프로세스들이 종료 시 영구적인 좀비가 되는 것을 방지하기 위해, Unix 커널은 이들의 부모를 init 프로세스로 재설정합니다. init 프로세스는 입양된 자식들에 대해 지속적으로 waitpid()를 호출하도록 설계되어 있어, 자식들의 리소스를 정리하고 시스템 안정성을 유지합니다.

Sources