현대 리눅스 IPC 메커니즘

Linux 커널의 현대적 IPC 메커니즘을 심층 분석합니다. eventfd/signalfd/timerfdepoll과 통합한 fd 기반 이벤트 처리, Netlink 소켓(Socket)을 통한 커널-유저 공간 제어 채널, Unix Domain Socket의 고성능 로컬 통신, process_vm_readv()/process_vm_writev()를 사용한 Cross Memory Attach, memfd_create()의 익명 공유 메모리와 sealing, pidfd의 레이스 프리 프로세스(Process) 관리, 그리고 Android Binder의 단일 복사 RPC까지 다룹니다.

관련 문서: 전통적 IPC(파이프(Pipe), 시그널(Signal), System V IPC, POSIX IPC, futex)는 IPC (Inter-Process Communication) 페이지(Page)를 참고하세요.
전제 조건: IPC (전통적 IPC) 문서를 먼저 읽으세요. 파이프, 시그널, System V/POSIX IPC의 기본 개념을 이해한 뒤 이 문서를 읽으면 현대 메커니즘의 개선점을 더 잘 파악할 수 있습니다.
일상 비유: 이 개념은 회사 메신저와 이벤트 버스(Bus)와 비슷합니다. 단순한 통로 하나가 아니라 용도별 채널과 브로커를 두듯이, 현대 IPC는 관심사와 권한에 따라 통신 방식을 더 세밀하게 나눕니다.

핵심 요약

  • eventfd / signalfd / timerfd — 다양한 이벤트 소스를 파일 디스크립터(File Descriptor)로 추상화하여 epoll과 통합할 수 있는 경량 메커니즘입니다.
  • epoll — 수만~수십만 개의 fd를 효율적으로 모니터링하는 Linux 전용 이벤트 다중화(Multiplexing) 인터페이스입니다.
  • Netlink — 커널과 유저 공간 간 구조화된 메시지를 교환하는 소켓 기반 제어 채널입니다.
  • memfd_create — 파일시스템(Filesystem) 경로 없이 RAM 기반 익명 공유 메모리를 생성하며, sealing으로 불변성을 보장합니다.
  • pidfd — PID 재사용 레이스를 근본적으로 해결하는 파일 디스크립터 기반 프로세스 참조입니다.

단계별 이해

  1. fd 기반 이벤트 통합 — eventfd, signalfd, timerfd를 하나의 epoll 루프에 등록하면 단일 스레드(Thread)에서 모든 이벤트를 비동기적으로 처리할 수 있습니다.
  2. epoll 이벤트 루프(Event Loop)epoll_create1()으로 인스턴스를 생성하고, epoll_ctl()로 fd를 등록한 뒤, epoll_wait()로 이벤트를 대기합니다.
  3. Netlink 활용ip, ss, tc 등의 네트워크 도구가 내부적으로 Netlink를 사용합니다. AF_NETLINK 소켓으로 커널 서브시스템과 직접 통신할 수 있습니다.
  4. 안전한 공유 메모리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 대비 커널 메모리 사용량이 훨씬 적습니다.

항목pipeeventfd
fd 수2개 (읽기 + 쓰기)1개
커널 버퍼기본 65,536 바이트64비트 카운터만 (~100 B)
이벤트 누적별도 프로토콜 필요카운터로 자동 누적
세마포어 모드불가EFD_SEMAPHORE 플래그
주 용도데이터/스트림 전달순수 이벤트 알림/카운팅
eventfd 동작 원리 생산자 (스레드 A) 작업 완료 write(efd, &1, 8) 카운터 += 1 eventfd 커널 내 64비트 카운터 count = 3 epoll에 등록 가능 (EPOLLIN) 소비자 (스레드 B) epoll_wait() 대기 이벤트 발생 시 깨어남 read(efd, &cnt, 8) cnt=3, 카운터→0 리셋 증가 읽기+리셋 EFD_SEMAPHORE 플래그 사용 시: read()마다 count를 1씩 감소 (세마포어 동작)
eventfd: 64비트 카운터 기반 단방향 이벤트 알림 채널. 단일 fd로 생산자→소비자 알림을 구현합니다.
/* 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은 ioeventfdirqfd를 통해 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_tread()로 128바이트 구조체 수신
💡

self-pipe trick: signalfd 이전에는 epoll과 시그널을 함께 처리하려면 pipe를 만들고, 시그널 핸들러 안에서 write(pipe[1], "x", 1)를 호출한 뒤, epollpipe[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()/SIGALRMsetitimer()는 모두 시그널로 만료를 통지합니다. 시그널 기반 통지는 async-signal-safe 제약, 멀티스레드 전달 불예측, epoll 통합 불가 등 앞서 설명한 문제를 그대로 안고 있습니다. 더 심각한 것은 프로세스 전체에 딱 하나의 SIGALRM 타이머만 존재할 수 있다는 점입니다.

timerfd는 커널의 고해상도 타이머(hrtimer)를 파일 디스크립터로 노출하여 이 모든 문제를 해결합니다. 수십 개의 타이머를 각각 독립적인 fd로 관리하고, 하나의 epoll_wait()에서 모두 모니터링할 수 있습니다.

항목alarm / SIGALRMsetitimertimerfd
해상도초(second) 단위마이크로초나노초 (hrtimer)
동시 타이머 수1개/프로세스3개 (REAL/VIRTUAL/PROF)제한 없음
만료 통지 방식SIGALRMSIGALRM/SIGVTALRM/SIGPROFfd 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 통합 이벤트 루프 패턴 epoll_wait() 이벤트 루프 (Event Loop) eventfd 프로세스/스레드 간 이벤트 signalfd 시그널 동기 수신 timerfd 주기적/원샷 타이머 pipe 자식 프로세스 출력 Unix Socket 로컬 클라이언트 연결 TCP Socket 네트워크 클라이언트 하나의 epoll 인스턴스로 모든 fd 소스를 통합 단일 스레드 비동기 이벤트 처리 가능

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 내부 아키텍처 struct eventpoll epoll_create1()로 생성 rb_root_cached (rbtree) 등록된 모든 fd 관리 ep ep ep rdllist (ready list) 이벤트 발생 fd만 fd=5 fd=12 fd=28 epoll_ctl(ADD) rbtree 삽입 ep_poll_callback() ready list에 추가 epoll_wait() ready list 수확 wake_up() / 드라이버 통지
epoll 내부 구조: rbtree(전체 fd) + ready list(이벤트 fd) + callback 메커니즘

이벤트 전달 흐름

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_waitsigprocmask 사이의 경쟁 조건(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는 커널과 유저 공간 프로세스, 또는 유저 공간 프로세스 간 통신을 위한 소켓 기반 IPC입니다. AF_NETLINK 주소 패밀리를 사용하며, 라우팅 테이블(Routing Table) 관리(NETLINK_ROUTE), 방화벽(Firewall) 설정(NETLINK_NETFILTER), 감사 로그(NETLINK_AUDIT) 등에 광범위하게 사용됩니다.

커널 설정이나 상태 조회는 전통적으로 ioctl()로 해왔습니다. 그런데 ioctl에는 근본적인 문제가 있습니다. 명령 번호가 고정 상수라 새로운 기능을 추가할 때마다 새 ioctl 번호를 예약해야 하고, 메시지 구조가 고정 크기 C 구조체라 확장이 어렵습니다. 또한 이벤트를 밀어(push) 보낼 방법이 없어 커널 이벤트를 받으려면 폴링(polling)이 필요합니다.

Netlink는 이 모든 문제를 소켓 추상화로 해결합니다:

항목ioctlNetlink/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 소켓은 표준 소켓 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 메시지는 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))

기존 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 멀티캐스트를 통해 커널은 이벤트를 여러 유저 공간 프로세스에 동시에 브로드캐스트(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);
}
Netlink 아키텍처 유저 공간 (User Space) ip / ss iw (WiFi) udevd tc (QoS) nft / iptables 사용자 앱 socket(AF_NETLINK, SOCK_DGRAM, NETLINK_*) 커널 공간 (Kernel Space) Netlink Core (af_netlink.c) 메시지 라우팅, 멀티캐스트 분배 rtnetlink NETLINK_ROUTE genetlink NETLINK_GENERIC nfnetlink NETLINK_NETFILTER uevent KOBJECT_UEVENT audit NETLINK_AUDIT 멀티캐스트 그룹 RTNLGRP_LINK | RTNLGRP_IPV4_IFADDR KOBJECT_UEVENT → udevd 브로드캐스트

Unix Domain Sockets

Unix Domain Socket(AF_UNIX / AF_LOCAL)은 같은 호스트 내 프로세스 간 통신에 최적화된 소켓 기반 IPC입니다. TCP/IP 프로토콜 스택(체크섬(Checksum), 라우팅(Routing), 시퀀스 번호 등)을 완전히 우회하여 커널 내부에서 직접 sk_buff를 전달하므로, localhost TCP 대비 2~3배 높은 처리량(Throughput)과 현저히 낮은 지연(Latency)를 제공합니다.

세 가지 소켓 타입을 지원합니다:

일반 소켓 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_INETAF_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 루프 대비 수십~수백 배 빠릅니다.

process_vm_readv() 동작 원리 호출 프로세스 (디버거) local_iov[] 버퍼 process_vm_readv(pid, ...) 읽기 결과가 local_iov에 단일 복사로 도착 커널 권한 검사 (PTRACE_MODE_ATTACH) 대상 페이지 테이블 참조 → 물리 페이지 직접 매핑 단일 복사 (1 copy) ptrace는 4B씩 N회 대상 프로세스 remote_iov[] 가상 주소 (읽으려는 메모리 위치) 대상 프로세스는 정지되지 않음 (ptrace와 다른 점) 대상 프로세스를 멈추지 않고 메모리를 직접 읽을 수 있어 라이브 디버깅/체크포인트에 적합
Cross Memory Attach: 커널이 대상 프로세스의 페이지 테이블을 참조하여 단일 복사로 메모리를 전달합니다.
방법대상 프로세스 정지접근 단위시스템 콜 수주 용도
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_createmmap(MAP_SHARED|MAP_ANONYMOUS)
파일시스템 경로/dev/shm/이름없음 (익명)없음 (익명)
fd 전달(다른 프로세스)이름 공유 후 재열기SCM_RIGHTS로 fd 전달fork() 후 자식만 공유
비정상 종료 시 정리수동 (shm_unlink)자동 (fd 닫히면 해제)자동
sealing (불변 보장)불가MFD_ALLOW_SEALING불가
샌드박스 내 사용파일시스템 접근 필요syscall만으로 가능가능
대표 사용처데이터베이스 공유 버퍼Wayland, 샌드박스, JVM JITfork 기반 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 재사용 레이스입니다.

PID 재사용 레이스 조건 (전통 kill vs pidfd) 시간 → ❌ 전통 kill(pid) 자식 PID=1234 실행 중 PID=1234 종료 새 프로세스 PID=1234 재사용! kill(1234, SIGTERM) → 엉뚱한 프로세스! ✅ pidfd_send_signal(pidfd) 자식 PID=1234 pidfd 생성 PID=1234 종료 (pidfd 여전히 유효) 새 프로세스 PID=1234 재사용 pidfd_send_signal() → ESRCH (안전하게 실패) 핵심: pidfd는 프로세스가 아니라 "프로세스의 수명"을 가리킨다 fd 참조 카운팅으로 원래 프로세스의 task_struct를 고정 → PID 재사용과 무관하게 정확히 동작 프로세스가 이미 종료되면 pidfd_send_signal()은 시그널을 보내지 않고 ESRCH를 반환
PID 재사용 레이스: 전통 kill()은 잘못된 프로세스에 시그널을 보낼 수 있지만, pidfd는 원래 프로세스 종료 후 안전하게 실패합니다.
#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_CREDENTIALSSO_PEERCRED는 PID 재사용 레이스에 취약했습니다. 커널 6.5에서 추가된 SCM_PIDFDSO_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 메커니즘을 쓰면 다음 문제가 생깁니다:

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

특성BinderUnix Domain Socket
복사 횟수1회 (mmap 활용)2회 (유저→커널→유저)
호출자 인증커널이 PID/UID 자동 첨부SCM_CREDENTIALS (opt-in)
객체 수명 관리참조 카운팅, 사망 통지없음
통신 모델동기 RPC (기본)스트림 / 데이터그램
보안SELinux Binder 훅파일 권한 + SCM
오버헤드(Overhead)ioctl + mmap 설정 비용단순 소켓 생성
사용 범위Android 전용모든 Unix 계열 OS
Binder 트랜잭션 흐름 (단일 복사) 클라이언트 프로세스 Proxy 객체 (BpBinder) ioctl(BINDER_WRITE_READ) BC_TRANSACTION → 데이터 전송 요청 타겟 프로세스 Stub 객체 (BBinder) ioctl(BINDER_WRITE_READ) BR_TRANSACTION ← 데이터 수신 통지 Binder 드라이버 (drivers/android/binder.c) copy_from_user() binder_buffer 물리 페이지 → mmap 매핑 물리 페이지를 타겟 프로세스의 mmap 영역에 직접 매핑 (추가 복사 없음) 응답 흐름 (Reply) 타겟: BC_REPLY → 드라이버 → 클라이언트: BR_REPLY 동일한 단일 복사 메커니즘으로 응답 전달 ※ UDS: send()→커널 복사→recv()→유저 복사 (2회) | Binder: copy_from_user()→mmap 매핑 (1회)

Binder 단일 복사 메커니즘:

  1. 송신: copy_from_user()로 유저 데이터 → 커널 binder_buffer
  2. 커널이 binder_buffer의 물리 페이지를 수신 측 mmap 영역에 매핑
  3. 수신: mmap된 가상 주소(Virtual Address)에서 직접 데이터 접근 (추가 복사 없음)

UDS는 send()→커널 버퍼 복사 → recv()→유저 버퍼 복사로 2회 복사가 필요합니다.

Android는 3개의 Binder 도메인을 사용합니다:

Binder 자료구조, 프로토콜, mmap 메커니즘, SELinux 정책 등 내용은 Android 커널 — Binder IPC 섹션을 참고합니다.

현대 IPC 메커니즘은 "fd 기반 + epoll 통합 + namespace-aware" 설계 원칙을 공유하며, 최근 커널 릴리스에서도 이 방향으로 보강이 이어지고 있습니다.

io_uring: 이벤트 루프의 다음 세대

io_uring은 단일 시스템 콜로 다수의 I/O 연산을 제출·수확할 수 있어, 전통적인 epoll + read/write/send/recv 루프를 대체하는 기반으로 자리잡고 있습니다. 커널 6.10 이후 주요 변화는 다음과 같습니다.

/* 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에서 다음과 같은 보강이 있었습니다.

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_PIPEpipe() 시스템 콜을 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를 사용합니다. 링 버퍼 메모리를 절약하면서도 필요한 곳에 메타데이터를 추가할 수 있습니다.

커널 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 환경에서 프로세스 신원을 안전하게 교환하는 새로운 패턴을 제공합니다.

참고자료