프로세스 간 통신 (IPC)
주소 공간이 분리된 프로세스들이 데이터를 주고받는 방법으로 파이프, FIFO, 메시지 큐, 공유 메모리, 신호, Unix 도메인 소켓을 살펴봅니다. 바이트 스트림과 메시지 경계의 차이, 공유 메모리에 동기화가 필요한 이유, 상황에 맞는 방식을 고르는 기준을 예제로 확인합니다.
앞 글에서 본 것처럼 자식 프로세스가 변수를 바꾸어도 부모의 변수는 그대로입니다. 프로세스마다 주소 공간이 따로 있기 때문입니다. 이 분리 덕분에 한 프로세스의 버그가 다른 프로세스의 메모리를 망가뜨리지 못하지만, 프로세스들이 함께 일해야 할 때는 이 분리가 걸림돌이 됩니다. 웹 서버는 데이터베이스 서버에 질의를 보내야 하고, 작업을 나누어 받은 워커 프로세스는 결과를 부모에게 돌려주어야 하며, 로그 수집 프로그램은 여러 서비스의 로그를 모아야 합니다.
그래서 운영체제는 프로세스들이 정해진 통로로 데이터를 주고받을 수 있는 방법을 제공하며, 이를 프로세스 간 통신(inter-process communication, IPC)이라고 합니다. 방법은 여러 가지이고, 데이터를 바이트 스트림으로 보내는지 메시지 단위로 보내는지, 데이터를 복사하는지 같은 메모리를 함께 보는지, 관계없는 프로세스끼리도 쓸 수 있는지가 저마다 다릅니다. 이 글에서는 Linux에서 쓰는 대표적인 방법을 차례로 살펴보고, 상황에 맞게 고르는 기준을 정리합니다.
파이프: 한쪽에서 쓰고 다른 쪽에서 읽는 통로
파이프(pipe)는 가장 오래되고 단순한 IPC 방법입니다. 한쪽 끝에 쓴 바이트를 다른 쪽 끝에서 쓴 순서대로 읽을 수 있는, 한 방향 통로입니다. 셸에서 ls -la | grep txt | wc -l을 실행하면 프로세스 셋이 파이프 둘로 이어져, ls의 출력이 grep의 입력으로, grep의 출력이 wc의 입력으로 흘러갑니다. 작은 도구 여러 개를 이어 붙여 큰 일을 하는 UNIX의 작업 방식이 이 단순한 통로 위에서 만들어졌습니다.
부모와 자식을 잇는 익명 파이프
pipe() 시스템 콜은 파이프를 만들고 파일 디스크립터 두 개를 돌려줍니다. fd[0]은 읽는 끝, fd[1]은 쓰는 끝입니다. 이 파이프에는 이름이 없어서 다른 프로세스가 나중에 찾아와 열 수 없으므로, 익명 파이프(anonymous pipe)라고 부릅니다. 그래서 보통 fork() 전에 파이프를 만들어, 부모와 자식이 디스크립터를 함께 물려받게 합니다.
#include <stdio.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void)
{
int fd[2]; /* fd[0]: 읽는 끝, fd[1]: 쓰는 끝 */
if (pipe(fd) == -1) {
perror("pipe");
return 1;
}
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return 1;
}
if (pid == 0) { /* 자식: 읽기만 한다. */
close(fd[1]);
char buf[256];
ssize_t n;
while ((n = read(fd[0], buf, sizeof(buf) - 1)) > 0) {
buf[n] = '\0';
printf("자식이 받음: %s\n", buf);
}
close(fd[0]);
_exit(0);
}
close(fd[0]); /* 부모: 쓰기만 한다. */
const char *msg = "부모가 보낸 메시지";
if (write(fd[1], msg, strlen(msg)) == -1)
perror("write");
close(fd[1]); /* 다 썼으면 닫아 자식이 파일 끝을 알게 한다. */
waitpid(pid, NULL, 0);
return 0;
}자식은 read()가 0을 돌려줄 때까지, 곧 파일 끝을 만날 때까지 읽습니다. 그런데 파이프에서 파일 끝은 언제 올까요? 파이프의 쓰는 끝을 가진 디스크립터가 모든 프로세스에서 닫히고, 남은 데이터를 다 읽었을 때입니다. 이 예제에서 자식이 맨 먼저 자기의 쓰는 끝 fd[1]을 닫는 이유가 여기에 있습니다. 자식이 쓰는 끝을 닫지 않으면, 부모가 자기 쪽을 닫아도 자식 자신이 쓰는 끝을 하나 쥐고 있으므로 파일 끝이 오지 않고, 자식은 데이터가 더 오기를 영원히 기다리게 됩니다.
반대 방향의 규칙도 있습니다. 읽는 끝이 모두 닫힌 파이프에 쓰면 커널은 쓰는 프로세스에 SIGPIPE 신호를 보내며, 이 신호의 기본 동작은 프로세스를 끝내는 것입니다. 신호를 무시하도록 설정했다면 write()가 EPIPE 오류로 실패합니다. yes | head -1에서 head가 한 줄을 읽고 끝나면 yes가 조용히 끝나는 것도 이 신호 때문입니다.
파이프에는 메시지 경계가 없다
파이프는 바이트 스트림(byte stream)을 전달할 뿐, 몇 번에 나누어 썼는지는 기억하지 않습니다. 다음 Python 코드는 AB와 CD를 따로 쓴 뒤 읽어 봅니다. os.pipe()는 pipe() 시스템 콜을 감싼 함수입니다.
import os
r, w = os.pipe()
os.write(w, b"AB") # 첫째 메시지
os.write(w, b"CD") # 둘째 메시지
print(os.read(r, 3)) # 메시지 경계와 상관없이 3바이트까지
print(os.read(r, 10)) # 남은 것
os.close(w) # 쓰기 끝을 모두 닫으면
print(os.read(r, 10)) # 빈 바이트열이 파일 끝을 알린다
os.close(r)b'ABC'
b'D'
b''두 번 썼지만 첫 읽기는 두 메시지에 걸친 ABC를 가져오고, 둘째 읽기가 남은 D를 가져옵니다. 쓰는 끝을 닫은 뒤의 읽기는 빈 바이트열을 돌려주어 파일 끝을 알립니다. 읽는 쪽이 받는 바이트의 순서는 쓴 순서와 같지만, 읽기 한 번에 메시지 하나가 대응한다는 보장은 없습니다.
그래서 파이프나, 네트워크 교재의 TCP의 핵심 특성에서 다루는 TCP처럼 바이트 스트림을 주는 통로로 메시지를 주고받으려면, 메시지가 어디서 끝나는지를 보내는 쪽과 받는 쪽이 약속해야 합니다. 이를 프레이밍(framing)이라고 하며, 줄바꿈 같은 구분자를 두거나 메시지 앞에 길이를 붙이는 방법을 많이 씁니다. 다음 코드는 메시지 앞에 4바이트 길이를 붙여 보냅니다.
import os
import struct
def send(fd, text):
data = text.encode()
os.write(fd, struct.pack("!I", len(data)) + data) # 4바이트 길이 + 본문
def read_exact(fd, n):
buf = b""
while len(buf) < n:
chunk = os.read(fd, n - len(buf))
if not chunk:
raise EOFError("메시지 도중에 파일 끝")
buf += chunk
return buf
def recv(fd):
(length,) = struct.unpack("!I", read_exact(fd, 4))
return read_exact(fd, length).decode()
r, w = os.pipe()
send(w, "AB")
send(w, "CD")
print(recv(r), recv(r))AB CD받는 쪽은 먼저 정확히 4바이트를 읽어 길이를 알아낸 뒤, 그 길이만큼 다시 읽습니다. read_exact가 원하는 만큼 모일 때까지 읽기를 반복하는 것은, 앞에서 보았듯이 읽기 한 번이 요청한 만큼을 다 가져온다는 보장이 없기 때문입니다.
더 깊이: 파이프의 용량과 원자적 쓰기
파이프는 커널 안에 버퍼를 두고, 그 버퍼가 가득 차면 쓰는 쪽이 기다리고 비면 읽는 쪽이 기다립니다. Linux에서 파이프 버퍼의 기본 크기는 페이지 16개로, 4KiB 페이지를 쓰는 시스템에서는 65,536바이트이며, fcntl()로 바꿀 수 있습니다. 또 POSIX는 PIPE_BUF 바이트 이하의 쓰기가 원자적이라고 정합니다. 여러 프로세스가 한 파이프에 동시에 쓰더라도 PIPE_BUF 이하로 쓴 덩어리는 다른 프로세스의 데이터와 섞이지 않고 이어진 채로 들어간다는 뜻이며, Linux에서 이 값은 4,096바이트입니다. 다만 이 성질은 쓰기끼리 섞이지 않게 해 줄 뿐, 읽는 쪽에 메시지 경계를 만들어 주지는 않습니다. 자세한 규칙은 pipe(7)에 있습니다.
이름 있는 파이프: FIFO
익명 파이프는 디스크립터를 물려받은 프로세스끼리만 쓸 수 있습니다. 부모와 자식 관계가 아닌 프로세스끼리 파이프를 쓰려면, 파일 시스템에 이름을 가진 파이프인 FIFO(named pipe)를 씁니다. FIFO는 mkfifo로 만들며, 겉보기에는 파일이지만 디스크에 데이터를 저장하지 않고 커널의 파이프 버퍼를 통해 전달합니다.
# 터미널 1
mkfifo /tmp/myfifo
cat /tmp/myfifo # 누군가 쓰기 위해 열 때까지 기다린다
# 터미널 2
echo "FIFO로 보낸 줄" > /tmp/myfifo
# 다 쓴 뒤
rm /tmp/myfifo터미널 1의 cat은 FIFO를 읽기용으로 열면서 멈춥니다. FIFO는 기본적으로 반대쪽이 열릴 때까지 open()이 기다리기 때문입니다. 터미널 2에서 쓰기용으로 열어 한 줄을 쓰면 cat이 그 줄을 출력하고, 쓰는 쪽이 닫혀 파일 끝을 만나면 끝납니다. FIFO도 바이트 스트림이므로 메시지 경계에 대한 이야기는 익명 파이프와 같습니다.
메시지 큐: 경계가 있는 메시지를 주고받기
메시지 큐(message queue)는 커널이 관리하는 메시지 목록입니다. 한 프로세스가 메시지를 넣으면 다른 프로세스가 꺼내 가며, 파이프와 달리 메시지 하나가 하나의 단위로 전달됩니다. 100바이트 메시지를 두 번 넣으면 받는 쪽도 100바이트 메시지 두 개를 받습니다.
Linux에는 두 종류의 메시지 큐 API가 있습니다. POSIX 메시지 큐는 mq_open()으로 /로 시작하는 이름의 큐를 열고, 메시지마다 우선순위를 붙일 수 있으며, 받을 때는 우선순위가 가장 높은 메시지 가운데 가장 오래된 것이 먼저 나옵니다. 더 오래된 System V 메시지 큐는 메시지마다 mtype이라는 정수를 붙이고, 받는 쪽이 원하는 타입의 메시지만 골라 받을 수 있습니다. 둘 다 순서를 조절하는 기능이지만 동작이 다르므로 섞어 생각하지 않아야 합니다.
다음 프로그램은 POSIX 메시지 큐에 우선순위가 다른 메시지 둘을 넣고 꺼냅니다.
#include <fcntl.h>
#include <mqueue.h>
#include <stdio.h>
#include <string.h>
#define QUEUE_NAME "/demo-queue"
#define MSG_SIZE 64
int main(void)
{
mq_unlink(QUEUE_NAME); /* 지난 실행에서 남은 큐가 있으면 지운다. */
struct mq_attr attr = { .mq_maxmsg = 10, .mq_msgsize = MSG_SIZE };
mqd_t mq = mq_open(QUEUE_NAME, O_CREAT | O_EXCL | O_RDWR, 0600, &attr);
if (mq == (mqd_t) -1) {
perror("mq_open");
return 1;
}
if (mq_send(mq, "low", strlen("low"), 1) == -1 ||
mq_send(mq, "high", strlen("high"), 5) == -1)
perror("mq_send");
char buf[MSG_SIZE + 1]; /* 받는 버퍼는 mq_msgsize 이상이어야 한다. */
unsigned int prio;
for (int i = 0; i < 2; i++) {
ssize_t n = mq_receive(mq, buf, MSG_SIZE, &prio);
if (n == -1) {
perror("mq_receive");
break;
}
buf[n] = '\0';
printf("받은 메시지: %s (우선순위 %u)\n", buf, prio);
}
mq_close(mq);
mq_unlink(QUEUE_NAME);
return 0;
}"low"를 먼저 넣었지만, 받을 때는 우선순위 5인 "high"가 먼저 나오고 우선순위 1인 "low"가 그다음에 나옵니다. mq_receive()에 넘기는 버퍼의 크기가 큐의 메시지 최대 크기(mq_msgsize)보다 작으면 메시지 길이와 상관없이 EMSGSIZE 오류로 실패하므로, 버퍼를 넉넉히 잡아야 합니다. 큐는 mq_unlink()로 지우기 전까지 프로세스가 끝나도 남아 있으므로, 다 쓴 큐는 지웁니다. glibc 버전에 따라서는 컴파일할 때 -lrt를 붙여야 링크되기도 합니다. 자세한 규칙은 mq_overview(7)에 있습니다.
여러 컴퓨터에 걸친 시스템에서는 운영체제의 메시지 큐 대신 RabbitMQ, Apache Kafka, Redis 같은 외부 메시지 브로커를 씁니다. 이들은 네트워크를 넘어 메시지를 전달하고, 디스크에 저장해 장애 뒤에도 메시지를 잃지 않게 하며, 여러 소비자에게 나누어 주는 기능을 더합니다. 그래도 생산자가 메시지를 넣고 소비자가 꺼내며 메시지 경계를 지킨다는 기본 모델은 같습니다.
공유 메모리: 같은 메모리를 함께 보기
공유 메모리(shared memory)는 여러 프로세스가 같은 물리 메모리를 각자의 가상 주소 공간에 매핑해 함께 쓰는 방법입니다. 한번 매핑해 두면, 데이터를 주고받을 때마다 시스템 콜로 커널 버퍼에 복사하고 다시 꺼내 오는 과정 없이 일반 메모리처럼 바로 읽고 씁니다. 그래서 큰 데이터를 자주 주고받을 때 복사 비용을 줄일 수 있습니다.
IPC · 데이터 이동과 공유 매핑
공유 메모리는 복사를 줄여도 동기화 책임은 남습니다
파이프·메시지 큐는 커널 버퍼를 통해 전달하고 공유 메모리는 같은 페이지를 각 주소 공간에 매핑합니다. 고정된 성능 순위 대신 payload·호출 빈도·동기화 비용을 함께 봅니다.
- 파이프 · 메시지 큐
- 경로 송신 프로세스 → 커널 버퍼 → 수신 프로세스
- 비용 보통
write()와read()경계에서 사용자·커널 공간 사이의 payload 복사가 일어난다. - 공유 메모리
- 경로 두 프로세스가 같은 메모리 객체의 페이지를 각 주소 공간에 매핑한다.
- 비용 반복 전달의 copy overhead는 줄지만 mutex·semaphore와 데이터 layout 규약이 필요하다.
그림의 왼쪽처럼 파이프와 메시지 큐는 보내는 쪽의 데이터를 커널 버퍼에 복사하고, 받는 쪽이 다시 자기 메모리로 복사합니다. 오른쪽의 공유 메모리는 두 프로세스가 같은 페이지를 매핑하므로 이 복사가 없습니다. 그렇다고 공유 메모리가 언제나 가장 빠른 것은 아닙니다. 공유 영역을 만들고 매핑하는 데도 비용이 들고, 무엇보다 두 프로세스가 같은 데이터를 동시에 고치지 않도록 동기화하는 비용이 더해집니다.
Python의 multiprocessing은 공유 메모리에 놓이는 Value와 Array를 제공합니다. 다음 코드는 프로세스 넷이 공유 정수 하나를 1씩 늘리고, 공유 배열의 원소를 각각 두 배로 만듭니다.
from multiprocessing import Array, Process, Value
def worker(counter, arr):
with counter.get_lock():
counter.value += 1
with arr.get_lock():
for i in range(len(arr)):
arr[i] *= 2
if __name__ == "__main__":
counter = Value("i", 0) # 공유 정수
arr = Array("d", [1.0, 2.0, 3.0]) # 공유 실수 배열
workers = [Process(target=worker, args=(counter, arr)) for _ in range(4)]
for p in workers:
p.start()
for p in workers:
p.join()
print("counter:", counter.value)
print("arr:", list(arr))counter: 4
arr: [16.0, 32.0, 48.0]네 프로세스가 각자 1을 더했으므로 counter는 4가 되고, 배열의 원소는 네 번 두 배가 되어 2⁴ = 16배가 됩니다. 이 결과가 나오는 것은 get_lock()으로 잠금을 쥐고 고쳤기 때문입니다. counter.value += 1은 값을 읽고, 1을 더하고, 다시 쓰는 세 단계로 이루어지는데, 두 프로세스가 잠금 없이 동시에 이 일을 하면 둘 다 같은 옛 값을 읽어 한 번의 증가가 사라질 수 있습니다. Value가 내부에 잠금을 가지고 있어도 += 전체를 하나로 묶어 주지는 않으므로, 읽고 고치고 쓰는 과정을 직접 잠금으로 감싸야 합니다. 공유 메모리는 복사를 줄여 주지만, 안전하게 고치는 방법은 따로 마련해야 하는 셈입니다. 이 문제는 6장에서 자세히 다룹니다.
신호: 사건이 일어났음을 알리기
신호(signal)는 프로세스에 어떤 사건이 일어났음을 알리는 짧은 통지입니다. 데이터를 나르는 통로라기보다는 "종료해 달라", "잠시 멈춰라", "자식이 끝났다" 같은 알림에 가깝습니다. 표준 신호는 신호의 번호 말고는 아무것도 전하지 않고, 같은 신호가 여러 번 와도 처리되기 전이라면 한 번으로 합쳐질 수 있습니다. 실시간 신호는 작은 값을 함께 보낼 수 있고 줄을 세워 전달되지만, 그래도 데이터 전달 수단으로 쓰기에는 맞지 않습니다. 자주 쓰는 신호는 다음과 같습니다.
| 신호 | x86, ARM Linux의 번호 | 기본 동작 | 뜻 |
|---|---|---|---|
SIGTERM | 15 | 종료 | 종료 요청. kill 명령이 기본으로 보낸다 |
SIGKILL | 9 | 종료 | 강제 종료. 처리하거나 무시할 수 없다 |
SIGINT | 2 | 종료 | 터미널에서 Ctrl+C |
SIGSTOP | 19 | 멈춤 | 강제로 멈춤. 처리하거나 무시할 수 없다 |
SIGCONT | 18 | 계속 | 멈춘 프로세스를 다시 실행 |
SIGCHLD | 17 | 무시 | 자식의 상태가 바뀜 |
SIGSEGV | 11 | 코어 덤프 후 종료 | 잘못된 메모리 접근 |
SIGPIPE | 13 | 종료 | 읽는 쪽이 없는 파이프에 씀 |
신호 번호는 아키텍처와 운영체제에 따라 다를 수 있으므로, 코드에서는 번호 대신 SIGTERM 같은 이름을 씁니다. 표에서 SIGCHLD의 기본 동작이 무시라고 해서 끝난 자식이 자동으로 정리되는 것은 아닙니다. 기본 상태에서도 자식은 부모가 거둘 때까지 좀비로 남으며, SIGCHLD를 명시적으로 SIG_IGN으로 설정했을 때만 커널이 바로 정리합니다. 전체 목록은 signal(7)에 있습니다.
종료 요청을 받아 정리하고 끝내기
SIGTERM과 SIGKILL의 차이는 실무에서 중요합니다. SIGTERM은 종료해 달라는 요청이므로, 프로세스는 이 신호를 처리하는 함수를 등록해 두고 진행 중인 작업을 마무리하거나, 파일을 저장하고, 연결을 닫은 뒤 스스로 끝낼 수 있습니다. 이를 우아한 종료(graceful shutdown)라고 합니다. SIGKILL은 처리 함수를 등록할 수 없어 프로세스가 아무 정리도 하지 못한 채 끝나므로, 쓰던 데이터를 잃을 수 있습니다. 또 SIGKILL이라도 앞에서 본 D 상태처럼 커널 안에서 깨울 수 없는 대기 중인 프로세스는 그 대기가 끝날 때까지 사라지지 않습니다.
다음 Python 코드는 SIGTERM을 받으면 하던 작업을 멈추고 정리한 뒤 끝냅니다. 실제로는 다른 프로세스가 신호를 보내겠지만, 실습을 위해 signal.raise_signal()로 자기 자신에게 보냅니다.
import signal
import time
stop_signal = None
def request_stop(signum, frame):
global stop_signal
stop_signal = signum # 처리 함수는 받은 신호를 기록만 한다
signal.signal(signal.SIGTERM, request_stop)
for step in range(1, 6):
if stop_signal is not None:
print(signal.Signals(stop_signal).name, "신호를 받아 작업을 멈춤")
break
print("작업", step)
if step == 2:
signal.raise_signal(signal.SIGTERM) # 실습을 위해 스스로 보낸다
time.sleep(0.1)
print("정리를 마치고 종료")작업 1
작업 2
SIGTERM 신호를 받아 작업을 멈춤
정리를 마치고 종료처리 함수는 받은 신호를 변수에 기록만 하고, 실제로 멈추고 정리하는 일은 메인 루프가 다음 작업을 시작하기 전에 합니다. 신호는 프로그램의 어느 지점에서든 끼어들 수 있으므로, 처리 함수 안에서 복잡한 정리를 하면 메인 코드가 고치던 자료구조를 처리 함수가 건드리는 문제가 생길 수 있기 때문입니다. C에서는 이 제약이 더 엄격해서, 처리 함수 안에서는 signal-safety(7)에 나열된 함수만 불러야 합니다.
컨테이너에서도 같은 원리가 쓰입니다. docker stop은 컨테이너에 정해진 종료 신호를 보내는데, 따로 정하지 않았다면 SIGTERM입니다. 정해진 시간 안에 끝나지 않으면 SIGKILL로 강제 종료하며, 기본 대기 시간은 Linux 컨테이너가 10초, Windows 컨테이너가 30초입니다. 그래서 종료할 때 정리할 것이 있는 서비스는 SIGTERM을 처리해 이 시간 안에 정리를 마치도록 만들어야 합니다.
Unix 도메인 소켓: 같은 컴퓨터 안의 소켓
소켓(socket)은 네트워크 통신에 쓰는 인터페이스지만, 같은 컴퓨터의 프로세스끼리 통신할 때도 씁니다. 이때 쓰는 것이 Unix 도메인 소켓(Unix domain socket)으로, 네트워크 교재의 소켓 API 기본에서 다루는 socket(), bind(), connect() API를 그대로 쓰되 주소 체계를 AF_UNIX로 지정합니다. IP 주소와 포트 대신 보통 파일 시스템의 경로를 주소로 쓰며, IP 라우팅이나 TCP의 재전송 처리를 거치지 않으므로 같은 컴퓨터 안의 클라이언트와 서버를 잇는 데 알맞습니다.
Unix 도메인 소켓은 우리가 쓰는 프로그램 곳곳에 있습니다. Docker 명령줄 도구는 /var/run/docker.sock으로 Docker 데몬과 통신하고, MySQL과 PostgreSQL은 같은 컴퓨터에서 접속할 때 Unix 도메인 소켓을 쓸 수 있으며, Nginx는 PHP-FPM과 FastCGI로 통신할 때 흔히 이 소켓을 씁니다. systemd와 D-Bus도 이 소켓으로 다른 프로세스와 통신합니다.
ls -l /var/run/docker.sock # 맨 앞 글자 s는 소켓 파일을 뜻한다
ss -x | head # 시스템의 Unix 도메인 소켓 목록ls -l의 맨 앞 글자는 파일 종류이며, 소켓은 s로 표시됩니다. 소켓 파일의 권한은 누가 이 소켓에 연결할 수 있는지를 정하므로, Docker 소켓처럼 강한 권한을 가진 서비스의 소켓은 접근할 수 있는 그룹을 신중하게 정해야 합니다. Unix 도메인 소켓은 바이트 스트림인 SOCK_STREAM뿐 아니라 메시지 경계를 지키는 SOCK_DGRAM과 SOCK_SEQPACKET도 지원합니다. 또 연결한 상대 프로세스의 PID와 사용자 ID를 커널이 확인해 알려 주고, 열린 파일 디스크립터 자체를 다른 프로세스에 넘겨줄 수도 있습니다. 이런 기능은 unix(7)에 정리되어 있습니다.
어떤 방식을 고를까
지금까지 본 방법을 데이터의 단위, 쓸 수 있는 상대, 주의할 점으로 비교하면 다음과 같습니다.
| 방식 | 데이터 단위 | 쓸 수 있는 상대 | 주의할 점 | 대표 용도 |
|---|---|---|---|---|
| 익명 파이프 | 경계 없는 바이트 스트림 | 디스크립터를 물려받은 프로세스 | 쓰지 않는 끝 닫기, 프레이밍 | 셸 파이프라인 |
| FIFO | 경계 없는 바이트 스트림 | 경로를 아는 같은 컴퓨터의 프로세스 | 반대쪽이 열릴 때까지 기다림, 프레이밍 | 단순한 생산자와 소비자 |
| POSIX 메시지 큐 | 우선순위가 붙은 메시지 | 이름을 아는 같은 컴퓨터의 프로세스 | 메시지 크기와 개수 한도, 큐 지우기 | 구조화된 비동기 메시지 |
| 공유 메모리 | 함께 매핑한 메모리 영역 | 매핑 권한이 있는 같은 컴퓨터의 프로세스 | 동기화, 데이터 배치 약속 | 큰 데이터를 자주 주고받기 |
| 신호 | 신호 번호(실시간 신호는 작은 값 하나) | 신호를 보낼 권한이 있는 프로세스 | 처리 함수의 제약, 신호가 합쳐짐 | 종료와 재시작 같은 알림 |
| Unix 도메인 소켓 | 바이트 스트림 또는 메시지 | 소켓 경로에 연결할 수 있는 프로세스 | 프로토콜 설계 | 로컬 클라이언트와 서버 |
고르는 순서는 아래 그림처럼 질문을 차례로 던져 보는 방식으로 정리할 수 있습니다. 데이터 없이 사건만 알리면 되는지, 큰 데이터를 함께 볼 것인지, 바이트 스트림이면 충분한지, 클라이언트와 서버처럼 양방향으로 주고받는지, 경계 있는 메시지를 쌓아 두고 비동기로 처리할 것인지를 확인합니다.
로컬 IPC · 요구사항 질문
데이터 경계와 동기화 부담으로 IPC 후보를 좁힙니다
다음 질문은 대표 후보를 찾는 출발점입니다. 조건이 겹칠 수 있으며 신뢰 경계·복구·backpressure·운영 환경까지 확인해야 최종 선택할 수 있습니다.
-
이벤트 알림만 필요한가?
그렇다면 시그널을 사용한다. bulk data 전달 수단으로 사용하지 않는다.
-
큰 payload를 같은 영역에서 반복해서 공유할까?
그렇다면 공유 메모리를 고려하고 mutex·semaphore와 데이터 layout 규약을 함께 설계한다.
-
경계 없는 연속 바이트 스트림이면 충분한가?
그렇다면 파일 디스크립터를 공유하는 파이프나 pathname으로 접속하는 FIFO를 사용한다.
-
로컬 client-server 양방향 통신인가?
그렇다면 Unix 도메인 소켓을 사용한다.
-
경계 있는 메시지를 비동기로 큐잉할까?
그렇다면 POSIX 메시지 큐를 사용하고 priority와 크기·개수 제한을 확인한다. 아니라면 신뢰 경계, 복구와 운영 환경을 포함해 요구사항을 다시 좁힌다.
이 질문들은 후보를 좁히는 출발점일 뿐이며, 실제로는 조건이 겹치는 경우도 많습니다. 정리하면, 셸에서 프로그램을 이을 때는 파이프를, 종료 같은 알림에는 신호를, 같은 컴퓨터의 클라이언트와 서버 사이에는 Unix 도메인 소켓을, 큰 데이터를 자주 주고받을 때는 동기화 방법과 함께 공유 메모리를 먼저 검토합니다. 통신 상대가 다른 컴퓨터에 있다면 TCP 소켓이나 외부 메시지 브로커로 넘어가게 됩니다.
정리
- 프로세스는 주소 공간이 분리되어 있으므로, 데이터를 주고받으려면 운영체제가 제공하는 IPC를 써야 합니다.
- 파이프는 한 방향 바이트 스트림입니다. 쓰는 끝이 모두 닫혀야 읽는 쪽이 파일 끝을 받으므로 쓰지 않는 끝은 닫아야 하고, 메시지 경계가 없으므로 메시지를 보내려면 길이나 구분자로 프레이밍해야 합니다.
- FIFO는 이름을 가진 파이프로, 관계없는 프로세스끼리도 쓸 수 있습니다. 메시지 큐는 메시지 경계를 지키며, POSIX 메시지 큐는 우선순위가 높은 메시지를 먼저 전달합니다.
- 공유 메모리는 복사 없이 같은 메모리를 함께 쓰게 해 주지만, 읽고 고치고 쓰는 과정을 잠금 같은 방법으로 직접 보호해야 합니다.
- 신호는 사건을 알리는 짧은 통지입니다.
SIGTERM은 처리해서 정리할 수 있는 종료 요청이고,SIGKILL은 처리할 수 없는 강제 종료입니다. - Unix 도메인 소켓은 같은 컴퓨터 안의 클라이언트와 서버를 소켓 API로 잇습니다.
연습 문제
1. 빈 파이프와 남아 있는 쓰기 끝
파이프의 버퍼가 비어 있고, 받는 프로세스는 블로킹 read()로 파일 끝까지 읽으려 합니다. 부모는 쓰는 끝을 닫았지만, fork()로 그 끝을 물려받은 다른 자식은 아직 닫지 않았습니다. 받는 쪽의 다음 read()는 바로 0을 돌려줄까요? 반대로 쓰는 끝이 모두 닫혔지만 버퍼에 XYZ가 남아 있다면, 다음 read()는 바로 0을 돌려줄까요?
해설 보기
첫째 경우에는 쓰는 끝이 아직 하나 열려 있으므로, 버퍼가 비어 있어도 read()는 0을 돌려주지 않고 데이터가 오기를 기다립니다. 부모 한 곳에서 닫았다고 파이프 전체의 쓰기가 끝난 것이 아니며, 그 디스크립터를 가진 모든 프로세스가 닫아야 합니다. 둘째 경우에는 남은 XYZ를 먼저 읽고, 데이터를 다 읽은 다음의 read()가 0을 돌려줍니다. 파일 끝은 쓰는 끝이 모두 닫히고, 그리고 버퍼도 비었을 때 옵니다. 파이프를 다룰 때는 누가 어느 끝을 가지고 있는지와 버퍼에 무엇이 남았는지를 함께 따져야 합니다.
2. 사라진 한 번의 증가
공유 메모리에 있는 카운터의 값이 42입니다. 프로세스 A와 B가 잠금 없이 각각 counter += 1을 실행했는데, 최종 값이 44가 아니라 43이 되었습니다. 어떤 순서로 실행되었을 때 이런 결과가 나오는지 단계별로 설명하고, 공유 메모리가 복사를 줄여 준다는 사실과 이 문제가 어떤 관계인지 설명하세요.
해설 보기
counter += 1은 값을 읽고, 1을 더하고, 결과를 쓰는 세 단계입니다. A가 42를 읽고, A가 쓰기 전에 B도 42를 읽습니다. A가 43을 쓰고, B도 자기가 계산한 43을 씁니다. 두 번 증가했지만 B가 A의 결과를 덮어써서 한 번의 증가가 사라졌습니다. 공유 메모리가 복사를 줄여 준다는 것은 데이터를 옮기는 비용에 대한 이야기이고, 여러 프로세스가 같은 값을 동시에 고칠 때 결과가 맞는지는 별개의 문제입니다. 두 증가를 모두 지키려면 읽고 더하고 쓰는 세 단계를 잠금으로 감싸, 한 프로세스가 끝날 때까지 다른 프로세스가 끼어들지 못하게 해야 합니다.
3. 상황에 맞는 IPC 고르기
다음 세 상황에 알맞은 IPC 방법을 고르고 이유를 설명하세요. 첫째, 설정 파일을 다시 읽으라고 실행 중인 데몬에게 알리고 싶습니다. 둘째, 같은 서버에서 영상 처리 프로세스가 1초에 30번씩 수 MB 크기의 프레임을 분석 프로세스에 넘겨야 합니다. 셋째, 같은 서버의 여러 클라이언트 프로그램이 하나의 서비스 데몬에 요청을 보내고 각자 응답을 받아야 합니다.
해설 보기
첫째는 데이터 없이 사건만 알리면 되므로 신호가 알맞습니다. 많은 데몬이 SIGHUP을 받으면 설정을 다시 읽도록 만들어져 있습니다. 둘째는 큰 데이터를 자주 넘겨야 하므로 공유 메모리가 알맞습니다. 프레임마다 커널 버퍼로 복사하는 비용을 줄일 수 있지만, 분석 프로세스가 읽는 중인 프레임을 영상 처리 프로세스가 덮어쓰지 않도록 버퍼를 여러 개 두고 어느 버퍼가 준비되었는지 알리는 동기화 약속이 필요합니다. 셋째는 여러 클라이언트가 한 서버에 연결해 양방향으로 주고받는 구조이므로 Unix 도메인 소켓이 알맞습니다. 클라이언트마다 연결이 따로 생겨 응답이 섞이지 않고, 소켓 파일의 권한으로 누가 연결할 수 있는지도 정할 수 있습니다.
다음 장의 스레드의 개념에서는 프로세스 안에서 주소 공간을 함께 쓰며 실행되는 더 가벼운 실행 단위인 스레드를 다룹니다.