🟣 프로세스는 왜 서로를 볼 수 없나
프로그램을 실행하면 커널은 그 프로그램에게 독립된 가상 주소 공간을 하나 준다. 코드, 힙, 스택이 이 공간 안에 놓이고, 다른 프로세스는 이 주소를 들여다볼 수 없다. 같은 물리 메모리를 나눠 쓰더라도 주소 공간이 분리돼 있기 때문에 A 프로세스의 포인터 값을 B 프로세스에 넘겨도 아무 의미가 없다.
이 격리는 안정성과 보안의 기반이다. 한 프로세스가 잘못된 메모리를 건드려 죽어도 옆 프로세스는 멀쩡하다. 문제는 실제 프로그램이 혼자 일하는 경우가 거의 없다는 데 있다. 쉘은 명령어의 출력을 다음 명령어의 입력으로 넘겨야 하고, 서버는 워커 프로세스에게 작업을 분배해야 한다. 격리된 프로세스들이 어떻게든 데이터를 주고받아야 한다는 이 모순이 IPC라는 주제 전체의 출발점이다.
커널은 이 통로를 여러 형태로 제공한다. 가장 먼저 만나는 것이 파이프인데, 파이프를 이해하려면 프로세스가 어떻게 생겨나는지부터 봐야 한다.
🟣 fork와 exec: 복제하고, 갈아끼운다
리눅스에서 새 프로세스는 무에서 생기지 않는다. 항상 기존 프로세스를 복제해서 만든다. 이 복제가 fork다.
fork를 호출하면 커널은 호출한 프로세스와 거의 똑같은 사본을 하나 만든다. 열려 있는 파일, 메모리 내용, 실행 위치까지 복사된다. 한 번 호출했는데 부모와 자식 두 프로세스에서 각각 반환되고, 반환값만 다르다. 부모는 자식의 PID를, 자식은 0을 받는다. 코드가 이 반환값으로 자기가 누구인지 구분한다.
pid_t pid = fork();
if (pid == 0) {
// 자식: 여기서 보통 exec으로 다른 프로그램이 된다
} else {
// 부모: 자식의 pid를 알고 있다
}복제만으로는 부모와 똑같은 일밖에 못 한다. 그래서 자식은 대개 곧바로 exec 계열 함수를 불러 자기 자신을 다른 프로그램으로 갈아끼운다. exec은 현재 프로세스의 메모리를 새 프로그램의 코드와 데이터로 덮어쓴다. 프로세스 껍데기(PID, 열린 파일)는 유지되지만 알맹이가 통째로 바뀐다.
쉘이 ls를 실행하는 과정이 정확히 이 두 단계다. 쉘이 fork로 자신을 복제하고, 복제된 자식이 exec으로 ls 프로그램이 된다. 부모 쉘은 그동안 자식이 끝나기를 기다린다.
fork와 exec을 굳이 두 단계로 나눈 설계가 처음엔 번거로워 보였다. 하지만 이 사이의 짧은 틈이 중요하다. 복제된 직후, exec으로 갈아끼우기 직전의 그 순간에 자식은 부모에게서 물려받은 파일 디스크립터를 손볼 수 있다. 입출력을 다른 곳으로 돌려놓고 나서 프로그램을 실행하는 이 구조가 바로 다음에 볼 파이프 연결의 핵심이다.
🟣 파일 디스크립터라는 공통 화폐
파이프로 넘어가기 전에 파일 디스크립터를 짚어야 한다. 유닉스에서 파일, 소켓, 파이프, 터미널은 전부 작은 정수 하나로 다뤄진다. 이 정수가 파일 디스크립터다. 프로세스마다 자기 디스크립터 표를 갖고, 0은 표준 입력, 1은 표준 출력, 2는 표준 에러로 관례상 예약돼 있다.
"모든 것은 파일이다"라는 유닉스의 오래된 문장이 여기서 나온다. 읽고 쓰는 대상이 무엇이든 read(fd, ...)와 write(fd, ...)라는 같은 문법으로 다룬다. 덕분에 프로그램은 자기 출력이 화면으로 가는지 파일로 가는지 파이프로 가는지 신경 쓸 필요가 없다. 그냥 1번에 쓴다. 그 1번이 실제로 어디에 연결돼 있는지는 프로그램을 실행한 쪽이 정한다.
이 간접성이 파이프를 가능하게 한다.
🟣 파이프: 커널 안의 한 방향 버퍼
파이프는 커널이 관리하는 작은 버퍼다. 한쪽 끝으로 쓰면 다른 쪽 끝에서 읽힌다. pipe()를 호출하면 디스크립터 두 개를 받는데, 하나는 읽기 전용, 하나는 쓰기 전용이다. 데이터는 항상 한 방향으로만 흐른다.
🚀 익명 파이프와 fork의 결합
익명 파이프는 이름이 없어서 무관한 두 프로세스는 서로를 찾을 수 없다. 대신 부모가 파이프를 먼저 만들고 fork 하면, 자식이 파이프 디스크립터를 그대로 물려받는다. 부모와 자식은 이제 같은 버퍼의 양 끝을 쥐고 있다.
쉘의 ls | grep txt가 이 그림이다. 쉘이 파이프를 만들고, ls 자식의 표준 출력(1번)을 파이프의 쓰기 끝으로 돌리고, grep 자식의 표준 입력(0번)을 파이프의 읽기 끝으로 돌린다. 두 프로그램은 자기가 파이프로 연결됐다는 사실조차 모른다. 그저 표준 출력에 쓰고 표준 입력에서 읽을 뿐이다. 아까 본 "fork와 exec 사이의 틈"에서 이 디스크립터 교체가 일어난다.
🚀 파이프는 흐름을 스스로 조절한다
파이프 버퍼는 유한하다. 읽는 쪽이 느려서 버퍼가 가득 차면, 쓰는 쪽의 write는 자리가 날 때까지 자동으로 멈춘다. 반대로 버퍼가 비었는데 읽으려 하면 read가 데이터가 올 때까지 멈춘다. 이 막힘(blocking)이 곧 흐름 제어다. 생산자와 소비자의 속도를 프로그래머가 따로 맞출 필요 없이 커널이 버퍼 하나로 조율해 준다.
쓰는 쪽이 모두 닫히면 읽는 쪽은 파일 끝(EOF)을 받는다. 반대로 읽는 쪽이 모두 닫힌 파이프에 쓰면 SIGPIPE 시그널이 날아온다. 이 신호를 무시하면 write가 EPIPE 오류를 돌려준다. 파이프의 이런 종료 규약은 뒤의 시그널 장에서 다시 만난다.
🚀 이름 있는 파이프(FIFO)
부모-자식 관계가 없는 프로세스를 잇고 싶으면 이름 있는 파이프를 쓴다. mkfifo로 파일 시스템에 특수 파일을 하나 만들면, 서로 무관한 프로세스들이 그 경로를 열어 같은 파이프에 접근한다. 파일처럼 이름은 있지만 디스크에 내용이 쌓이지는 않는다. 여전히 커널 버퍼일 뿐이고, 경로는 만남의 장소 역할만 한다.
🟣 자식을 회수하지 않으면: 좀비와 고아
자식 프로세스가 먼저 끝나면 커널은 그 종료 상태(exit code)를 잠시 남겨 둔다. 부모가 wait으로 이 상태를 읽어 가야 커널이 자식의 흔적을 완전히 지운다. 부모가 회수하지 않으면 자식은 죽었는데 프로세스 표에는 남아 있는 상태가 된다. 이것이 좀비 프로세스다.
좀비 하나하나는 메모리를 거의 안 쓰지만, 회수하지 않는 코드는 자식을 만들 때마다 프로세스 표에 항목을 쌓는다. 오래 도는 서버라면 언젠가 표가 가득 차 새 프로세스를 못 만든다. 그래서 자식을 만드는 쪽은 반드시 wait으로 종료를 회수하거나, SIGCHLD 시그널을 받아 처리하도록 설계해야 한다.
반대로 부모가 자식보다 먼저 죽으면 자식은 고아가 된다. 고아는 init(PID 1)에게 입양되고, init이 대신 회수해 준다. 좀비는 문제고 고아는 문제가 아니라는 점이 종종 헷갈리는데, 회수해 줄 부모가 있느냐 없느냐로 갈린다.
🟣 정리
- 프로세스는 주소 공간이 격리돼 서로의 메모리를 못 본다. IPC는 이 격리를 뚫는 커널의 도구들이다.
- 새 프로세스는
fork로 복제한 뒤exec으로 다른 프로그램이 된다. 이 둘 사이의 틈에서 입출력을 다른 곳으로 돌릴 수 있다. - 파일 디스크립터라는 공통 인터페이스 덕분에 프로그램은 자기 출력이 어디로 가는지 몰라도 된다. 파이프는 이 간접성 위에 선다.
- 파이프는 커널 안의 한 방향 버퍼다. 버퍼가 막히면 쓰기·읽기가 자동으로 멈춰 흐름을 조절한다.
- 자식은 반드시 회수해야 한다. 회수하지 않은 자식은 좀비가 되어 프로세스 표를 갉아먹는다.
파이프로 부모와 자식은 통하게 됐지만, 파이프는 한 방향이고 구조 없는 바이트 흐름일 뿐이다. 메시지 단위로, 그것도 무관한 프로세스끼리 주고받으려면 다른 도구가 필요하다. 다음 장의 메시지 큐가 그 자리를 채운다.