IPC (Inter-Process Communication)

Linux 커널의 프로세스(Process) 간 통신(IPC)을 데이터 전달, 동기화, 이벤트 통지 관점으로 체계화해 설명합니다. Pipe/Unix domain socket 경로, System V와 POSIX IPC의 자원 관리 모델, futex 기반 사용자 동기화, eventfd/signalfd/timerfdepoll 결합, Netlink 제어 평면, ipc namespace 격리(Isolation)와 권한 이슈, 실무 성능 병목(Bottleneck)과 디버깅(Debugging) 절차까지 심층 분석합니다.

관련 표준: POSIX.1-2017 (POSIX IPC, 시그널(Signal), 파이프), System V IPC (메시지 큐, 공유 메모리, 세마포어(Semaphore)) — 커널 IPC 메커니즘이 구현하는 핵심 규격입니다. 종합 목록은 참고자료 — 표준 & 규격 섹션을 참고하세요.
전제 조건: 프로세스 스케줄러(Scheduler)프로세스 문서를 먼저 읽으세요. 실행 단위 관리 주제는 태스크(Task) 상태 전이와 큐 정책이 핵심이므로, 스케줄링 기준과 wakeup 경로를 먼저 이해해야 합니다.

핵심 요약

  • Pipe — 단방향 바이트 스트림. 부모-자식 프로세스 간 가장 간단한 통신 수단입니다.
  • 공유 메모리 — 가장 빠른 IPC. 여러 프로세스가 같은 물리 페이지(Page)를 매핑(Mapping)하여 데이터를 공유합니다.
  • 메시지 큐 — 구조화된 메시지를 큐에 넣고 꺼내는 방식. System V와 POSIX 두 가지 API가 있습니다.
  • Unix Domain Socket — 같은 호스트 내 프로세스 간 양방향 통신. 네트워크 소켓(Socket)과 동일한 API를 사용합니다.
  • eventfd / signalfd — 이벤트 통지를 위한 경량 파일 디스크립터(File Descriptor) 기반 메커니즘입니다.

단계별 이해

  1. 파이프 체험ls | grep txt처럼 셸의 |가 바로 파이프입니다. 왼쪽 프로세스의 stdout이 오른쪽의 stdin으로 연결됩니다.

    C에서는 pipe() 시스템 콜(System Call)로 생성합니다.

  2. 공유 메모리 이해shmget()/shmat() 또는 mmap(MAP_SHARED)로 생성합니다.

    동기화(세마포어, 뮤텍스(Mutex))가 없으면 데이터 경쟁이 발생할 수 있습니다.

  3. 소켓 통신 — Unix Domain Socket은 파일 경로를 주소로 사용하며, AF_UNIX로 생성합니다.

    Docker, systemd, X11 등 많은 시스템 데몬이 UDS를 사용합니다.

  4. 선택 기준 — 단순 데이터 전달은 파이프, 대용량 공유는 공유 메모리, 구조화 메시지는 메시지 큐, 네트워크 호환 필요 시 소켓을 선택합니다.

    ipcs 명령어로 시스템의 현재 IPC 리소스를 확인할 수 있습니다.

IPC 개요와 분류

Linux 커널은 프로세스 간 데이터 교환, 동기화, 이벤트 통지를 위해 다양한 IPC 메커니즘을 제공합니다. 각 메커니즘은 서로 다른 사용 사례에 최적화되어 있으며, 커널 내부에서 별도의 서브시스템으로 구현됩니다.

Linux IPC 아키텍처 전체 개요 유저 공간 (User Space) Shell / Daemon Application A Application B glibc (pthread) systemd / D-Bus Android (Binder) 시스템 콜 인터페이스 (System Call Interface) pipe2() kill() rt_sigaction() msgget/snd/rcv mq_open mq_send/recv futex() eventfd signalfd / timerfd socket() / bind() ioctl (binder) 커널 공간 (Kernel Space) Pipe / FIFO pipefs (fs/pipe.c) pipe_inode_info circular buffer Signal kernel/signal.c sigpending, sigaction TIF_SIGPENDING SysV IPC msg.c, shm.c, sem.c ipc_namespace kern_ipc_perm + IDR POSIX IPC ipc/mqueue.c tmpfs (/dev/shm) mqueue_inode_info Futex kernel/futex/ hash bucket rt_mutex / PI eventfd signalfd / timerfd fs/eventfd.c epoll 통합, wait_queue Socket AF_UNIX / Netlink sk_buff, unix_sock SCM_RIGHTS Binder drivers/android/ binder.c mmap + 트랜잭션 공통 인프라 wait_queue_head_t | schedule() / wake_up() | copy_from_user / copy_to_user | ipc_namespace | VFS (file_operations) | mm_struct (mmap) 유저 공간 시스템 콜 커널 서브시스템 공통 인프라
Linux IPC 아키텍처 전체 개요 - 유저 공간 애플리케이션이 시스템 콜을 통해 커널의 각 IPC 서브시스템에 접근하는 구조

IPC 메커니즘 분류

범주 메커니즘 데이터 방향 핵심 특징
바이트 스트림 Pipe, FIFO 단방향 fd 기반, 부모-자식 또는 이름 기반 통신
시그널 Signal, signalfd 단방향 (통지) 비동기 이벤트 전달, 제한된 정보량
메시지 전달 SysV MQ, POSIX MQ 양방향 메시지 경계 보존, 우선순위(Priority) 지원
공유 메모리 SysV SHM, POSIX SHM, mmap 양방향 최고 성능, 별도 동기화 필요
동기화 SysV Sem, POSIX Sem, Futex - 프로세스 간 잠금(Lock)/동기화
이벤트/fd 기반 eventfd, signalfd, timerfd 단방향 epoll 통합, 이벤트 루프(Event Loop) 친화적
소켓 Unix Domain Socket, Netlink 양방향 네트워크 API 활용, 유연한 프로토콜

IPC Namespace

Linux 커널은 ipc_namespace를 통해 IPC 자원을 네임스페이스별로 격리합니다. System V IPC 객체(메시지 큐, 공유 메모리, 세마포어)는 모두 이 네임스페이스(Namespace)에 의해 격리되며, 컨테이너(Container) 환경에서 IPC 자원의 독립성을 보장합니다.

/* include/linux/ipc_namespace.h */
struct ipc_namespace {
    struct ipc_ids  ids[3];    /* IPC_SEM, IPC_MSG, IPC_SHM */

    int     sem_ctls[4];         /* SEMMSL, SEMMNS, SEMOPM, SEMMNI */
    int     msg_ctlmax;            /* MSGMAX */
    int     msg_ctlmnb;            /* MSGMNB */
    int     msg_ctlmni;            /* MSGMNI */
    size_t  shm_ctlmax;            /* SHMMAX */
    size_t  shm_ctlall;            /* SHMALL */
    int     shm_ctlmni;            /* SHMMNI */
    ...
};

/* IPC 식별자 관리 구조체 */
struct ipc_ids {
    int               in_use;        /* 사용 중인 IPC 객체 수 */
    unsigned short    seq;           /* 시퀀스 번호 */
    struct rw_semaphore rwsem;       /* 읽기/쓰기 세마포어 */
    struct idr        ipcs_idr;      /* IDR 기반 ID 관리 */
    int               max_idx;       /* 최대 인덱스 */
    ...
};

/* 모든 SysV IPC 객체의 공통 권한 구조체 */
struct kern_ipc_perm {
    spinlock_t  lock;
    int         id;                /* IPC 식별자 */
    key_t       key;               /* IPC 키 */
    kuid_t      uid, gid;          /* 소유자 */
    kuid_t      cuid, cgid;        /* 생성자 */
    umode_t     mode;              /* 접근 모드 */
    unsigned long seq;             /* 시퀀스 번호 */
    refcount_t  refcount;
    ...
};
코드 설명
  • ipc_namespace 컨테이너(Container) 격리의 핵심 구조체입니다. 각 IPC 네임스페이스는 독립적인 ids[3] 배열을 가지며, 인덱스 0/1/2가 각각 세마포어/메시지 큐/공유 메모리에 대응합니다. sem_ctls, msg_ctl*, shm_ctl* 필드는 /proc/sys/kernel/ 아래 sysctl 한계값의 네임스페이스별 복사본으로, Docker나 LXC 같은 컨테이너 런타임이 IPC 자원 한도를 독립적으로 설정할 수 있게 합니다. (include/linux/ipc_namespace.h)
  • ipc_ids IPC 객체의 ID 할당과 조회를 관리합니다. ipcs_idr은 기존 배열 기반 ID 관리를 대체한 IDR(ID Radix tree) 구조로, O(log n) 탐색을 제공합니다. rwsemipcget()(쓰기)과 ipc_obtain_object_check()(읽기) 사이의 동시성을 제어하며, seq는 ID 재사용 시 stale 핸들을 탐지하기 위한 시퀀스 번호입니다. (ipc/util.h)
  • kern_ipc_perm 모든 System V IPC 객체(msg_queue, shmid_kernel, sem_array)의 첫 번째 멤버로 임베딩되어, 다형적 접근을 가능하게 하는 공통 헤더입니다. keyftok()가 생성하는 사용자 식별자이고, id는 커널이 인덱스+시퀀스를 조합하여 반환하는 실제 핸들입니다. uid/gidcuid/cgid를 분리하여 소유권 이전 후에도 원래 생성자를 추적할 수 있으며, mode는 파일 퍼미션과 동일한 9비트 접근 제어를 제공합니다. (include/linux/ipc.h)
ℹ️

IPC 키와 ID: System V IPC에서 key_tftok()로 생성하는 전역 식별자이고, id는 커널이 반환하는 실제 IPC 객체 핸들입니다. ID는 인덱스와 시퀀스 번호로 구성되어 재사용 시 stale 참조를 감지합니다.

Pipes & FIFOs

Pipe는 Linux에서 가장 기본적인 IPC 메커니즘으로, 두 개의 파일 디스크립터(읽기/쓰기)를 통해 단방향 바이트 스트림을 전달합니다. 커널 내부에서 pipe는 pipefs 가상 파일시스템(VFS) 위의 특수 inode로 구현됩니다.

Pipe 내부 구조

Pipe의 핵심 자료구조는 struct pipe_inode_info이며, 환형 버퍼(Buffer)로 구현됩니다. 기본 버퍼 크기는 16개 pipe_buffer 슬롯(총 64KB)이며, F_SETPIPE_SZ로 최대 1MB까지 조절 가능합니다.

/* include/linux/pipe_fs_i.h */
struct pipe_inode_info {
    struct mutex       mutex;        /* pipe 잠금 */
    wait_queue_head_t  rd_wait;      /* 읽기 대기 큐 */
    wait_queue_head_t  wr_wait;      /* 쓰기 대기 큐 */
    unsigned int       head;         /* 쓰기 위치 (생산자) */
    unsigned int       tail;         /* 읽기 위치 (소비자) */
    unsigned int       max_usage;    /* 최대 슬롯 수 */
    unsigned int       ring_size;    /* 할당된 링 크기 */
    unsigned int       readers;      /* 읽기 참조 카운트 */
    unsigned int       writers;      /* 쓰기 참조 카운트 */
    struct pipe_buffer *bufs;         /* pipe_buffer 배열 */
    ...
};

struct pipe_buffer {
    struct page     *page;          /* 데이터가 담긴 페이지 */
    unsigned int    offset;         /* 페이지 내 오프셋 */
    unsigned int    len;            /* 데이터 길이 */
    const struct pipe_buf_operations *ops;
    unsigned int    flags;
};
Pipe 환형 버퍼 구조 Writer write(fd[1]) pipe_buffer[ring_size] [0] [1] [2] [3] [4] ... [N-1] tail (읽기) head (쓰기) Reader read(fd[0]) 기본: 16 슬롯 × 4KB 페이지 = 64KB 가득 차면 writer 블록 / 비면 reader 블록 pipe_buffer: { page*, offset, len, ops, flags }
Pipe 환형 버퍼 구조 - Writer가 head에 쓰고 Reader가 tail에서 읽음

pipe2() 시스템 콜

pipe2()는 파이프를 생성하는 시스템 콜로, 커널 내부에서 do_pipe2()를 통해 처리됩니다. O_CLOEXECO_NONBLOCK 플래그를 지원합니다.

/* fs/pipe.c - do_pipe2() 핵심 흐름 (간략화) */
static int do_pipe2(int __user *fildes, int flags)
{
    struct file *files[2];
    int fd[2];
    int error;

    /* 1. pipe inode + pipe_inode_info 할당 */
    error = __do_pipe_flags(fd, files, flags);
    if (!error) {
        /* 2. fd[0] = 읽기, fd[1] = 쓰기를 유저에 복사 */
        if (copy_to_user(fildes, fd, sizeof(fd))) {
            fput(files[0]);
            fput(files[1]);
            put_unused_fd(fd[0]);
            put_unused_fd(fd[1]);
            error = -EFAULT;
        } else {
            fd_install(fd[0], files[0]);
            fd_install(fd[1], files[1]);
        }
    }
    return error;
}

splice / tee / vmsplice

Linux는 파이프와 파일 사이에서 데이터를 복사 없이(zero-copy) 이동할 수 있는 시스템 콜을 제공합니다. 이들은 pipe_buffer의 페이지 참조를 직접 전달하여 불필요한 메모리 복사를 제거합니다.

시스템 콜 동작 용도
splice() fd ↔ pipe 간 zero-copy 이동 파일→파이프→소켓 (sendfile 대체)
tee() pipe → pipe 간 zero-copy 복제 파이프 데이터 분기 (tee 명령어)
vmsplice() 유저 메모리 → pipe zero-copy 유저 버퍼를 파이프에 직접 매핑

Named Pipes (FIFO)

FIFO(Named Pipe)는 파일시스템(Filesystem)에 이름을 가지는 파이프로, mkfifo() 또는 mknod()로 생성합니다. 관계없는 프로세스 간에도 파이프 통신이 가능하며, 내부적으로는 anonymous pipe와 동일한 pipe_inode_info 구조를 사용합니다.

/* FIFO 생성과 사용 (유저 공간) */
mkfifo("/tmp/myfifo", 0666);

/* Writer 프로세스 */
int wfd = open("/tmp/myfifo", O_WRONLY);
write(wfd, buf, len);

/* Reader 프로세스 */
int rfd = open("/tmp/myfifo", O_RDONLY);
read(rfd, buf, len);
💡

Pipe 크기 제한: /proc/sys/fs/pipe-max-size는 비특권 사용자가 설정할 수 있는 최대 파이프 크기(기본 1MB)를 제어합니다. /proc/sys/fs/pipe-user-pages-softpipe-user-pages-hard는 사용자별 총 파이프 버퍼 페이지 수를 제한합니다.

Signals

시그널은 프로세스에 비동기적으로 이벤트를 통지하는 가장 오래된 IPC 메커니즘입니다. 커널은 시그널 전달 시 대상 프로세스의 task_struct에 시그널 정보를 큐잉하고, 해당 프로세스가 유저 모드로 복귀할 때 시그널 핸들러(Handler)를 실행합니다.

표준 시그널 목록

리눅스는 31개의 표준 시그널(1~31)을 정의합니다. 각 시그널의 기본 동작(default action)은 다섯 가지 중 하나입니다:

기본 동작 설명
Term프로세스 종료
Core코어 덤프(Core Dump) 생성 후 종료
Stop프로세스 중지 (suspended)
Cont중지된 프로세스 재개
Ign시그널 무시
번호 이름 기본 동작 설명
1SIGHUPTerm제어 터미널 hangup 또는 제어 프로세스 종료
2SIGINTTerm키보드 인터럽트 (Ctrl+C)
3SIGQUITCore키보드 종료 (Ctrl+\)
4SIGILLCore잘못된 명령어 (illegal instruction)
5SIGTRAPCore트레이스/브레이크포인트 트랩
6SIGABRTCoreabort() 호출
7SIGBUSCore버스(Bus) 오류 (잘못된 메모리 접근 정렬)
8SIGFPECore부동소수점 예외 (0으로 나누기 포함)
9SIGKILLTerm강제 종료 (캐치/무시 불가)
10SIGUSR1Term사용자 정의 시그널 1
11SIGSEGVCore잘못된 메모리 참조 (segmentation fault)
12SIGUSR2Term사용자 정의 시그널 2
13SIGPIPETerm읽는 쪽이 없는 파이프에 쓰기
14SIGALRMTermalarm() 타이머(Timer) 만료
15SIGTERMTerm정상 종료 요청
16SIGSTKFLTTerm코프로세서 스택 오류 (미사용)
17SIGCHLDIgn자식 프로세스 중지 또는 종료
18SIGCONTCont중지된 프로세스 재개
19SIGSTOPStop프로세스 중지 (캐치/무시 불가)
20SIGTSTPStop터미널 중지 (Ctrl+Z)
21SIGTTINStop백그라운드 프로세스의 터미널 입력
22SIGTTOUStop백그라운드 프로세스의 터미널 출력
23SIGURGIgn소켓의 긴급(OOB) 데이터
24SIGXCPUCoreCPU 시간 제한 초과
25SIGXFSZCore파일 크기 제한 초과
26SIGVTALRMTerm가상 타이머 만료
27SIGPROFTerm프로파일링(Profiling) 타이머 만료
28SIGWINCHIgn터미널 윈도우 크기 변경
29SIGIOTermI/O 가능 (async I/O)
30SIGPWRTerm전원 장애
31SIGSYSCore잘못된 시스템 콜 (seccomp 필터 위반 포함)
ℹ️

SIGKILL(9)과 SIGSTOP(19)은 캐치, 무시, 마스크할 수 없는 유일한 시그널입니다. 커널은 sigaction()sigprocmask()에서 이 두 시그널에 대한 변경을 자동으로 무시합니다.

시그널 생성 경로

시그널은 네 가지 소스에서 생성됩니다:

생성 소스 메커니즘 대표 시그널
하드웨어 예외 CPU 트랩/폴트 → 커널 예외 핸들러 → force_sig() SIGSEGV, SIGFPE, SIGBUS, SIGILL
커널 소프트웨어 이벤트 커널 내부 조건 발생 → send_sig() SIGCHLD, SIGPIPE, SIGURG, SIGALRM
유저 프로세스 시스템 콜 → 권한 검사 → __send_signal() kill(), tkill(), tgkill(), rt_sigqueueinfo()
터미널 드라이버 TTY line discipline → isig()kill_pgrp() SIGINT(Ctrl+C), SIGQUIT(Ctrl+\), SIGTSTP(Ctrl+Z)
/* 하드웨어 예외에 의한 시그널 생성 (arch/x86/kernel/traps.c 기반) */
/* 예: 0으로 나누기 → #DE 예외 → do_divide_error() */
static void do_error_trap(struct pt_regs *regs, long error_code,
                          char *str, unsigned long trapnr, int signr)
{
    /* 유저 모드에서 발생한 경우 시그널 전송 */
    if (user_mode(regs)) {
        force_sig(signr);  /* SIGFPE, SIGSEGV 등 */
        return;
    }
    /* 커널 모드면 oops/panic */
    die(str, regs, error_code);
}

/* 유저 프로세스에 의한 시그널 생성 (kernel/signal.c) */
/* kill(pid, sig) → sys_kill() → kill_something_info() */
SYSCALL_DEFINE2(kill, pid_t, pid, int, sig)
{
    struct kernel_siginfo info;
    prepare_kill_siginfo(sig, &info);
    return kill_something_info(sig, &info, pid);
}

시그널 전달 내부 구조

Signal 전달 흐름 kill() / rt_sigqueueinfo() 시그널 발생 __send_signal() sigqueue 할당 sigpending 큐 TIF_SIGPENDING 설정 do_signal() 유저 복귀 시 handler 실행 struct sigpending { struct list_head list; /* sigqueue */ sigset_t signal; /* 비트마스크 */ }; struct sigqueue { struct list_head list; kernel_siginfo_t info; /* signo, code, ... */ };
Signal 전달 흐름 - 시그널 발생부터 핸들러 실행까지
/* kernel/signal.c - __send_signal() 핵심 (간략화) */
static int __send_signal(int sig, struct kernel_siginfo *info,
                         struct task_struct *t, enum pid_type type)
{
    struct sigpending *pending;
    struct sigqueue *q;

    /* 프로세스/스레드 pending 큐 선택 */
    pending = (type != PIDTYPE_PID) ?
              &t->signal->shared_pending : &t->pending;

    /* 이미 동일 시그널이 pending이면 (비RT) 무시 */
    if (legacy_queue(pending, sig))
        goto ret;

    /* sigqueue 구조체 할당 */
    q = __sigqueue_alloc(sig, t, GFP_ATOMIC, override_rlimit);
    if (q) {
        list_add_tail(&q->list, &pending->list);
        copy_siginfo(&q->info, info);
    }

    /* 시그널 비트마스크 설정 */
    sigaddset(&pending->signal, sig);

    /* TIF_SIGPENDING 플래그 설정 → 유저 복귀 시 확인 */
    complete_signal(sig, t, type);
ret:
    return 0;
}
코드 설명
  • pending 큐 선택 PIDTYPE_PID이면 특정 스레드의 t->pending에, 그 외(PIDTYPE_TGID 등)이면 프로세스 그룹 공유 큐 t->signal->shared_pending에 시그널을 넣습니다. kill(pid, sig)는 그룹 큐를, tgkill(tgid, tid, sig)는 개별 스레드 큐를 사용합니다.
  • legacy_queue() 검사 표준 시그널(1~31번)은 큐잉되지 않으므로, 동일 시그널이 이미 pending 비트마스크에 설정되어 있으면 중복 전달을 방지합니다. 반면 실시간 시그널(32~64번)은 이 검사를 건너뛰어 동일 시그널이 여러 번 큐잉될 수 있습니다.
  • __sigqueue_alloc() sigqueue 구조체를 GFP_ATOMIC으로 할당합니다. 인터럽트 컨텍스트(하드웨어 예외 등)에서도 호출될 수 있으므로 슬립 불가능한 할당을 사용합니다. 사용자당 시그널 큐 제한(RLIMIT_SIGPENDING)을 초과하면 할당이 실패하고, siginfo 없이 시그널 비트만 설정됩니다.
  • complete_signal() 대상 스레드(또는 스레드 그룹 중 적합한 스레드)에 TIF_SIGPENDING 플래그를 설정합니다. 이 플래그는 시스템 콜/인터럽트에서 유저 모드로 복귀하는 경로(exit_to_user_mode_loop())에서 확인되어 시그널 전달을 트리거합니다. (kernel/signal.c)

시그널 핸들러 실행 메커니즘

시그널 핸들러 실행은 커널이 유저 스택을 직접 조작하는 정교한 과정입니다. 프로세스가 시스템 콜이나 인터럽트에서 유저 모드로 복귀하기 직전에 do_signal()이 호출되며, pending 시그널이 있으면 handle_signal()을 통해 핸들러 실행을 설정합니다.

시그널 핸들러 실행 흐름 유저 → 커널 전환 syscall / interrupt do_signal() pending 확인 handle_signal() 핸들러 선택 setup_rt_frame() 유저 스택 조작 handler() 유저 모드 handler 종료 return rt_sigreturn() trampoline이 호출 restore_sigcontext() 레지스터/마스크 복원 원래 실행 재개 유저 모드 유저 스택 rt_sigframe 구조 pretcode (sigreturn) struct siginfo struct ucontext ↑ 리턴 주소를 sigreturn trampoline로 ↑ si_signo, si_code si_addr 등 정보 ↑ uc_mcontext: 저장된 레지스터 + 시그널 마스크 핸들러 return → pretcode의 sigreturn 트램폴린 실행 → 커널이 ucontext로 원래 상태 복원
시그널 핸들러 실행과 스택 프레임(Stack Frame) 구조 - 커널이 유저 스택을 조작하여 핸들러를 호출하고 sigreturn으로 복원
/* arch/x86/kernel/signal.c - 핸들러 실행 설정 (간략화) */
static int setup_rt_frame(struct ksignal *ksig,
                           struct pt_regs *regs)
{
    struct rt_sigframe __user *frame;

    /* 유저 스택에 rt_sigframe 공간 확보 */
    frame = get_sigframe(ksig, regs, sizeof(*frame));

    /* 현재 레지스터 상태를 ucontext에 저장 */
    put_user_sigcontext(&frame->uc.uc_mcontext, regs);

    /* siginfo 복사 */
    copy_siginfo_to_user(&frame->info, &ksig->info);

    /* 시그널 마스크 저장 */
    __put_user(current->blocked, &frame->uc.uc_sigmask);

    /* sigreturn 트램폴린 설정 (VDSO 사용) */
    frame->pretcode = current->mm->context.vdso +
                      vdso_image_32.sym___kernel_rt_sigreturn;

    /* 레지스터 조작: 핸들러가 실행되도록 설정 */
    regs->ip = (unsigned long)ksig->ka.sa.sa_handler;
    regs->sp = (unsigned long)frame;
    regs->di = ksig->sig;            /* 첫 번째 인자: signo */
    regs->si = (unsigned long)&frame->info;   /* SA_SIGINFO: siginfo */
    regs->dx = (unsigned long)&frame->uc;     /* SA_SIGINFO: ucontext */

    return 0;
}

/* 핸들러 종료 후 트램폴린이 호출하는 시스템 콜 */
SYSCALL_DEFINE0(rt_sigreturn)
{
    struct pt_regs *regs = current_pt_regs();
    struct rt_sigframe __user *frame;

    frame = (struct rt_sigframe __user *)(regs->sp - sizeof(long));

    /* ucontext에서 레지스터와 시그널 마스크 복원 */
    restore_sigcontext(regs, &frame->uc.uc_mcontext);
    set_current_blocked(&frame->uc.uc_sigmask);

    return regs->ax;  /* 원래 시스템 콜 반환값 복원 */
}

sighand_struct

sighand_struct는 프로세스(스레드 그룹)가 공유하는 시그널 핸들러 테이블을 관리합니다. 64개 시그널 각각에 대한 k_sigaction을 저장합니다.

/* include/linux/sched/signal.h */
struct sighand_struct {
    refcount_t        count;         /* 참조 카운트 */
    struct k_sigaction action[_NSIG]; /* 64개 시그널 핸들러 */
    spinlock_t        siglock;       /* 시그널 처리 잠금 */
    wait_queue_head_t signalfd_wqh;  /* signalfd 대기 큐 */
};

struct k_sigaction {
    struct sigaction sa;
    /* sa.sa_handler: SIG_DFL, SIG_IGN, 또는 유저 핸들러 */
    /* sa.sa_flags: SA_RESTART, SA_SIGINFO, SA_NOCLDSTOP, ... */
    /* sa.sa_mask: 핸들러 실행 중 차단할 시그널 마스크 */
};

시그널 마스크

각 스레드(Thread)는 task_struct.blocked에 시그널 마스크(sigset_t)를 가지며, sigprocmask()로 제어합니다. 마스크된 시그널은 pending 상태로 유지되다가 마스크가 해제되면 전달됩니다. SIGKILLSIGSTOP은 마스크할 수 없습니다.

대체 시그널 스택 (sigaltstack)

시그널 핸들러는 기본적으로 현재 프로세스의 유저 스택에서 실행됩니다. 문제는 스택 오버플로(Stack Overflow)가 발생하면 SIGSEGV가 생성되는데, 핸들러도 같은 스택에서 실행하려 하면 또 다시 스택 오버플로가 발생하여 핸들러 자체를 실행할 수 없다는 것입니다. sigaltstack()은 이 문제를 해결하기 위해 별도의 시그널 전용 스택을 설정합니다.

/* 유저 공간: 대체 시그널 스택 설정 */
stack_t ss;
ss.ss_sp = malloc(SIGSTKSZ);     /* 시그널 스택 메모리 할당 */
ss.ss_size = SIGSTKSZ;             /* 기본 크기 (보통 8192) */
ss.ss_flags = 0;
sigaltstack(&ss, NULL);

/* SA_ONSTACK 플래그로 핸들러 등록 */
struct sigaction sa;
sa.sa_handler = segv_handler;
sa.sa_flags = SA_ONSTACK;          /* 대체 스택에서 실행 */
sigaction(SIGSEGV, &sa, NULL);
/* kernel/signal.c - get_sigframe()에서 대체 스택 선택 로직 (간략화) */
static unsigned long get_sigframe(struct ksignal *ksig,
                                   struct pt_regs *regs,
                                   size_t frame_size)
{
    unsigned long sp = regs->sp;

    /* SA_ONSTACK 설정되었고, 현재 대체 스택 위가 아닌 경우 */
    if (ksig->ka.sa.sa_flags & SA_ONSTACK) {
        if (!on_sig_stack(sp) &&
            !(current->sas_ss_flags & SS_DISABLE))
            sp = current->sas_ss_sp + current->sas_ss_size;
    }

    sp -= frame_size;
    sp = round_down(sp, 16);  /* 16바이트 정렬 */
    return sp;
}
💡

ss_flags 값: SS_DISABLE는 대체 스택을 비활성화하고, SS_ONSTACK은 현재 대체 스택 위에서 실행 중임을 나타냅니다 (읽기 전용(Read-Only)). SS_AUTODISARM(Linux 4.7+)은 핸들러 진입 시 자동으로 대체 스택을 해제하여 중첩 시그널 시 스택 충돌을 방지합니다.

fork/exec 시그널 상속

fork()exec()는 시그널 관련 속성을 각각 다르게 처리합니다. 이 차이를 이해하는 것은 데몬 프로세스나 멀티프로세스 프로그램 설계에 중요합니다.

시그널 속성 fork() exec()
시그널 핸들러 상속 (부모와 동일) 모두 SIG_DFL로 리셋
시그널 마스크 (blocked) 상속 유지
Pending 시그널 클리어 (자식은 비어있음) 유지
SIG_IGN 설정 상속 유지 (SIG_IGN은 리셋 안 됨)
대체 시그널 스택 상속 비활성화
SA_NOCLDWAIT 등 플래그 상속 SIG_DFL 리셋 시 함께 클리어
/* kernel/fork.c - copy_sighand() (fork 시 핸들러 테이블 복사) */
static int copy_sighand(unsigned long clone_flags,
                         struct task_struct *tsk)
{
    if (clone_flags & CLONE_SIGHAND) {
        /* 스레드 생성: sighand_struct 공유 (refcount++) */
        refcount_inc(&current->sighand->count);
        return 0;
    }
    /* 프로세스 fork: sighand_struct 복사 */
    struct sighand_struct *sig = kmem_cache_alloc(sighand_cachep, GFP_KERNEL);
    memcpy(sig->action, current->sighand->action, sizeof(sig->action));
    tsk->sighand = sig;
    return 0;
}

/* fs/exec.c - flush_signal_handlers() (exec 시 핸들러 리셋) */
void flush_signal_handlers(struct task_struct *t)
{
    struct k_sigaction *ka = &t->sighand->action[0];
    for (int i = _NSIG; i != 0; i--) {
        /* SIG_IGN은 유지, 나머지 핸들러는 SIG_DFL로 */
        if (ka->sa.sa_handler != SIG_IGN)
            ka->sa.sa_handler = SIG_DFL;
        ka->sa.sa_flags = 0;
        sigemptyset(&ka->sa.sa_mask);
        ka++;
    }
}

멀티스레드 시그널 전달

POSIX 스레드 모델에서 시그널 전달은 프로세스 지향(process-directed)과 스레드 지향(thread-directed)으로 나뉩니다. 커널은 이 두 종류를 shared_pendingpending 큐로 구분합니다.

구분 프로세스 지향 시그널 스레드 지향 시그널
signal->shared_pending task_struct->pending
발생 API kill(), sigqueue() tgkill(), tkill(), pthread_kill()
하드웨어 예외 예외 발생 스레드에 직접 전달
전달 대상 마스크 안 한 임의의 스레드 지정된 특정 스레드
/* kernel/signal.c - complete_signal() (간략화)
 * 프로세스 지향 시그널의 대상 스레드 선택 알고리즘 */
static void complete_signal(int sig, struct task_struct *p,
                             enum pid_type type)
{
    struct task_struct *t, *signal_target;

    /* 1단계: 메인 스레드가 마스크 안 했으면 메인 스레드 선택 */
    signal_target = p;
    if (wants_signal(sig, p))
        goto found;

    /* 2단계: 마스크 안 한 스레드를 순회하여 찾기 */
    t = p;
    while_each_thread(p, t) {
        if (wants_signal(sig, t)) {
            signal_target = t;
            goto found;
        }
    }
    return;  /* 모든 스레드가 마스크 → pending으로 유지 */

found:
    /* TIF_SIGPENDING 설정하고 필요 시 깨움 */
    signal_wake_up(signal_target, sig == SIGKILL);
}

/* wants_signal(): 해당 스레드가 시그널을 받을 수 있는지 확인 */
static bool wants_signal(int sig, struct task_struct *p)
{
    if (sigismember(&p->blocked, sig))
        return false;        /* 마스크됨 */
    if (p->flags & PF_EXITING)
        return false;        /* 종료 중 */
    if (task_is_stopped_or_traced(p))
        return sig == SIGKILL; /* 중지 상태면 SIGKILL만 */
    return true;
}
💡

멀티스레드 시그널 처리 패턴: 일반적으로 모든 스레드에서 pthread_sigmask()로 시그널을 블록하고, 전담 스레드에서 sigwait() 또는 signalfd()로 동기적으로 처리하는 것이 가장 안전합니다. 이렇게 하면 비동기 시그널 핸들러의 복잡한 안전성 문제를 피할 수 있습니다.

실시간(Real-time) 시그널

표준 시그널(1~31)은 pending 비트마스크만 설정하므로 동일 시그널이 여러 번 발생해도 한 번만 전달됩니다. 실시간 시그널(SIGRTMIN~SIGRTMAX, 32~64)은 큐잉되어 발생 횟수만큼 전달되며, sigqueue()로 추가 데이터(sigval)를 전달할 수 있습니다.

속성 표준 시그널 (1~31) 실시간 시그널 (32~64)
큐잉 안 됨 (비트마스크만) 됨 (발생 횟수만큼)
순서 보장(Ordering) 보장 안 됨 번호 순서 보장
추가 데이터 제한적 (siginfo) sigval (int 또는 void*)
기본 동작 시그널별 다름 모두 프로세스 종료

signalfd

signalfd()는 시그널을 파일 디스크립터를 통해 동기적으로 수신할 수 있게 합니다. epoll/select/poll과 통합하여 이벤트 루프에서 시그널을 처리할 수 있습니다.

/* signalfd 사용 예 (유저 공간) */
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGTERM);

/* 시그널을 블록하고 fd로 수신 */
sigprocmask(SIG_BLOCK, &mask, NULL);
int sfd = signalfd(-1, &mask, SFD_NONBLOCK | SFD_CLOEXEC);

/* epoll에 등록 후 이벤트 루프에서 처리 */
struct signalfd_siginfo si;
read(sfd, &si, sizeof(si));
printf("signal %d from PID %d\\n", si.ssi_signo, si.ssi_pid);

pidfd_send_signal()

전통적인 kill() 시스템 콜은 PID로 대상 프로세스를 지정하는데, PID는 프로세스 종료 후 재사용될 수 있어 race condition이 발생할 수 있습니다. Linux 5.1에서 추가된 pidfd_send_signal()은 파일 디스크립터 기반으로 프로세스를 식별하여 이 문제를 해결합니다.

비교 kill(pid, sig) pidfd_send_signal(pidfd, sig, ...)
대상 식별 PID (정수) pidfd (파일 디스크립터)
PID 재사용 안전 안전하지 않음 안전 (fd가 특정 프로세스에 바인딩)
race condition 검사~전송 사이 대상 변경 가능 fd가 유효한 한 동일 프로세스 보장
추가 정보 siginfo 제한적 siginfo_t 직접 지정 가능
/* pidfd를 이용한 안전한 시그널 전송 (유저 공간) */
#include <sys/syscall.h>
#include <signal.h>

/* 1. 대상 프로세스의 pidfd 획득 (Linux 5.3+) */
int pidfd = syscall(SYS_pidfd_open, target_pid, 0);
if (pidfd < 0) {
    perror("pidfd_open");
    return -1;
}

/* 이 시점에서 target_pid가 종료되고 PID가 재사용되더라도
 * pidfd는 원래 프로세스를 가리킨다.
 * 원래 프로세스가 이미 종료되었으면 ESRCH 반환 */

/* 2. pidfd를 통해 시그널 전송 */
int ret = syscall(SYS_pidfd_send_signal, pidfd, SIGTERM, NULL, 0);
if (ret < 0)
    perror("pidfd_send_signal");

/* 3. pidfd는 pollable — waitid(P_PIDFD)와도 연동 가능 */
close(pidfd);
/* kernel/signal.c - pidfd_send_signal 구현 (간략화) */
SYSCALL_DEFINE4(pidfd_send_signal, int, pidfd, int, sig,
                siginfo_t __user *, info, unsigned int, flags)
{
    struct pid *pid;
    struct task_struct *task;

    /* pidfd에서 struct pid 획득 */
    pid = pidfd_to_pid(pidfd);

    /* pid에서 task_struct 조회 (RCU 보호) */
    task = pid_task(pid, PIDTYPE_PID);
    if (!task)
        return -ESRCH;  /* 프로세스 이미 종료 */

    /* 권한 검사 후 시그널 전송 */
    return group_send_sig_info(sig, &kinfo, task, PIDTYPE_TGID);
}
⚠️

시그널 핸들러 안전성 (Async-Signal Safety): 시그널 핸들러는 프로그램 실행의 임의 지점에서 비동기적으로 호출됩니다. 따라서 핸들러 내에서는 async-signal-safe 함수만 사용해야 합니다. 대표적 안전 함수: write(), _exit(), signal(), sigaction(), read(), open(), close(), kill(). malloc(), printf(), pthread_mutex_lock() 등 대부분의 라이브러리 함수는 안전하지 않습니다. 핸들러에서 전역 변수에 접근할 때는 volatile sig_atomic_t 타입을 사용해야 합니다.

System V IPC

System V IPC는 메시지 큐, 공유 메모리, 세마포어의 세 가지 IPC 메커니즘을 제공하는 전통적인 UNIX IPC 인터페이스입니다. 커널의 ipc/ 디렉토리에 구현되어 있으며, 공통 인프라(ipc_ids, kern_ipc_perm)를 공유합니다.

SysV IPC 공통 인프라

/* ipc/util.h - IPC 연산 테이블 */
struct ipc_ops {
    int  (*getnew)(struct ipc_namespace *, struct ipc_params *);
    int  (*associate)(struct kern_ipc_perm *, int);
    int  (*more_checks)(struct kern_ipc_perm *, struct ipc_params *);
};

/* 공통 IPC 객체 조회/생성 흐름 */
/* xxxget() → ipcget() → ipcget_new() / ipcget_public()
   → ops->getnew() 또는 ops->associate() */

Message Queues (메시지 큐)

System V 메시지 큐는 프로세스 간에 메시지 경계가 보존되는 메시지를 주고받을 수 있게 합니다. 메시지는 타입 필드를 가지며, 수신자는 특정 타입의 메시지만 선택적으로 수신할 수 있습니다.

/* ipc/msg.c - 핵심 구조체 */
struct msg_queue {
    struct kern_ipc_perm q_perm;    /* 공통 IPC 권한 */
    time64_t             q_stime;   /* 마지막 msgsnd 시간 */
    time64_t             q_rtime;   /* 마지막 msgrcv 시간 */
    unsigned long        q_cbytes;  /* 큐 내 총 바이트 수 */
    unsigned long        q_qnum;    /* 큐 내 메시지 수 */
    unsigned long        q_qbytes;  /* 큐 최대 바이트 */
    struct list_head     q_messages; /* 메시지 연결 리스트 */
    struct list_head     q_receivers; /* 수신 대기 프로세스 */
    struct list_head     q_senders;  /* 송신 대기 프로세스 */
};

struct msg_msg {
    struct list_head m_list;
    long             m_type;       /* 메시지 타입 */
    size_t           m_ts;         /* 메시지 텍스트 크기 */
    struct msg_msgseg *next;       /* 큰 메시지용 세그먼트 */
    void            *security;
    /* 이후: 메시지 데이터 (인라인) */
};
코드 설명
  • msg_queue 하나의 System V 메시지 큐를 나타내며, 첫 멤버 q_perm(kern_ipc_perm)을 통해 공통 IPC 인프라에 등록됩니다. q_messages는 큐에 저장된 msg_msg 노드들의 이중 연결 리스트이고, q_receivers/q_senders는 각각 msgrcv()/msgsnd()에서 블로킹된 프로세스의 대기 리스트입니다. q_cbytesq_qbytes(기본값 MSGMNB=16384)를 초과하면 송신자가 슬립합니다. (ipc/msg.c)
  • msg_msg 개별 메시지를 나타내는 구조체로, 헤더 직후에 메시지 데이터가 인라인으로 저장됩니다. 한 페이지(4KB)에 담기지 않는 큰 메시지는 next 포인터로 msg_msgseg 체인을 연결하여 분할 저장합니다. m_type 필드가 메시지 선택적 수신의 핵심으로, msgrcv()msgtyp 인자가 양수이면 해당 타입만, 음수이면 절대값 이하의 최소 타입을 선택합니다. security는 LSM(SELinux 등)이 메시지별 보안 레이블을 부착하는 데 사용됩니다. (ipc/msg.c)
/* 유저 공간 사용 예 */
#include <sys/msg.h>

struct msgbuf {
    long mtype;
    char mtext[256];
};

/* 메시지 큐 생성/열기 */
key_t key = ftok("/tmp/mqfile", 65);
int msqid = msgget(key, 0666 | IPC_CREAT);

/* 메시지 전송 */
struct msgbuf msg = { .mtype = 1 };
strcpy(msg.mtext, "Hello IPC");
msgsnd(msqid, &msg, strlen(msg.mtext) + 1, 0);

/* 메시지 수신 (타입 1만) */
struct msgbuf rcv;
msgrcv(msqid, &rcv, sizeof(rcv.mtext), 1, 0);

msgsnd/msgrcv 커널 내부 흐름

SysV 메시지 큐 커널 흐름 msgsnd() 경로 copy_from_user() alloc_msg() → msg_msg 할당 ipc_lock(msg_ids) 획득 q_messages에 msg 연결 list_add_tail(&msg→m_list) wake_up(q_receivers) msgrcv() 경로 ipc_lock(msg_ids) 획득 q_messages에서 타입 검색 find_msg(msq, msgtyp) 발견 미발견 unlink msg_msg q_receivers에 등록 schedule() → 슬립 copy_to_user() struct msg_queue q_messages (msg_msg 리스트) q_receivers (대기 수신자) q_senders (대기 송신자) q_qnum / q_qbytes wake
SysV 메시지 큐 - msgsnd()는 메시지를 복사·할당·연결 후 수신 대기자를 깨우고, msgrcv()는 타입별 검색 후 복사하거나 대기
ℹ️

메시지 큐 크기 제한: System V 메시지 큐는 세 가지 커널 파라미터로 제한됩니다.

파라미터의미기본값sysctl 경로
MSGMAX단일 메시지 최대 크기 (바이트)8,192kernel.msgmax
MSGMNB큐 하나의 최대 총 바이트16,384kernel.msgmnb
MSGMNI시스템 전체 최대 큐 수32,000kernel.msgmni

큐가 MSGMNB에 도달하면 msgsnd()는 공간이 확보될 때까지 대기합니다 (IPC_NOWAIT 미설정 시). 대기 프로세스는 msg_queue.q_senders 리스트에 등록되며, msgrcv()가 메시지를 꺼낸 뒤 깨웁니다.

Shared Memory (공유 메모리)

System V 공유 메모리는 가장 빠른 IPC 메커니즘으로, 여러 프로세스가 동일한 물리 메모리(Physical Memory) 영역을 자신의 가상 주소 공간(Address Space)에 매핑하여 직접 접근합니다. 별도의 동기화 메커니즘(세마포어, futex 등)이 필요합니다.

/* ipc/shm.c - 핵심 구조체 */
struct shmid_kernel {
    struct kern_ipc_perm shm_perm;
    struct file         *shm_file;   /* shmem/hugetlb 파일 */
    unsigned long        shm_nattch;  /* attach 카운트 */
    size_t               shm_segsz;   /* 세그먼트 크기 */
    time64_t             shm_atim;    /* 마지막 attach 시간 */
    time64_t             shm_dtim;    /* 마지막 detach 시간 */
    pid_t                shm_cprid;   /* 생성자 PID */
    pid_t                shm_lprid;   /* 마지막 작업 PID */
    ...
};
코드 설명
  • shm_perm kern_ipc_perm을 첫 멤버로 임베딩하여, ipc_obtain_object_check() 등 공통 IPC 조회 경로에서 다형적으로 접근할 수 있습니다.
  • shm_file 공유 메모리의 실제 백엔드(Backend)를 가리키는 struct file 포인터입니다. 일반 공유 메모리는 shmem_kernel_file_setup()으로 tmpfs 파일을 생성하고, SHM_HUGETLB 플래그 사용 시 hugetlbfs 파일을 생성합니다. shmat() 시 이 파일을 do_mmap()으로 프로세스 주소 공간에 매핑합니다. (ipc/shm.cnewseg()shmem_kernel_file_setup())
  • shm_nattch 현재 이 세그먼트를 shmat()한 프로세스 수입니다. shmctl(IPC_RMID) 호출 시 즉시 삭제되지 않고 SHM_DEST 플래그만 설정되며, shm_nattch가 0이 되는 마지막 shmdt() 시점에 실제 메모리가 해제됩니다.
  • shm_cprid / shm_lprid 각각 세그먼트 생성자와 마지막 shmat()/shmdt()/shmctl() 수행자의 PID를 기록합니다. ipcs -pm 명령으로 확인할 수 있어 디버깅 시 어떤 프로세스가 공유 메모리를 사용하는지 추적하는 데 유용합니다.
SysV Shared Memory 매핑 프로세스 A 코드 영역 SHM 매핑 (shmat) 힙/스택 프로세스 B 코드 영역 SHM 매핑 (shmat) 힙/스택 공유 물리 페이지 shmem / tmpfs backing shmid_kernel → shm_file 페이지 테이블 페이지 테이블
SysV Shared Memory - 두 프로세스가 동일한 물리 페이지를 각자의 가상 주소(Virtual Address)에 매핑

Semaphores (세마포어)

System V 세마포어는 프로세스 간 동기화를 위한 카운팅 세마포어 배열을 제공합니다. 하나의 세마포어 세트에 여러 개의 세마포어를 포함할 수 있으며, semop()으로 원자적(Atomic) 다중 연산을 수행합니다.

/* ipc/sem.c - 핵심 구조체 */
struct sem_array {
    struct kern_ipc_perm sem_perm;
    time64_t             sem_ctime;    /* 마지막 변경 시간 */
    struct list_head     pending_alter; /* 대기 중인 연산 */
    struct list_head     pending_const; /* 대기 중인 연산 (0 대기) */
    struct sem          *sems;         /* 세마포어 배열 */
};

struct sem {
    int              semval;   /* 현재 값 */
    pid_t            sempid;   /* 마지막 연산 PID */
    struct list_head pending_alter;
    struct list_head pending_const;
    time64_t         sem_otime; /* 마지막 semop 시간 */
};

/* semop() 연산 구조체 */
struct sembuf {
    unsigned short sem_num;   /* 세마포어 인덱스 */
    short          sem_op;    /* 연산 (+n, -n, 0) */
    short          sem_flg;   /* IPC_NOWAIT, SEM_UNDO */
};
코드 설명
  • sem_array 하나의 세마포어 세트를 나타냅니다. semssemget()nsems 인자만큼 할당된 struct sem 배열 포인터입니다. pending_alterpending_const는 세트 전체에 걸친 복합 연산(여러 세마포어를 원자적으로 조작)이 블로킹될 때 대기 큐 역할을 합니다. 커널 5.10+에서는 단일 세마포어 연산 시 세트 전체 잠금 대신 개별 sem.lock만 획득하는 최적화가 적용되어 높은 동시성을 달성합니다. (ipc/sem.c)
  • struct sem 개별 세마포어 인스턴스입니다. semval이 카운터 값이며, pending_alter/pending_const는 이 특정 세마포어에서 대기 중인 연산 리스트입니다. sempid는 마지막으로 성공한 semop()을 수행한 프로세스의 PID로, GETPID 명령으로 조회 가능합니다.
  • sembuf semop() 시스템 콜에 전달하는 연산 지시 구조체입니다. sem_op이 양수면 값 증가(V 연산), 음수면 값 감소(P 연산), 0이면 값이 0이 될 때까지 대기합니다. SEM_UNDO 플래그는 프로세스가 비정상 종료 시 커널이 연산을 자동 복원하도록 sem_undo 리스트에 기록합니다. 이 undo 메커니즘은 exit_sem()(ipc/sem.c)에서 프로세스 종료 시 처리됩니다.

세마포어 사용 예제

#include <sys/sem.h>
#include <sys/ipc.h>
#include <stdio.h>
#include <stdlib.h>

/* semctl()용 공용체 (일부 시스템에서 직접 정의 필요) */
union semun {
    int              val;
    struct semid_ds *buf;
    unsigned short  *array;
};

/* 세마포어 P 연산 (wait/lock) */
void sem_wait(int semid)
{
    struct sembuf sb = {
        .sem_num = 0,
        .sem_op  = -1,         /* 값 감소 (잠금) */
        .sem_flg = SEM_UNDO    /* 프로세스 종료 시 자동 복원 */
    };
    semop(semid, &sb, 1);
}

/* 세마포어 V 연산 (signal/unlock) */
void sem_signal(int semid)
{
    struct sembuf sb = {
        .sem_num = 0,
        .sem_op  = 1,          /* 값 증가 (해제) */
        .sem_flg = SEM_UNDO
    };
    semop(semid, &sb, 1);
}

int main(void)
{
    key_t key = ftok("/tmp/semfile", 65);

    /* 세마포어 세트 생성 (1개 세마포어) */
    int semid = semget(key, 1, 0666 | IPC_CREAT);

    /* 초기값 1로 설정 (바이너리 세마포어) */
    union semun arg = { .val = 1 };
    semctl(semid, 0, SETVAL, arg);

    /* 임계 구역 진입 */
    sem_wait(semid);
    printf("임계 구역 실행 중...\n");
    /* ... 공유 자원 접근 ... */
    sem_signal(semid);

    /* 정리: 세마포어 세트 삭제 */
    semctl(semid, 0, IPC_RMID);
    return 0;
}

semop() 커널 내부 흐름

semop() 커널 내부 흐름 sys_semop() / sys_semtimedop() copy_from_user(sembuf[]) try_atomic_semop() 모든 연산이 즉시 수행 가능한지 검사 성공 (Fast Path) 실패 (Slow Path) sem.semval 값 업데이트 SEM_UNDO → undo 값 기록 대기자 wake (do_smart_update) return 0 sem_queue 할당 pending_alter 리스트에 등록 schedule() → 슬립 (TASK_INTERRUPTIBLE) 다른 프로세스가 semop() do_smart_update() → wake_up try_atomic_semop() 재시도 SEM_UNDO 추적 task_struct → sysvsem.undo_list exit_sem()에서 자동 복원
semop() 커널 흐름 - Fast Path에서는 즉시 세마포어 값을 업데이트하고, Slow Path에서는 pending 리스트에 등록 후 슬립
⚠️

SEM_UNDO: SEM_UNDO 플래그를 사용하면 프로세스가 비정상 종료해도 커널이 세마포어 값을 자동으로 원복합니다. task_structsysvsem.undo_list에 undo 정보를 기록하며, exit_sem()에서 처리됩니다.

POSIX IPC

POSIX IPC는 System V IPC의 현대적 대안으로, 파일 디스크립터 기반의 일관된 API를 제공합니다. 이름 규칙(/name), 에러 처리, 권한 모델이 일반 파일 API와 유사하여 사용이 직관적입니다.

POSIX MQ / SHM / Sem

메커니즘 API 커널 구현
Message Queue mq_open(), mq_send(), mq_receive() mqueue 가상 파일시스템 (ipc/mqueue.c)
Shared Memory shm_open(), mmap(), shm_unlink() tmpfs 기반 (/dev/shm)
Semaphore sem_open(), sem_wait(), sem_post() futex 기반 (glibc 구현)

POSIX Message Queue 커널 내부

POSIX 메시지 큐는 전용 가상 파일시스템인 mqueue(ipc/mqueue.c)를 통해 구현됩니다. 각 큐는 mqueue_inode_info 구조체로 관리되며, 메시지들은 우선순위(Priority)에 따라 정렬된 레드-블랙 트리(Red-Black Tree)로 저장됩니다. 이를 통해 mq_receive()는 항상 가장 높은 우선순위의 메시지를 먼저 반환합니다.

/* ipc/mqueue.c - 핵심 구조체 */
struct mqueue_inode_info {
    spinlock_t           lock;
    struct rb_root       msg_tree;       /* RB-tree: 우선순위별 메시지 */
    struct posix_msg_tree_node *node_cache;
    struct mq_attr       attr;           /* mq_maxmsg, mq_msgsize 등 */
    struct sigevent      notify;         /* mq_notify 설정 */
    struct pid          *notify_owner;  /* 통지 대상 프로세스 */
    struct user_namespace *notify_user_ns;
    struct ucounts      *ucounts;       /* 사용자별 큐 카운트 */
    unsigned long        qsize;          /* 큐 내 총 바이트 */
    struct inode         vfs_inode;
    struct list_head     e_wait_q[2];   /* [0]=수신대기, [1]=송신대기 */
};

/* 우선순위별 메시지 트리 노드 */
struct posix_msg_tree_node {
    struct rb_node       rb_node;
    struct list_head     msg_list;       /* 동일 우선순위 메시지 리스트 */
    int                  priority;
};
코드 설명
  • mqueue_inode_info POSIX 메시지 큐의 커널 표현으로, mqueue 가상 파일시스템(보통 /dev/mqueue에 마운트)의 inode에 임베딩됩니다. System V 메시지 큐와 달리 파일 디스크립터 기반이므로 select()/poll()/epoll()과 통합 가능합니다. (ipc/mqueue.c)
  • msg_tree (RB-tree) System V 메시지 큐의 단순 연결 리스트와 달리, POSIX MQ는 레드-블랙 트리(Red-Black Tree)로 우선순위별 메시지를 관리합니다. mq_receive()는 항상 가장 높은 우선순위의 메시지를 O(log n)에 추출합니다.
  • notify / notify_owner mq_notify()로 등록한 비동기 통지 설정입니다. 빈 큐에 첫 메시지가 도착하면 notify_owner 프로세스에 시그널을 보내거나 새 스레드를 생성합니다. 한 큐에 하나의 통지만 등록 가능하며, 통지가 발생하면 자동으로 해제됩니다.
  • e_wait_q[2] 인덱스 0은 수신 대기(mq_receive() 블로킹), 인덱스 1은 송신 대기(mq_send() 블로킹) 프로세스의 대기 큐입니다. 큐가 꽉 차면 송신자가 e_wait_q[1]에서 슬립하고, 비어 있으면 수신자가 e_wait_q[0]에서 슬립합니다.
  • posix_msg_tree_node 동일 우선순위를 가진 메시지들을 msg_list에 FIFO 순서로 연결하고, rb_node를 통해 RB-tree에 삽입됩니다. 같은 우선순위의 메시지가 모두 소비되면 해당 노드는 node_cache에 캐싱되어 재할당 비용을 줄입니다.

mq_notify()는 큐에 메시지가 도착했을 때 비동기 알림을 받을 수 있게 합니다. 두 가지 주요 통지 방식이 있습니다:

통지 방식sigev_notify동작
시그널 전달SIGEV_SIGNAL지정한 시그널(sigev_signo)을 프로세스에 전송
스레드 생성SIGEV_THREAD새 스레드를 생성하여 sigev_notify_function 실행
없음SIGEV_NONE통지 없음 (등록만)

POSIX MQ 우선순위 예제

#include <mqueue.h>
#include <stdio.h>
#include <string.h>

int main(void)
{
    struct mq_attr attr = {
        .mq_maxmsg  = 10,
        .mq_msgsize = 256
    };

    /* 큐 생성 (이름은 반드시 '/'로 시작) */
    mqd_t mq = mq_open("/test_queue", O_CREAT | O_RDWR, 0644, &attr);

    /* 우선순위별 메시지 전송 (높은 숫자 = 높은 우선순위) */
    mq_send(mq, "low priority",  13, 1);   /* 우선순위 1 */
    mq_send(mq, "high priority", 14, 10);  /* 우선순위 10 */
    mq_send(mq, "mid priority",  13, 5);   /* 우선순위 5 */

    /* 수신: 항상 가장 높은 우선순위부터 */
    char buf[256];
    unsigned int prio;
    mq_receive(mq, buf, 256, &prio);
    printf("1st: %s (prio=%u)\n", buf, prio);  /* "high priority" (10) */

    mq_receive(mq, buf, 256, &prio);
    printf("2nd: %s (prio=%u)\n", buf, prio);  /* "mid priority" (5) */

    mq_close(mq);
    mq_unlink("/test_queue");
    return 0;
}
컴파일 시 -lrt 링크 필요: gcc -o mq_example mq_example.c -lrt. POSIX MQ API는 librt(Real-Time library)에 구현되어 있습니다.

POSIX Shared Memory 커널 내부

POSIX 공유 메모리는 shm_open()으로 생성하며, 실제로는 tmpfs(/dev/shm) 위에 일반 파일을 여는 것과 동일합니다. glibc의 shm_open()은 내부적으로 /dev/shm/ 경로에 대해 open()을 호출합니다. 이후 ftruncate()로 크기를 설정하고 mmap()으로 주소 공간에 매핑합니다.

/* POSIX 공유 메모리 예제 */
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>

#define SHM_SIZE 4096

int main(void)
{
    /* 공유 메모리 객체 생성 (이름 '/'로 시작) */
    int fd = shm_open("/my_shm", O_CREAT | O_RDWR, 0644);

    /* 크기 설정 */
    ftruncate(fd, SHM_SIZE);

    /* 주소 공간에 매핑 */
    void *ptr = mmap(NULL, SHM_SIZE,
                     PROT_READ | PROT_WRITE,
                     MAP_SHARED, fd, 0);
    close(fd);  /* fd는 mmap 후 닫아도 됨 */

    /* 공유 메모리에 데이터 기록 */
    strcpy(ptr, "Hello from POSIX SHM");
    printf("Written: %s\n", (char *)ptr);

    /* 정리 */
    munmap(ptr, SHM_SIZE);
    shm_unlink("/my_shm");
    return 0;
}
ℹ️

memfd_create() — 현대적 대안: Linux 3.17에서 추가된 memfd_create()는 파일시스템 경로 없이 익명 공유 메모리를 생성합니다. 반환된 fd를 Unix Domain Socket의 SCM_RIGHTS로 다른 프로세스에 전달할 수 있어, 이름 충돌이나 파일시스템 마운트 의존성이 없습니다. MFD_CLOEXEC, MFD_ALLOW_SEALING 플래그로 보안을 강화할 수 있습니다.

int fd = memfd_create("shared_data", MFD_CLOEXEC);
ftruncate(fd, 4096);
void *p = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
/* fd를 SCM_RIGHTS로 다른 프로세스에 전달 가능 */

POSIX Named Semaphore 내부 구현

POSIX Named Semaphore는 sem_open()으로 생성하며, glibc 내부에서 /dev/shm/sem.NAME 경로에 파일을 생성합니다. 파일 내용은 sem_t 구조체가 매핑되며, 실제 동기화는 커널의 futex 메커니즘을 통해 수행됩니다. 경합이 없는 경우(uncontended) 커널 진입 없이 원자적 연산만으로 처리됩니다.

/* glibc 내부 sem_t 구조 (간략화) - nptl/internaltypes.h */
struct new_sem {
    unsigned int value;       /* 세마포어 현재 값 (atomic) */
    unsigned int nwaiters;    /* 대기 중인 스레드 수 */
    int          private;      /* FUTEX_PRIVATE_FLAG 여부 */
};
/*
 * sem_wait() 흐름:
 *   1. atomic_decrement(&value) — 성공(>0)이면 즉시 리턴 (Fast Path)
 *   2. 실패(0)이면 → futex(FUTEX_WAIT, &value, 0) → 커널 슬립
 *
 * sem_post() 흐름:
 *   1. atomic_increment(&value)
 *   2. nwaiters > 0 이면 → futex(FUTEX_WAKE, &value, 1)
 */
Named vs Unnamed: sem_open()은 named 세마포어(파일 기반)를 생성하고, sem_init()은 unnamed 세마포어(메모리 기반)를 초기화합니다. unnamed 세마포어의 pshared=1 설정 시 공유 메모리에 배치하여 프로세스 간 공유가 가능합니다. 두 방식 모두 내부적으로 futex를 사용합니다.

SysV vs POSIX 비교

항목 System V IPC POSIX IPC
식별 정수 키 (ftok()) 이름 문자열 (/name)
API 스타일 xxxget(), xxxctl(), xxxop() xxx_open(), xxx_close(), xxx_unlink()
fd 기반 아니오 (정수 ID) 예 (MQ, SHM)
epoll 통합 불가 MQ: mq_notify() + fd
네임스페이스 격리 ipc_namespace로 격리 SHM: mount_namespace, MQ: ipc_namespace
자원 정리 명시적 삭제 필요 (xxxctl(IPC_RMID)) xxx_unlink() + 참조 카운트(Reference Count) 기반

Futex (Fast Userspace Mutex)

Futex는 유저 공간에서 빠르게 동기화를 수행하고, 경합(contention)이 발생할 때만 커널에 진입하는 하이브리드 동기화 메커니즘입니다. glibc의 pthread_mutex, pthread_cond, sem_wait() 등 거의 모든 유저 공간 동기화 프리미티브의 근간입니다.

Futex 동작 원리

Futex Fast/Slow 경로 유저 공간 Fast Path cmpxchg(futex_word) 성공? lock 획득/해제 완료 (커널 진입 없음) 대부분의 경우 성공 커널 공간 futex(FUTEX_WAIT) 시스템 콜 진입 futex_wait() hash bucket 대기 futex(FUTEX_WAKE) 대기자 깨우기 (wake_up) 실패 (경합) 커널: futex_hash_bucket에 waiter를 등록하고 TASK_INTERRUPTIBLE로 슬립 해제 측: futex_word 변경 후 FUTEX_WAKE로 waiter를 깨움
Futex Fast/Slow 경로 - 유저 공간 cmpxchg 성공 시 커널 진입 없이 완료

futex() 시스템 콜

/* kernel/futex/waitwake.c - futex_wait() 핵심 (간략화) */
int futex_wait(u32 __user *uaddr, unsigned int flags,
              u32 val, ktime_t *abs_time, u32 bitset)
{
    struct futex_hash_bucket *hb;
    struct futex_q q = futex_q_init;

    /* 1. futex 키 계산 (공유/비공유) */
    get_futex_key(uaddr, flags, &q.key);

    /* 2. hash bucket 결정 및 잠금 */
    hb = futex_q_lock(&q);

    /* 3. 현재 값 검증 (값이 바뀌었으면 즉시 리턴) */
    if (futex_get_value_locked(&uval, uaddr))
        goto out;
    if (uval != val) {
        futex_q_unlock(hb);
        return -EAGAIN;
    }

    /* 4. 대기 큐에 등록하고 슬립 */
    futex_wait_queue(hb, &q, abs_time);

    return 0;
}
코드 설명
  • get_futex_key() 유저 공간 주소 uaddr에서 futex 키를 계산합니다. 프로세스 간 공유 futex(FUTEX_SHARED)는 페이지의 struct page + 오프셋으로 키를 생성하고, 프로세스 내부 futex(FUTEX_PRIVATE)는 mm_struct + 가상 주소로 키를 생성합니다. 공유 키는 서로 다른 프로세스가 같은 물리 페이지를 매핑해도 동일한 해시 버킷에 매칭됩니다. (kernel/futex/core.c)
  • futex_q_lock() 계산된 키로 글로벌 해시 테이블(futex_queues[], 기본 256개 버킷)에서 대응하는 futex_hash_bucket을 찾아 spinlock을 획득합니다. 이 잠금 하에서 값 검증과 대기 큐 등록이 원자적으로 수행되어 lost wakeup 문제를 방지합니다.
  • 값 검증 (uval != val) futex의 핵심 설계 원칙인 "조건부 슬립"입니다. 해시 버킷 잠금을 잡은 상태에서 유저 공간 값을 다시 읽어, 기대값(val)과 다르면 -EAGAIN을 반환합니다. 이로써 FUTEX_WAKEFUTEX_WAIT 사이에 발생해도 잠자는 태스크가 영구히 깨어나지 못하는 경쟁 조건을 방지합니다.
  • futex_wait_queue() 현재 태스크를 해시 버킷의 대기 리스트에 등록하고, schedule()로 슬립합니다. 타임아웃(abs_time)이 지정된 경우 hrtimer를 설정하여 시간 만료 시 자동으로 깨어납니다. FUTEX_WAKE 호출자는 같은 키의 해시 버킷에서 대기자를 찾아 wake_up_q()로 깨웁니다. (kernel/futex/waitwake.c)

PI-Futex (Priority Inheritance)

PI-Futex는 우선순위 역전(Priority Inversion) 문제를 해결하기 위해 FUTEX_LOCK_PI/FUTEX_UNLOCK_PI 연산을 제공합니다. 우선순위 역전은 실시간(Real-Time) 시스템에서 치명적인 문제로, 높은 우선순위의 태스크가 낮은 우선순위 태스크가 보유한 lock을 기다리는 동안, 중간 우선순위의 태스크가 실행되어 전체 시스템의 응답 시간이 무한히 늘어나는 현상입니다.

우선순위 역전 (Priority Inversion) 문제와 PI 해결 문제 시나리오 (PI 없음) 시간 → A (높음) B (중간) C (낮음) C: lock 보유, 실행 도착 A: lock 대기 (blocked) ← 우선순위 역전! B: C를 선점(preempt), 실행 중... C: B에 의해 선점됨 (lock 여전히 보유) C: unlock A: 드디어 실행! PI 해결 시나리오 (FUTEX_LOCK_PI) 시간 → A (높음) B (중간) C (낮음→높음) C: lock 보유, 실행 도착 A: 대기 C: PI 부스트! (A 우선순위로 승격) B: 선점 불가 (C가 더 높은 우선순위) A: 빠르게 실행! B: A 완료 후 실행 PI boost 커널 메커니즘: rt_mutex 체인 FUTEX_LOCK_PI → futex_lock_pi() → rt_mutex_slowlock() → task_boost_prio(owner, waiter→prio) 우선순위 체인(PI chain)을 따라 중첩된 lock 소유자까지 전파 (rt_mutex_adjust_prio_chain)
우선순위 역전 - PI 없이는 중간 우선순위 B가 C를 선점하여 A가 무한 대기. PI-Futex는 C의 우선순위를 A 수준으로 부스트하여 해결

PI-Futex는 커널 내부의 rt_mutex 인프라를 활용합니다. FUTEX_LOCK_PI 호출 시 유저 공간 futex 값의 하위 비트에 소유자 TID가 기록되며, 경합 발생 시 커널이 소유자의 우선순위를 자동으로 조정합니다.

/* kernel/futex/pi.c - PI futex 핵심 흐름 (간략화) */
int futex_lock_pi(u32 __user *uaddr, unsigned int flags,
                  ktime_t *time, int trylock)
{
    struct rt_mutex_waiter rt_waiter;
    struct futex_pi_state *pi_state;
    struct task_struct *exiting = NULL;

    /* 1. futex 키 계산, hash bucket 잠금 */
    get_futex_key(uaddr, flags, &q.key);
    hb = futex_q_lock(&q);

    /* 2. 유저 공간 값에서 소유자 TID 확인 */
    get_futex_value_locked(&uval, uaddr);

    /* 3. 소유자가 없으면 CAS로 즉시 획득 (fast path) */
    if (!(uval & FUTEX_TID_MASK)) {
        futex_atomic_cmpxchg_inatomic(uaddr, uval,
                                       uval | current->pid);
        return 0;
    }

    /* 4. 소유자 task_struct 조회, PI state 연결 */
    attach_to_pi_state(uval, pi_state, &exiting);

    /* 5. rt_mutex를 통한 PI 대기 (소유자 우선순위 부스트) */
    rt_mutex_slowlock_block(&pi_state->pi_mutex,
                            &rt_waiter, NULL);
    /* 깨어나면 lock 획득 완료, 유저값에 자신의 TID 기록 */
    return 0;
}
/* PI-Futex 사용 예 (glibc pthread_mutex_init에서 자동 사용) */
#include <pthread.h>

pthread_mutex_t pi_mutex;
pthread_mutexattr_t attr;

/* PI 프로토콜 설정 */
pthread_mutexattr_init(&attr);
pthread_mutexattr_setprotocol(&attr, PTHREAD_PRIO_INHERIT);
pthread_mutex_init(&pi_mutex, &attr);

/* 이후 pthread_mutex_lock()은 내부적으로 FUTEX_LOCK_PI 사용 */
pthread_mutex_lock(&pi_mutex);
/* 임계 구역 */
pthread_mutex_unlock(&pi_mutex);

Robust Futex

Robust futex는 lock을 보유한 프로세스가 비정상 종료(crash)했을 때 deadlock을 방지하는 메커니즘입니다. 일반 futex에서는 lock 소유자가 죽으면 다른 대기자들이 영원히 깨어나지 못하는 문제가 발생합니다. Robust futex는 이 문제를 커널이 자동으로 감지하고 복구합니다.

동작 원리는 다음과 같습니다:

  1. 프로세스가 set_robust_list()로 robust futex 리스트의 헤드를 커널에 등록합니다.
  2. glibc의 pthread_mutex_lock()이 lock 획득 시 해당 futex를 robust 리스트에 연결합니다.
  3. 프로세스가 비정상 종료하면 커널이 exit_robust_list()를 호출합니다.
  4. 리스트의 각 futex에 FUTEX_OWNER_DIED 비트(bit 30)를 설정합니다.
  5. 해당 futex에서 대기 중인 프로세스가 깨어나 EOWNERDEAD를 받고 복구 절차를 수행합니다.
/* Robust futex 사용 예 */
#include <pthread.h>
#include <stdio.h>
#include <errno.h>

/* 공유 메모리에 배치된 mutex (프로세스 간 공유) */
pthread_mutex_t *shared_mutex;  /* mmap으로 공유 메모리에 매핑 */

void init_robust_mutex(void)
{
    pthread_mutexattr_t attr;
    pthread_mutexattr_init(&attr);

    /* robust 속성 설정 */
    pthread_mutexattr_setrobust(&attr, PTHREAD_MUTEX_ROBUST);

    /* 프로세스 간 공유 설정 */
    pthread_mutexattr_setpshared(&attr, PTHREAD_PROCESS_SHARED);

    pthread_mutex_init(shared_mutex, &attr);
}

void safe_lock(void)
{
    int ret = pthread_mutex_lock(shared_mutex);

    if (ret == EOWNERDEAD) {
        /* 이전 소유자가 죽었음 → 공유 상태 복구 필요 */
        printf("이전 소유자 사망 감지, 상태 복구 중...\n");

        /* ... 공유 데이터 일관성 복구 ... */

        /* mutex를 일관된 상태로 표시 */
        pthread_mutex_consistent(shared_mutex);
    } else if (ret == ENOTRECOVERABLE) {
        /* 복구 불가능 상태 (consistent 호출 안 하고 unlock한 경우) */
        fprintf(stderr, "mutex 복구 불가\n");
        return;
    }

    /* 정상 임계 구역 진입 */
    /* ... */

    pthread_mutex_unlock(shared_mutex);
}
/* 커널 내부: exit_robust_list() 핵심 (간략화) */
/* kernel/futex/core.c */
void exit_robust_list(struct task_struct *curr)
{
    struct robust_list_head __user *head = curr->robust_list;
    struct robust_list __user *entry, *next_entry;

    /* robust 리스트 순회 */
    for (entry = head->list.next; entry != &head->list;
         entry = next_entry) {
        next_entry = entry->next;

        /* futex 값에서 소유자 TID 확인 */
        get_user(uval, futex_uaddr);

        if ((uval & FUTEX_TID_MASK) == curr->pid) {
            /* FUTEX_OWNER_DIED 비트 설정 */
            handle_futex_death(futex_uaddr, curr);
            /* → FUTEX_WAKE로 대기자 깨움 */
        }
    }
}
⚠️

EOWNERDEAD 처리 필수: pthread_mutex_lock()EOWNERDEAD를 반환하면 반드시 공유 상태를 복구한 뒤 pthread_mutex_consistent()를 호출해야 합니다. 이를 생략하고 unlock()만 하면 mutex는 ENOTRECOVERABLE 상태가 되어 어떤 프로세스도 다시 lock할 수 없습니다.

ℹ️

futex2: Linux 5.16+에서 futex_waitv() 시스템 콜이 추가되어 여러 futex를 동시에 대기할 수 있습니다. Windows의 WaitForMultipleObjects()에 대응하며, Proton/Wine의 게임 호환성을 위해 도입되었습니다.

ntsync — NT 동기화 프리미티브 (v6.14+)

커널 6.14에서 ntsync 서브시스템이 추가되었습니다. Windows NT 커널의 동기화 원시 객체(뮤텍스, 세마포어, 이벤트)를 Linux 커널에 네이티브로 구현하여, Wine/Proton의 게임 에뮬레이션 성능을 크게 향상시킵니다.

NT 객체ntsync 대응기존 Wine 구현
NtCreateMutant/dev/ntsync ioctl서버 프로세스 RPC (wineserver)
NtCreateSemaphore/dev/ntsync ioctl서버 프로세스 RPC
NtCreateEvent/dev/ntsync ioctl서버 프로세스 RPC
WaitForMultipleObjects커널 내 원자적 다중 대기wineserver 경유 (고비용)
성능 개선 원리: 기존 Wine은 WaitForMultipleObjects 같은 NT 동기화 연산을 wineserver 프로세스를 경유한 RPC로 처리했습니다. ntsync는 이를 커널 내에서 직접 처리하여 프로세스 간 통신 오버헤드(Overhead)를 제거합니다. 특히 동기화가 빈번한 게임에서 프레임 레이트가 유의미하게 향상됩니다(벤치마크에 따라 5~20%).

eventfd / signalfd / timerfd

Linux는 다양한 이벤트 소스를 파일 디스크립터로 추상화하여 epoll/select/poll과 통합할 수 있는 fd 기반 IPC를 제공합니다.

fd 유형 용도 핵심 구조체(Struct)
eventfd 프로세스/스레드 간 이벤트 카운터 struct eventfd_ctx
signalfd 시그널을 fd로 수신 struct signalfd_ctx
timerfd 타이머 만료를 fd로 통지 struct timerfd_ctx
/* 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는 호스트에서 게스트에 인터럽트를 주입하는 데 사용됩니다.

signalfd 커널 내부 구조

signalfd는 전통적인 시그널 핸들러 대신 파일 디스크립터를 통해 시그널을 동기적으로 수신할 수 있게 해주는 메커니즘입니다. 시그널을 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()를 통해 대기 큐에 등록되어 시그널 도착 시 깨어납니다.

#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 커널 내부 구조

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() 콜백이 실행되어 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 상태 변화 시 한 번만 반환 높은 성능, 반드시 논블로킹 + 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을 반환할 때까지 루프로 읽어야 합니다. 그렇지 않으면 버퍼에 남은 데이터가 영원히 통지되지 않는 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()은 시그널 마스크를 원자적으로 설정하면서 이벤트를 대기하여, 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 경합 같은 이벤트를 여러 스레드가 처리 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는 별도 시스템 콜이 필요하지만, 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) 등에 광범위하게 사용됩니다.

/* 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(태스크 통계), 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 라우팅 변경 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)과 현저히 낮은 레이턴시를 제공합니다.

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

일반 소켓 API 외에도 SCM_RIGHTS(파일 디스크립터 전달)와 SCM_CREDENTIALS(프로세스 자격 증명 전달) 같은 고유 기능을 제공하며, systemd 소켓 활성화(Socket Activation), 컨테이너 런타임(containerd, CRI-O), 데이터베이스(PostgreSQL, MySQL) 등에서 핵심 통신 채널로 사용됩니다.

상세 문서: 커널 자료구조(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), 체크포인트/복원(CRIU), 고성능 MPI 라이브러리 등에서 사용됩니다.

#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에 적합합니다.

#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()로 크기 축소 금지 매핑 무효화(SIGBUS) 방지
F_SEAL_GROW ftruncate()/write()로 크기 확장 금지 메모리 사용량 예측 가능
F_SEAL_WRITE 모든 쓰기 금지 (mmap PROT_WRITE 포함) IPC 버퍼 불변 보장
F_SEAL_FUTURE_WRITE 새로운 쓰기 매핑 금지 (기존 매핑은 허용) 점진적 잠금
💡

memfd_secret() (v5.14+): memfd_secret()는 한 단계 더 나아가 커널 직접 매핑(direct map)에서 해당 메모리를 제거합니다. 커널조차 해당 페이지에 접근할 수 없으므로 비밀 키(Secret Key), 암호화 자료 등을 보호하는 데 사용됩니다. 단, 커널 직접 매핑 수정은 TLB 플러시 비용이 크므로 성능에 민감한 대량 데이터에는 부적합합니다.

ℹ️

활용 사례: Wayland는 memfd + sealing으로 컴포지터(Compositor)와 클라이언트 간 그래픽 버퍼를 공유합니다. 클라이언트가 렌더링 완료 후 F_SEAL_WRITE를 설정하면 컴포지터는 버퍼가 변경되지 않음을 보장받아 안전하게 합성할 수 있습니다. 샌드박스 환경에서도 파일시스템 접근 없이 메모리를 공유할 수 있어 유용합니다.

pidfd — 레이스 프리 프로세스 관리

pidfd는 Linux 5.2+에 도입된 프로세스를 가리키는 파일 디스크립터입니다. 전통적인 PID 기반 API(kill(), waitpid())는 PID 재사용(Recycle) 레이스(Race) 문제가 있습니다 — 프로세스가 종료되고 같은 PID가 다른 프로세스에 재할당되면 잘못된 프로세스에 시그널을 보낼 수 있습니다. 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를 동시에 생성
💡

활용 사례: systemd는 pidfd를 사용하여 서비스 프로세스를 추적합니다. PID 재사용 레이스 없이 정확한 프로세스에 시그널을 보내고 종료를 감지할 수 있습니다. 컨테이너 런타임(runc, crun)도 pidfd로 컨테이너 init 프로세스를 관리하며, pidfd_getfd()는 디버거나 라이브 마이그레이션 도구에서 타겟 프로세스의 fd를 안전하게 복제하는 데 활용됩니다.

IPC 흔한 실수와 모범 사례

IPC 프로그래밍에서 자주 발생하는 실수와 이를 방지하기 위한 모범 사례를 정리합니다.

흔한 실수 증상 모범 사례
SysV IPC 자원 미정리 프로세스 종료 후에도 ipcs에 자원 잔존, 시스템 한계 도달 항상 IPC_RMID로 제거하거나, 자동 정리되는 POSIX IPC 사용
Pipe 데드락 (양쪽 fd를 같은 프로세스에서 보유) write가 버퍼 가득 차서 블록, read 불가 fork() 후 사용하지 않는 pipe 끝을 즉시 close()
시그널 핸들러에서 재진입 불안전 함수 호출 교착, 데이터 손상, 미정의 동작 async-signal-safe 함수만 사용하거나, signalfd로 전환
공유 메모리에 동기화 없이 접근 데이터 경합(Data Race), 불일치 읽기 반드시 세마포어(Semaphore)/뮤텍스(Mutex)/futex와 함께 사용
Unix socket 버퍼 오버플로 무시 send() 블록 또는 EAGAIN 반환, 메시지 유실 반환값 확인, 논블로킹(Non-blocking) + epoll 사용
FIFO open() 블로킹 읽기/쓰기 쪽 중 하나만 열면 무한 대기 O_NONBLOCK 사용 또는 O_RDWR로 열기
futex 의사 깨움(Spurious Wakeup) 미처리 조건 미충족 상태에서 진행, 논리 오류 항상 루프 내에서 조건 재검사: while (cond) futex_wait()
epoll EPOLLET 모드에서 불완전 읽기 데이터 잔류, 이벤트 미수신 (edge 재발생 안 됨) ET 모드에서는 EAGAIN이 반환될 때까지 논블로킹 read로 완전히 소진
PID 재사용 레이스로 잘못된 프로세스에 시그널 의도하지 않은 프로세스 종료 pidfd_open() + pidfd_send_signal() 사용
메시지 큐 크기 제한 무시 mq_send() 블록 또는 EAGAIN mq_getattr()로 용량 확인, 적절한 mq_maxmsg 설정
💡

IPC 선택 가이드: 새 프로젝트에서는 SysV IPC보다 POSIX IPC(자동 정리, fd 기반)를 권장합니다. 단순 이벤트 통지에는 eventfd, 타이머에는 timerfd, 시그널 처리에는 signalfd를 사용하여 모든 이벤트 소스를 하나의 epoll 루프로 통합하면 코드 복잡성을 크게 줄일 수 있습니다. 프로세스 간 대량 데이터 전달에는 memfd_create() + mmap()을, 안전한 프로세스 관리에는 pidfd를 활용하세요.

IPC 종합 비교

메커니즘 방향 메시지 경계 fd 기반 epoll 통합 Namespace 속도 주요 용도
Pipe 단방향 없음 O O - 빠름 부모-자식 통신, 셸 파이프라인(Pipeline)
FIFO 단방향 없음 O O mount NS 빠름 관계없는 프로세스 간 통신
Signal 단방향 - X signalfd PID NS 빠름 비동기 이벤트 통지
SysV MQ 양방향 있음 X X IPC NS 보통 타입별 메시지 교환
SysV SHM 양방향 - X X IPC NS 최고 대량 데이터 공유
SysV Sem 동기화 - X X IPC NS 보통 프로세스 간 잠금
POSIX MQ 양방향 있음 O O IPC NS 보통 우선순위 메시지, fd 통합
POSIX SHM 양방향 - O X mount NS 최고 /dev/shm 기반 공유
Futex 동기화 - X X - 최고 저수준 동기화 프리미티브
eventfd 양방향 카운터 O O - 빠름 이벤트 통지, KVM 연동
Netlink 양방향 있음 O O net NS 보통 커널-유저 제어 채널
Unix Socket 양방향 선택 O O net NS 빠름 범용 로컬 IPC, fd 전달
timerfd 단방향 카운터 O O - 빠름 타이머 이벤트

IPC 디버깅

커널 IPC 자원의 상태를 진단하고 문제를 해결하기 위한 도구들입니다.

ipcs / ipcrm

# System V IPC 자원 조회
ipcs -a              # 모든 IPC 자원 (MQ, SHM, SEM)
ipcs -m              # 공유 메모리만
ipcs -q              # 메시지 큐만
ipcs -s              # 세마포어만
ipcs -l              # 시스템 제한값 표시

# IPC 자원 삭제
ipcrm -m <shmid>     # 공유 메모리 삭제
ipcrm -q <msqid>     # 메시지 큐 삭제
ipcrm -s <semid>     # 세마포어 삭제

/proc/sysvipc

# 커널이 노출하는 IPC 정보
cat /proc/sysvipc/msg    # 메시지 큐 상세
cat /proc/sysvipc/shm    # 공유 메모리 상세
cat /proc/sysvipc/sem    # 세마포어 상세

# IPC 제한 파라미터 확인/변경
sysctl kernel.msgmax     # 최대 메시지 크기
sysctl kernel.shmmax     # 최대 공유 메모리 세그먼트 크기
sysctl kernel.sem        # SEMMSL SEMMNS SEMOPM SEMMNI

strace / bpftrace

# IPC 관련 시스템 콜 추적
strace -e trace=ipc ./program           # SysV IPC 시스템 콜 추적
strace -e trace=%signal ./program       # 시그널 관련 추적
strace -e read,write -e fd=3 ./program  # 특정 fd의 read/write 추적

# bpftrace로 pipe 쓰기 모니터링
bpftrace -e 'tracepoint:syscalls:sys_enter_write
  /args->fd > 2/ { @bytes = hist(args->count); }'

# futex 경합 분석
bpftrace -e 'tracepoint:syscalls:sys_enter_futex
  { @ops = count(); @cmd[args->op & 0xf] = count(); }'

lsof로 IPC 자원 조회

# 특정 프로세스의 열린 IPC 관련 fd 조회
lsof -p <PID> | grep -E 'pipe|socket|eventfd|signalfd|timerfd'

# Unix 도메인 소켓 사용 현황
lsof -U                          # 모든 Unix 소켓
lsof -U -a -c nginx              # nginx의 Unix 소켓만

# 특정 파이프/소켓의 양쪽 끝 찾기
lsof | grep 'pipe:\[12345\]'      # inode 번호로 pipe 양쪽 프로세스 식별

# /proc/PID/fdinfo로 상세 정보 확인
cat /proc/<PID>/fdinfo/3        # fd 3의 상세 정보
# eventfd-count: 0               ← eventfd의 현재 카운터
# sigmask: 0000000000000006      ← signalfd의 시그널 마스크
# clockid: 0                     ← timerfd의 클럭 소스

# /proc/PID/fd 심볼릭 링크로 fd 타입 확인
ls -la /proc/<PID>/fd/
# lrwx------ 1 user user 0 ... 3 -> pipe:[12345]
# lrwx------ 1 user user 0 ... 4 -> socket:[67890]
# lrwx------ 1 user user 0 ... 5 -> anon_inode:[eventfd]

perf를 사용한 IPC 성능 분석

# IPC 중 컨텍스트 스위치 횟수 측정
perf stat -e context-switches,cpu-migrations,page-faults \
    -p <PID> -- sleep 10

# 시스템 콜 레이턴시 추적 (IPC 관련)
perf trace -e sendmsg,recvmsg,read,write,epoll_wait \
    -p <PID> -s 2>/dev/null

# 특정 IPC 시스템 콜의 지연 시간 히스토그램
perf trace -e epoll_wait --duration 1 -p <PID> 2>&1 | \
    awk '{print $NF}' | sort -n

# flamegraph로 IPC 병목 지점 식별
perf record -g -p <PID> -- sleep 30
perf script | stackcollapse-perf.pl | flamegraph.pl > ipc-flame.svg

# bpftrace로 pipe/socket read 지연 시간 측정
bpftrace -e 'tracepoint:syscalls:sys_enter_read { @start[tid] = nsecs; }
tracepoint:syscalls:sys_exit_read /@start[tid]/ {
    @usecs = hist((nsecs - @start[tid]) / 1000);
    delete(@start[tid]);
}'

일반적인 IPC 문제 진단

증상 가능한 원인 진단 방법 해결책
프로세스 hang (응답 없음) futex/세마포어 데드락 cat /proc/PID/wchan, strace -p PID 잠금 순서 통일, 타임아웃(Timeout) 추가
ipcs에 좀비 자원 잔존 SysV IPC IPC_RMID 누락 ipcs -a, /proc/sysvipc/* 확인 ipcrm으로 수동 삭제, POSIX IPC로 전환
pipe/socket write() 블록 수신 측 읽기 안 함, 버퍼 가득 참 cat /proc/PID/fdinfo/N, ss -x 논블로킹 + epoll, 버퍼 크기 조정 (SO_SNDBUF)
EACCES / EPERM IPC 네임스페이스(Namespace) 불일치, 권한 부족 ls -la /dev/shm/, nsenter로 NS 확인 같은 네임스페이스 사용, 적절한 권한 부여
SIGPIPE로 프로세스 종료 pipe/socket 상대방이 이미 닫힘 strace에서 write → SIGPIPE 확인 signal(SIGPIPE, SIG_IGN) 또는 MSG_NOSIGNAL
ENOMEM / ENOSPC IPC 시스템 제한 초과 sysctl kernel.msg*, sysctl kernel.shm* sysctl로 제한값 증가, 불필요한 자원 정리
epoll에서 이벤트 수신 안 됨 ET 모드 불완전 소진, fd 미등록 strace -e epoll_ctl,epoll_wait LT 모드 사용, 또는 ET에서 EAGAIN까지 읽기

Android Binder IPC

Binder는 Android의 핵심 IPC 메커니즘으로, 커널 드라이버(drivers/android/binder.c)가 구현합니다. 일반 Unix IPC(pipe, UDS 등)와 달리 단일 복사(single-copy) 전송, 호출자 PID/UID 자동 첨부, 참조 카운팅 기반 원격 객체 수명 관리, 사망 통지(death notification) 등 Android에 특화된 기능을 제공합니다.

Binder vs Unix Domain Socket

특성BinderUnix Domain Socket
복사 횟수1회 (mmap 활용)2회 (유저→커널→유저)
호출자 인증커널이 PID/UID 자동 첨부SCM_CREDENTIALS (opt-in)
객체 수명 관리참조 카운팅, 사망 통지없음
통신 모델동기 RPC (기본)스트림 / 데이터그램
보안SELinux Binder 훅파일 권한 + SCM
오버헤드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된 가상 주소에서 직접 데이터 접근 (추가 복사 없음)
 *
 * vs 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 섹션을 참고하라.

참고 링크