현대 리눅스 IPC 메커니즘
Linux 커널의 현대적 IPC 메커니즘을 심층 분석합니다. eventfd/signalfd/timerfd를 epoll과 통합한 fd 기반 이벤트 처리, Netlink 소켓(Socket)을 통한 커널-유저 공간 제어 채널, Unix Domain Socket의 고성능 로컬 통신, process_vm_readv()/process_vm_writev()를 사용한 Cross Memory Attach, memfd_create()의 익명 공유 메모리와 sealing, pidfd의 레이스 프리 프로세스(Process) 관리, 그리고 Android Binder의 단일 복사 RPC까지 다룹니다.
핵심 요약
- eventfd / signalfd / timerfd — 다양한 이벤트 소스를 파일 디스크립터(File Descriptor)로 추상화하여 epoll과 통합할 수 있는 경량 메커니즘입니다.
- epoll — 수만~수십만 개의 fd를 효율적으로 모니터링하는 Linux 전용 이벤트 다중화(Multiplexing) 인터페이스입니다.
- Netlink — 커널과 유저 공간 간 구조화된 메시지를 교환하는 소켓 기반 제어 채널입니다.
- memfd_create — 파일시스템(Filesystem) 경로 없이 RAM 기반 익명 공유 메모리를 생성하며, sealing으로 불변성을 보장합니다.
- pidfd — PID 재사용 레이스를 근본적으로 해결하는 파일 디스크립터 기반 프로세스 참조입니다.
단계별 이해
- fd 기반 이벤트 통합 — eventfd, signalfd, timerfd를 하나의 epoll 루프에 등록하면 단일 스레드(Thread)에서 모든 이벤트를 비동기적으로 처리할 수 있습니다.
- epoll 이벤트 루프(Event Loop) —
epoll_create1()으로 인스턴스를 생성하고,epoll_ctl()로 fd를 등록한 뒤,epoll_wait()로 이벤트를 대기합니다. - Netlink 활용 —
ip,ss,tc등의 네트워크 도구가 내부적으로 Netlink를 사용합니다.AF_NETLINK소켓으로 커널 서브시스템과 직접 통신할 수 있습니다. - 안전한 공유 메모리 —
memfd_create()+ sealing으로 공유 메모리의 불변성을 커널 수준에서 보장받을 수 있습니다.
eventfd / signalfd / timerfd
Linux는 다양한 이벤트 소스를 파일 디스크립터로 추상화하여 epoll/select/poll과 통합할 수 있는 fd 기반 IPC를 제공합니다.
| fd 유형 | 용도 | 핵심 구조체(Struct) |
|---|---|---|
eventfd |
프로세스/스레드 간 이벤트 카운터 | struct eventfd_ctx |
signalfd |
시그널을 fd로 수신 | struct signalfd_ctx |
timerfd |
타이머(Timer) 만료를 fd로 통지 | struct timerfd_ctx |
eventfd — 개념과 기초
이벤트 알림에 pipe를 쓰면 왜 불편할까요? 전통적으로 스레드나 프로세스 간 "작업 완료"를 알리려면 pipe를 사용했습니다. 그런데 pipe는 바이트 스트림 채널이라 이벤트 개수를 누적하거나 세마포어(Semaphore)처럼 동작시키려면 별도 로직이 필요하고, 한 방향에 fd가 두 개(읽기/쓰기)씩 필요합니다. 이벤트가 여러 번 발생해도 "알림 한 번"으로 충분한 경우에 pipe는 낭비입니다.
eventfd는 커널 내부에 64비트 카운터 하나를 두고 이를 단일 파일 디스크립터로 노출합니다. 이벤트 발생 측은 write(8바이트)로 카운터를 증가시키고, 대기 측은 read(8바이트)로 카운터 값을 읽으면서 동시에 카운터가 0으로 리셋됩니다. epoll/poll/select에 바로 등록할 수 있고, pipe 대비 커널 메모리 사용량이 훨씬 적습니다.
| 항목 | pipe | eventfd |
|---|---|---|
| fd 수 | 2개 (읽기 + 쓰기) | 1개 |
| 커널 버퍼 | 기본 65,536 바이트 | 64비트 카운터만 (~100 B) |
| 이벤트 누적 | 별도 프로토콜 필요 | 카운터로 자동 누적 |
| 세마포어 모드 | 불가 | EFD_SEMAPHORE 플래그 |
| 주 용도 | 데이터/스트림 전달 | 순수 이벤트 알림/카운팅 |
/* fs/eventfd.c */
struct eventfd_ctx {
struct kref kref;
wait_queue_head_t wqh; /* 대기 큐 */
__u64 count; /* 이벤트 카운터 */
unsigned int flags; /* EFD_SEMAPHORE 등 */
int id;
};
/* eventfd 사용 예 */
int efd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC);
/* 이벤트 발생 (카운터 증가) */
uint64_t val = 1;
write(efd, &val, sizeof(val));
/* 이벤트 수신 (카운터 읽고 0으로 리셋) */
uint64_t cnt;
read(efd, &cnt, sizeof(cnt));
KVM과 eventfd: KVM은 ioeventfd와 irqfd를 통해 eventfd를 활용합니다. 게스트 VM의 I/O 포트 접근이 ioeventfd를 통해 호스트의 유저 공간 에뮬레이터(QEMU)에 통지되고, irqfd는 호스트에서 게스트에 인터럽트(Interrupt)를 주입하는 데 사용됩니다.
signalfd — 전통 시그널 핸들러의 문제점
전통적인 시그널 핸들러는 프로세스 실행 흐름을 언제든지 중단하고 핸들러를 실행합니다. 이 때문에 핸들러 안에서는 "재진입 안전(async-signal-safe)" 함수만 호출할 수 있습니다. printf(), malloc(), mutex_lock() 같은 일상적인 함수들은 내부 락(Lock)이나 전역 상태를 사용하기 때문에 핸들러 안에서 호출하면 교착(Deadlock)이나 메모리 오염이 발생할 수 있습니다.
또한 시그널은 멀티스레드 환경에서 어느 스레드로 전달될지 예측하기 어렵고, epoll 루프와 통합하려면 "self-pipe trick"(시그널 핸들러에서 pipe에 1바이트 쓰기)이라는 우회책이 필요했습니다.
| 문제점 | 전통 시그널 핸들러 | signalfd 해결책 |
|---|---|---|
| 함수 호출 제약 | async-signal-safe 함수만 허용 | 일반 코드처럼 자유롭게 처리 |
| epoll 통합 | self-pipe trick 필요 | fd 직접 등록 |
| 멀티스레드 | 전달 스레드 예측 불가 | read()하는 특정 스레드만 수신 |
| 시그널 정보 | 핸들러 인자 siginfo_t | read()로 128바이트 구조체 수신 |
self-pipe trick: signalfd 이전에는 epoll과 시그널을 함께 처리하려면 pipe를 만들고, 시그널 핸들러 안에서 write(pipe[1], "x", 1)를 호출한 뒤, epoll은 pipe[0]를 감시하는 방법을 사용했습니다. 코드가 복잡하고 시그널 핸들러 안에서 write()가 async-signal-safe인지 확인해야 하는 부담이 있었습니다. signalfd는 이 우회책을 완전히 대체합니다.
signalfd 커널 내부 구조
signalfd는 전통적인 시그널 핸들러(Handler) 대신 파일 디스크립터를 통해 시그널을 동기적으로 수신할 수 있게 해주는 메커니즘입니다. 시그널을 fd로 변환함으로써 epoll과 통합할 수 있고, 시그널 핸들러의 재진입성(Reentrancy) 문제를 근본적으로 회피합니다.
/* fs/signalfd.c */
struct signalfd_ctx {
sigset_t sigmask; /* 감시 대상 시그널 마스크 */
};
/* signalfd의 read()가 반환하는 구조체 */
struct signalfd_siginfo {
__u32 ssi_signo; /* 시그널 번호 */
__s32 ssi_errno; /* 에러 번호 */
__s32 ssi_code; /* 시그널 코드 (SI_USER, SI_QUEUE 등) */
__u32 ssi_pid; /* 송신자 PID */
__u32 ssi_uid; /* 송신자 UID */
__s32 ssi_fd; /* SIGIO용 fd */
__u32 ssi_tid; /* 송신자 TID */
__u64 ssi_ptr; /* sigqueue의 값 포인터 */
__u64 ssi_int; /* sigqueue의 정수 값 */
/* ... 기타 필드 (128바이트 고정 크기) */
};
signalfd의 read()는 내부적으로 dequeue_signal()을 호출하여 task_struct->pending(개별 대기 시그널)과 signal->shared_pending(프로세스 그룹 공유 시그널)에서 시그널을 꺼냅니다. 시그널이 없으면 poll_wait()를 통해 대기 큐(Wait Queue)에 등록되어 시그널 도착 시 깨어납니다.
#include <sys/signalfd.h>
#include <sys/epoll.h>
#include <signal.h>
#include <unistd.h>
#include <stdio.h>
int main(void) {
sigset_t mask;
/* 1. 시그널 블록 — 기본 핸들러 억제 */
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGTERM);
sigprocmask(SIG_BLOCK, &mask, NULL);
/* 2. signalfd 생성 */
int sfd = signalfd(-1, &mask, SFD_NONBLOCK | SFD_CLOEXEC);
/* 3. epoll에 등록 */
int epfd = epoll_create1(EPOLL_CLOEXEC);
struct epoll_event ev = { .events = EPOLLIN, .data.fd = sfd };
epoll_ctl(epfd, EPOLL_CTL_ADD, sfd, &ev);
/* 4. 이벤트 루프 */
struct epoll_event events[8];
for (;;) {
int n = epoll_wait(epfd, events, 8, -1);
for (int i = 0; i < n; i++) {
struct signalfd_siginfo si;
read(events[i].data.fd, &si, sizeof(si));
printf("시그널 %d 수신 (PID=%u)\n", si.ssi_signo, si.ssi_pid);
if (si.ssi_signo == SIGTERM)
return 0;
}
}
}
| 비교 항목 | 전통 시그널 핸들러 | signalfd |
|---|---|---|
| 처리 방식 | 비동기 (핸들러 인터럽트) | 동기 (read/epoll) |
| 재진입성 | 핸들러 내 async-signal-safe 함수만 허용 | 일반 코드처럼 안전하게 처리 |
| epoll 통합 | 불가 (self-pipe trick 필요) | 직접 통합 가능 |
| 시그널 정보 | siginfo_t (핸들러 인자) |
signalfd_siginfo (128바이트) |
| 멀티스레드 | 핸들러 전달 스레드 예측 어려움 | 특정 스레드에서 read()로 수신 |
timerfd — 기존 타이머 API의 한계
전통적인 타이머 API인 alarm()/SIGALRM과 setitimer()는 모두 시그널로 만료를 통지합니다. 시그널 기반 통지는 async-signal-safe 제약, 멀티스레드 전달 불예측, epoll 통합 불가 등 앞서 설명한 문제를 그대로 안고 있습니다. 더 심각한 것은 프로세스 전체에 딱 하나의 SIGALRM 타이머만 존재할 수 있다는 점입니다.
timerfd는 커널의 고해상도 타이머(hrtimer)를 파일 디스크립터로 노출하여 이 모든 문제를 해결합니다. 수십 개의 타이머를 각각 독립적인 fd로 관리하고, 하나의 epoll_wait()에서 모두 모니터링할 수 있습니다.
| 항목 | alarm / SIGALRM | setitimer | timerfd |
|---|---|---|---|
| 해상도 | 초(second) 단위 | 마이크로초 | 나노초 (hrtimer) |
| 동시 타이머 수 | 1개/프로세스 | 3개 (REAL/VIRTUAL/PROF) | 제한 없음 |
| 만료 통지 방식 | SIGALRM | SIGALRM/SIGVTALRM/SIGPROF | fd readable (epoll 통합) |
| 멀티스레드 안전 | 불안전 | 불안전 | 안전 (fd 공유 가능) |
| 누적 만료 횟수 | 없음 | 없음 | read() 반환값으로 확인 |
| 절전 통과 | 멈춤 (MONOTONIC) | 멈춤 | CLOCK_BOOTTIME으로 가능 |
timerfd 커널 내부 구조
timerfd는 커널의 고해상도 타이머(hrtimer)를 파일 디스크립터로 노출하여 epoll 기반 이벤트 루프에서 타이머를 통합 관리할 수 있게 합니다.
/* fs/timerfd.c */
struct timerfd_ctx {
union {
struct hrtimer tmr; /* 고해상도 타이머 */
struct alarm alarm; /* CLOCK_REALTIME_ALARM용 */
} t;
ktime_t moffs; /* CLOCK_REALTIME 보정값 */
wait_queue_head_t wqh; /* 대기 큐 */
u64 ticks; /* 만료 횟수 카운터 */
int clockid; /* 클럭 소스 ID */
short unsigned expired; /* 만료 상태 플래그 */
short unsigned settime_flags; /* TFD_TIMER_ABSTIME 등 */
struct rcu_head rcu;
struct list_head clist; /* cancel list */
spinlock_t cancel_lock;
bool might_cancel;
};
| 클럭 소스 | 기준 | 절전 모드(Suspend) | 시간 변경 영향 | 용도 |
|---|---|---|---|---|
CLOCK_MONOTONIC |
부팅 시점 | 멈춤 | 없음 | 일반 타이머, 성능 측정 |
CLOCK_REALTIME |
UTC 에포크(Epoch) | 멈춤 | 영향 받음 (NTP 등) | 절대 시각 기반 스케줄링 |
CLOCK_BOOTTIME |
부팅 시점 | 계속 진행 | 없음 | 절전 포함 경과 시간 |
CLOCK_REALTIME_ALARM |
UTC 에포크 | 시스템 깨움 | 영향 받음 | 알람 (Android wake alarm) |
TFD_TIMER_ABSTIME 플래그를 사용하면 절대 시각 기반으로 타이머를 설정할 수 있습니다. 이 플래그 없이는 상대 시간(현재로부터의 경과 시간)으로 해석됩니다.
#include <sys/timerfd.h>
#include <sys/epoll.h>
#include <stdint.h>
#include <stdio.h>
#include <unistd.h>
int main(void) {
int epfd = epoll_create1(EPOLL_CLOEXEC);
/* --- 원샷(One-shot) 타이머: 3초 후 1회 발동 --- */
int tfd1 = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC);
struct itimerspec one = {
.it_value = { .tv_sec = 3, .tv_nsec = 0 },
.it_interval = { .tv_sec = 0, .tv_nsec = 0 } /* interval=0 → 원샷 */
};
timerfd_settime(tfd1, 0, &one, NULL);
/* --- 주기적(Periodic) 타이머: 1초마다 반복 --- */
int tfd2 = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC);
struct itimerspec periodic = {
.it_value = { .tv_sec = 1, .tv_nsec = 0 },
.it_interval = { .tv_sec = 1, .tv_nsec = 0 } /* 1초마다 반복 */
};
timerfd_settime(tfd2, 0, &periodic, NULL);
/* epoll에 둘 다 등록 */
struct epoll_event ev1 = { .events = EPOLLIN, .data.fd = tfd1 };
struct epoll_event ev2 = { .events = EPOLLIN, .data.fd = tfd2 };
epoll_ctl(epfd, EPOLL_CTL_ADD, tfd1, &ev1);
epoll_ctl(epfd, EPOLL_CTL_ADD, tfd2, &ev2);
/* 이벤트 루프 */
struct epoll_event events[4];
for (int count = 0; count < 10; ) {
int n = epoll_wait(epfd, events, 4, -1);
for (int i = 0; i < n; i++) {
uint64_t ticks;
read(events[i].data.fd, &ticks, sizeof(ticks));
if (events[i].data.fd == tfd1)
printf("원샷 타이머 만료! (ticks=%lu)\n", ticks);
else
printf("주기 타이머 #%d (ticks=%lu)\n", ++count, ticks);
}
}
close(tfd1); close(tfd2); close(epfd);
}
커널 내부 흐름: timerfd_settime()은 hrtimer_start()를 호출하여 고해상도 타이머를 등록합니다. 타이머 만료 시 timerfd_tmrproc() 콜백(Callback)이 실행되어 ticks 카운터를 증가시키고 wake_up()으로 대기 중인 epoll_wait()/read() 호출자를 깨웁니다. read()는 누적된 ticks 값을 반환하고 카운터를 0으로 리셋합니다.
epoll + fd 통합 이벤트 루프 패턴
eventfd, signalfd, timerfd를 포함한 다양한 fd 소스를 하나의 epoll 인스턴스로 통합하면 단일 이벤트 루프에서 모든 I/O와 이벤트를 처리할 수 있습니다.
epoll: 확장 가능한 I/O 이벤트 다중화(Multiplexing)
epoll은 Linux 전용 I/O 이벤트 알림 인터페이스로, select()/poll()의 확장성 한계를 극복하기 위해 Linux 2.5.44(2002)에 도입되었습니다. 수만~수십만 개의 파일 디스크립터를 효율적으로 모니터링할 수 있으며, Nginx, HAProxy, Redis, Node.js 등 고성능 네트워크 서버의 핵심 이벤트 루프 기반입니다.
select/poll의 한계와 epoll의 차별점
| 비교 항목 | select | poll | epoll |
|---|---|---|---|
| fd 상한 | FD_SETSIZE (1024) |
제한 없음 (배열 크기) | 제한 없음 (/proc/sys/fs/epoll/max_user_watches) |
| fd 전달 방식 | 매 호출마다 전체 fd_set 복사 | 매 호출마다 전체 pollfd 배열 복사 | 커널이 관심 fd를 영속 관리 (epoll_ctl로 1회 등록) |
| 이벤트 검사 | O(n) — 전체 fd 순회 | O(n) — 전체 배열 순회 | O(1) — ready list에서 즉시 반환 |
| Edge-Triggered | 불가 | 불가 | 지원 (EPOLLET) |
| 스레드 안전 | 불가 (fd_set 공유 불가) | 제한적 | EPOLLEXCLUSIVE로 thundering herd 방지 |
O(1) 이벤트 전달: select/poll은 호출마다 커널이 등록된 모든 fd를 순회하며 이벤트를 확인합니다. fd가 10만 개여도 이벤트가 발생한 fd가 10개뿐이면, select/poll은 10만 번 검사하지만 epoll_wait은 ready list에서 10개만 반환합니다.
epoll API
#include <sys/epoll.h>
/* 1. epoll 인스턴스 생성 — 커널에 struct eventpoll 할당 */
int epfd = epoll_create1(EPOLL_CLOEXEC);
/* epoll_create(size)는 폐기 예정 — size 인자 무시됨 */
/* 2. 관심 fd 등록/수정/삭제 */
struct epoll_event ev = {
.events = EPOLLIN | EPOLLET, /* 이벤트 마스크 */
.data.fd = client_fd /* 사용자 데이터 (union) */
};
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev); /* 등록 */
epoll_ctl(epfd, EPOLL_CTL_MOD, client_fd, &ev); /* 수정 */
epoll_ctl(epfd, EPOLL_CTL_DEL, client_fd, NULL); /* 삭제 */
/* 3. 이벤트 대기 — ready 상태인 fd만 반환 */
struct epoll_event events[64];
int nfds = epoll_wait(epfd, events, 64, -1); /* -1 = 무한 대기 */
for (int i = 0; i < nfds; i++) {
if (events[i].events & EPOLLIN)
handle_read(events[i].data.fd);
}
커널 내부 자료구조
epoll은 세 가지 핵심 자료구조의 조합으로 동작합니다: Red-Black Tree(관심 fd 관리), Ready List(이벤트 발생 fd), Wait Queue(epoll_wait 호출자 대기).
/* fs/eventpoll.c */
struct eventpoll {
rwlock_t lock; /* rdllist/ovflist 보호 */
struct mutex mtx; /* epoll_ctl 직렬화 */
wait_queue_head_t wq; /* epoll_wait() 대기 큐 */
wait_queue_head_t poll_wait; /* epoll fd 자체가 poll 될 때 */
struct list_head rdllist; /* ★ ready list (이벤트 발생 fd) */
struct rb_root_cached rbr; /* ★ rbtree (등록된 전체 fd) */
struct epitem *ovflist; /* 전송 중 오버플로 리스트 */
struct wakeup_source *ws; /* EPOLLWAKEUP용 */
struct user_struct *user; /* 리소스 제한 추적 */
struct file *file; /* epoll fd의 struct file */
struct hlist_head refs; /* 중첩 epoll 참조 */
unsigned int napi_id; /* busy poll NAPI ID */
};
/* 등록된 각 fd를 나타내는 노드 */
struct epitem {
union {
struct rb_node rbn; /* rbtree 노드 */
struct rcu_head rcu; /* RCU 해제용 */
};
struct list_head rdllink; /* ready list 링크 */
struct epitem *next; /* ovflist 체인 */
struct epoll_filefd ffd; /* {file *, fd} 쌍 */
struct eppoll_entry *pwqlist; /* poll wait queue 리스트 */
struct eventpoll *ep; /* 소속 eventpoll */
struct epoll_event event; /* 사용자 이벤트 + 데이터 */
};
이벤트 전달 흐름
epoll의 핵심은 콜백(Callback) 기반 이벤트 전달입니다. fd 등록 시 해당 파일의 wait queue에 콜백(ep_poll_callback)을 설치하여, 이벤트 발생 시 커널이 자동으로 ready list에 추가합니다.
/* fs/eventpoll.c — 핵심 흐름 요약 */
/* ① epoll_ctl(EPOLL_CTL_ADD): fd 등록 */
static int ep_insert(struct eventpoll *ep, struct epoll_event *event,
struct file *tfile, int fd)
{
struct epitem *epi;
epi = kmem_cache_zalloc(epi_cache, GFP_KERNEL);
/* rbtree에 삽입 (fd + file 포인터로 정렬) */
ep_rbtree_insert(ep, epi);
/* 대상 fd의 wait queue에 콜백 등록 */
init_poll_funcptr(&epq.pt, ep_ptable_queue_proc);
revents = ep_item_poll(epi, &epq.pt, 1);
/* → 내부에서 file->poll()을 호출하여 */
/* ep_poll_callback을 wait queue에 설치 */
/* 이미 ready 상태이면 즉시 ready list에 추가 */
if (revents && !ep_is_linked(epi)) {
list_add_tail(&epi->rdllink, &ep->rdllist);
wake_up(&ep->wq); /* epoll_wait 대기자 깨우기 */
}
}
/* ② 이벤트 발생 시: 드라이버가 wake_up() → ep_poll_callback 호출 */
static int ep_poll_callback(wait_queue_entry_t *wait,
unsigned mode, int sync, void *key)
{
struct epitem *epi = ep_item_from_wait(wait);
struct eventpoll *ep = epi->ep;
__poll_t pollflags = (__poll_t)(unsigned long)key;
/* 이벤트 마스크 확인 */
if (pollflags && !(pollflags & epi->event.events))
return 0; /* 관심 없는 이벤트 무시 */
/* ready list에 추가 (중복 방지: rdllink가 이미 연결되어 있으면 스킵) */
if (!ep_is_linked(epi))
list_add_tail(&epi->rdllink, &ep->rdllist);
/* epoll_wait()에서 대기 중인 태스크 깨우기 */
wake_up(&ep->wq);
return 1;
}
/* ③ epoll_wait(): ready list에서 이벤트 수확 */
static int ep_poll(struct eventpoll *ep, struct epoll_event *events,
int maxevents, struct timespec64 *timeout)
{
/* ready list가 비어있으면 wq에서 대기 */
if (list_empty(&ep->rdllist))
schedule_hrtimeout_range(timeout, ...);
/* ready list → 사용자 공간 epoll_event 배열로 복사 */
return ep_send_events(ep, events, maxevents);
}
Level-Triggered vs Edge-Triggered
epoll의 두 가지 트리거 모드는 이벤트 재전달 정책의 차이입니다.
| 모드 | 플래그 | 동작 | 특징 |
|---|---|---|---|
| LT (Level-Triggered) | 기본값 | 조건이 유지되는 동안 epoll_wait가 반복 반환 |
프로그래밍 용이, select/poll과 동일 의미론 |
| ET (Edge-Triggered) | EPOLLET |
상태 변화 시 한 번만 반환 | 높은 성능, 반드시 비차단(Non-blocking) + EAGAIN까지 읽기 |
/* fs/eventpoll.c — ep_send_events_proc() 내 LT/ET 분기 */
static __poll_t ep_send_events_proc(struct eventpoll *ep,
struct list_head *txlist, ...)
{
struct epitem *epi;
list_for_each_entry_safe(epi, tmp, txlist, rdllink) {
/* ready list에서 분리 */
list_del_init(&epi->rdllink);
/* 실제 이벤트 재확인 (poll 호출) */
revents = ep_item_poll(epi, &pt, 1);
if (!revents)
continue;
/* 사용자 공간으로 이벤트 전달 */
__put_user(revents, &uevent->events);
__put_user(epi->event.data, &uevent->data);
/* ★ LT 모드: 이벤트가 아직 유효하면 ready list에 재삽입 */
if (!(epi->event.events & EPOLLET))
list_add_tail(&epi->rdllink, &ep->rdllist);
}
}
ET 모드 필수 패턴: Edge-Triggered 사용 시 반드시 (1) 소켓을 O_NONBLOCK으로 설정하고, (2) read()/recv()가 EAGAIN을 반환할 때까지 루프로 읽어야 합니다. 그렇지 않으면 버퍼(Buffer)에 남은 데이터가 영원히 통지되지 않는 starvation이 발생합니다.
/* ET 모드 올바른 읽기 패턴 */
void handle_et_read(int fd) {
for (;;) {
ssize_t n = read(fd, buf, sizeof(buf));
if (n == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK)
break; /* 모든 데이터 소진 — 정상 종료 */
perror("read");
break;
}
if (n == 0) { /* 연결 종료 */
close(fd);
break;
}
process_data(buf, n);
}
}
고급 이벤트 플래그
| 플래그 | 도입 버전 | 설명 |
|---|---|---|
EPOLLIN |
2.5.44 | 읽기 가능 (데이터 도착 또는 FIN 수신) |
EPOLLOUT |
2.5.44 | 쓰기 가능 (송신 버퍼 여유) |
EPOLLRDHUP |
2.6.17 | 상대방이 연결을 반만 닫음 (shutdown(SHUT_WR)) — EPOLLIN+read()=0보다 명확 |
EPOLLHUP |
2.5.44 | 연결 완전 종료 (자동 설정, 등록 불필요) |
EPOLLERR |
2.5.44 | 에러 발생 (자동 설정, 등록 불필요) |
EPOLLET |
2.5.44 | Edge-Triggered 모드 활성화 |
EPOLLONESHOT |
2.6.2 | 이벤트 1회 전달 후 자동 비활성화 — 재활성화는 epoll_ctl(MOD) |
EPOLLEXCLUSIVE |
4.5 | 여러 epoll이 같은 fd를 감시할 때 하나만 깨움 (thundering herd 방지) |
EPOLLWAKEUP |
3.5 | 이벤트 처리 중 시스템 suspend 방지 (wakeup source 유지) |
EPOLLONESHOT과 멀티스레드 패턴
EPOLLONESHOT은 멀티스레드 서버에서 하나의 fd를 여러 스레드가 동시에 처리하는 것을 방지합니다. 이벤트가 발생하면 해당 fd의 이벤트 감시가 자동으로 비활성화되어, 정확히 하나의 스레드만 처리하게 됩니다.
/* 멀티스레드 epoll 서버 — EPOLLONESHOT 패턴 */
void *worker_thread(void *arg) {
int epfd = *(int *)arg;
struct epoll_event events[32];
for (;;) {
int n = epoll_wait(epfd, events, 32, -1);
for (int i = 0; i < n; i++) {
int fd = events[i].data.fd;
/* 이 시점에서 fd의 이벤트 감시는 이미 비활성화 */
/* 다른 스레드는 이 fd의 이벤트를 받지 않음 */
handle_et_read(fd);
/* 처리 완료 후 재활성화 */
struct epoll_event ev = {
.events = EPOLLIN | EPOLLET | EPOLLONESHOT,
.data.fd = fd
};
epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev);
}
}
}
/* Nginx-style: 여러 워커 프로세스가 listen fd를 공유할 때 */
/* EPOLLEXCLUSIVE로 thundering herd 방지 (커널 4.5+) */
struct epoll_event ev = {
.events = EPOLLIN | EPOLLEXCLUSIVE,
.data.fd = listen_fd
};
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
epoll_pwait / epoll_pwait2
epoll_pwait()은 시그널 마스크를 원자적(Atomic)으로 설정하면서 이벤트를 대기하여, epoll_wait와 sigprocmask 사이의 경쟁 조건(Race Condition)을 방지합니다. epoll_pwait2()(Linux 5.11+)는 타임아웃을 struct timespec으로 지정하여 나노초 정밀도를 제공합니다.
/* epoll_pwait: 시그널 안전한 이벤트 대기 */
sigset_t sigmask;
sigemptyset(&sigmask);
sigaddset(&sigmask, SIGINT);
/* SIGINT를 차단한 상태로 이벤트 대기 */
/* → 반환 후 원래 시그널 마스크 자동 복원 */
int n = epoll_pwait(epfd, events, 64, -1, &sigmask);
/* epoll_pwait2: 나노초 정밀도 타임아웃 (5.11+) */
struct timespec ts = { .tv_sec = 0, .tv_nsec = 500000000 }; /* 500ms */
int n = epoll_pwait2(epfd, events, 64, &ts, NULL);
실전 패턴: ET 모드 이벤트 루프 서버
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <fcntl.h>
#include <unistd.h>
#define MAX_EVENTS 1024
static void set_nonblock(int fd) {
int flags = fcntl(fd, F_GETFL);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
int main(void) {
/* listen 소켓 생성 */
int lfd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
int opt = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(8080),
.sin_addr.s_addr = INADDR_ANY
};
bind(lfd, (struct sockaddr *)&addr, sizeof(addr));
listen(lfd, SOMAXCONN);
/* epoll 인스턴스 생성 + listen fd 등록 */
int epfd = epoll_create1(EPOLL_CLOEXEC);
struct epoll_event ev = { .events = EPOLLIN, .data.fd = lfd };
epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
struct epoll_event events[MAX_EVENTS];
for (;;) {
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < nfds; i++) {
int fd = events[i].data.fd;
if (fd == lfd) {
/* 새 연결 수락 (ET: 모두 accept) */
for (;;) {
int cfd = accept4(lfd, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC);
if (cfd == -1) break;
ev.events = EPOLLIN | EPOLLET | EPOLLRDHUP;
ev.data.fd = cfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev);
}
} else if (events[i].events & (EPOLLRDHUP | EPOLLHUP | EPOLLERR)) {
/* 연결 종료 또는 에러 */
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
close(fd);
} else if (events[i].events & EPOLLIN) {
/* 데이터 수신 (ET: EAGAIN까지 읽기) */
handle_et_read(fd);
}
}
}
}
epoll 주의 사항과 함정
dup/fork와 epoll: epoll은 fd가 아니라 파일 디스크립션(struct file) 단위로 이벤트를 감시합니다. dup()이나 fork()로 fd가 복제되면, 원본 fd를 close()해도 복제된 fd가 남아있는 한 epoll 모니터링이 계속됩니다. 모든 복제본이 닫혀야 epoll에서 자동 해제됩니다.
| 함정 | 증상 | 해결책 |
|---|---|---|
| ET + 불완전 읽기 | 이벤트 누락, 데이터 정체 | EAGAIN까지 반드시 루프 읽기 |
close(fd) 전 DEL 누락 |
dup/fork 시 좀비 모니터링 |
close() 전 항상 EPOLL_CTL_DEL |
LT + EPOLLOUT 상시 등록 |
CPU 100% (항상 쓰기 가능 이벤트 발생) | 쓸 데이터가 있을 때만 EPOLLOUT 활성화 |
| 스레드 간 fd 경합(Contention) | 같은 이벤트를 여러 스레드가 처리 | EPOLLONESHOT 또는 EPOLLEXCLUSIVE 사용 |
| epoll fd 누수 | fd 테이블 고갈 | EPOLL_CLOEXEC 사용, 정리 코드 필수 |
| 중첩 epoll 순환 참조 | 커널 교착 가능 | epoll fd를 다른 epoll에 등록 시 순환 검사 (ep_loop_check) |
epoll 커널 튜닝 파라미터
# 사용자당 최대 epoll watch 수 (기본값: 약 400,000~800,000)
cat /proc/sys/fs/epoll/max_user_watches
# 각 watch는 약 90바이트(struct epitem) + 20바이트(struct eppoll_entry)
# → 100만 watch ≈ 약 100MB 커널 메모리
# ulimit -n (fd 상한)도 함께 조정 필요
ulimit -n 1048576
# /etc/security/limits.conf 영구 설정
# * soft nofile 1048576
# * hard nofile 1048576
io_uring과의 관계: epoll은 이벤트 준비 상태를 알려주고 실제 I/O는 별도 시스템 콜(System Call)이 필요하지만, io_uring은 I/O 제출과 완료를 공유 메모리 링으로 처리하여 시스템 콜 자체를 최소화합니다. 네트워크 소켓 이벤트 다중화에는 epoll이 여전히 표준이며, 파일 I/O와 네트워크를 통합해야 하는 고성능 시나리오에서는 io_uring을 고려하세요. 상세 비교는 io_uring vs epoll을 참조하세요.
Netlink Sockets
Netlink는 커널과 유저 공간 프로세스, 또는 유저 공간 프로세스 간 통신을 위한 소켓 기반 IPC입니다. AF_NETLINK 주소 패밀리를 사용하며, 라우팅 테이블(Routing Table) 관리(NETLINK_ROUTE), 방화벽(Firewall) 설정(NETLINK_NETFILTER), 감사 로그(NETLINK_AUDIT) 등에 광범위하게 사용됩니다.
왜 Netlink인가 — ioctl의 한계
커널 설정이나 상태 조회는 전통적으로 ioctl()로 해왔습니다. 그런데 ioctl에는 근본적인 문제가 있습니다. 명령 번호가 고정 상수라 새로운 기능을 추가할 때마다 새 ioctl 번호를 예약해야 하고, 메시지 구조가 고정 크기 C 구조체라 확장이 어렵습니다. 또한 이벤트를 밀어(push) 보낼 방법이 없어 커널 이벤트를 받으려면 폴링(polling)이 필요합니다.
Netlink는 이 모든 문제를 소켓 추상화로 해결합니다:
- 확장 가능 TLV 포맷 — Type-Length-Value 속성 체계로 하위 호환성을 유지하면서 새 속성 추가 가능
- 양방향 비동기 통신 — 커널이 유저 공간에 이벤트를 자발적으로 멀티캐스트로 전송 가능 (예: 인터페이스 UP/DOWN)
- 요청/응답 + 덤프 — 단일 응답(unicast)과 대용량 테이블 덤프(dump) 모두 지원
- 네임스페이스 인식 — 컨테이너(Container)별 독립 Netlink 소켓 공간
| 항목 | ioctl | Netlink | /proc, /sys |
|---|---|---|---|
| 메시지 확장성 | 고정 구조체 | TLV 속성 (무제한 확장) | 텍스트 파싱 |
| 커널→유저 이벤트 | 불가 (폴링 필요) | 멀티캐스트 그룹 구독 | 불가 |
| epoll 통합 | 불가 | 소켓 fd → epoll 등록 | inotify 필요 |
| 대용량 테이블 조회 | 어려움 | NLM_F_DUMP 지원 | 파일 읽기 |
| 대표 사용처 | (구) net 설정 | ip, ss, iw, nft, udev | 커널 매개변수 |
ip 명령어도 Netlink를 씁니다: ip link show, ip addr add, ip route 같은 일상적인 네트워크 명령어는 모두 내부적으로 NETLINK_ROUTE 소켓을 통해 커널과 통신합니다. strace ip link show 2>&1 | grep socket으로 확인할 수 있습니다.
/* Netlink 메시지 헤더 */
struct nlmsghdr {
__u32 nlmsg_len; /* 전체 메시지 길이 */
__u16 nlmsg_type; /* 메시지 타입 */
__u16 nlmsg_flags; /* 플래그 (NLM_F_REQUEST, NLM_F_DUMP, ...) */
__u32 nlmsg_seq; /* 시퀀스 번호 */
__u32 nlmsg_pid; /* 포트 ID */
};
/* 주요 Netlink 프로토콜 패밀리 */
/* NETLINK_ROUTE - 라우팅, 링크, 주소 관리 (ip 명령어) */
/* NETLINK_NETFILTER - nftables, conntrack */
/* NETLINK_KOBJECT_UEVENT - udev 이벤트 */
/* NETLINK_AUDIT - 감사 서브시스템 */
/* NETLINK_GENERIC - Generic Netlink (확장 가능) */
상세 정보: Netlink 소켓의 아키텍처, rtnetlink, genetlink 등 심층 내용은 네트워크 스택(Network Stack) 문서에서 다룹니다.
Netlink 소켓 생성과 바인딩
Netlink 소켓은 표준 소켓 API로 생성하며, struct sockaddr_nl로 바인딩합니다. nl_pid는 유저 공간 소켓의 포트 ID(일반적으로 프로세스 PID), nl_groups는 멀티캐스트(Multicast) 그룹 비트마스크입니다.
#include <linux/netlink.h>
#include <sys/socket.h>
/* Netlink 주소 구조체 */
struct sockaddr_nl {
__kernel_sa_family_t nl_family; /* AF_NETLINK */
unsigned short nl_pad; /* 패딩 (0) */
__u32 nl_pid; /* 포트 ID (0=커널, 보통 getpid()) */
__u32 nl_groups; /* 멀티캐스트 그룹 비트마스크 */
};
/* 소켓 생성 — NETLINK_ROUTE로 라우팅/링크 관리 */
int fd = socket(AF_NETLINK, SOCK_DGRAM, NETLINK_ROUTE);
/* 유니캐스트 바인딩 — 커널과 1:1 통신 */
struct sockaddr_nl sa = {
.nl_family = AF_NETLINK,
.nl_pid = getpid(), /* 포트 ID (유일해야 함) */
.nl_groups = 0 /* 멀티캐스트 미구독 */
};
bind(fd, (struct sockaddr *)&sa, sizeof(sa));
/* 멀티캐스트 바인딩 — 네트워크 링크 변경 이벤트 수신 */
struct sockaddr_nl sa_mc = {
.nl_family = AF_NETLINK,
.nl_pid = 0,
.nl_groups = RTMGRP_LINK | RTMGRP_IPV4_IFADDR /* 링크 + IPv4 주소 변경 */
};
bind(fd, (struct sockaddr *)&sa_mc, sizeof(sa_mc));
Netlink 메시지 포맷
Netlink 메시지는 nlmsghdr 헤더 뒤에 프로토콜별 페이로드(Payload)가 따르고, 페이로드 내부에는 TLV(Type-Length-Value) 형식의 nlattr 속성들이 중첩(Nested)될 수 있습니다. 모든 영역은 NLMSG_ALIGN(4) 경계로 정렬됩니다.
Netlink 메시지 레이아웃: ┌──────────────────────────────────────────────────────────────┐ │ nlmsghdr (16 bytes) │ │ nlmsg_len | nlmsg_type | nlmsg_flags | nlmsg_seq | pid │ ├──────────────────────────────────────────────────────────────┤ │ [padding to NLMSG_ALIGN] │ ├──────────────────────────────────────────────────────────────┤ │ Protocol Header (예: struct ifinfomsg, struct rtmsg) │ ├──────────────────────────────────────────────────────────────┤ │ [padding to NLMSG_ALIGN] │ ├──────────────────────────────────────────────────────────────┤ │ Attributes (nlattr TLV) │ │ ┌─────────────────────────────────────────────────┐ │ │ │ nla_len (2B) | nla_type (2B) | payload | [pad] │ │ │ ├─────────────────────────────────────────────────┤ │ │ │ nla_len | nla_type | nested attrs... | [pad] │ │ │ └─────────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────────────┘
/* Netlink 속성 (TLV) */
struct nlattr {
__u16 nla_len; /* 전체 속성 길이 (헤더 + 페이로드) */
__u16 nla_type; /* 속성 타입 (프로토콜별 정의) */
/* 페이로드 데이터가 이어짐 */
};
/* 정렬 및 헬퍼 매크로 */
#define NLA_ALIGNTO 4
#define NLA_ALIGN(len) (((len) + NLA_ALIGNTO - 1) & ~(NLA_ALIGNTO - 1))
#define NLA_HDRLEN ((int) NLA_ALIGN(sizeof(struct nlattr))) /* = 4 */
/* 메시지 구성 헬퍼 매크로 */
#define NLMSG_ALIGN(len) (((len) + 3) & ~3)
#define NLMSG_HDRLEN ((int) NLMSG_ALIGN(sizeof(struct nlmsghdr)))
#define NLMSG_DATA(nlh) ((void *)((char *)(nlh) + NLMSG_HDRLEN))
#define NLMSG_NEXT(nlh, len) ... /* 다음 메시지로 이동 */
/* 속성 순회 매크로 */
#define nla_for_each_attr(pos, head, len, rem) \
for (pos = head, rem = len; \
nla_ok(pos, rem); \
pos = nla_next(pos, &rem))
Generic Netlink (genetlink)
기존 Netlink 프로토콜 패밀리(NETLINK_ROUTE, NETLINK_NETFILTER 등)는 헤더 파일에 하드코딩된 상수로 정의되어 있어 최대 32개까지만 등록할 수 있습니다. Generic Netlink(genetlink)는 이 제한을 극복하기 위해 단일 NETLINK_GENERIC 패밀리 위에 동적 패밀리 등록을 지원합니다.
/* include/net/genetlink.h */
struct genl_family {
int id; /* 동적 할당 패밀리 ID (커널이 부여) */
unsigned int hdrsize; /* 사용자 헤더 크기 */
char name[GENL_NAMSIZ]; /* 패밀리 이름 */
unsigned int version; /* 프로토콜 버전 */
unsigned int maxattr; /* 최대 속성 번호 */
const struct nla_policy *policy; /* 속성 검증 정책 */
const struct genl_ops *ops; /* 명령 핸들러 배열 */
unsigned int n_ops; /* ops 배열 크기 */
const struct genl_multicast_group *mcgrps;
unsigned int n_mcgrps;
struct module *module;
};
struct genl_ops {
int (*doit)(struct sk_buff *skb, struct genl_info *info); /* 단일 요청 */
int (*dumpit)(struct sk_buff *skb, struct netlink_callback *cb); /* 덤프 */
u8 cmd; /* 명령 번호 */
u8 flags; /* GENL_ADMIN_PERM 등 */
};
유저 공간에서 genetlink 패밀리를 사용하려면 먼저 패밀리 이름으로 ID를 조회해야 합니다. 컨트롤러 패밀리(GENL_ID_CTRL)에 CTRL_CMD_GETFAMILY 명령을 보내면 커널이 해당 패밀리의 동적 ID를 반환합니다. nl80211(WiFi), taskstats(태스크(Task) 통계), TASKSTATS 등이 genetlink의 대표적 사용 예입니다.
/* 커널 모듈에서 genetlink 패밀리 등록 예제 (스켈레톤) */
#include <net/genetlink.h>
/* 명령 정의 */
enum {
MY_CMD_UNSPEC,
MY_CMD_ECHO, /* 에코 명령 */
__MY_CMD_MAX,
};
/* 속성 정의 */
enum {
MY_ATTR_UNSPEC,
MY_ATTR_MSG, /* NLA_STRING 타입 메시지 */
__MY_ATTR_MAX,
};
static const struct nla_policy my_policy[__MY_ATTR_MAX] = {
[MY_ATTR_MSG] = { .type = NLA_STRING, .len = 256 },
};
static int my_echo_doit(struct sk_buff *skb, struct genl_info *info)
{
struct sk_buff *reply;
void *hdr;
if (!info->attrs[MY_ATTR_MSG])
return -EINVAL;
reply = genlmsg_new(NLMSG_DEFAULT_SIZE, GFP_KERNEL);
hdr = genlmsg_put_reply(reply, info, &my_family, 0, MY_CMD_ECHO);
nla_put_string(reply, MY_ATTR_MSG, nla_data(info->attrs[MY_ATTR_MSG]));
genlmsg_end(reply, hdr);
return genlmsg_reply(reply, info);
}
static const struct genl_ops my_ops[] = {
{
.cmd = MY_CMD_ECHO,
.doit = my_echo_doit,
.flags = 0,
},
};
static struct genl_family my_family = {
.name = "MY_GENL",
.version = 1,
.maxattr = __MY_ATTR_MAX - 1,
.policy = my_policy,
.ops = my_ops,
.n_ops = ARRAY_SIZE(my_ops),
.module = THIS_MODULE,
};
/* 모듈 초기화 시 */
genl_register_family(&my_family);
/* 모듈 해제 시 */
genl_unregister_family(&my_family);
Netlink 멀티캐스트
Netlink 멀티캐스트를 통해 커널은 이벤트를 여러 유저 공간 프로세스에 동시에 브로드캐스트(Broadcast)할 수 있습니다. 대표적으로 NETLINK_KOBJECT_UEVENT는 udev에 디바이스 핫플러그(Hotplug) 이벤트를 전달하며, RTNLGRP_LINK는 네트워크 인터페이스 상태 변경을 알립니다.
| 멀티캐스트 그룹 | 패밀리 | 이벤트 내용 | 대표 수신자 |
|---|---|---|---|
RTNLGRP_LINK |
NETLINK_ROUTE | 인터페이스 up/down, 생성/삭제 | NetworkManager, systemd-networkd |
RTNLGRP_IPV4_IFADDR |
NETLINK_ROUTE | IPv4 주소 추가/삭제 | ip monitor, dhclient |
RTNLGRP_IPV6_IFADDR |
NETLINK_ROUTE | IPv6 주소 추가/삭제 | radvd, NetworkManager |
RTNLGRP_IPV4_ROUTE |
NETLINK_ROUTE | IPv4 라우팅(Routing) 변경 | ip monitor, bird |
| KOBJECT_UEVENT | NETLINK_KOBJECT_UEVENT | 디바이스 추가/제거 | udevd, mdev |
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <linux/netlink.h>
#include <linux/rtnetlink.h>
#include <net/if.h>
/* 네트워크 링크 변경 모니터링 예제 */
int main(void) {
int fd = socket(AF_NETLINK, SOCK_DGRAM, NETLINK_ROUTE);
struct sockaddr_nl sa = {
.nl_family = AF_NETLINK,
.nl_groups = RTMGRP_LINK | RTMGRP_IPV4_IFADDR
};
bind(fd, (struct sockaddr *)&sa, sizeof(sa));
char buf[8192];
for (;;) {
ssize_t len = recv(fd, buf, sizeof(buf), 0);
struct nlmsghdr *nh = (struct nlmsghdr *)buf;
for (; NLMSG_OK(nh, len); nh = NLMSG_NEXT(nh, len)) {
if (nh->nlmsg_type == RTM_NEWLINK || nh->nlmsg_type == RTM_DELLINK) {
struct ifinfomsg *ifi = NLMSG_DATA(nh);
char name[IF_NAMESIZE];
if_indextoname(ifi->ifi_index, name);
printf("링크 %s: %s (flags=0x%x)\n",
nh->nlmsg_type == RTM_NEWLINK ? "UP" : "DOWN",
name, ifi->ifi_flags);
}
if (nh->nlmsg_type == RTM_NEWADDR || nh->nlmsg_type == RTM_DELADDR) {
printf("주소 변경 감지 (type=%d)\n", nh->nlmsg_type);
}
}
}
close(fd);
}
Unix Domain Sockets
Unix Domain Socket(AF_UNIX / AF_LOCAL)은 같은 호스트 내 프로세스 간 통신에 최적화된 소켓 기반 IPC입니다. TCP/IP 프로토콜 스택(체크섬(Checksum), 라우팅(Routing), 시퀀스 번호 등)을 완전히 우회하여 커널 내부에서 직접 sk_buff를 전달하므로, localhost TCP 대비 2~3배 높은 처리량(Throughput)과 현저히 낮은 지연(Latency)를 제공합니다.
세 가지 소켓 타입을 지원합니다:
SOCK_STREAM— 연결 기반 바이트 스트림 (TCP와 유사)SOCK_DGRAM— 비연결 데이터그램 (UDP와 유사, 단 순서 보장(Ordering))SOCK_SEQPACKET— 연결 기반 + 메시지 경계 보존
일반 소켓 API 외에도 SCM_RIGHTS(파일 디스크립터 전달)와 SCM_CREDENTIALS(프로세스 자격 증명 전달) 같은 고유 기능을 제공하며, systemd 소켓 활성화(Socket Activation), 컨테이너(Container) 런타임(containerd, CRI-O), 데이터베이스(PostgreSQL, MySQL) 등에서 핵심 통신 채널로 사용됩니다.
AF_UNIX vs localhost TCP — 왜 더 빠른가
같은 호스트 내 통신임에도 TCP를 쓰면 커널이 체크섬 계산, 포트 바인딩/해제, IP 라우팅 결정, 시퀀스 번호 관리 등 네트워크 스택 전체를 통과해야 합니다. AF_UNIX는 이 과정을 모두 건너뛰고 커널 내부에서 직접 버퍼를 전달합니다. 대용량 데이터 전송 시에도 SCM_RIGHTS로 파일 디스크립터 자체를 건네면 데이터 복사 없이 공유 메모리에 접근할 수 있습니다.
기초 예제 — SOCK_STREAM 에코 서버
다음 예제는 /tmp/echo.sock를 주소로 사용하는 가장 단순한 에코 서버와 클라이언트입니다. TCP 소켓 코드에서 AF_INET을 AF_UNIX로 바꾸고 주소를 경로로 지정하면 됩니다.
/* 서버: /tmp/echo.sock 생성 후 수신 데이터를 그대로 돌려보냄 */
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
#include <stdio.h>
#define SOCK_PATH "/tmp/echo.sock"
int main(void) {
int sfd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
struct sockaddr_un addr = { .sun_family = AF_UNIX };
snprintf(addr.sun_path, sizeof(addr.sun_path), SOCK_PATH);
unlink(SOCK_PATH); /* 이전 소켓 파일 제거 */
bind(sfd, (struct sockaddr *)&addr, sizeof(addr));
listen(sfd, 5);
int cfd = accept(sfd, NULL, NULL);
char buf[256];
ssize_t n;
while ((n = read(cfd, buf, sizeof(buf))) > 0)
write(cfd, buf, n); /* 에코: 받은 내용 그대로 전송 */
close(cfd); close(sfd); unlink(SOCK_PATH);
}
/* 클라이언트: 서버에 접속하여 메시지 전송 후 에코 수신 */
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>
#define SOCK_PATH "/tmp/echo.sock"
int main(void) {
int sfd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
struct sockaddr_un addr = { .sun_family = AF_UNIX };
snprintf(addr.sun_path, sizeof(addr.sun_path), SOCK_PATH);
connect(sfd, (struct sockaddr *)&addr, sizeof(addr));
const char *msg = "Hello, Unix Socket!\n";
write(sfd, msg, strlen(msg));
char buf[256] = {};
read(sfd, buf, sizeof(buf));
printf("에코 수신: %s", buf);
close(sfd);
}
추상 주소(Abstract Address): 경로 앞에 \0(null 바이트)를 붙이면 파일시스템에 소켓 파일이 생기지 않는 "추상 네임스페이스(Abstract Namespace)" 주소가 됩니다. addr.sun_path[0] = '\0'; strcpy(addr.sun_path + 1, "myapp"); 형식이며, 프로세스 종료 시 자동 해제됩니다. Android의 Zygote 소켓이 이 방식을 사용합니다.
struct unix_sock), 주소 유형(pathname/abstract/socketpair) 비교, SCM_RIGHTS/SCM_CREDENTIALS 구현, 가비지 컬렉터(GC), 성능 최적화, 컨테이너 환경 활용 등은 Unix Domain Socket 페이지에서 상세히 다룹니다.
Cross Memory Attach
process_vm_readv()와 process_vm_writev()는 Linux 3.2에 도입된 시스템 콜로, 한 프로세스가 다른 프로세스의 메모리를 직접 읽거나 쓸 수 있게 합니다. 커널 내부에서 단일 복사로 처리되어 공유 메모리나 pipe를 경유하는 것보다 효율적이며, 디버거(Debugger), 체크포인트(Checkpoint)/복원(CRIU), 고성능 MPI 라이브러리 등에서 사용됩니다.
개념 — 왜 공유 메모리나 ptrace 대신?
다른 프로세스의 메모리를 읽는 전통적인 방법은 두 가지였습니다. 첫째, ptrace(PTRACE_PEEKDATA)로 4바이트씩 읽는 방법 — 단어 하나마다 시스템 콜이 필요하므로 대용량 메모리 접근에는 매우 느립니다. 둘째, 공유 메모리 설정 — 사전에 두 프로세스가 협력하여 동일 메모리 영역을 매핑해야 하므로 기존 프로세스(CRIU 복원, 디버거 대상 등)에는 적용하기 어렵습니다.
process_vm_readv()는 벡터 I/O(scatter/gather) 방식으로 한 번의 시스템 콜에서 원격 프로세스의 여러 메모리 영역을 읽어옵니다. 커널이 대상 프로세스의 페이지 테이블(Page Table)을 직접 참조하여 단일 복사로 전달하므로, ptrace 루프 대비 수십~수백 배 빠릅니다.
| 방법 | 대상 프로세스 정지 | 접근 단위 | 시스템 콜 수 | 주 용도 |
|---|---|---|---|---|
ptrace(PEEKDATA) | 필요 | 4/8바이트 | N (데이터 크기/단위) | 디버거 (gdb) |
process_vm_readv | 불필요 | 벡터 (다중 영역) | 1회 | CRIU, MPI, 디버거 |
| 공유 메모리 | 불필요 | 임의 | 설정 시 1회 | 협력적 프로세스 간 |
/proc/<pid>/mem | 불필요 | 임의 (lseek+read) | N | 간단한 단일 접근 |
#include <sys/uio.h>
/* process_vm_readv — 원격 프로세스 메모리 읽기 */
ssize_t process_vm_readv(
pid_t pid, /* 대상 프로세스 PID */
const struct iovec *local_iov, /* 로컬 scatter 버퍼 */
unsigned long liovcnt, /* 로컬 iovec 수 */
const struct iovec *remote_iov, /* 원격 gather 버퍼 */
unsigned long riovcnt, /* 원격 iovec 수 */
unsigned long flags /* 현재 0 */
);
/* process_vm_writev — 원격 프로세스 메모리 쓰기 */
ssize_t process_vm_writev(
pid_t pid,
const struct iovec *local_iov, unsigned long liovcnt,
const struct iovec *remote_iov, unsigned long riovcnt,
unsigned long flags
);
#include <sys/uio.h>
#include <stdio.h>
#include <string.h>
/* 대상 프로세스(pid)의 remote_addr에서 데이터 읽기 예제 */
int read_remote_memory(pid_t pid, void *remote_addr, size_t len)
{
char buf[4096];
struct iovec local = { .iov_base = buf, .iov_len = len };
struct iovec remote = { .iov_base = remote_addr, .iov_len = len };
ssize_t nread = process_vm_readv(pid, &local, 1, &remote, 1, 0);
if (nread < 0) {
perror("process_vm_readv");
return -1;
}
printf("원격 프로세스에서 %zd 바이트 읽기 완료\n", nread);
return 0;
}
/* scatter-gather: 여러 영역을 한 번의 호출로 읽기 */
void scatter_read(pid_t pid)
{
char buf1[128], buf2[256];
struct iovec local[2] = {
{ buf1, sizeof(buf1) },
{ buf2, sizeof(buf2) }
};
struct iovec remote[2] = {
{ (void *)0x7fff00001000, 128 },
{ (void *)0x7fff00002000, 256 }
};
process_vm_readv(pid, local, 2, remote, 2, 0);
}
보안 요구사항: process_vm_readv()/process_vm_writev()는 PTRACE_MODE_ATTACH 권한 검사를 수행합니다. 호출자와 대상이 같은 UID이거나 호출자가 CAP_SYS_PTRACE 능력(Capability)을 가져야 합니다. Yama LSM이 활성화된 경우 /proc/sys/kernel/yama/ptrace_scope 설정도 확인됩니다.
memfd_create
memfd_create()는 파일시스템 경로 없이 RAM에 백업되는 익명 파일(Anonymous File)을 생성합니다. tmpfs 기반으로 동작하며, mmap()으로 공유 메모리를 설정하거나 다른 프로세스에 SCM_RIGHTS로 fd를 전달하여 익명 공유 메모리를 구현할 수 있습니다. 특히 sealing 메커니즘으로 파일 크기나 내용의 변경을 금지할 수 있어 안전한 IPC에 적합합니다.
POSIX 공유 메모리와의 차이
POSIX 공유 메모리(shm_open())는 /dev/shm/이름 형태로 파일시스템에 이름이 남습니다. 이름을 알면 다른 프로세스가 권한만 있으면 해당 공유 메모리를 열 수 있어 보안상 위험할 수 있습니다. 또한 프로세스가 비정상 종료되면 공유 메모리가 남아 수동 정리가 필요합니다. memfd_create()는 이름이 파일시스템에 없는 익명(Anonymous) 파일로, fd를 가진 프로세스만 접근할 수 있고 모든 fd가 닫히면 자동으로 해제됩니다.
| 항목 | POSIX SHM (shm_open) | memfd_create | mmap(MAP_SHARED|MAP_ANONYMOUS) |
|---|---|---|---|
| 파일시스템 경로 | /dev/shm/이름 | 없음 (익명) | 없음 (익명) |
| fd 전달(다른 프로세스) | 이름 공유 후 재열기 | SCM_RIGHTS로 fd 전달 | fork() 후 자식만 공유 |
| 비정상 종료 시 정리 | 수동 (shm_unlink) | 자동 (fd 닫히면 해제) | 자동 |
| sealing (불변 보장) | 불가 | MFD_ALLOW_SEALING | 불가 |
| 샌드박스 내 사용 | 파일시스템 접근 필요 | syscall만으로 가능 | 가능 |
| 대표 사용처 | 데이터베이스 공유 버퍼 | Wayland, 샌드박스, JVM JIT | fork 기반 IPC |
JVM JIT 코드 보호: memfd_create()로 생성한 익명 파일에 JIT 컴파일된 코드를 쓴 뒤 F_SEAL_WRITE로 봉인하면, 이후 쓰기 가능한 매핑을 만들 수 없어 JIT 스프레이(Spray) 공격을 방어할 수 있습니다. OpenJDK와 V8(Chrome)이 이 패턴을 사용합니다.
#define _GNU_SOURCE
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>
#include <linux/memfd.h>
int main(void) {
/* 1. 익명 파일 생성 (MFD_ALLOW_SEALING으로 seal 허용) */
int fd = memfd_create("shared-buffer", MFD_CLOEXEC | MFD_ALLOW_SEALING);
/* 2. 크기 설정 */
ftruncate(fd, 4096);
/* 3. 메모리 매핑하여 데이터 기록 */
char *ptr = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
strcpy(ptr, "Hello from memfd!");
/* 4. Sealing — 쓰기 완료 후 내용 변경 금지 */
fcntl(fd, F_ADD_SEALS,
F_SEAL_WRITE | /* 쓰기 금지 */
F_SEAL_SHRINK | /* 축소 금지 */
F_SEAL_GROW | /* 확장 금지 */
F_SEAL_SEAL); /* 추가 seal 금지 (불변) */
/* 이제 fd를 다른 프로세스에 전달 (SCM_RIGHTS 또는 /proc/PID/fd/N)
* 수신자는 내용이 변경되지 않음을 보장받음 */
/* 5. seal 상태 확인 */
int seals = fcntl(fd, F_GET_SEALS);
printf("Seals: 0x%x\n", seals);
printf(" WRITE: %s\n", (seals & F_SEAL_WRITE) ? "yes" : "no");
printf(" SHRINK: %s\n", (seals & F_SEAL_SHRINK) ? "yes" : "no");
printf(" GROW: %s\n", (seals & F_SEAL_GROW) ? "yes" : "no");
munmap(ptr, 4096);
close(fd);
}
| Seal 플래그 | 효과 | 용도 |
|---|---|---|
F_SEAL_SEAL |
추가 seal 설정 금지 | seal 상태를 불변(Immutable)으로 만듦 |
F_SEAL_SHRINK |
ftruncate()로 크기 축소 금지 |
매핑(Mapping) 무효화(Invalidation)(SIGBUS) 방지 |
F_SEAL_GROW |
ftruncate()/write()로 크기 확장 금지 |
메모리 사용량 예측 가능 |
F_SEAL_WRITE |
모든 쓰기 금지 (mmap PROT_WRITE 포함) | IPC 버퍼 불변 보장 |
F_SEAL_FUTURE_WRITE |
새로운 쓰기 매핑 금지 (기존 매핑은 허용) | 점진적 잠금(Lock) |
memfd_secret() (v5.14+): memfd_secret()는 한 단계 더 나아가 커널 직접 매핑(direct map)에서 해당 메모리를 제거합니다. 커널조차 해당 페이지에 접근할 수 없으므로 비밀 키(Secret Key), 암호화(Encryption) 자료 등을 보호하는 데 사용됩니다. 단, 커널 직접 매핑 수정은 TLB 플러시(Flush) 비용이 크므로 성능에 민감한 대량 데이터에는 부적합합니다.
활용 사례: Wayland는 memfd + sealing으로 컴포지터(Compositor)와 클라이언트 간 그래픽 버퍼를 공유합니다. 클라이언트가 렌더링 완료 후 F_SEAL_WRITE를 설정하면 컴포지터는 버퍼가 변경되지 않음을 보장받아 안전하게 합성할 수 있습니다. 샌드박스(Sandbox) 환경에서도 파일시스템 접근 없이 메모리를 공유할 수 있어 유용합니다.
pidfd — 레이스 프리 프로세스 관리
pidfd는 Linux 5.2+에 도입된 프로세스를 가리키는 파일 디스크립터입니다. 전통적인 PID 기반 API(kill(), waitpid())는 PID 재사용(Recycle) 레이스(Race) 문제가 있습니다 — 프로세스가 종료되고 같은 PID가 다른 프로세스에 재할당되면 잘못된 프로세스에 시그널을 보낼 수 있습니다. pidfd는 파일 디스크립터의 참조 카운팅으로 이 문제를 근본적으로 해결합니다.
PID 재사용 레이스 — 문제 시각화
Linux의 PID는 최댓값(/proc/sys/kernel/pid_max, 기본 32768)에 도달하면 낮은 번호부터 재사용됩니다. 프로세스 수퍼바이저가 PID를 기록해 두고 나중에 시그널을 보내는 사이에 해당 PID가 재사용될 수 있습니다. 이것이 PID 재사용 레이스입니다.
#define _GNU_SOURCE
#include <sys/pidfd.h>
#include <sys/wait.h>
#include <sys/syscall.h>
#include <signal.h>
#include <unistd.h>
#include <stdio.h>
#include <fcntl.h>
int main(void) {
pid_t child = fork();
if (child == 0) {
sleep(5);
return 42;
}
/* 1. pidfd_open — PID를 fd로 변환 (레이스 프리) */
int pidfd = pidfd_open(child, 0);
/* pidfd는 프로세스가 종료되어도 유효 (좀비 상태까지 참조) */
/* 2. pidfd_send_signal — 안전한 시그널 전달 */
/* PID가 재사용되어도 원래 프로세스에만 전달됨 */
pidfd_send_signal(pidfd, SIGCONT, NULL, 0);
/* 3. epoll에 등록하여 비동기 종료 감지 */
int epfd = epoll_create1(EPOLL_CLOEXEC);
struct epoll_event ev = { .events = EPOLLIN, .data.fd = pidfd };
epoll_ctl(epfd, EPOLL_CTL_ADD, pidfd, &ev);
struct epoll_event events[1];
epoll_wait(epfd, events, 1, -1); /* 자식 종료 대기 */
/* 4. waitid(P_PIDFD) — pidfd로 종료 상태 수확 */
siginfo_t si;
waitid(P_PIDFD, pidfd, &si, WEXITED);
printf("자식 종료: PID=%d, 상태=%d\n", si.si_pid, si.si_status);
/* 5. pidfd_getfd — 다른 프로세스의 fd를 복제 (Linux 5.6+) */
/* 대상 프로세스의 fd 3을 현재 프로세스로 가져옴 */
/* int stolen_fd = pidfd_getfd(pidfd, 3, 0); */
close(pidfd);
close(epfd);
}
| API | 도입 버전 | 기능 |
|---|---|---|
pidfd_open(pid, flags) |
Linux 5.3 | 기존 PID를 pidfd로 변환 |
pidfd_send_signal(pidfd, sig, info, flags) |
Linux 5.1 | 레이스 프리 시그널 전달 |
pidfd_getfd(pidfd, targetfd, flags) |
Linux 5.6 | 대상 프로세스의 fd 복제 (dup over process) |
waitid(P_PIDFD, pidfd, ...) |
Linux 5.4 | pidfd로 종료 상태 수확 |
clone3(CLONE_PIDFD) |
Linux 5.2 | fork 시 pidfd를 동시에 생성 |
SCM_PIDFD / SO_PEERPIDFD |
Linux 6.5 | AF_UNIX 제어 메시지로 peer의 pidfd 전달/조회 (PID 재사용 무시) |
| pidfs 파일시스템 | Linux 6.9 | pidfd가 anon_inode에서 독자 pidfs inode로 전환 — 고유 ino 기반 신원 식별 |
PIDFD_SELF, PIDFD_SELF_THREAD |
Linux 6.13 | 자신의 프로세스/스레드 pidfd 없이 센티넬 값만으로 pidfd API 호출 |
pidfd_send_signal() reaped task 지원 |
Linux 6.9 | 좀비/수확된 태스크에 대해서도 ESRCH 대신 명확한 오류 반환 |
활용 사례: systemd는 pidfd를 사용하여 서비스 프로세스를 추적합니다. PID 재사용 레이스 없이 정확한 프로세스에 시그널을 보내고 종료를 감지할 수 있습니다. 컨테이너 런타임(runc, crun)도 pidfd로 컨테이너 init 프로세스를 관리하며, pidfd_getfd()는 디버거나 라이브 마이그레이션 도구에서 타겟 프로세스의 fd를 안전하게 복제하는 데 활용됩니다.
pidfs — 고유 신원을 가진 pidfd (v6.9+)
리눅스 6.9부터 pidfd는 내부적으로 별도의 의사 파일시스템(pidfs) inode로 구현됩니다. 과거 anon_inode로 구현되던 시기에는 같은 프로세스를 가리키는 두 pidfd가 서로 같은 ino를 공유해 단순 stat 비교로 신원 확인이 불가능했습니다. pidfs 전환 이후에는 fstat(pidfd)->st_ino가 해당 struct pid의 수명 동안 고유성을 보장하므로, 컨테이너 관리자/샌드박스 감시자가 pidfd 인증을 훨씬 단순하게 수행할 수 있습니다.
/* pidfs 전환 이후: fstat으로 고유 신원 확인 (v6.9+) */
int pidfd = pidfd_open(target, 0);
struct stat st;
fstat(pidfd, &st);
/* st.st_ino는 프로세스 수명 동안 고유 — 재사용 없음 */
/* 두 pidfd가 같은 프로세스를 가리키는지 비교에 사용 가능 */
PIDFD_SELF 센티넬 (v6.13+)
커널 6.13에서 PIDFD_SELF, PIDFD_SELF_PROCESS, PIDFD_SELF_THREAD, PIDFD_SELF_THREAD_GROUP 센티넬이 추가되었습니다. 자신에게 시그널을 보내거나 자신의 fd를 타 시스템 콜에 전달하기 위해 의도적으로 pidfd를 열 필요가 없어집니다.
/* v6.13+ — 자기 자신에게 보내는 pidfd 연산 */
pidfd_send_signal(PIDFD_SELF_THREAD, SIGSTOP, NULL, 0);
/* 컨테이너 내부에서 자신의 fd를 타 프로세스로 공유 */
int peer_pidfd = pidfd_open(peer_pid, 0);
int dup_fd = pidfd_getfd(PIDFD_SELF_PROCESS, my_fd, 0);
SCM_PIDFD / SO_PEERPIDFD를 활용한 peer 인증 (v6.5+)
AF_UNIX에서 peer의 신원을 확인할 때 전통적으로 사용되던 SCM_CREDENTIALS와 SO_PEERCRED는 PID 재사용 레이스에 취약했습니다. 커널 6.5에서 추가된 SCM_PIDFD와 SO_PEERPIDFD는 peer의 pidfd를 그대로 전달/조회하여 이 문제를 해결합니다. systemd 256 이후의 서비스 권한 검증, Portal/Flatpak의 클라이언트 식별 등이 이 메커니즘을 채택했습니다.
/* v6.5+ — peer의 pidfd 획득 */
int peer_pidfd;
socklen_t len = sizeof(peer_pidfd);
getsockopt(sock, SOL_SOCKET, SO_PEERPIDFD, &peer_pidfd, &len);
/* pidfs 고유 ino로 peer 신원 검증 (재사용 불가) */
struct stat st;
fstat(peer_pidfd, &st);
log_info("peer pid=%u inode=%llu", st.st_uid, (unsigned long long)st.st_ino);
cgroup user.* xattr (v6.8)과의 결합: SO_PEERPIDFD만으로는 "누구의 프로세스인지" 확인할 수 없습니다. 커널 6.8부터 cgroup inode에 user.* 확장 속성(Extended Attribute)을 붙일 수 있어, 서비스 매니저가 cgroup에 신원 메타데이터(tag/role/sandbox_id)를 기록하고 SO_PEERPIDFD로 받은 pidfd의 /proc/<pid>/cgroup을 역추적(Backtrace)해 확인할 수 있습니다.
Android Binder IPC
Binder는 Android의 핵심 IPC 메커니즘으로, 커널 드라이버(drivers/android/binder.c)가 구현합니다. 일반 Unix IPC(pipe, UDS 등)와 달리 단일 복사(single-copy) 전송, 호출자 PID/UID 자동 첨부, 참조 카운팅 기반 원격 객체 수명 관리, 사망 통지(death notification) 등 Android에 특화된 기능을 제공합니다.
RPC 개념과 왜 Android는 Binder를 만들었나
RPC(Remote Procedure Call, 원격 프로시저 호출(RPC))는 한 프로세스에서 다른 프로세스의 함수를 마치 로컬 함수처럼 호출하는 추상화입니다. 호출 측은 "Proxy(프록시) 객체"에 메서드를 호출하고, 커널이나 미들웨어가 실제 구현을 가진 "Stub(스텁) 객체"까지 인자를 전달한 뒤 반환값을 가져옵니다.
Android는 수십 개의 시스템 서비스(카메라, 오디오, 위치, 전화 등)가 각자 별도 프로세스로 실행되고, 앱은 이 서비스에 빈번하게 RPC 호출을 합니다. 이 구조에서 일반 IPC 메커니즘을 쓰면 다음 문제가 생깁니다:
- 보안 인증 — "이 요청이 정말 정당한 앱에서 온 것인가?" pipe나 UDS는 호출자 UID를 별도로 검증해야 합니다.
- 객체 수명 관리 — 서비스가 죽으면 앱에 알려야 하고, 앱이 죽으면 서비스가 자원을 회수해야 합니다.
- 성능 — 카메라 미리보기처럼 초당 수백 번 호출되는 경우 복사 횟수가 성능에 직결됩니다.
Binder는 이 세 가지를 커널 드라이버 수준에서 해결합니다. /dev/binder(또는 binderfs) 장치에 ioctl()로 트랜잭션을 제출하면, 드라이버가 대상 프로세스의 mmap 영역에 데이터를 단 한 번 복사하고 스레드를 깨웁니다.
| Android 서비스 레이어 | 역할 | Binder 활용 |
|---|---|---|
| ActivityManagerService | 앱 생명주기 관리 | 앱 → AMS 모든 호출이 Binder RPC |
| SurfaceFlinger | 화면 합성 | 앱 → SF 버퍼 제출/합성 요청 |
| AudioFlinger | 오디오 믹싱 | 앱 → AF 오디오 트랙 생성/제어 |
| PackageManagerService | 앱 설치/관리 | 설치 프로세스 ↔ PMS |
| Zygote | 앱 프로세스 부모 | 예외: Unix Socket 사용 (Binder 초기화 전) |
Binder vs Unix Domain Socket
| 특성 | Binder | Unix Domain Socket |
|---|---|---|
| 복사 횟수 | 1회 (mmap 활용) | 2회 (유저→커널→유저) |
| 호출자 인증 | 커널이 PID/UID 자동 첨부 | SCM_CREDENTIALS (opt-in) |
| 객체 수명 관리 | 참조 카운팅, 사망 통지 | 없음 |
| 통신 모델 | 동기 RPC (기본) | 스트림 / 데이터그램 |
| 보안 | SELinux Binder 훅 | 파일 권한 + SCM |
| 오버헤드(Overhead) | ioctl + mmap 설정 비용 | 단순 소켓 생성 |
| 사용 범위 | Android 전용 | 모든 Unix 계열 OS |
Binder 단일 복사 메커니즘:
- 송신: copy_from_user()로 유저 데이터 → 커널 binder_buffer
- 커널이 binder_buffer의 물리 페이지를 수신 측 mmap 영역에 매핑
- 수신: mmap된 가상 주소(Virtual Address)에서 직접 데이터 접근 (추가 복사 없음)
UDS는 send()→커널 버퍼 복사 → recv()→유저 버퍼 복사로 2회 복사가 필요합니다.
Android는 3개의 Binder 도메인을 사용합니다:
/dev/binder— Framework ↔ App/dev/hwbinder— Framework ↔ HAL (HIDL/AIDL)/dev/vndbinder— 벤더 내부 IPC
Binder 자료구조, 프로토콜, mmap 메커니즘, SELinux 정책 등 내용은 Android 커널 — Binder IPC 섹션을 참고합니다.
최신 커널 동향 (2025~2026)
현대 IPC 메커니즘은 "fd 기반 + epoll 통합 + namespace-aware" 설계 원칙을 공유하며, 최근 커널 릴리스에서도 이 방향으로 보강이 이어지고 있습니다.
io_uring: 이벤트 루프의 다음 세대
io_uring은 단일 시스템 콜로 다수의 I/O 연산을 제출·수확할 수 있어, 전통적인 epoll + read/write/send/recv 루프를 대체하는 기반으로 자리잡고 있습니다. 커널 6.10 이후 주요 변화는 다음과 같습니다.
- Send zerocopy 성능 개선 (v6.10) — 작은 메시지까지 이득이 발생하도록 버퍼 합병(Coalescing)을 적용하여, 약 3 KB 이상 메시지부터 zerocopy가 이익을 냅니다(이전에는 10 KB 이상).
- Receive zerocopy(ZCRX) — v6.11 실험적, v6.13 본격화 —
io_uring_prep_recv_multishot()과 연계하여 NIC의 페이지 풀을 직접 유저스페이스 버퍼 링으로 매핑합니다. 고-QPS 서비스에서 AF_UNIX/AF_INET 수신 경로의 CPU를 추가로 절감합니다. - IORING_RECVSEND_BUNDLE (v6.10) — 한 SQE로 여러 버퍼를 묶어 송수신하여 syscall 경계 수를 줄입니다. Unix 도메인 소켓 RPC에서 특히 효과적입니다.
- IORING_OP_FUTEX_WAIT/WAKE (v6.7) — futex 대기를 io_uring SQE로 제출할 수 있어, 기존 epoll 기반 이벤트 루프가 futex와 자연스럽게 통합됩니다.
- IORING_OP_EPOLL_WAIT (v6.12) — 레거시 epoll 파일 디스크립터를 io_uring으로 감쌀 수 있어, 하이브리드 전환이 용이합니다.
/* v6.10+ BUNDLE — Unix 소켓 RPC 배치 수신 */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv_multishot(sqe, fd, NULL, 0, 0);
sqe->buf_group = BUF_RING_ID;
sqe->ioprio |= IORING_RECVSEND_BUNDLE;
io_uring_submit(&ring);
pidfs 생태계 확장
v6.9에서 도입된 pidfs는 이후 릴리스에서도 지속 확장되어, IPC에서 peer/target 프로세스 신원을 "파일시스템 inode처럼" 취급하는 문법을 정착시키고 있습니다. v6.12 이후에는 pidfs inode에 대한 statx 확장 필드로 프로세스의 exit 상태/부모 정보를 노출하는 제안이 진행 중이며, v6.15(2025-05)에서 PIDFD_GET_INFO ioctl이 안정화되어 /proc 접근 없이도 pidfd만으로 기본 식별 정보를 얻을 수 있게 되었습니다.
memfd의 보안 하드닝
memfd_create는 shmem 기반 공유 메모리의 표준으로 자리잡았고, 커널 6.x에서 다음과 같은 보강이 있었습니다.
- mseal(2) — v6.10 — memfd를 mmap한 영역에 대해 보호 플래그를 고정하여, 공격자가 실행 페이지를 쓰기 가능한 메모리로 변환하는 공격을 원천 차단합니다.
- memfd_secret 개선 — v6.10 이후 — secret 메모리가 hibernation/kexec 경로에서 제대로 zeroize되도록 보강되었고, SELinux/Landlock에서 secret memfd 정책이 별도로 적용 가능해졌습니다.
- MFD_NOEXEC_SEAL (v6.3) 기본화 (v6.6) — 컨테이너 런타임(runc 1.2, crun 1.15)이 기본적으로
MFD_NOEXEC_SEAL을 요구하게 되어, 실수로 실행 가능한 메모리 맵(Memory Map)을 공유하는 보안 사고를 차단합니다.
Binder의 Desktop 확산 논의
Android 바깥에서 Binder 사용은 금기시되어 왔으나, GNOME/KDE와 ChromeOS 개발자들이 "데스크톱 IPC를 단일화"하기 위한 대안으로 Binder를 재검토하고 있습니다. 커널 6.12 이후 binderfs가 user namespace를 전면 지원하면서 rootless 컨테이너에서도 Binder 인스턴스를 만들 수 있게 되었고, 이는 Waydroid와 같은 Android 호환 레이어 뿐 아니라 특정 시스템 서비스의 고성능 IPC에 적용될 여지를 열었습니다.
선택 기준: 동일 호스트 내 IPC는 작은 제어 메시지는 AF_UNIX + SCM_PIDFD + pidfs 인증 조합이 안정적입니다. 스트리밍/고-QPS는 io_uring + AF_UNIX 또는 io_uring + memfd 공유 링 버퍼(Ring Buffer)가 유리하고, Android 호환 레이어/데스크톱 통합은 binderfs를 고려할 수 있습니다. D-Bus는 여전히 바탕 프로토콜이지만 systemd 257부터 Varlink로 점진 이행이 시작되었습니다.
IORING_OP_PIPE / ZCRX dmabuf — 커널 6.16 (2025-07)
커널 6.16에서 io_uring에 두 가지 중요한 IPC 관련 기능이 추가되었습니다. 첫째, IORING_OP_PIPE는 pipe() 시스템 콜을 io_uring 링 안에서 비동기적으로 실행할 수 있게 합니다. 멀티스레드 서버에서 파이프 생성을 링 처리 흐름에 인라인으로 포함시켜 컨텍스트 전환(Context Switch)을 줄일 수 있습니다. 둘째, 제로카피 수신(Zero-Copy Receive, ZCRX)이 dmabuf와 연동되어 NIC → GPU/NPU 직접 전달 경로를 지원합니다. AI 추론 파이프라인(Pipeline)에서 네트워크 패킷(Packet)을 CPU 복사 없이 가속기 메모리로 직접 전달하는 구성이 가능해집니다.
/* v6.16+ IORING_OP_PIPE: 링 내 비동기 파이프 생성 */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_pipe(sqe, pipefd, 0); /* 결과: pipefd[0], pipefd[1] */
sqe->user_data = TOKEN_PIPE_CREATE;
pidfs 영구 xattr 및 종료된 피어 pidfd — 커널 6.17 (2025-09)
커널 6.17에서 pidfs에 두 가지 확장이 합병되었습니다. 첫째, 영구 확장 속성(persistent xattr): pidfd가 가리키는 프로세스가 종료된 뒤에도 pidfs inode에 설정된 xattr이 유지됩니다. 감사(Audit) 또는 cgroup 정책 엔진(Policy Engine)이 좀비(zombie) 상태에서도 프로세스 메타데이터를 읽을 수 있습니다. 둘째, 종료된 피어의 pidfd 수신(Reaped Peer pidfd): AF_UNIX SCM_PIDFD 제어 메시지로 전달받은 pidfd가 송신 프로세스가 이미 종료된 경우에도 유효한 pidfd를 반환합니다. 기존에는 ESRCH가 반환되어 race 조건이 있었으나, 6.17부터 종료된 프로세스의 pidfd로 waitid(P_PIDFD, …)를 호출해 exit status를 수집할 수 있습니다.
활용: 프로세스 수퍼바이저(supervisor)가 AF_UNIX로 자식 프로세스의 pidfd를 전달받는 패턴에서, 자식이 메시지 도착 전에 종료되어도 SCM_PIDFD로 수신한 fd로 안전하게 waitid()를 호출할 수 있습니다. ESRCH 핸들링 코드를 제거할 수 있습니다.
io_uring 벡터화 송신 / 혼합 크기 CQE — 커널 6.17~6.18
커널 6.17에서 io_uring에 벡터화 송신(vectored send)이 추가되어 IORING_OP_SENDMSG_ZC가 여러 iovec를 단일 SQE(Submission Queue Entry)로 처리합니다. 커널 6.18에서는 혼합 크기 CQE(mixed-size CQE)가 도입되어 CQE(Completion Queue Entry)의 크기를 작업 유형별로 다르게 할 수 있습니다. 일반 I/O는 기존 16바이트 CQE를 유지하고, 확장 정보(타임스탬프, 오류 코드 등)가 필요한 작업은 32바이트 CQE를 사용합니다. 링 버퍼 메모리를 절약하면서도 필요한 곳에 메타데이터를 추가할 수 있습니다.
Binder 트랜잭션 netlink 보고 — 커널 6.18
커널 6.18에서 Android Binder 드라이버가 트랜잭션 통계를 Netlink 소켓을 통해 외부로 노출하는 기능이 추가되었습니다. NETLINK_GENERIC 패밀리를 통해 Binder 트랜잭션(Transaction)의 지연, 큐 깊이, 스레드 풀 소진 이벤트를 구독할 수 있습니다. 시스템 모니터링 데몬이 binderfs의 /sys 폴링(Polling) 없이 푸시 방식으로 Binder 성능 저하를 감지할 수 있으며, ChromeOS나 Waydroid 환경에서 Binder 기반 IPC의 관측 가능성(Observability)이 대폭 향상됩니다.
pidfd / 네임스페이스 파일 핸들 — 커널 6.18
커널 6.18에서 name_to_handle_at() / open_by_handle_at() 인터페이스가 pidfd와 네임스페이스 파일 디스크립터(nsfd)를 지원하도록 확장되었습니다. 이를 통해 프로세스 또는 네임스페이스를 파일 핸들(File Handle) 형태로 직렬화(Serialization)해 다른 프로세스에 전달하고, 수신측이 해당 핸들로 동일 pid/ns를 다시 열 수 있습니다. SCM_RIGHTS로 fd를 직접 전달하기 어려운 cross-container 환경에서 프로세스 신원을 안전하게 교환하는 새로운 패턴을 제공합니다.
참고자료
- eventfd(2) — eventfd 파일 디스크립터를 통한 이벤트 알림 메커니즘입니다
- signalfd(2) — 시그널을 파일 디스크립터로 수신하는 인터페이스입니다
- timerfd_create(2) — 타이머 만료를 파일 디스크립터로 전달하는 인터페이스입니다
- epoll(7) — epoll I/O 이벤트 다중화 인터페이스의 동작 모드(ET/LT)를 설명합니다
- netlink(7) — Netlink 소켓 프로토콜 패밀리와 멀티캐스트 그룹을 다룹니다
- unix(7) — Unix 도메인 소켓의 주소 체계, ancillary 데이터, SCM_RIGHTS를 설명합니다
- memfd_create(2) — 익명 파일을 생성하여 프로세스 간 공유하는 시스템 콜입니다
- pidfd_open(2) — PID 파일 디스크립터를 통한 레이스 프리 프로세스 관리 시스템 콜입니다
- process_vm_readv(2) — Cross Memory Attach를 통한 원격 프로세스 메모리 직접 읽기입니다
- LWN: eventfd and its uses — eventfd의 설계 의도와 KVM, VFIO 등에서의 활용 사례를 설명합니다
- LWN: Completing the pidfd API — pidfd 인터페이스의 설계 과정과 레이스 조건 해결 방법을 다룹니다
- LWN: Sealed files — memfd_create()와 파일 실링(sealing) 메커니즘의 보안 모델을 설명합니다
- LWN: The pidfs filesystem — pidfd의 독자 파일시스템 전환(v6.9)과 신원 식별 개선을 다룹니다
- LWN: PIDFD_SELF and friends — 자기 자신을 가리키는 pidfd 센티넬(v6.13) 도입을 설명합니다
- LWN: SCM_PIDFD and SO_PEERPIDFD — AF_UNIX peer 신원 확인 pidfd 확장(v6.5)을 다룹니다
- 커널 공식 문서: io_uring zero-copy receive — ZCRX API와 NIC 페이지 풀 매핑을 설명합니다
- 커널 공식 문서: ntsync — Wine/Proton용 NT 동기화 프리미티브(v6.14+) ioctl 인터페이스입니다
fs/eventfd.c— eventfd 파일 디스크립터의 커널 구현부입니다fs/eventpoll.c— epoll 서브시스템의 커널 구현부입니다net/unix/af_unix.c— Unix 도메인 소켓의 프로토콜 구현부입니다net/netlink/af_netlink.c— Netlink 소켓의 커널 구현부입니다drivers/android/binder.c— Android Binder IPC 드라이버 구현부입니다
관련 문서
- IPC (Inter-Process Communication) - 파이프, 시그널, System V IPC, POSIX IPC, futex 등 전통적 IPC 메커니즘
- Unix Domain Socket - AF_UNIX 커널 구현, SCM_RIGHTS, GC, 성능 최적화
- 네트워크 스택 - Netlink 심층 구현
- io_uring - epoll을 넘어선 차세대 비동기 I/O 인터페이스
- 동기화 기법 - spinlock, mutex, rwlock 등 커널 내부 동기화 프리미티브
- 네임스페이스(Namespace) - IPC namespace를 포함한 커널 네임스페이스 격리(Isolation)
- 프로세스 관리 - task_struct, 시그널 핸들링, fork/exec