[CS300 #122] 프로세스 간 통신 — 파이프·공유 메모리·소켓
컴퓨터공학 300 주제 시리즈의 122번째 글이다. 전체 지도는 여기.
한 줄 요약
프로세스는 서로의 메모리를 볼 수 없다. 그래서 데이터를 주고받으려면 커널이 마련한 통로(IPC)를 써야 하고, 대표적인 통로가 파이프, 공유 메모리, 소켓이다. 셋은 복사 횟수, 동기화 책임, 기계 경계를 넘을 수 있는지가 다르다.
왜 필요한가
운영체제는 프로세스마다 독립된 가상 주소 공간을 준다. 한 프로세스가 잘못된 포인터를 써도 다른 프로세스는 멀쩡하다. 이 격리 덕분에 시스템이 안정적이다.
하지만 격리는 협력을 어렵게 만든다. 웹 서버는 워커 프로세스에 요청을 나눠 줘야 하고, 데이터베이스는 클라이언트와 대화해야 하고, 브라우저는 탭 프로세스와 GPU 프로세스가 화면을 주고받아야 한다. 여기서 어떤 IPC 를 고르느냐가 성능과 복잡도를 함께 결정한다.
그리고 소켓은 같은 기계 안의 IPC 이면서 동시에 다른 기계와의 통신 수단이다. “한 대에서 여러 대로” 가는 다리가 바로 여기다.
핵심 개념
두 가지 모델: 메시지 전달과 공유 메모리
| 모델 | 방식 | 장점 | 단점 |
|---|---|---|---|
| 메시지 전달 | 커널을 거쳐 바이트를 복사 | 동기화가 단순, 격리 유지 | 복사 비용, 시스템 콜 비용 |
| 공유 메모리 | 같은 물리 페이지를 양쪽 주소 공간에 매핑 | 복사 없음, 가장 빠름 | 동기화를 직접 해야 함 |
파이프와 소켓은 메시지 전달, 공유 메모리는 이름 그대로다.
파이프와 FIFO
익명 파이프(pipe())는 단방향 바이트 스트림이다. 부모가 만들어 fork 로 자식에게 물려줘야 하므로 관련 있는 프로세스끼리만 쓴다. 이름 있는 파이프(FIFO, mkfifo)는 파일 시스템에 경로가 있어서 관련 없는 프로세스도 열 수 있다. 둘 다 같은 기계 안에서만 동작한다.
공유 메모리
POSIX 공유 메모리는 shm_open() 으로 이름 있는 객체를 만들고 ftruncate() 로 크기를 정한 뒤 mmap() 으로 주소 공간에 붙인다. Linux 에서는 이 객체가 /dev/shm 아래 tmpfs 파일로 보인다.
프로세스 A 가상 주소 물리 메모리 프로세스 B 가상 주소
0x7f..1000 ------------> [ 페이지 ] <------------ 0x7f..9000
같은 페이지를 두 프로세스가 서로 다른 주소로 본다. 한쪽이 쓰면 다른 쪽이 즉시 볼 수 있다. 대신 “언제 다 썼는지” 를 알릴 방법이 없다. 세마포어, 뮤텍스(프로세스 공유 속성), 혹은 다른 IPC 로 신호를 보내야 한다. 동기화 없이 쓰면 경쟁 상태가 생긴다.
소켓
소켓은 통신 끝점을 나타내는 fd 다. 주소 체계(domain)와 타입으로 종류가 갈린다.
| 조합 | 쓰임 |
|---|---|
AF_UNIX + SOCK_STREAM |
같은 기계, 연결형 스트림. Docker·containerd·PostgreSQL 로컬 접속 |
AF_UNIX + SOCK_DGRAM |
같은 기계, 메시지 경계 유지. syslog 계열 |
AF_INET/AF_INET6 + SOCK_STREAM |
TCP. 다른 기계와 통신 |
AF_INET + SOCK_DGRAM |
UDP |
유닉스 도메인 소켓은 파일 시스템 경로를 주소로 쓰고, 네트워크 스택을 거치지 않아 같은 기계 안에서는 TCP 루프백보다 가볍다. 또 SCM_RIGHTS 로 열린 fd 자체를 다른 프로세스에 넘기거나, SO_PEERCRED 로 상대 프로세스의 PID·UID 를 확인할 수 있다. TCP 로는 할 수 없는 일이다.
프로그래밍 모델은 같다. socket → bind → listen → accept 와 socket → connect. 그래서 처음에 유닉스 소켓으로 짠 서비스를 나중에 TCP 로 바꾸는 일이 어렵지 않다.
그 밖의 IPC
- 시그널: 데이터 없이 사건만 알린다. 다음 글에서 다룬다.
- 메시지 큐(POSIX
mq_open): 커널이 메시지 경계와 우선순위를 유지한다. - eventfd, 파일 잠금, mmap 한 일반 파일 등도 있다.
고르는 기준
- 다른 기계와 통신해야 하나? 그렇다면 소켓 말고는 답이 없다.
- 데이터가 크고 빈번하며 지연에 민감한가? 공유 메모리를 고려한다. 동기화 비용을 감수할 수 있을 때만.
- 부모-자식 간 단순 스트림인가? 파이프가 가장 간단하다.
- 같은 기계의 서비스 간 요청-응답인가? 유닉스 도메인 소켓이 무난하다.
직접 해 보기
파이썬 표준 라이브러리로 세 가지를 한 번에 써 본다.
import os, socket
from multiprocessing import Process, shared_memory
# 1) 파이프
r, w = os.pipe()
if os.fork() == 0:
os.close(r); os.write(w, b"hello via pipe"); os._exit(0)
os.close(w); print(os.read(r, 100)); os.wait()
# 2) 공유 메모리
def writer(name):
shm = shared_memory.SharedMemory(name=name)
shm.buf[:5] = b"HELLO"
shm.close()
shm = shared_memory.SharedMemory(create=True, size=16)
p = Process(target=writer, args=(shm.name,)); p.start(); p.join()
print(bytes(shm.buf[:5])) # join 이 '다 썼다' 는 동기화 역할
shm.close(); shm.unlink()
# 3) 유닉스 도메인 소켓 쌍
a, b = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM)
if os.fork() == 0:
a.close(); b.sendall(b"hello via unix socket"); os._exit(0)
b.close(); print(a.recv(100)); os.wait()
출력은 세 줄이다.
b'hello via pipe'
b'HELLO'
b'hello via unix socket'
공유 메모리 예제에서 p.join() 을 빼면 부모가 아직 쓰이지 않은 0 바이트를 읽을 수 있다. 공유 메모리는 동기화를 대신해 주지 않는다는 것을 직접 확인할 수 있다.
현업에서는
- 컨테이너 런타임.
kubelet은 containerd 와 유닉스 도메인 소켓 위의 gRPC(CRI)로 대화한다.dockerCLI 도 기본적으로/var/run/docker.sock으로 데몬에 접속한다. 이 소켓 파일의 권한이 곧 루트 권한이라는 점이 보안 점검 단골 항목이다. - 데이터베이스. PostgreSQL 은 같은 기계 접속에 유닉스 소켓을 쓸 수 있고, 백엔드 프로세스들은 공유 버퍼를 공유 메모리로 나눠 쓴다. 컨테이너에서
/dev/shm이 작게 잡혀 PostgreSQL 이나 크롬이 실패하는 사례가 자주 보고된다. - 사이드카. 같은 파드 안의 컨테이너들은 네트워크 네임스페이스를 공유하므로
localhostTCP 나 공유 볼륨 위의 유닉스 소켓으로 대화한다. - 데이터 과학. 파이썬 멀티프로세싱에서 큰 배열을 피클로 넘기면 복사 비용이 크다.
shared_memory나 메모리 매핑 파일로 바꾸면 수 배 빨라지는 경우가 많다.
확인 문제
- 공유 메모리가 파이프보다 빠른 근본 이유와, 그 대가는 무엇인가?
- 익명 파이프와 FIFO 의 차이는?
- 유닉스 도메인 소켓만 할 수 있고 TCP 소켓은 할 수 없는 일 두 가지를 들어라.
- 서로 다른 두 서버의 프로세스가 통신하려면 어떤 IPC 를 써야 하는가?
풀이
- 커널을 거친 복사 없이 같은 물리 페이지를 공유하기 때문이다. 대가는 동기화를 프로그램이 직접 책임져야 한다는 것이다.
- 익명 파이프는 이름이 없어 fd 를 물려받은 관련 프로세스끼리만 쓰고, FIFO 는 파일 시스템 경로가 있어 관련 없는 프로세스도 열 수 있다.
SCM_RIGHTS로 fd 넘기기,SO_PEERCRED로 상대 자격 증명 확인. 파일 권한으로 접근 제어하는 것도 있다.- 네트워크 소켓(TCP/UDP)이다. 파이프와 공유 메모리는 기계 경계를 넘지 못한다.