IPC (Inter-Process Communication)
Linux 커널의 프로세스 간 통신(IPC) 메커니즘 전반을 다룹니다. Pipe, Signal, System V IPC(Message Queue, Shared Memory, Semaphore), POSIX IPC, Futex, eventfd/signalfd/timerfd, epoll, Netlink Socket, Unix Domain Socket까지 커널 내부 구현을 심층 분석합니다.
핵심 요약
- Pipe — 단방향 바이트 스트림. 부모-자식 프로세스 간 가장 간단한 통신 수단입니다.
- 공유 메모리 — 가장 빠른 IPC. 여러 프로세스가 같은 물리 페이지를 매핑하여 데이터를 공유합니다.
- 메시지 큐 — 구조화된 메시지를 큐에 넣고 꺼내는 방식. System V와 POSIX 두 가지 API가 있습니다.
- Unix Domain Socket — 같은 호스트 내 프로세스 간 양방향 통신. 네트워크 소켓과 동일한 API를 사용합니다.
- eventfd / signalfd — 이벤트 통지를 위한 경량 파일 디스크립터 기반 메커니즘입니다.
단계별 이해
- 파이프 체험 —
ls | grep txt처럼 셸의|가 바로 파이프입니다. 왼쪽 프로세스의 stdout이 오른쪽의 stdin으로 연결됩니다.C에서는
pipe()시스템 콜로 생성합니다. - 공유 메모리 이해 —
shmget()/shmat()또는mmap(MAP_SHARED)로 생성합니다.동기화(세마포어, 뮤텍스)가 없으면 데이터 경쟁이 발생할 수 있습니다.
- 소켓 통신 — Unix Domain Socket은 파일 경로를 주소로 사용하며,
AF_UNIX로 생성합니다.Docker, systemd, X11 등 많은 시스템 데몬이 UDS를 사용합니다.
- 선택 기준 — 단순 데이터 전달은 파이프, 대용량 공유는 공유 메모리, 구조화 메시지는 메시지 큐, 네트워크 호환 필요 시 소켓을 선택합니다.
ipcs명령어로 시스템의 현재 IPC 리소스를 확인할 수 있습니다.
IPC 개요와 분류
Linux 커널은 프로세스 간 데이터 교환, 동기화, 이벤트 통지를 위해 다양한 IPC 메커니즘을 제공합니다. 각 메커니즘은 서로 다른 사용 사례에 최적화되어 있으며, 커널 내부에서 별도의 서브시스템으로 구현됩니다.
IPC 메커니즘 분류
| 범주 | 메커니즘 | 데이터 방향 | 핵심 특징 |
|---|---|---|---|
| 바이트 스트림 | Pipe, FIFO | 단방향 | fd 기반, 부모-자식 또는 이름 기반 통신 |
| 시그널 | Signal, signalfd | 단방향 (통지) | 비동기 이벤트 전달, 제한된 정보량 |
| 메시지 전달 | SysV MQ, POSIX MQ | 양방향 | 메시지 경계 보존, 우선순위 지원 |
| 공유 메모리 | SysV SHM, POSIX SHM, mmap | 양방향 | 최고 성능, 별도 동기화 필요 |
| 동기화 | SysV Sem, POSIX Sem, Futex | - | 프로세스 간 잠금/동기화 |
| 이벤트/fd 기반 | eventfd, signalfd, timerfd | 단방향 | epoll 통합, 이벤트 루프 친화적 |
| 소켓 | Unix Domain Socket, Netlink | 양방향 | 네트워크 API 활용, 유연한 프로토콜 |
IPC Namespace
Linux 커널은 ipc_namespace를 통해 IPC 자원을 네임스페이스별로 격리합니다. System V IPC 객체(메시지 큐, 공유 메모리, 세마포어)는 모두 이 네임스페이스에 의해 격리되며, 컨테이너 환경에서 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 키와 ID: System V IPC에서 key_t는 ftok()로 생성하는 전역 식별자이고, id는 커널이 반환하는 실제 IPC 객체 핸들입니다. ID는 인덱스와 시퀀스 번호로 구성되어 재사용 시 stale 참조를 감지합니다.
Pipes & FIFOs
Pipe는 Linux에서 가장 기본적인 IPC 메커니즘으로, 두 개의 파일 디스크립터(읽기/쓰기)를 통해 단방향 바이트 스트림을 전달합니다. 커널 내부에서 pipe는 pipefs 가상 파일시스템 위의 특수 inode로 구현됩니다.
Pipe 내부 구조
Pipe의 핵심 자료구조는 struct pipe_inode_info이며, 환형 버퍼로 구현됩니다. 기본 버퍼 크기는 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)는 파일시스템에 이름을 가지는 파이프로, 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에 시그널 정보를 큐잉하고, 해당 프로세스가 유저 모드로 복귀할 때 시그널 핸들러를 실행합니다.
표준 시그널 목록
리눅스는 31개의 표준 시그널(1~31)을 정의합니다. 각 시그널의 기본 동작(default action)은 다섯 가지 중 하나입니다:
| 기본 동작 | 설명 |
|---|---|
| Term | 프로세스 종료 |
| Core | 코어 덤프 생성 후 종료 |
| 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 | 버스 오류 (잘못된 메모리 접근 정렬) |
| 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() 타이머 만료 |
| 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 | 프로파일링 타이머 만료 |
| 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;
}
시그널 핸들러 실행 메커니즘
시그널 핸들러 실행은 커널이 유저 스택을 직접 조작하는 정교한 과정입니다. 프로세스가 시스템 콜이나 인터럽트에서 유저 모드로 복귀하기 직전에 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: 핸들러 실행 중 차단할 시그널 마스크 */
};
시그널 마스크
각 스레드는 task_struct.blocked에 시그널 마스크(sigset_t)를 가지며, sigprocmask()로 제어합니다. 마스크된 시그널은 pending 상태로 유지되다가 마스크가 해제되면 전달됩니다. SIGKILL과 SIGSTOP은 마스크할 수 없습니다.
대체 시그널 스택 (sigaltstack)
시그널 핸들러는 기본적으로 현재 프로세스의 유저 스택에서 실행됩니다. 문제는 스택 오버플로가 발생하면 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은 현재 대체 스택 위에서 실행 중임을 나타냅니다 (읽기 전용). 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()로 동기적으로 처리하는 것이 가장 안전합니다. 이렇게 하면 비동기 시그널 핸들러의 복잡한 안전성 문제를 피할 수 있습니다.
실시간 시그널
표준 시그널(1~31)은 pending 비트마스크만 설정하므로 동일 시그널이 여러 번 발생해도 한 번만 전달됩니다. 실시간 시그널(SIGRTMIN~SIGRTMAX, 32~64)은 큐잉되어 발생 횟수만큼 전달되며, sigqueue()로 추가 데이터(sigval)를 전달할 수 있습니다.
| 속성 | 표준 시그널 (1~31) | 실시간 시그널 (32~64) |
|---|---|---|
| 큐잉 | 안 됨 (비트마스크만) | 됨 (발생 횟수만큼) |
| 순서 보장 | 보장 안 됨 | 번호 순서 보장 |
| 추가 데이터 | 제한적 (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;
/* 이후: 메시지 데이터 (인라인) */
};
/* 유저 공간 사용 예 */
#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);
Shared Memory (공유 메모리)
System V 공유 메모리는 가장 빠른 IPC 메커니즘으로, 여러 프로세스가 동일한 물리 메모리 영역을 자신의 가상 주소 공간에 매핑하여 직접 접근합니다. 별도의 동기화 메커니즘(세마포어, 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 */
...
};
Semaphores (세마포어)
System V 세마포어는 프로세스 간 동기화를 위한 카운팅 세마포어 배열을 제공합니다. 하나의 세마포어 세트에 여러 개의 세마포어를 포함할 수 있으며, semop()으로 원자적 다중 연산을 수행합니다.
/* 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_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 구현) |
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() + 참조 카운트 기반 |
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;
}
PI-Futex (Priority Inheritance)
PI-Futex는 우선순위 역전(priority inversion) 문제를 해결하기 위해 FUTEX_LOCK_PI/FUTEX_UNLOCK_PI 연산을 제공합니다. 높은 우선순위의 태스크가 낮은 우선순위 태스크가 보유한 lock을 기다릴 때, 커널이 lock 소유자의 우선순위를 일시적으로 올려줍니다. 커널 내부의 rt_mutex 인프라를 활용합니다.
Robust Futex
Robust futex는 lock을 보유한 프로세스가 비정상 종료했을 때 deadlock을 방지합니다. set_robust_list()로 등록한 futex 목록을 커널이 관리하며, 프로세스 종료 시 exit_robust_list()에서 FUTEX_OWNER_DIED 비트를 설정하여 다른 대기자에게 알립니다.
futex2: Linux 5.16+에서 futex_waitv() 시스템 콜이 추가되어 여러 futex를 동시에 대기할 수 있습니다. Windows의 WaitForMultipleObjects()에 대응하며, Proton/Wine의 게임 호환성을 위해 도입되었습니다.
eventfd / signalfd / timerfd
Linux는 다양한 이벤트 소스를 파일 디스크립터로 추상화하여 epoll/select/poll과 통합할 수 있는 fd 기반 IPC를 제공합니다.
| fd 유형 | 용도 | 핵심 구조체 |
|---|---|---|
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는 호스트에서 게스트에 인터럽트를 주입하는 데 사용됩니다.
epoll: 확장 가능한 I/O 이벤트 다중화
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의 핵심은 콜백 기반 이벤트 전달입니다. 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 사이의 경쟁 조건을 방지합니다. 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 주소 패밀리를 사용하며, 라우팅 테이블 관리(NETLINK_ROUTE), 방화벽 설정(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 등 심층 내용은 네트워크 스택 문서에서 다룹니다.
Unix Domain Sockets
Unix Domain Socket(AF_UNIX / AF_LOCAL)은 같은 호스트 내 프로세스 간 통신에 최적화된 소켓 기반 IPC입니다. TCP/IP 프로토콜 스택(체크섬, 라우팅, 시퀀스 번호 등)을 완전히 우회하여 커널 내부에서 직접 sk_buff를 전달하므로, localhost TCP 대비 2~3배 높은 처리량과 현저히 낮은 레이턴시를 제공합니다. SOCK_STREAM(바이트 스트림), SOCK_DGRAM(데이터그램), SOCK_SEQPACKET(순서 보장 메시지)를 모두 지원하며, 파일 디스크립터 전달(SCM_RIGHTS)과 자격 증명 전달(SCM_CREDENTIALS) 같은 고유 기능을 갖추고 있습니다.
커널 자료구조
Unix Domain Socket의 커널 구현은 net/unix/af_unix.c에 위치합니다. 핵심 자료구조는 일반 소켓 struct sock을 확장한 struct unix_sock입니다.
/* include/net/af_unix.h */
struct unix_sock {
struct sock sk; /* 공통 소켓 구조체 (반드시 첫 번째 멤버) */
struct unix_address *addr; /* 바인드된 주소 정보 */
struct path path; /* 파일시스템 경로 (pathname 소켓) */
struct mutex iolock; /* 동시 I/O 직렬화 */
struct sock *peer; /* 연결된 피어 소켓 */
struct sock *listener; /* listen 소켓 (accept 대기 시) */
struct list_head link; /* 전역 unix_socket_table 링크 */
unsigned long inflight; /* 전송 중인 fd 수 (GC 추적) */
spinlock_t lock; /* 구조체 보호 */
unsigned int recursion_level; /* GC 재귀 깊이 제한 */
};
/* 유저 공간 주소 구조체 */
struct sockaddr_un {
sa_family_t sun_family; /* AF_UNIX */
char sun_path[108]; /* 소켓 경로 (최대 108바이트) */
};
/* 커널 내부 주소 표현 */
struct unix_address {
refcount_t refcnt; /* 참조 카운트 */
int len; /* 주소 길이 */
struct sockaddr_un name[]; /* 유연한 배열 멤버 */
};
unix_sock에서 peer 포인터가 핵심입니다. connect() 또는 socketpair() 시 두 소켓의 peer가 상호 참조되며, 데이터 전송은 송신측 소켓에서 peer->sk_receive_queue로 sk_buff를 직접 enqueue하는 방식으로 이루어집니다.
주소 체계: Pathname / Abstract / Unnamed
Unix Domain Socket은 세 가지 주소 유형을 지원하며, 각각 다른 네임스페이스와 수명 주기를 가집니다.
| 유형 | sun_path | 네임스페이스 | 파일시스템 엔트리 | 접근 제어 | 주요 사용처 |
|---|---|---|---|---|---|
| Pathname | /run/myapp.sock |
파일시스템 | 생성됨 (S_IFSOCK) |
파일 퍼미션 | D-Bus, systemd, Docker |
| Abstract | \0myapp (첫 바이트 NUL) |
네트워크 NS | 없음 | 없음 (커널 5.4+ SO_PASSCRED) | Android (Zygote), Wayland |
| Unnamed | (없음) | 프로세스 쌍 | 없음 | fd 접근만 | socketpair() |
/* 1. Pathname 소켓 — 파일시스템에 소켓 파일 생성 */
struct sockaddr_un addr;
addr.sun_family = AF_UNIX;
strncpy(addr.sun_path, "/run/myapp.sock", sizeof(addr.sun_path) - 1);
bind(fd, (struct sockaddr *)&addr, sizeof(addr));
/* → ls -l /run/myapp.sock: srwxr-xr-x 1 root root 0 ... */
/* 2. Abstract 소켓 — sun_path[0] = '\0', 파일 미생성 (Linux 전용) */
memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_UNIX;
addr.sun_path[0] = '\0';
memcpy(addr.sun_path + 1, "myapp", 5);
socklen_t len = offsetof(struct sockaddr_un, sun_path) + 1 + 5;
bind(fd, (struct sockaddr *)&addr, len);
/* → 파일시스템에 흔적 없음, net namespace 내에서만 유효 */
/* 3. Unnamed 소켓 — socketpair()로 생성, bind 불필요 */
int sv[2];
socketpair(AF_UNIX, SOCK_STREAM, 0, sv);
/* → sv[0]↔sv[1] 양방향 연결, 부모-자식 간 통신에 적합 */
sun_path 길이 제한: sun_path는 108바이트로 고정되어 있어 긴 경로에서 절단(truncation) 문제가 발생할 수 있습니다. /var/lib/docker/... 같은 깊은 경로를 사용할 때는 심볼릭 링크나 Abstract 소켓을 고려하세요. 일부 시스템에서는 sizeof(sun_path)가 104바이트(macOS)이므로 이식성을 위해 offsetof(struct sockaddr_un, sun_path) + strlen(path) + 1로 길이를 계산하는 것이 안전합니다.
소켓 타입 비교
| 특성 | SOCK_STREAM | SOCK_DGRAM | SOCK_SEQPACKET |
|---|---|---|---|
| 연결 | 연결 지향 (connect/accept) | 비연결 (sendto/recvfrom) | 연결 지향 (connect/accept) |
| 메시지 경계 | 보존 안 됨 (바이트 스트림) | 보존됨 | 보존됨 |
| 순서 보장 | O | O (로컬이므로) | O |
| 신뢰성 | O | O (로컬이므로 드롭 없음) | O |
| 흐름 제어 | O (sk_sndbuf/sk_rcvbuf) | 제한적 (큐 가득 시 EAGAIN) | O |
| 최대 메시지 크기 | 무제한 (스트리밍) | sk_sndbuf (기본 ~212KB) | sk_sndbuf |
| SCM_RIGHTS | O | O | O |
| 피어 종료 감지 | read() → 0, EPOLLHUP | 감지 어려움 | read() → 0, EPOLLHUP |
| 대표 사용처 | D-Bus, MySQL, Redis | syslog, rsyslog | Bluetooth (BlueZ), SCTP 대체 |
/* SOCK_SEQPACKET 예제: 메시지 경계를 보존하면서 연결 지향 */
int sfd = socket(AF_UNIX, SOCK_SEQPACKET, 0);
bind(sfd, (struct sockaddr *)&addr, sizeof(addr));
listen(sfd, 5);
int cfd = accept(sfd, NULL, NULL);
char buf[1024];
ssize_t n = recv(cfd, buf, sizeof(buf), 0);
/* n = 정확히 한 메시지의 크기 (STREAM과 달리 경계 보존)
* MSG_TRUNC 플래그로 절단 여부 확인 가능 */
커널 내부 데이터 전달 흐름
Unix Domain Socket의 데이터 전달은 TCP/IP와 달리 프로토콜 처리 없이 커널 메모리 내에서 직접 이루어집니다.
/* net/unix/af_unix.c — SOCK_STREAM 송신 핵심 경로 (단순화) */
static int unix_stream_sendmsg(struct socket *sock,
struct msghdr *msg, size_t len)
{
struct sock *sk = sock->sk;
struct sock *other = unix_peer(sk); /* 피어 소켓 획득 */
struct sk_buff *skb;
int sent = 0;
while (sent < len) {
int size = min_t(int, len - sent, sk->sk_sndbuf);
/* sk_buff 할당 및 유저 공간에서 커널로 데이터 복사 */
skb = sock_alloc_send_pskb(sk, 0, size, ...);
skb_copy_datagram_from_iter(skb, 0, &msg->msg_iter, size);
/* 보조 메시지 (SCM_RIGHTS 등) 처리 */
maybe_add_creds(skb, sock, other);
if (msg->msg_controllen)
unix_scm_to_skb(&scm, skb, 1);
/* 피어의 수신 큐에 직접 enqueue */
skb_queue_tail(&other->sk_receive_queue, skb);
/* 피어가 read()에서 대기 중이면 깨우기 */
other->sk_data_ready(other);
sent += size;
}
return sent;
}
SOCK_DGRAM vs SOCK_STREAM 내부 차이: SOCK_STREAM은 unix_stream_sendmsg() → 데이터를 분할하여 여러 sk_buff로 전송할 수 있지만, SOCK_DGRAM은 unix_dgram_sendmsg() → 메시지 전체를 단일 sk_buff에 담아 전달합니다. SOCK_SEQPACKET은 연결 관리는 STREAM과 동일하되, 수신 시 MSG_EOR(End Of Record)로 메시지 경계를 표시합니다.
SCM_RIGHTS: 파일 디스크립터 전달
Unix Domain Socket의 가장 독특한 기능은 SCM_RIGHTS를 통한 파일 디스크립터 전달입니다. 보조 메시지(ancillary message)로 fd를 전송하면, 커널이 fget()으로 송신측의 struct file을 참조한 뒤, 수신측에서 recvmsg() 시 __scm_install_fd()를 호출하여 수신 프로세스의 fd 테이블에 새 엔트리를 생성하고 동일한 struct file을 참조하게 합니다.
/* 파일 디스크립터 전달 — 완전한 예제 */
/* ─── 송신 측 ─── */
int sv[2];
socketpair(AF_UNIX, SOCK_STREAM, 0, sv);
int target_fd = open("/dev/gpu0", O_RDWR);
struct msghdr msg = {0};
struct iovec iov = { .iov_base = "x", .iov_len = 1 };
char cmsgbuf[CMSG_SPACE(sizeof(int))];
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = cmsgbuf;
msg.msg_controllen = sizeof(cmsgbuf);
struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
cmsg->cmsg_level = SOL_SOCKET;
cmsg->cmsg_type = SCM_RIGHTS;
cmsg->cmsg_len = CMSG_LEN(sizeof(int));
*((int *)CMSG_DATA(cmsg)) = target_fd;
sendmsg(sv[0], &msg, 0);
/* ─── 수신 측 ─── */
struct msghdr rmsg = {0};
char rbuf[1];
struct iovec riov = { .iov_base = rbuf, .iov_len = 1 };
char rcmsgbuf[CMSG_SPACE(sizeof(int))];
rmsg.msg_iov = &riov;
rmsg.msg_iovlen = 1;
rmsg.msg_control = rcmsgbuf;
rmsg.msg_controllen = sizeof(rcmsgbuf);
recvmsg(sv[1], &rmsg, 0);
struct cmsghdr *rcmsg = CMSG_FIRSTHDR(&rmsg);
int received_fd = *((int *)CMSG_DATA(rcmsg));
/* received_fd는 송신측의 target_fd와 같은 struct file을 참조
* → 별도의 fd 번호지만 동일한 파일 오프셋, 잠금 등을 공유 */
/* net/core/scm.c — 커널 내부: fd 전달 핵심 로직 */
static int __scm_install_fd(struct file *file,
int __user *ufd)
{
int new_fd;
/* 수신 프로세스에서 사용 가능한 fd 번호 할당 */
new_fd = get_unused_fd_flags(O_CLOEXEC);
if (new_fd < 0)
return new_fd;
/* 할당된 fd에 동일한 struct file 설치 */
fd_install(new_fd, get_file(file));
/* 유저 공간에 fd 번호 기록 */
put_user(new_fd, ufd);
return 0;
}
SCM_RIGHTS fd 누수 주의: recvmsg()로 수신한 fd는 반드시 close()해야 합니다. 수신 프로세스가 보조 메시지를 처리하지 않으면(예: msg_controllen = 0으로 수신), 커널이 할당한 fd가 누수됩니다. 또한 SCM_RIGHTS로 전달 가능한 fd 수는 SCM_MAX_FD(253)로 제한됩니다.
SCM_CREDENTIALS: 자격 증명 전달
SCM_CREDENTIALS는 송신 프로세스의 PID, UID, GID를 수신 측에 전달하는 메커니즘입니다. 커널이 자격 증명을 검증하므로 위조가 불가능하며, D-Bus, systemd, polkit 등의 인증 기반으로 널리 사용됩니다.
/* 수신 측: SO_PASSCRED 활성화 + 자격 증명 수신 */
int optval = 1;
setsockopt(fd, SOL_SOCKET, SO_PASSCRED, &optval, sizeof(optval));
struct msghdr msg = {0};
char cmsgbuf[CMSG_SPACE(sizeof(struct ucred))];
msg.msg_control = cmsgbuf;
msg.msg_controllen = sizeof(cmsgbuf);
/* ... iov 설정 ... */
recvmsg(fd, &msg, 0);
struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
if (cmsg && cmsg->cmsg_type == SCM_CREDENTIALS) {
struct ucred *cred = (struct ucred *)CMSG_DATA(cmsg);
printf("peer: pid=%d, uid=%d, gid=%d\n",
cred->pid, cred->uid, cred->gid);
/* 커널이 검증한 값 — 위조 불가
* (단, CAP_SYS_ADMIN이면 다른 프로세스의 credential 전송 가능) */
}
/* SO_PEERCRED: connect 시점의 자격 증명을 언제든 조회 (연결 지향 소켓) */
struct ucred peercred;
socklen_t len = sizeof(peercred);
getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &peercred, &len);
/* net/unix/af_unix.c — 커널이 자격 증명을 skb에 기록하는 경로 */
static void maybe_add_creds(struct sk_buff *skb,
const struct socket *sock,
const struct sock *other)
{
if (test_bit(SOCK_PASSCRED, &other->sk_socket->flags)) {
/* SO_PASSCRED가 설정된 수신측이 있으면 */
UNIXCB(skb).pid = get_pid(task_tgid(current));
UNIXCB(skb).uid = current_uid();
UNIXCB(skb).gid = current_gid();
}
}
가비지 컬렉션 (Inflight fd GC)
Unix Domain Socket을 통해 전달된 fd가 순환 참조를 만들면 메모리 누수가 발생합니다. 예를 들어 소켓 A가 소켓 B의 fd를 보유하고, 동시에 소켓 B가 소켓 A의 fd를 보유하면, 두 소켓의 참조 카운트가 0에 도달하지 않습니다. 커널은 이를 해결하기 위해 Unix GC(Garbage Collector)를 구현합니다.
/* net/unix/garbage.c — Unix socket GC 핵심 알고리즘 */
/* 1단계: 전송 중(inflight)인 모든 unix 소켓을 후보 목록에 추가
* 2단계: 후보 소켓의 external reference를 계산
* (inflight가 아닌 외부 참조가 있으면 도달 가능)
* 3단계: 도달 가능한 소켓에서 참조하는 모든 소켓을 재귀적으로 마킹
* 4단계: 마킹되지 않은 소켓의 inflight fd를 해제 */
void unix_gc(void)
{
struct sk_buff *skb;
struct sock *s;
spin_lock(&unix_gc_lock);
/* inflight 소켓 수집 → 후보 리스트 */
list_for_each_entry(s, &gc_inflight_list, ...) {
/* internal_ref = total_ref - external_ref */
if (atomic_read(&s->sk_refcnt) == s->inflight)
/* 외부 참조 없음 → GC 후보 */
list_add(&s->gc_list, &gc_candidates);
}
/* Mark 단계: 도달 가능한 소켓 마킹 */
scan_children(&gc_candidates, inc_inflight);
/* Sweep 단계: 도달 불가능한 소켓의 skb에서 fd 해제 */
list_for_each_entry_safe(s, ..., &gc_candidates, ...) {
skb_queue_walk(&s->sk_receive_queue, skb)
__skb_unlink(skb, ...); /* fd 참조 해제 */
}
spin_unlock(&unix_gc_lock);
}
GC 성능 영향: Unix GC는 unix_gc_lock을 잡고 전체 inflight 소켓을 순회하므로, 대량의 fd 전달이 이루어지는 시스템에서 지연 스파이크를 유발할 수 있습니다. 커널 5.15+에서는 GC 트리거 조건이 최적화되었으며, /proc/sys/net/unix/max_dgram_qlen으로 DGRAM 큐 크기를 제어하여 GC 빈도를 간접적으로 조절할 수 있습니다.
성능 특성: Unix Socket vs TCP Loopback
| 항목 | Unix Domain Socket | TCP Loopback (127.0.0.1) |
|---|---|---|
| 프로토콜 처리 | 없음 (직접 sk_buff 전달) | TCP/IP 전체 스택 (체크섬, ACK, 윈도우 등) |
| 데이터 복사 | 2회 (유저→커널→유저) | 2~3회 (+ TCP 세그먼트 처리) |
| 컨텍스트 스위치 | 최소 (wake_up_interruptible) | softirq 경유 가능 |
| 레이턴시 (64B 메시지) | ~2-4μs | ~7-15μs |
| 처리량 (대용량 스트리밍) | ~8-12 GB/s | ~3-6 GB/s |
| fd 전달 (SCM_RIGHTS) | 지원 | 불가 |
| 자격 증명 전달 | SO_PASSCRED / SO_PEERCRED | 불가 |
| 네트워크 NS 격리 | Abstract만 격리 | 완전 격리 |
| 원격 접근 가능 | 불가 (로컬 전용) | 가능 |
# 성능 벤치마크: socat / nuttcp를 이용한 간이 비교
# Unix Domain Socket 처리량 측정
socat -u UNIX-LISTEN:/tmp/bench.sock,fork - < /dev/null &
dd if=/dev/zero bs=1M count=1024 | socat - UNIX-CONNECT:/tmp/bench.sock
# /proc/net/unix에서 소켓 상태 확인
cat /proc/net/unix | grep bench
# Num RefCount Protocol Flags Type St Inode Path
# 0000... 00000002 00000000 00010000 0001 01 12345 /tmp/bench.sock
보안 및 접근 제어
Unix Domain Socket의 보안은 다층적으로 적용됩니다.
| 보안 계층 | 메커니즘 | 적용 범위 |
|---|---|---|
| DAC (Discretionary) | 소켓 파일의 퍼미션 (chmod/chown) | Pathname 소켓만 |
| 디렉토리 퍼미션 | 소켓 파일이 위치한 디렉토리의 쓰기 권한 | Pathname 소켓만 |
| 네트워크 NS | net_namespace별 격리 |
Abstract 소켓만 |
| SELinux | unix_stream_connect, unix_dgram_sendto 정책 |
모든 유형 |
| AppArmor | unix 프로파일 규칙 |
모든 유형 |
| Seccomp-BPF | socket(), connect(), sendmsg() 시스템 콜 필터링 |
모든 유형 |
| SO_PEERCRED | 피어의 UID/GID/PID 확인 (커널 검증) | 연결 지향 소켓 |
# Pathname 소켓의 DAC 보안 설정 예시
# Docker 데몬 소켓: root만 접근 (docker 그룹 허용)
srw-rw---- 1 root docker 0 ... /var/run/docker.sock
# systemd 타이머 소켓: 모든 사용자 접근 허용
srwxrwxrwx 1 root root 0 ... /run/systemd/journal/stdout
# SELinux 정책 예시: httpd가 mysqld 소켓에 연결 허용
# allow httpd_t mysqld_var_run_t:sock_file write;
# allow httpd_t mysqld_t:unix_stream_socket connectto;
Abstract 소켓 보안 한계: Abstract 소켓은 파일시스템 퍼미션이 없어 같은 네트워크 네임스페이스 내의 모든 프로세스가 접근할 수 있습니다. 컨테이너 환경에서는 네트워크 NS 격리로 보호되지만, 같은 NS를 공유하는 프로세스 간에는 추가적인 인증(SO_PEERCRED 기반 UID 검증)이 필요합니다.
커널 파라미터 튜닝
# /proc/sys/net/unix/ 파라미터
# SOCK_DGRAM 소켓의 기본 큐 길이 (수신 데이터그램 최대 대기 수)
sysctl net.unix.max_dgram_qlen
# 기본값: 10 (많은 DGRAM 클라이언트 시 증가 필요)
sysctl -w net.unix.max_dgram_qlen=512
# 소켓 버퍼 크기 (net.core 파라미터로 제어)
sysctl net.core.wmem_default # 송신 버퍼 기본 (212992 = ~208KB)
sysctl net.core.rmem_default # 수신 버퍼 기본
sysctl net.core.wmem_max # 송신 버퍼 최대
sysctl net.core.rmem_max # 수신 버퍼 최대
# 소켓별 버퍼 크기 조정 (유저 공간)
# int sndbuf = 1048576;
# setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &sndbuf, sizeof(sndbuf));
# listen 백로그 최대 (SOCK_STREAM/SOCK_SEQPACKET)
sysctl net.core.somaxconn # 기본 4096 (커널 5.4+)
디버깅: /proc/net/unix 및 ss
# /proc/net/unix — 모든 Unix 소켓 목록
cat /proc/net/unix
# Num RefCount Protocol Flags Type St Inode Path
# ffff... 00000002 00000000 00010000 0001 01 23456 /run/dbus/system_bus_socket
# ffff... 00000003 00000000 00000000 0001 03 23457 (abstract: /tmp/.X11-unix/X0)
#
# Type: 0001=STREAM, 0002=DGRAM, 0005=SEQPACKET
# St: 01=UNCONNECTED, 02=CONNECTING, 03=CONNECTED, 04=DISCONNECTING
# ss (socket statistics) — 더 상세한 Unix 소켓 정보
ss -xln # listening Unix 소켓 목록
ss -xp # 연결된 Unix 소켓 + 프로세스 정보
ss -x -e state connected # 연결 상태인 소켓만 (inode, UID 포함)
# 특정 소켓의 상세 정보
ss -xp | grep docker.sock
# u_str ESTAB 0 0 /var/run/docker.sock 12345 * 12346
# users:(("dockerd",pid=1234,fd=5))
# lsof로 특정 프로세스의 Unix 소켓 확인
lsof -U -p $(pidof nginx)
# strace로 Unix 소켓 통신 추적
strace -e trace=network -p <pid> 2>&1 | grep -E 'socket|bind|connect|sendmsg|recvmsg'
# bpftrace로 Unix 소켓 sendmsg 레이턴시 측정
bpftrace -e 'kprobe:unix_stream_sendmsg { @start[tid] = nsecs; }
kretprobe:unix_stream_sendmsg /@start[tid]/ {
@latency_us = hist((nsecs - @start[tid]) / 1000);
delete(@start[tid]); }'
실제 활용 사례
| 서비스 | 소켓 경로 | 소켓 타입 | 활용 기능 |
|---|---|---|---|
| systemd | /run/systemd/private |
STREAM | 소켓 활성화 (fd 전달), 자격 증명 검증 |
| D-Bus | /run/dbus/system_bus_socket |
STREAM | SCM_CREDENTIALS (인증), fd 전달 |
| Docker | /var/run/docker.sock |
STREAM | REST API over Unix socket |
| MySQL | /var/run/mysqld/mysqld.sock |
STREAM | 로컬 DB 연결 (TCP 대비 ~30% 빠름) |
| PostgreSQL | /var/run/postgresql/.s.PGSQL.5432 |
STREAM | 로컬 DB 연결, peer 인증 |
| X11 / Wayland | /tmp/.X11-unix/X0 (Abstract) |
STREAM | 디스플레이 프로토콜, fd 전달 (DMA-BUF) |
| syslog (rsyslog) | /dev/log |
DGRAM | 로그 수집 (비연결, 메시지 단위) |
| containerd/CRI | /run/containerd/containerd.sock |
STREAM | gRPC over Unix socket |
| SSH Agent | $SSH_AUTH_SOCK |
STREAM | ssh-agent와의 키 서명 통신 |
/* systemd 소켓 활성화 — 서비스가 fd를 상속받는 패턴
* systemd가 소켓을 미리 열고 listen 상태로 유지한 후,
* 첫 연결 시 서비스를 시작하며 SCM_RIGHTS로 fd를 전달합니다. */
/* sd_listen_fds() 구현 핵심 (libsystemd) */
int sd_listen_fds(int unset_environment) {
const char *e;
int n;
e = getenv("LISTEN_PID");
if (!e || atoi(e) != getpid())
return 0;
e = getenv("LISTEN_FDS");
n = atoi(e);
/* fd 3부터 n개가 systemd로부터 전달된 소켓
* SD_LISTEN_FDS_START = 3 */
return n;
}
IPC 종합 비교
| 메커니즘 | 방향 | 메시지 경계 | fd 기반 | epoll 통합 | Namespace | 속도 | 주요 용도 |
|---|---|---|---|---|---|---|---|
| Pipe | 단방향 | 없음 | O | O | - | 빠름 | 부모-자식 통신, 셸 파이프라인 |
| 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(); }'
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 섹션을 참고하라.