fork() + exec() 의 은퇴 — Linux 커널은 정말 새 spawn 모델로 갈 것인가

50 년 된 프로세스 생성 관용구가 멀티스레드 시대의 부담을 더는 감당하지 못한다는 합의가 LWN 토론에서 형성되었다. 그러나 그 다음 길은 셋으로 갈라져 있다.

도입 — 2 퍼센트 패치에서 시작된 큰 질문

2026 년 6 월 5 일, LWN 은 Li Chen 이 보낸 spawn template 패치 시리즈에 관한 분석 기사를 공개했다. Chen 의 제안 자체는 단출했다. fork() 와 exec() 를 묶어 호출하는 대신 “프로세스 발사대” 에 해당하는 spawn template 을 커널이 미리 구성해 두고, 같은 실행 파일을 반복해 시작할 때 그 템플릿을 재사용하자는 것이었다. Chen 이 첨부한 벤치마크는 약 2 퍼센트의 성능 향상이라는, 그 자체로는 결코 새 시스템 콜 두 개를 정당화하지 못할 작은 숫자였다.

그러나 LWN 의 댓글 스레드는 그 작은 숫자에서 출발해 곧장 큰 질문으로 옮겨갔다. fork() + exec() 라는 50 년 된 Unix 의 기본 관용구를 이제는 정말 은퇴시켜야 하는가. Mateusz Guzik 의 표현은 단호했다. “fork + exec 라는 관용구 전체가 끔찍하다. 이제 은퇴할 때가 되었다.” Christian Brauner 는 Chen 의 제안을 받아들이지는 않았지만, 대안으로 pidfd 기반의 다른 길을 제시했다. Josh Poimboeuf 는 io_uring 위에 새 프로세스 생성 명령을 얹는 또 다른 시나리오를 던졌다. 세 갈래의 길이 각각 다른 트레이드오프를 끼고 같은 무대 위에 올라왔다. 이 글은 그 세 갈래가 가리키는 미래를 추적한다.

Li Chen 의 spawn template — 작은 패치, 큰 질문

Chen 이 제출한 패치는 두 개의 시스템 콜로 구성된다. spawn_template_create() 는 특정 실행 파일에 대한 “발사 템플릿” 을 커널 안에 만든다. 환경 변수, 파일 디스크립터 설정, 시그널 처리 등을 미리 spawn_template_action 구조체로 묶어 둔다. spawn_template_spawn() 은 그 템플릿을 가지고 실제로 새 프로세스를 띄운다. 발상은 fsconfig() / fsopen() / fsmount() 시리즈와 비슷하다. 자주 반복되는 설정을 한 번만 커널에 등록해 두고, 호출 지점에서는 가벼운 트리거만 당기는 식이다.

벤치마크가 보여 준 약 2 퍼센트의 개선은 작은 숫자다. 패치를 변호하는 쪽에서는, 같은 실행 파일을 매 요청마다 띄우는 워크로드 — 예컨대 CGI 시절의 fork-per-request 서버나 Bazel 의 sandbox 헬퍼처럼 짧은 수명의 보조 프로세스를 대량으로 만드는 빌드 시스템 — 에서 누적되면 의미 있는 차이라는 주장이다. 짧은 자식 프로세스가 몇 백만 개씩 만들어지는 CI 파이프라인을 생각하면, 2 퍼센트는 빌드 농장 한 행씩을 줄여 주는 숫자다.

문제는 검토자들이 같은 숫자를 정반대 각도에서 읽었다는 점이다. Mateusz Guzik 의 정독은 새 인터페이스의 복잡도와 2 퍼센트라는 이득 사이의 간격을 정조준했다. 새 시스템 콜 두 개, 새 구조체, 새 자원 수명 주기를 도입하는 비용은 영원하지만, 이득은 특정 패턴의 워크로드에만 한정된다. 게다가 글리브씨 (glibc) 의 posix_spawn() 구현은 같은 문제를 사용자 영역에서 — 다소 둔하긴 해도 — 풀어 두었다. “비용이 결정의 기준이라면 구체적인 숫자가 더 많이 필요하다” 는 댓글이 곧장 따라붙었다.

흥미로운 지점은 Chen 본인의 반응이다. Brauner 의 pidfd 기반 대안을 듣고 나서, Chen 은 그 방향이 더 낫다고 동의했고 “앞으로의 작업은 그 쪽으로 가겠다” 고 말했다. 패치 자체는 머지되지 않지만, Chen 의 작은 패치가 던진 질문이 더 큰 설계 토론으로 옮겨붙은 셈이다. LWN 은 기사의 마지막 줄에서 이렇게 정리했다. “spawn 템플릿이 Linux 커널에 들어가는 일은 없을 것이다. 그러나 Chen 의 다음 작업이 결실을 맺는다면, Linux 는 마침내 제대로 된 posix_spawn() 구현을 갖게 될지도 모른다.”

멀티스레드 시대의 fork() — 왜 “끔찍한” 패턴인가

fork() 가 처음 도입된 1970 년대 PDP 의 Unix 에서는 프로세스가 가벼웠고, 메모리는 비쌌으며, 스레드는 존재하지도 않았다. fork() 는 호출자의 주소 공간을 자식에게 그대로 복제하는 단순한 패턴이었다. 1980 년대에 copy-on-write (COW) 가 들어오면서, 자식이 곧 exec() 를 호출해 메모리를 모두 버릴 것을 알고 있는 커널이 실제 복제 비용을 회피할 수 있게 되었다. 50 년 가까이 작동해 온 이 패턴이 지금 와서 왜 “끔찍” 한가.

답은 멀티스레드 애플리케이션에 있다. LWN 댓글 가운데 한 개발자는 이렇게 적었다. “vfork + exec 은 멀티스레드 앱에서 끔찍하다. COW 로 잠깐 사용하고 버릴 페이지를 산더미처럼 만들어내거나, 아니면 모든 스레드를 정지시켜야 한다.” fork() 는 호출 시점의 모든 스레드를 잘라낸다. 호출한 스레드 하나만 자식 프로세스로 따라가고, 나머지는 사라진다. 이때 자식 프로세스가 들고 있는 mutex 와 condition variable 의 상태는 어디로 가는가. 다른 스레드가 락을 쥔 채로 잘려나갔다면, 그 락은 영원히 풀리지 않은 채로 자식 안에 남는다.

여기서 등장하는 것이 async-signal-safe 함수 목록이다. fork() 이후 exec() 까지 사이에 호출할 수 있는 함수는 사실상 시그널 핸들러에서 호출 가능한 함수로 제한된다. printf() 도 안 된다. malloc() 도 안 된다. 어떤 라이브러리 초기화 코드도 안전하지 않다. POSIX 사양은 이 사실을 명문화했지만, 현실의 코드베이스에는 이 규칙을 지키지 못해 일어나는 데드락이 가득하다. Python 의 multiprocessing 이 macOS 에서 spawn 을 기본으로 바꾼 이유도, JVM 이 fork() 후 exec() 사이에 절대 다른 일을 하지 않도록 강박적으로 코드를 짠 이유도, 모두 같은 함정에서 출발한다.

vfork() 는 부분적인 답이었다. 부모를 일시 정지시켜 메모리 복제를 피하고, exec() 가 끝날 때까지 부모 스레드를 멈춘다. 댓글 가운데 한 개발자가 정정한 바에 따르면, vfork() 는 “전체 프로세스가 아니라 호출 스레드만 멈춘다” 고 한다. 하지만 그 호출 스레드가 다른 작업의 임계 구간에 있었다면, 다른 스레드들이 그 임계 구간 진입 직전에 멈춰서 데드락에 빠질 수 있다. vfork() 는 깔끔한 우회로가 아니라 또 다른 함정이다. 더 심각한 문제는 COW 그 자체에도 있다. 멀티스레드 프로세스가 fork() 를 호출하면, 부모의 모든 페이지가 일단 read-only 로 마킹된다. 다른 스레드 하나가 한 페이지에 쓸 때마다 페이지 폴트가 발생하고 페이지가 복제된다. 자식이 exec() 를 호출하기 전까지 부모는 자기 자신의 메모리를 한 페이지씩 복제하는 비용을 다 떠안는다. “산더미처럼 만들어내고 곧장 버린다” 는 댓글의 표현은 정확하다.

posix_spawn() 은 POSIX 가 1990 년대에 이미 인지한 답이다. 부모와 자식을 명확히 분리하고, 자식의 환경 — 파일 디스크립터, 시그널 마스크, 작업 디렉터리 — 을 호출 시점에 attr 구조체로 명시적으로 기술한다. 다만 LWN 댓글이 지적하듯, 글리브씨를 비롯한 대부분의 libc 는 posix_spawn() 을 내부적으로 vfork() + exec() 로 구현한다. POSIX 의 추상화가 깔끔해도, 그 아래에는 여전히 fork() 의 그림자가 깔려 있다. 진짜 “네이티브” 한 posix_spawn() — 즉 fork() 의 의미론을 거치지 않고 곧장 새 프로세스를 만드는 커널 수준 구현 — 은 아직 Linux 에 없다. 이번 LWN 의 논의가 결국 도달하려는 곳도 거기다.

세 갈래의 미래 — pidfd_config, io_uring, posix_spawn

Chen 의 spawn 템플릿이 거부된 자리에 세 가지 다른 길이 제안되었다. 각각이 다른 추상화 층을 노린다.

pidfd_config 길 — Christian Brauner 의 제안. Brauner 는 이미 pidfd 인터페이스 (pidfd_open(), pidfd_send_signal(), 이후 추가된 pidfd_getfd()) 를 통해 프로세스를 “파일 디스크립터 같은 객체” 로 다루는 추상화를 정착시켜 왔다. 그가 제안하는 흐름은 이렇다. 먼저 pidfd_open() 으로 “빈 프로세스” 를 만든다. 그 빈 프로세스에 pidfd_config() 라는 새 시스템 콜을 반복 호출해 환경 변수, 파일 디스크립터, 자격 증명을 채워 넣는다. 마지막으로 exec() 비슷한 호출로 실행 파일을 적재한다. 형태는 fsconfig() / fsopen() / fsmount() 시리즈와 똑같다. Brauner 의 설계 미학이 그대로 드러나는 길이다. 강점은 일관성이다. pidfd 라는 이미 검증된 추상화 위에 점진적으로 쌓을 수 있고, 사용자 영역에서 단계별로 디버깅하기도 쉽다.

io_uring 길 — Josh Poimboeuf 의 제안. Josh 는 io_uring 의 ring 자체를 “프로세스 발사대” 로 쓰자고 제안했다. 빈 프로세스를 만든 뒤, 그 안에서 ring 을 돌려 파일 디스크립터를 수신하거나 설치하고, 끝에 가서 한 번 또는 여러 번 exec() 를 시도한다는 그림이다. 이 접근의 장점은 io_uring 이 이미 갖춘 비동기·배치 처리 능력을 그대로 활용한다는 점이다. 단점도 명확하다. 한 댓글이 짚었듯 “io_uring 에는 seccomp 지원이 없다. 프로세스를 띄우는 도구에 그 결함은 심각한 문제다.” seccomp 필터는 컨테이너 런타임과 브라우저 샌드박스의 1선 방어다. 그것이 빠진 spawn 경로를 도입하는 일은 보안 측면에서 후퇴다. Linux 의 spawn 추상화가 결국 seccomp 와 어떻게 만나는가는 io_uring 길의 가장 큰 숙제다.

posix_spawn() 길 — 보수파의 제안. 가장 많은 댓글이 동의한 입장은 사실 “새 시스템 콜은 더 만들지 말고 글리브씨의 posix_spawn() 만 잘 다듬자” 는 쪽이었다. “libc 의 상당 부분이 고품질 posix_spawn() 구현으로 이 발등의 불 문제를 처리한다” 는 댓글이 대표적이다. 그러나 이 길의 한계는 명확하다. posix_spawn() 이 내부적으로 vfork() + exec() 위에 있는 한, 멀티스레드 데드락의 가능성은 여전히 남는다. 사용자 영역에서 아무리 정교하게 attr 를 설계해도, 커널이 진짜로 fork() 의미론에서 자유로워지지 않으면 근본 문제는 해결되지 않는다. 그래서 보수파의 제안조차 결국 커널 안의 “네이티브한 spawn 경로” 라는 같은 종착지로 향한다.

세 길 가운데 어디로 가든, 합의된 출발점은 하나다. fork() + exec() 는 멀티스레드 시대의 부담을 더는 감당하지 못한다. 다만 Linux 커뮤니티가 이 문제를 해결하는 방식은 새 추상화를 한꺼번에 만드는 빅뱅보다 기존 추상화 (pidfd, io_uring, posix_spawn) 를 한 발씩 늘려가는 점진주의에 가깝다. Chen 의 패치가 거부된 이유도 거기에 있다. 두 개의 새 시스템 콜로 한 번에 답을 내려 한 것이, 작은 합의를 쌓아 가는 커뮤니티의 호흡과 맞지 않았다. 다음 LTS 커널이 나올 즈음에는 pidfd_config 의 첫 시안이 메일링 리스트에 올라올 가능성이 가장 높다. 그것이 머지되는 시점이 Linux 에서 fork() 의 “공식 은퇴” 가 시작되는 날이 될 것이다.

결론 — POSIX 의 환영과 결별할 수 있을까

fork() + exec() 가 끔찍한가? 1970 년대의 단일 스레드 프로세스 모델에서는 우아한 발명이었다. 부모와 자식을 같은 코드 경로로 다루고, exec() 라는 한 줄로 다른 프로그램으로 갈아탄다는 발상은 아름답다. 1990 년대의 멀티스레드 시대에는 그 우아함이 함정이 되었다. 2020 년대의 컨테이너·io_uring·pidfd 시대에는 차라리 짐이 되었다.

그러나 Mateusz Guzik 의 “은퇴” 라는 말이 곧 “삭제” 를 뜻하지는 않는다. fork() 는 Linux 커널 안에 영원히 남는다. shell 의 자식 프로세스도, init 도, container runtime 의 부트스트랩 단계도 fork() 위에 서 있다. 은퇴라는 표현이 가리키는 것은 “새 코드에서 더는 권장하지 않는다” 는 의미에 가깝다. 진짜 질문은 그다음이다. 우리는 fork() 가 가졌던 단순함 — 두 줄짜리 코드로 새 프로세스를 띄울 수 있다는 그 단순함 — 을 잃지 않고도, 멀티스레드 안전성과 보안 격리와 성능을 모두 가질 수 있을까. Chen 의 작은 패치가 거부된 자리에서 시작된 이 질문은, 다음 LTS 커널이 나올 즈음 어떤 형태로든 답을 받아야 한다. 그 답이 pidfd_config 의 모습으로 오든, io_uring 의 모습으로 오든, 우리가 시스템 콜 책의 첫 챕터에서 만나는 그림은 50 년 만에 처음으로 다시 그려지게 된다.


출처: