IPC (Inter-Process Communication)
Linux 커널의 프로세스(Process) 간 통신(IPC)을 데이터 전달, 동기화, 이벤트 통지 관점으로 체계화해 설명합니다. Pipe/Unix domain socket 경로, System V와 POSIX IPC의 자원 관리 모델, futex 기반 사용자 동기화, eventfd/signalfd/timerfd와 epoll 결합, Netlink 제어 평면, ipc namespace 격리(Isolation)와 권한 이슈, 실무 성능 병목(Bottleneck)과 디버깅(Debugging) 절차까지 심층 분석합니다.
핵심 요약
- Pipe — 단방향 바이트 스트림. 부모-자식 프로세스 간 가장 간단한 통신 수단입니다.
- 공유 메모리 — 가장 빠른 IPC. 여러 프로세스가 같은 물리 페이지(Page)를 매핑(Mapping)하여 데이터를 공유합니다.
- 메시지 큐 — 구조화된 메시지를 큐에 넣고 꺼내는 방식. System V와 POSIX 두 가지 API가 있습니다.
- Unix Domain Socket — 같은 호스트 내 프로세스 간 양방향 통신. 네트워크 소켓(Socket)과 동일한 API를 사용합니다.
- eventfd / signalfd — 이벤트 통지를 위한 경량 파일 디스크립터(File Descriptor) 기반 메커니즘입니다.
단계별 이해
- 파이프 체험 —
ls | grep txt처럼 셸의|가 바로 파이프입니다. 왼쪽 프로세스의 stdout이 오른쪽의 stdin으로 연결됩니다.C에서는
pipe()시스템 콜(System Call)로 생성합니다. - 공유 메모리 이해 —
shmget()/shmat()또는mmap(MAP_SHARED)로 생성합니다.동기화(세마포어, 뮤텍스(Mutex))가 없으면 데이터 경쟁이 발생할 수 있습니다.
- 소켓 통신 — Unix Domain Socket은 파일 경로를 주소로 사용하며,
AF_UNIX로 생성합니다.Docker, systemd, X11 등 많은 시스템 데몬이 UDS를 사용합니다.
- 선택 기준 — 단순 데이터 전달은 파이프, 대용량 공유는 공유 메모리, 구조화 메시지는 메시지 큐, 네트워크 호환 필요 시 소켓을 선택합니다.
ipcs명령어로 시스템의 현재 IPC 리소스를 확인할 수 있습니다.
IPC 개요와 분류
Linux 커널은 프로세스 간 데이터 교환, 동기화, 이벤트 통지를 위해 다양한 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)탐색을 제공합니다.rwsem은ipcget()(쓰기)과ipc_obtain_object_check()(읽기) 사이의 동시성을 제어하며,seq는 ID 재사용 시 stale 핸들을 탐지하기 위한 시퀀스 번호입니다. (ipc/util.h) -
kern_ipc_perm
모든 System V IPC 객체(
msg_queue,shmid_kernel,sem_array)의 첫 번째 멤버로 임베딩되어, 다형적 접근을 가능하게 하는 공통 헤더입니다.key는ftok()가 생성하는 사용자 식별자이고,id는 커널이 인덱스+시퀀스를 조합하여 반환하는 실제 핸들입니다.uid/gid와cuid/cgid를 분리하여 소유권 이전 후에도 원래 생성자를 추적할 수 있으며,mode는 파일 퍼미션과 동일한 9비트 접근 제어를 제공합니다. (include/linux/ipc.h)
IPC 키와 ID: System V IPC에서 key_t는 ftok()로 생성하는 전역 식별자이고, 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;
};
pipe2() 시스템 콜
pipe2()는 파이프를 생성하는 시스템 콜로, 커널 내부에서 do_pipe2()를 통해 처리됩니다. O_CLOEXEC과 O_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-soft와 pipe-user-pages-hard는 사용자별 총 파이프 버퍼 페이지 수를 제한합니다.
Signals
시그널은 프로세스에 비동기적으로 이벤트를 통지하는 가장 오래된 IPC 메커니즘입니다. 커널은 시그널 전달 시 대상 프로세스의 task_struct에 시그널 정보를 큐잉하고, 해당 프로세스가 유저 모드로 복귀할 때 시그널 핸들러(Handler)를 실행합니다.
표준 시그널 목록
리눅스는 31개의 표준 시그널(1~31)을 정의합니다. 각 시그널의 기본 동작(default action)은 다섯 가지 중 하나입니다:
| 기본 동작 | 설명 |
|---|---|
| Term | 프로세스 종료 |
| Core | 코어 덤프(Core Dump) 생성 후 종료 |
| Stop | 프로세스 중지 (suspended) |
| Cont | 중지된 프로세스 재개 |
| Ign | 시그널 무시 |
| 번호 | 이름 | 기본 동작 | 설명 |
|---|---|---|---|
| 1 | SIGHUP | Term | 제어 터미널 hangup 또는 제어 프로세스 종료 |
| 2 | SIGINT | Term | 키보드 인터럽트 (Ctrl+C) |
| 3 | SIGQUIT | Core | 키보드 종료 (Ctrl+\) |
| 4 | SIGILL | Core | 잘못된 명령어 (illegal instruction) |
| 5 | SIGTRAP | Core | 트레이스/브레이크포인트 트랩 |
| 6 | SIGABRT | Core | abort() 호출 |
| 7 | SIGBUS | Core | 버스(Bus) 오류 (잘못된 메모리 접근 정렬) |
| 8 | SIGFPE | Core | 부동소수점 예외 (0으로 나누기 포함) |
| 9 | SIGKILL | Term | 강제 종료 (캐치/무시 불가) |
| 10 | SIGUSR1 | Term | 사용자 정의 시그널 1 |
| 11 | SIGSEGV | Core | 잘못된 메모리 참조 (segmentation fault) |
| 12 | SIGUSR2 | Term | 사용자 정의 시그널 2 |
| 13 | SIGPIPE | Term | 읽는 쪽이 없는 파이프에 쓰기 |
| 14 | SIGALRM | Term | alarm() 타이머(Timer) 만료 |
| 15 | SIGTERM | Term | 정상 종료 요청 |
| 16 | SIGSTKFLT | Term | 코프로세서 스택 오류 (미사용) |
| 17 | SIGCHLD | Ign | 자식 프로세스 중지 또는 종료 |
| 18 | SIGCONT | Cont | 중지된 프로세스 재개 |
| 19 | SIGSTOP | Stop | 프로세스 중지 (캐치/무시 불가) |
| 20 | SIGTSTP | Stop | 터미널 중지 (Ctrl+Z) |
| 21 | SIGTTIN | Stop | 백그라운드 프로세스의 터미널 입력 |
| 22 | SIGTTOU | Stop | 백그라운드 프로세스의 터미널 출력 |
| 23 | SIGURG | Ign | 소켓의 긴급(OOB) 데이터 |
| 24 | SIGXCPU | Core | CPU 시간 제한 초과 |
| 25 | SIGXFSZ | Core | 파일 크기 제한 초과 |
| 26 | SIGVTALRM | Term | 가상 타이머 만료 |
| 27 | SIGPROF | Term | 프로파일링(Profiling) 타이머 만료 |
| 28 | SIGWINCH | Ign | 터미널 윈도우 크기 변경 |
| 29 | SIGIO | Term | I/O 가능 (async I/O) |
| 30 | SIGPWR | Term | 전원 장애 |
| 31 | SIGSYS | Core | 잘못된 시스템 콜 (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);
}
시그널 전달 내부 구조
/* 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()을 통해 핸들러 실행을 설정합니다.
/* 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 상태로 유지되다가 마스크가 해제되면 전달됩니다. SIGKILL과 SIGSTOP은 마스크할 수 없습니다.
대체 시그널 스택 (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(¤t->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_pending과 pending 큐로 구분합니다.
| 구분 | 프로세스 지향 시그널 | 스레드 지향 시그널 |
|---|---|---|
| 큐 | 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_cbytes가q_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 커널 내부 흐름
메시지 큐 크기 제한: System V 메시지 큐는 세 가지 커널 파라미터로 제한됩니다.
| 파라미터 | 의미 | 기본값 | sysctl 경로 |
|---|---|---|---|
MSGMAX | 단일 메시지 최대 크기 (바이트) | 8,192 | kernel.msgmax |
MSGMNB | 큐 하나의 최대 총 바이트 | 16,384 | kernel.msgmnb |
MSGMNI | 시스템 전체 최대 큐 수 | 32,000 | kernel.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.c의newseg()→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명령으로 확인할 수 있어 디버깅 시 어떤 프로세스가 공유 메모리를 사용하는지 추적하는 데 유용합니다.
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
하나의 세마포어 세트를 나타냅니다.
sems는semget()의nsems인자만큼 할당된struct sem배열 포인터입니다.pending_alter와pending_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() 커널 내부 흐름
SEM_UNDO: SEM_UNDO 플래그를 사용하면 프로세스가 비정상 종료해도 커널이 세마포어 값을 자동으로 원복합니다. task_struct의 sysvsem.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)
*/
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() 시스템 콜
/* 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_WAKE가FUTEX_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을 기다리는 동안, 중간 우선순위의 태스크가 실행되어 전체 시스템의 응답 시간이 무한히 늘어나는 현상입니다.
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는 이 문제를 커널이 자동으로 감지하고 복구합니다.
동작 원리는 다음과 같습니다:
- 프로세스가
set_robust_list()로 robust futex 리스트의 헤드를 커널에 등록합니다. - glibc의
pthread_mutex_lock()이 lock 획득 시 해당 futex를 robust 리스트에 연결합니다. - 프로세스가 비정상 종료하면 커널이
exit_robust_list()를 호출합니다. - 리스트의 각 futex에
FUTEX_OWNER_DIED비트(bit 30)를 설정합니다. - 해당 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 경유 (고비용) |
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은 ioeventfd와 irqfd를 통해 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: 확장 가능한 I/O 이벤트 다중화(Multiplexing)
epoll은 Linux 전용 I/O 이벤트 알림 인터페이스로, select()/poll()의 확장성 한계를 극복하기 위해 Linux 2.5.44(2002)에 도입되었습니다. 수만~수십만 개의 파일 디스크립터를 효율적으로 모니터링할 수 있으며, Nginx, HAProxy, Redis, Node.js 등 고성능 네트워크 서버의 핵심 이벤트 루프 기반입니다.
select/poll의 한계와 epoll의 차별점
| 비교 항목 | select | poll | epoll |
|---|---|---|---|
| fd 상한 | FD_SETSIZE (1024) |
제한 없음 (배열 크기) | 제한 없음 (/proc/sys/fs/epoll/max_user_watches) |
| fd 전달 방식 | 매 호출마다 전체 fd_set 복사 | 매 호출마다 전체 pollfd 배열 복사 | 커널이 관심 fd를 영속 관리 (epoll_ctl로 1회 등록) |
| 이벤트 검사 | O(n) — 전체 fd 순회 | O(n) — 전체 배열 순회 | O(1) — ready list에서 즉시 반환 |
| Edge-Triggered | 불가 | 불가 | 지원 (EPOLLET) |
| 스레드 안전 | 불가 (fd_set 공유 불가) | 제한적 | EPOLLEXCLUSIVE로 thundering herd 방지 |
O(1) 이벤트 전달: select/poll은 호출마다 커널이 등록된 모든 fd를 순회하며 이벤트를 확인합니다. fd가 10만 개여도 이벤트가 발생한 fd가 10개뿐이면, select/poll은 10만 번 검사하지만 epoll_wait은 ready list에서 10개만 반환합니다.
epoll API
#include <sys/epoll.h>
/* 1. epoll 인스턴스 생성 — 커널에 struct eventpoll 할당 */
int epfd = epoll_create1(EPOLL_CLOEXEC);
/* epoll_create(size)는 폐기 예정 — size 인자 무시됨 */
/* 2. 관심 fd 등록/수정/삭제 */
struct epoll_event ev = {
.events = EPOLLIN | EPOLLET, /* 이벤트 마스크 */
.data.fd = client_fd /* 사용자 데이터 (union) */
};
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev); /* 등록 */
epoll_ctl(epfd, EPOLL_CTL_MOD, client_fd, &ev); /* 수정 */
epoll_ctl(epfd, EPOLL_CTL_DEL, client_fd, NULL); /* 삭제 */
/* 3. 이벤트 대기 — ready 상태인 fd만 반환 */
struct epoll_event events[64];
int nfds = epoll_wait(epfd, events, 64, -1); /* -1 = 무한 대기 */
for (int i = 0; i < nfds; i++) {
if (events[i].events & EPOLLIN)
handle_read(events[i].data.fd);
}
커널 내부 자료구조
epoll은 세 가지 핵심 자료구조의 조합으로 동작합니다: Red-Black Tree(관심 fd 관리), Ready List(이벤트 발생 fd), Wait Queue(epoll_wait 호출자 대기).
/* fs/eventpoll.c */
struct eventpoll {
rwlock_t lock; /* rdllist/ovflist 보호 */
struct mutex mtx; /* epoll_ctl 직렬화 */
wait_queue_head_t wq; /* epoll_wait() 대기 큐 */
wait_queue_head_t poll_wait; /* epoll fd 자체가 poll 될 때 */
struct list_head rdllist; /* ★ ready list (이벤트 발생 fd) */
struct rb_root_cached rbr; /* ★ rbtree (등록된 전체 fd) */
struct epitem *ovflist; /* 전송 중 오버플로 리스트 */
struct wakeup_source *ws; /* EPOLLWAKEUP용 */
struct user_struct *user; /* 리소스 제한 추적 */
struct file *file; /* epoll fd의 struct file */
struct hlist_head refs; /* 중첩 epoll 참조 */
unsigned int napi_id; /* busy poll NAPI ID */
};
/* 등록된 각 fd를 나타내는 노드 */
struct epitem {
union {
struct rb_node rbn; /* rbtree 노드 */
struct rcu_head rcu; /* RCU 해제용 */
};
struct list_head rdllink; /* ready list 링크 */
struct epitem *next; /* ovflist 체인 */
struct epoll_filefd ffd; /* {file *, fd} 쌍 */
struct eppoll_entry *pwqlist; /* poll wait queue 리스트 */
struct eventpoll *ep; /* 소속 eventpoll */
struct epoll_event event; /* 사용자 이벤트 + 데이터 */
};
이벤트 전달 흐름
epoll의 핵심은 콜백(Callback) 기반 이벤트 전달입니다. fd 등록 시 해당 파일의 wait queue에 콜백(ep_poll_callback)을 설치하여, 이벤트 발생 시 커널이 자동으로 ready list에 추가합니다.
/* fs/eventpoll.c — 핵심 흐름 요약 */
/* ① epoll_ctl(EPOLL_CTL_ADD): fd 등록 */
static int ep_insert(struct eventpoll *ep, struct epoll_event *event,
struct file *tfile, int fd)
{
struct epitem *epi;
epi = kmem_cache_zalloc(epi_cache, GFP_KERNEL);
/* rbtree에 삽입 (fd + file 포인터로 정렬) */
ep_rbtree_insert(ep, epi);
/* 대상 fd의 wait queue에 콜백 등록 */
init_poll_funcptr(&epq.pt, ep_ptable_queue_proc);
revents = ep_item_poll(epi, &epq.pt, 1);
/* → 내부에서 file->poll()을 호출하여 */
/* ep_poll_callback을 wait queue에 설치 */
/* 이미 ready 상태이면 즉시 ready list에 추가 */
if (revents && !ep_is_linked(epi)) {
list_add_tail(&epi->rdllink, &ep->rdllist);
wake_up(&ep->wq); /* epoll_wait 대기자 깨우기 */
}
}
/* ② 이벤트 발생 시: 드라이버가 wake_up() → ep_poll_callback 호출 */
static int ep_poll_callback(wait_queue_entry_t *wait,
unsigned mode, int sync, void *key)
{
struct epitem *epi = ep_item_from_wait(wait);
struct eventpoll *ep = epi->ep;
__poll_t pollflags = (__poll_t)(unsigned long)key;
/* 이벤트 마스크 확인 */
if (pollflags && !(pollflags & epi->event.events))
return 0; /* 관심 없는 이벤트 무시 */
/* ready list에 추가 (중복 방지: rdllink가 이미 연결되어 있으면 스킵) */
if (!ep_is_linked(epi))
list_add_tail(&epi->rdllink, &ep->rdllist);
/* epoll_wait()에서 대기 중인 태스크 깨우기 */
wake_up(&ep->wq);
return 1;
}
/* ③ epoll_wait(): ready list에서 이벤트 수확 */
static int ep_poll(struct eventpoll *ep, struct epoll_event *events,
int maxevents, struct timespec64 *timeout)
{
/* ready list가 비어있으면 wq에서 대기 */
if (list_empty(&ep->rdllist))
schedule_hrtimeout_range(timeout, ...);
/* ready list → 사용자 공간 epoll_event 배열로 복사 */
return ep_send_events(ep, events, maxevents);
}
Level-Triggered vs Edge-Triggered
epoll의 두 가지 트리거 모드는 이벤트 재전달 정책의 차이입니다.
| 모드 | 플래그 | 동작 | 특징 |
|---|---|---|---|
| LT (Level-Triggered) | 기본값 | 조건이 유지되는 동안 epoll_wait가 반복 반환 |
프로그래밍 용이, select/poll과 동일 의미론 |
| ET (Edge-Triggered) | EPOLLET |
상태 변화 시 한 번만 반환 | 높은 성능, 반드시 논블로킹 + 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_wait와 sigprocmask 사이의 경쟁 조건(Race Condition)을 방지합니다. epoll_pwait2()(Linux 5.11+)는 타임아웃을 struct timespec으로 지정하여 나노초 정밀도를 제공합니다.
/* epoll_pwait: 시그널 안전한 이벤트 대기 */
sigset_t sigmask;
sigemptyset(&sigmask);
sigaddset(&sigmask, SIGINT);
/* SIGINT를 차단한 상태로 이벤트 대기 */
/* → 반환 후 원래 시그널 마스크 자동 복원 */
int n = epoll_pwait(epfd, events, 64, -1, &sigmask);
/* epoll_pwait2: 나노초 정밀도 타임아웃 (5.11+) */
struct timespec ts = { .tv_sec = 0, .tv_nsec = 500000000 }; /* 500ms */
int n = epoll_pwait2(epfd, events, 64, &ts, NULL);
실전 패턴: ET 모드 이벤트 루프 서버
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <fcntl.h>
#include <unistd.h>
#define MAX_EVENTS 1024
static void set_nonblock(int fd) {
int flags = fcntl(fd, F_GETFL);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
int main(void) {
/* listen 소켓 생성 */
int lfd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
int opt = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(8080),
.sin_addr.s_addr = INADDR_ANY
};
bind(lfd, (struct sockaddr *)&addr, sizeof(addr));
listen(lfd, SOMAXCONN);
/* epoll 인스턴스 생성 + listen fd 등록 */
int epfd = epoll_create1(EPOLL_CLOEXEC);
struct epoll_event ev = { .events = EPOLLIN, .data.fd = lfd };
epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
struct epoll_event events[MAX_EVENTS];
for (;;) {
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < nfds; i++) {
int fd = events[i].data.fd;
if (fd == lfd) {
/* 새 연결 수락 (ET: 모두 accept) */
for (;;) {
int cfd = accept4(lfd, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC);
if (cfd == -1) break;
ev.events = EPOLLIN | EPOLLET | EPOLLRDHUP;
ev.data.fd = cfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev);
}
} else if (events[i].events & (EPOLLRDHUP | EPOLLHUP | EPOLLERR)) {
/* 연결 종료 또는 에러 */
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
close(fd);
} else if (events[i].events & EPOLLIN) {
/* 데이터 수신 (ET: EAGAIN까지 읽기) */
handle_et_read(fd);
}
}
}
}
epoll 주의 사항과 함정
dup/fork와 epoll: epoll은 fd가 아니라 파일 디스크립션(struct file) 단위로 이벤트를 감시합니다. dup()이나 fork()로 fd가 복제되면, 원본 fd를 close()해도 복제된 fd가 남아있는 한 epoll 모니터링이 계속됩니다. 모든 복제본이 닫혀야 epoll에서 자동 해제됩니다.
| 함정 | 증상 | 해결책 |
|---|---|---|
| ET + 불완전 읽기 | 이벤트 누락, 데이터 정체 | EAGAIN까지 반드시 루프 읽기 |
close(fd) 전 DEL 누락 |
dup/fork 시 좀비 모니터링 |
close() 전 항상 EPOLL_CTL_DEL |
LT + EPOLLOUT 상시 등록 |
CPU 100% (항상 쓰기 가능 이벤트 발생) | 쓸 데이터가 있을 때만 EPOLLOUT 활성화 |
| 스레드 간 fd 경합 | 같은 이벤트를 여러 스레드가 처리 | 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 Sockets
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 소켓 생성과 바인딩
Netlink 소켓은 표준 소켓 API로 생성하며, struct sockaddr_nl로 바인딩합니다. nl_pid는 유저 공간 소켓의 포트 ID(일반적으로 프로세스 PID), nl_groups는 멀티캐스트(Multicast) 그룹 비트마스크입니다.
#include <linux/netlink.h>
#include <sys/socket.h>
/* Netlink 주소 구조체 */
struct sockaddr_nl {
__kernel_sa_family_t nl_family; /* AF_NETLINK */
unsigned short nl_pad; /* 패딩 (0) */
__u32 nl_pid; /* 포트 ID (0=커널, 보통 getpid()) */
__u32 nl_groups; /* 멀티캐스트 그룹 비트마스크 */
};
/* 소켓 생성 — NETLINK_ROUTE로 라우팅/링크 관리 */
int fd = socket(AF_NETLINK, SOCK_DGRAM, NETLINK_ROUTE);
/* 유니캐스트 바인딩 — 커널과 1:1 통신 */
struct sockaddr_nl sa = {
.nl_family = AF_NETLINK,
.nl_pid = getpid(), /* 포트 ID (유일해야 함) */
.nl_groups = 0 /* 멀티캐스트 미구독 */
};
bind(fd, (struct sockaddr *)&sa, sizeof(sa));
/* 멀티캐스트 바인딩 — 네트워크 링크 변경 이벤트 수신 */
struct sockaddr_nl sa_mc = {
.nl_family = AF_NETLINK,
.nl_pid = 0,
.nl_groups = RTMGRP_LINK | RTMGRP_IPV4_IFADDR /* 링크 + IPv4 주소 변경 */
};
bind(fd, (struct sockaddr *)&sa_mc, sizeof(sa_mc));
Netlink 메시지 포맷
Netlink 메시지는 nlmsghdr 헤더 뒤에 프로토콜별 페이로드(Payload)가 따르고, 페이로드 내부에는 TLV(Type-Length-Value) 형식의 nlattr 속성들이 중첩(Nested)될 수 있습니다. 모든 영역은 NLMSG_ALIGN(4) 경계로 정렬됩니다.
/* Netlink 메시지 레이아웃:
*
* ┌──────────────────────────────────────────────────────────────┐
* │ nlmsghdr (16 bytes) │
* │ nlmsg_len | nlmsg_type | nlmsg_flags | nlmsg_seq | pid │
* ├──────────────────────────────────────────────────────────────┤
* │ [padding to NLMSG_ALIGN] │
* ├──────────────────────────────────────────────────────────────┤
* │ Protocol Header (예: struct ifinfomsg, struct rtmsg) │
* ├──────────────────────────────────────────────────────────────┤
* │ [padding to NLMSG_ALIGN] │
* ├──────────────────────────────────────────────────────────────┤
* │ Attributes (nlattr TLV) │
* │ ┌─────────────────────────────────────────────────┐ │
* │ │ nla_len (2B) | nla_type (2B) | payload | [pad] │ │
* │ ├─────────────────────────────────────────────────┤ │
* │ │ nla_len | nla_type | nested attrs... | [pad] │ │
* │ └─────────────────────────────────────────────────┘ │
* └──────────────────────────────────────────────────────────────┘
*/
/* Netlink 속성 (TLV) */
struct nlattr {
__u16 nla_len; /* 전체 속성 길이 (헤더 + 페이로드) */
__u16 nla_type; /* 속성 타입 (프로토콜별 정의) */
/* 페이로드 데이터가 이어짐 */
};
/* 정렬 및 헬퍼 매크로 */
#define NLA_ALIGNTO 4
#define NLA_ALIGN(len) (((len) + NLA_ALIGNTO - 1) & ~(NLA_ALIGNTO - 1))
#define NLA_HDRLEN ((int) NLA_ALIGN(sizeof(struct nlattr))) /* = 4 */
/* 메시지 구성 헬퍼 매크로 */
#define NLMSG_ALIGN(len) (((len) + 3) & ~3)
#define NLMSG_HDRLEN ((int) NLMSG_ALIGN(sizeof(struct nlmsghdr)))
#define NLMSG_DATA(nlh) ((void *)((char *)(nlh) + NLMSG_HDRLEN))
#define NLMSG_NEXT(nlh, len) ... /* 다음 메시지로 이동 */
/* 속성 순회 매크로 */
#define nla_for_each_attr(pos, head, len, rem) \
for (pos = head, rem = len; \
nla_ok(pos, rem); \
pos = nla_next(pos, &rem))
Generic Netlink (genetlink)
기존 Netlink 프로토콜 패밀리(NETLINK_ROUTE, NETLINK_NETFILTER 등)는 헤더 파일에 하드코딩된 상수로 정의되어 있어 최대 32개까지만 등록할 수 있습니다. Generic Netlink(genetlink)는 이 제한을 극복하기 위해 단일 NETLINK_GENERIC 패밀리 위에 동적 패밀리 등록을 지원합니다.
/* include/net/genetlink.h */
struct genl_family {
int id; /* 동적 할당 패밀리 ID (커널이 부여) */
unsigned int hdrsize; /* 사용자 헤더 크기 */
char name[GENL_NAMSIZ]; /* 패밀리 이름 */
unsigned int version; /* 프로토콜 버전 */
unsigned int maxattr; /* 최대 속성 번호 */
const struct nla_policy *policy; /* 속성 검증 정책 */
const struct genl_ops *ops; /* 명령 핸들러 배열 */
unsigned int n_ops; /* ops 배열 크기 */
const struct genl_multicast_group *mcgrps;
unsigned int n_mcgrps;
struct module *module;
};
struct genl_ops {
int (*doit)(struct sk_buff *skb, struct genl_info *info); /* 단일 요청 */
int (*dumpit)(struct sk_buff *skb, struct netlink_callback *cb); /* 덤프 */
u8 cmd; /* 명령 번호 */
u8 flags; /* GENL_ADMIN_PERM 등 */
};
유저 공간에서 genetlink 패밀리를 사용하려면 먼저 패밀리 이름으로 ID를 조회해야 합니다. 컨트롤러 패밀리(GENL_ID_CTRL)에 CTRL_CMD_GETFAMILY 명령을 보내면 커널이 해당 패밀리의 동적 ID를 반환합니다. nl80211(WiFi), taskstats(태스크 통계), TASKSTATS 등이 genetlink의 대표적 사용 예입니다.
/* 커널 모듈에서 genetlink 패밀리 등록 예제 (스켈레톤) */
#include <net/genetlink.h>
/* 명령 정의 */
enum {
MY_CMD_UNSPEC,
MY_CMD_ECHO, /* 에코 명령 */
__MY_CMD_MAX,
};
/* 속성 정의 */
enum {
MY_ATTR_UNSPEC,
MY_ATTR_MSG, /* NLA_STRING 타입 메시지 */
__MY_ATTR_MAX,
};
static const struct nla_policy my_policy[__MY_ATTR_MAX] = {
[MY_ATTR_MSG] = { .type = NLA_STRING, .len = 256 },
};
static int my_echo_doit(struct sk_buff *skb, struct genl_info *info)
{
struct sk_buff *reply;
void *hdr;
if (!info->attrs[MY_ATTR_MSG])
return -EINVAL;
reply = genlmsg_new(NLMSG_DEFAULT_SIZE, GFP_KERNEL);
hdr = genlmsg_put_reply(reply, info, &my_family, 0, MY_CMD_ECHO);
nla_put_string(reply, MY_ATTR_MSG, nla_data(info->attrs[MY_ATTR_MSG]));
genlmsg_end(reply, hdr);
return genlmsg_reply(reply, info);
}
static const struct genl_ops my_ops[] = {
{
.cmd = MY_CMD_ECHO,
.doit = my_echo_doit,
.flags = 0,
},
};
static struct genl_family my_family = {
.name = "MY_GENL",
.version = 1,
.maxattr = __MY_ATTR_MAX - 1,
.policy = my_policy,
.ops = my_ops,
.n_ops = ARRAY_SIZE(my_ops),
.module = THIS_MODULE,
};
/* 모듈 초기화 시 */
genl_register_family(&my_family);
/* 모듈 해제 시 */
genl_unregister_family(&my_family);
Netlink 멀티캐스트
Netlink 멀티캐스트를 통해 커널은 이벤트를 여러 유저 공간 프로세스에 동시에 브로드캐스트(Broadcast)할 수 있습니다. 대표적으로 NETLINK_KOBJECT_UEVENT는 udev에 디바이스 핫플러그(Hotplug) 이벤트를 전달하며, RTNLGRP_LINK는 네트워크 인터페이스 상태 변경을 알립니다.
| 멀티캐스트 그룹 | 패밀리 | 이벤트 내용 | 대표 수신자 |
|---|---|---|---|
RTNLGRP_LINK |
NETLINK_ROUTE | 인터페이스 up/down, 생성/삭제 | NetworkManager, systemd-networkd |
RTNLGRP_IPV4_IFADDR |
NETLINK_ROUTE | IPv4 주소 추가/삭제 | ip monitor, dhclient |
RTNLGRP_IPV6_IFADDR |
NETLINK_ROUTE | IPv6 주소 추가/삭제 | radvd, NetworkManager |
RTNLGRP_IPV4_ROUTE |
NETLINK_ROUTE | IPv4 라우팅 변경 | ip monitor, bird |
| KOBJECT_UEVENT | NETLINK_KOBJECT_UEVENT | 디바이스 추가/제거 | udevd, mdev |
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <linux/netlink.h>
#include <linux/rtnetlink.h>
#include <net/if.h>
/* 네트워크 링크 변경 모니터링 예제 */
int main(void) {
int fd = socket(AF_NETLINK, SOCK_DGRAM, NETLINK_ROUTE);
struct sockaddr_nl sa = {
.nl_family = AF_NETLINK,
.nl_groups = RTMGRP_LINK | RTMGRP_IPV4_IFADDR
};
bind(fd, (struct sockaddr *)&sa, sizeof(sa));
char buf[8192];
for (;;) {
ssize_t len = recv(fd, buf, sizeof(buf), 0);
struct nlmsghdr *nh = (struct nlmsghdr *)buf;
for (; NLMSG_OK(nh, len); nh = NLMSG_NEXT(nh, len)) {
if (nh->nlmsg_type == RTM_NEWLINK || nh->nlmsg_type == RTM_DELLINK) {
struct ifinfomsg *ifi = NLMSG_DATA(nh);
char name[IF_NAMESIZE];
if_indextoname(ifi->ifi_index, name);
printf("링크 %s: %s (flags=0x%x)\n",
nh->nlmsg_type == RTM_NEWLINK ? "UP" : "DOWN",
name, ifi->ifi_flags);
}
if (nh->nlmsg_type == RTM_NEWADDR || nh->nlmsg_type == RTM_DELADDR) {
printf("주소 변경 감지 (type=%d)\n", nh->nlmsg_type);
}
}
}
close(fd);
}
Unix Domain Sockets
Unix Domain Socket(AF_UNIX / AF_LOCAL)은 같은 호스트 내 프로세스 간 통신에 최적화된 소켓 기반 IPC입니다. TCP/IP 프로토콜 스택(체크섬(Checksum), 라우팅(Routing), 시퀀스 번호 등)을 완전히 우회하여 커널 내부에서 직접 sk_buff를 전달하므로, localhost TCP 대비 2~3배 높은 처리량(Throughput)과 현저히 낮은 레이턴시를 제공합니다.
세 가지 소켓 타입을 지원합니다:
SOCK_STREAM— 연결 기반 바이트 스트림 (TCP와 유사)SOCK_DGRAM— 비연결 데이터그램 (UDP와 유사, 단 순서 보장)SOCK_SEQPACKET— 연결 기반 + 메시지 경계 보존
일반 소켓 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
| 특성 | Binder | Unix Domain Socket |
|---|---|---|
| 복사 횟수 | 1회 (mmap 활용) | 2회 (유저→커널→유저) |
| 호출자 인증 | 커널이 PID/UID 자동 첨부 | SCM_CREDENTIALS (opt-in) |
| 객체 수명 관리 | 참조 카운팅, 사망 통지 | 없음 |
| 통신 모델 | 동기 RPC (기본) | 스트림 / 데이터그램 |
| 보안 | SELinux Binder 훅 | 파일 권한 + SCM |
| 오버헤드 | ioctl + mmap 설정 비용 | 단순 소켓 생성 |
| 사용 범위 | Android 전용 | 모든 Unix 계열 OS |
/* 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 섹션을 참고하라.
참고 링크
- Linux Kernel — IPC Userspace API — 커널 공식 문서의 IPC 유저스페이스 API 섹션입니다
- sysvipc(7) — System V IPC 메커니즘(메시지 큐, 세마포어, 공유 메모리) 전체 개요 매뉴얼입니다
- pipe(7) — 파이프와 FIFO의 용량, 원자성 보장, O_NONBLOCK 동작을 설명합니다
- pipe(2) — pipe() 및 pipe2() 시스템 콜의 인터페이스와 플래그를 다룹니다
- mq_overview(7) — POSIX 메시지 큐의 생성, 속성, 알림 메커니즘을 설명합니다
- shm_overview(7) — POSIX 공유 메모리 객체의 생성과 메모리 매핑 방법을 다룹니다
- sem_overview(7) — POSIX 세마포어(named/unnamed)의 동작과 사용법을 설명합니다
- msgget(2) — System V 메시지 큐 생성 및 접근 시스템 콜입니다
- shmget(2) — System V 공유 메모리 세그먼트 할당 시스템 콜입니다
- semget(2) — System V 세마포어 집합 생성 시스템 콜입니다
- unix(7) — Unix 도메인 소켓의 주소 체계, ancillary 데이터, SCM_RIGHTS를 설명합니다
- netlink(7) — Netlink 소켓 프로토콜 패밀리와 멀티캐스트 그룹을 다룹니다
- eventfd(2) — eventfd 파일 디스크립터를 통한 이벤트 알림 메커니즘입니다
- signalfd(2) — 시그널을 파일 디스크립터로 수신하는 인터페이스입니다
- timerfd_create(2) — 타이머 만료를 파일 디스크립터로 전달하는 인터페이스입니다
- epoll(7) — epoll I/O 이벤트 다중화 인터페이스의 동작 모드(ET/LT)를 설명합니다
- futex(2) — Fast Userspace Mutex 시스템 콜의 op 코드와 동작을 다룹니다
- memfd_create(2) — 익명 파일을 생성하여 프로세스 간 공유하는 시스템 콜입니다
- pidfd_open(2) — PID 파일 디스크립터를 통한 레이스 프리 프로세스 관리 시스템 콜입니다
- process_vm_readv(2) — Cross Memory Attach를 통한 원격 프로세스 메모리 직접 읽기입니다
- signal(7) — 리눅스 시그널의 종류, 기본 동작, 실시간 시그널 확장을 설명합니다
- LWN: Scalability of pipes — 파이프 구현의 확장성 개선 패치와 성능 분석을 다룹니다
- LWN: A futex overview and update — futex의 내부 구현과 PI futex, robust futex를 심층 분석합니다
- LWN: eventfd and its uses — eventfd의 설계 의도와 KVM, VFIO 등에서의 활용 사례를 설명합니다
- LWN: Completing the pidfd API — pidfd 인터페이스의 설계 과정과 레이스 조건 해결 방법을 다룹니다
- LWN: Sealed files — memfd_create()와 파일 실링(sealing) 메커니즘의 보안 모델을 설명합니다
- POSIX.1-2024 (IEEE Std 1003.1) — IPC 관련 POSIX 표준 규격의 최신 버전입니다
ipc/msg.c— System V 메시지 큐의 커널 구현부입니다ipc/shm.c— System V 공유 메모리의 커널 구현부입니다ipc/sem.c— System V 세마포어의 커널 구현부입니다ipc/mqueue.c— POSIX 메시지 큐의 커널 구현부입니다fs/pipe.c— 파이프와 FIFO의 커널 구현부입니다kernel/futex/— futex 서브시스템 전체 구현 디렉터리입니다net/unix/af_unix.c— Unix 도메인 소켓의 프로토콜 구현부입니다fs/eventfd.c— eventfd 파일 디스크립터의 커널 구현부입니다