6장:
포크와 소에 대해 이야기해 봅시다 GitHub에서 편집하기

마지막 질문입니다. 우리는 어떻게 여기까지 왔습니까? 최초의 프로세스는 어디에서 오는 것입니까?

이 글도 거의 끝나갑니다. 이제 마지막 구간에 들어섰습니다. 곧 홈런을 칠 참입니다. 더 푸른 초원으로 나아가는 중입니다. 그 밖에도 온갖 끔찍한 관용구가 있지만, 모두 여러분이 6장 분량 하나만 더 읽으면 밖에 나가 풀을 만지거나, CPU 아키텍처에 관한 15,000단어짜리 글을 읽지 않을 때 하는 무언가를 하러 갈 수 있다는 뜻입니다.

execve가 현재 프로세스를 대체하여 새 프로그램을 시작한다면, 새 프로그램을 새 프로세스에서 따로 시작하려면 어떻게 해야 합니까? 컴퓨터에서 여러 가지 일을 하고 싶다면 이는 꽤 중요한 능력입니다. 앱을 더블 클릭해 실행하면 그 앱은 따로 열리고, 이전에 사용하던 프로그램은 계속 실행됩니다.

답은 또 다른 시스템 호출인 fork이며, 모든 멀티프로세싱의 근간이 되는 시스템 호출입니다. fork는 사실 꽤 단순합니다. 현재 프로세스와 그 메모리를 복제하고, 저장된 명령어 포인터는 원래 위치에 그대로 둔 다음, 두 프로세스가 평소처럼 계속 진행하도록 합니다. 아무런 개입이 없으면 두 프로그램은 서로 독립적으로 계속 실행되며, 모든 연산이 두 배가 됩니다.

새로 실행되는 프로세스를 “자식”이라 하고, 원래 fork를 호출한 프로세스를 “부모”라고 합니다. 프로세스는 fork를 여러 번 호출할 수 있으므로 자식을 여러 개 가질 수 있습니다. 각 자식에는 1부터 시작하는 프로세스 ID(PID)가 번호로 매겨집니다.

같은 코드를 아무 생각 없이 두 배로 실행하는 것은 별 쓸모가 없으므로, fork는 부모와 자식에서 서로 다른 값을 반환합니다. 부모에서는 새 자식 프로세스의 PID를 반환하고, 자식에서는 0을 반환합니다. 이 덕분에 새 프로세스에서 다른 작업을 할 수 있어 포크가 실제로 쓸모 있게 됩니다.

main.c
pid_t pid = fork();

// Code continues from this point as usual, but now across
// two "identical" processes.
//
// Identical... except for the PID returned from fork!
//
// This is the only indicator to either program that they
// are not one of a kind.

if (pid == 0) {
	// We're in the child.
	// Do some computation and feed results to the parent!
} else {
	// We're in the parent.
	// Probably continue whatever we were doing before.
}

프로세스 포크는 이해하기가 조금 어려울 수 있습니다. 지금부터는 여러분이 이해했다고 가정하겠습니다. 아직 이해하지 못했다면 꽤 괜찮은 설명이 있는 이 끔찍하게 생긴 웹사이트를 확인해 보십시오.

어쨌든 유닉스 프로그램은 fork를 호출한 다음 자식 프로세스에서 곧바로 execve를 실행하는 방식으로 새 프로그램을 시작합니다. 이를 fork-exec 패턴이라고 합니다. 프로그램을 실행하면 컴퓨터는 다음과 비슷한 코드를 실행합니다.

launcher.c
pid_t pid = fork();

if (pid == 0) {
	// Immediately replace the child process with the new program.
	execve(...);
}

// Since we got here, the process didn't get replaced. We're in the parent!
// Helpfully, we also now have the PID of the new child process in the PID
// variable, if we ever need to kill it.

// Parent program continues here...

음메에에에!

프로세스의 메모리를 복제해 놓고 다른 프로그램을 불러올 때 곧바로 전부 버리는 것은 조금 비효율적으로 들린다는 점을 눈치채셨을지도 모릅니다. 다행히도 우리에게는 MMU가 있습니다. 느린 부분은 페이지 테이블 복제가 아니라 물리 메모리의 데이터 복제이므로, 우리는 RAM을 아예 복제하지 않습니다. 대신 새 프로세스를 위해 기존 프로세스의 페이지 테이블 사본을 만들고, 매핑은 그대로 같은 물리 메모리를 가리키도록 둡니다.

하지만 자식 프로세스는 부모로부터 독립되고 격리되어야 합니다! 자식이 부모의 메모리에 쓰거나 그 반대가 되어서는 안 됩니다!

COW(copy on write) 페이지를 소개합니다. COW 페이지를 사용하면 두 프로세스는 메모리에 쓰기를 시도하지 않는 한 같은 물리 주소에서 읽습니다. 둘 중 하나가 메모리에 쓰려고 하는 순간 해당 페이지가 RAM에 복사됩니다. COW 페이지 덕분에 두 프로세스는 전체 메모리 공간을 미리 복제하는 비용 없이도 메모리 격리를 누릴 수 있습니다. fork-exec 패턴이 효율적인 이유가 바로 이것입니다. 새 바이너리를 로드하기 전까지 이전 프로세스의 메모리에는 아무것도 쓰이지 않으므로 메모리를 복사할 필요가 전혀 없습니다.

다른 여러 재미있는 기능처럼 COW도 페이징 꼼수와 하드웨어 인터럽트 처리로 구현됩니다. fork는 부모를 복제한 뒤 두 프로세스의 모든 페이지를 읽기 전용으로 표시합니다. 프로그램이 메모리에 쓰면 해당 메모리가 읽기 전용이기 때문에 쓰기가 실패합니다. 그러면 세그폴트(하드웨어 인터럽트 쪽의 세그폴트)가 발생하고, 이를 커널이 처리합니다. 커널은 메모리를 복제하고, 쓰기가 가능하도록 페이지를 갱신한 다음, 인터럽트에서 복귀하여 쓰기를 다시 시도합니다.

A: 똑똑!
B: 누구십니까?
A: 끼어드는 소입니다.
B: 끼어드는 소 누 —
A: 음메에에에에!

태초에 (창세기 1장 1절 아님)

여러분 컴퓨터의 모든 프로세스는 부모 프로그램이 fork-exec한 것입니다. 단 하나, init 프로세스만 예외입니다. init 프로세스는 커널이 직접 수동으로 설정합니다. init 프로세스는 가장 먼저 실행되는 유저랜드 프로그램이자 시스템 종료 시 가장 마지막에 종료되는 프로그램입니다.

순식간에 화면이 까맣게 변하는 멋진 광경을 보고 싶으십니까? macOS나 Linux를 사용 중이라면 작업을 저장하고 터미널을 연 뒤 init 프로세스(PID 1)를 종료해 보십시오:

셸 세션
$ sudo kill 1

저자 주: 안타깝게도 init 프로세스에 관한 지식은 macOS나 Linux 같은 유닉스 계열 시스템에만 적용됩니다. 지금부터 배우는 내용 대부분은 커널 아키텍처가 매우 다른 Windows를 이해하는 데에는 적용되지 않습니다.

execve 절에서와 마찬가지로 이 점을 분명히 짚고 넘어갑니다 — NT 커널에 대해서도 글 한 편을 통째로 쓸 수 있지만 꾹 참고 있습니다. (적어도 지금은 말입니다.)

init 프로세스는 운영체제를 구성하는 모든 프로그램과 서비스를 생성하는 역할을 합니다. 그중 다수는 다시 자신만의 서비스와 프로그램을 생성합니다.

프로세스 트리입니다. 루트 노드에는 "init"이라는 레이블이 붙어 있습니다. 자식 노드에는 모두 레이블이 없지만, init 프로세스가 생성한 것으로 보입니다.

init 프로세스를 종료하면 그 자식들과 자식들의 자식들까지 모두 종료되어 OS 환경이 꺼집니다.

다시 커널로

3장에서 Linux 커널 코드를 살펴보는 일이 무척 재미있었으니, 이번에도 조금 더 살펴보겠습니다! 이번에는 커널이 init 프로세스를 어떻게 시작하는지 살펴보는 것으로 시작하겠습니다.

컴퓨터는 다음과 같은 순서로 부팅됩니다:

  1. 메인보드에는 연결된 디스크에서 부트로더라는 프로그램을 찾는 작은 소프트웨어가 함께 탑재되어 있습니다. 이 소프트웨어는 부트로더를 하나 골라 그 기계어 코드를 RAM에 적재한 뒤 실행합니다.

    아직은 운영체제가 실행 중인 세계에 들어선 것이 아니라는 점을 기억하십시오. 운영체제 커널이 init 프로세스를 시작하기 전까지는 멀티프로세싱도 시스템 호출도 사실상 존재하지 않습니다. init 이전의 맥락에서 프로그램을 “실행”한다는 것은 돌아올 것을 기대하지 않고 RAM에 있는 그 프로그램의 기계어 코드로 곧바로 점프한다는 뜻입니다.

  2. 부트로더는 커널을 찾아 RAM에 적재하고 실행하는 일을 담당합니다. GRUB 같은 일부 부트로더는 설정을 바꿀 수 있거나 여러 운영체제 중 하나를 선택할 수 있게 해 줍니다. BootX와 Windows Boot Manager는 각각 macOS와 Windows에 내장된 부트로더입니다.

  3. 이제 커널이 실행되어 인터럽트 핸들러 설정, 드라이버 적재, 초기 메모리 매핑 생성 등 방대한 초기화 작업을 시작합니다. 마지막으로 커널은 권한 수준을 사용자 모드로 전환하고 init 프로그램을 시작합니다.

  4. 드디어 운영체제의 유저랜드에 들어왔습니다! init 프로그램은 init 스크립트를 실행하고, 서비스를 시작하고, 셸/UI 같은 프로그램을 실행하기 시작합니다.

Linux 초기화하기

리눅스에서 3단계(커널 초기화)의 대부분은 init/main.c의 start_kernel 함수에서 이루어집니다. 이 함수는 다른 여러 init 함수를 호출하는 코드가 200줄을 넘기 때문에 이 글에 전체 내용을 싣지는 않겠지만, 한번 훑어보시기를 권합니다! start_kernel의 끝에서는 arch_call_rest_init라는 함수가 호출됩니다:

start_kernel @ init/main.c
	/* Do the rest non-__init'ed, we're now alive */
	arch_call_rest_init();

non-__init’ed란 무슨 뜻입니까?

start_kernel 함수는 asmlinkage __visible void __init __no_sanitize_address start_kernel(void)로 정의되어 있습니다. __visible, __init, __no_sanitize_address 같은 이상한 키워드는 모두 Linux 커널에서 함수에 여러 코드나 동작을 덧붙이는 데 쓰이는 C 전처리기 매크로입니다.

여기서 __init은 단지 공간을 절약하기 위해, 부팅 과정이 끝나는 즉시 해당 함수와 그 데이터를 메모리에서 해제하도록 커널에 지시하는 매크로입니다.

이것은 어떻게 동작하는 것입니까? 너무 세세한 부분까지 들어가지 않고 말하자면, Linux 커널 자체도 ELF 파일로 패키징되어 있습니다. __init 매크로는 __section(".init.text")로 확장되는데, 이는 코드를 일반적인 .text 섹션 대신 .init.text라는 섹션에 배치하도록 하는 컴파일러 지시문입니다. __section(".init.data")로 확장되는 __initdata처럼, 데이터와 상수를 특별한 init 섹션에 배치할 수 있게 해 주는 다른 매크로들도 있습니다.

arch_call_rest_init는 단순한 래퍼 함수에 불과합니다:

init/main.c
void __init __weak arch_call_rest_init(void)
{
	rest_init();
}

주석에 “do the rest non-__init’ed”라고 적힌 것은 rest_init가 __init 매크로로 정의되지 않았기 때문입니다. 즉, init 메모리를 정리할 때 이 함수는 해제되지 않습니다:

init/main.c
noinline void __ref rest_init(void)
{

이제 rest_init는 init 프로세스를 위한 스레드를 생성합니다:

rest_init @ init/main.c
	/*
	 * We need to spawn init first so that it obtains pid 1, however
	 * the init task will end up wanting to create kthreads, which, if
	 * we schedule it before we create kthreadd, will OOPS.
	 */
	pid = user_mode_thread(kernel_init, NULL, CLONE_FS);

user_mode_thread에 전달되는 kernel_init 매개변수는 몇 가지 초기화 작업을 마친 뒤 유효한 init 프로그램을 찾아 실행하는 함수입니다. 이 과정은 몇 가지 기본 설정 작업으로 시작합니다. free_initmem이 호출되는 부분을 제외하면 대부분은 건너뛰겠습니다. 바로 여기서 커널이 .init 섹션을 해제합니다!

kernel_init @ init/main.c
	free_initmem();

이제 커널은 실행할 적절한 init 프로그램을 찾을 수 있습니다:

kernel_init @ init/main.c
	/*
	 * We try each of these until one succeeds.
	 *
	 * The Bourne shell can be used instead of init if we are
	 * trying to recover a really broken machine.
	 */
	if (execute_command) {
		ret = run_init_process(execute_command);
		if (!ret)
			return 0;
		panic("Requested init %s failed (error %d).",
		      execute_command, ret);
	}

	if (CONFIG_DEFAULT_INIT[0] != '\0') {
		ret = run_init_process(CONFIG_DEFAULT_INIT);
		if (ret)
			pr_err("Default init %s failed (error %d)\n",
			       CONFIG_DEFAULT_INIT, ret);
		else
			return 0;
	}

	if (!try_to_run_init_process("/sbin/init") ||
	    !try_to_run_init_process("/etc/init") ||
	    !try_to_run_init_process("/bin/init") ||
	    !try_to_run_init_process("/bin/sh"))
		return 0;

	panic("No working init found.  Try passing init= option to kernel. "
	      "See Linux Documentation/admin-guide/init.rst for guidance.");

Linux에서 init 프로그램은 거의 항상 /sbin/init에 있거나 그곳으로 심볼릭 링크되어 있습니다. 흔히 쓰이는 init으로는 systemd(웹사이트가 유난히 훌륭합니다), OpenRC, runit 등이 있습니다. kernel_init은 다른 것을 전혀 찾지 못하면 기본값으로 /bin/sh를 사용하며, /bin/sh마저 찾지 못한다면 무언가가 끔찍하게 잘못된 것입니다.

MacOS에도 init 프로그램이 있습니다! 이름은 launchd이며 /sbin/launchd에 있습니다. 터미널에서 실행해 보십시오. 커널이 아니라고 혼날 것입니다.

이 시점부터는 부팅 과정의 4단계에 해당합니다: init 프로세스가 유저랜드에서 실행되며 fork-exec 패턴을 사용해 다양한 프로그램을 실행하기 시작합니다.

Fork 메모리 매핑

프로세스를 fork할 때 Linux 커널이 메모리의 하위 절반을 어떻게 다시 매핑하는지 궁금해서 조금 뒤져 보았습니다. 프로세스 fork 코드의 대부분은 kernel/fork.c에 들어 있는 것으로 보입니다. 고맙게도 그 파일의 앞부분이 어디를 봐야 하는지 알려 주었습니다:

kernel/fork.c
/*
 *  'fork.c' contains the help-routines for the 'fork' system call
 * (see also entry.S and others).
 * Fork is rather simple, once you get the hang of it, but the memory
 * management can be a bitch. See 'mm/memory.c': 'copy_page_range()'
 */

이 copy_page_range 함수는 메모리 매핑에 관한 정보를 받아 페이지 테이블을 복사하는 것으로 보입니다. 이 함수가 호출하는 함수들을 빠르게 훑어보면, 페이지를 읽기 전용으로 설정해 COW 페이지로 만드는 곳도 바로 여기입니다. 이 작업을 해야 하는지는 is_cow_mapping이라는 함수를 호출해 확인합니다.

is_cow_mapping은 include/linux/mm.h에 정의되어 있으며, 메모리 매핑에 해당 메모리가 쓰기 가능하고 프로세스 간에 공유되지 않음을 나타내는 플래그가 있으면 true를 반환합니다. 공유 메모리는 애초에 공유되도록 설계되었으므로 COW를 적용할 필요가 없습니다. 약간 이해하기 어려운 비트마스킹을 감상해 보십시오:

include/linux/mm.h
static inline bool is_cow_mapping(vm_flags_t flags)
{
	return (flags & (VM_SHARED | VM_MAYWRITE)) == VM_MAYWRITE;
}

kernel/fork.c로 돌아가서 copy_page_range를 Command-F로 간단히 검색해 보면 dup_mmap 함수에서 한 번 호출된다는 것을 알 수 있습니다… 이 함수는 다시 dup_mm에서 호출되고… 그것은 copy_mm에서 호출되며… 그것은 마지막으로 거대한 copy_process 함수에서 호출됩니다! copy_process는 fork 함수의 핵심이자, 어떤 의미에서는 Unix 시스템이 프로그램을 실행하는 방식의 중심입니다. 부팅 시 첫 번째 프로세스를 위해 만들어진 템플릿을 언제나 복사하고 수정하는 방식이기 때문입니다.

요약하자면…

그래서… 프로그램은 어떻게 실행됩니까?

가장 낮은 수준에서 보면 프로세서는 멍청합니다. 프로세서는 메모리를 가리키는 포인터를 하나 가지고 있으며, 다른 곳으로 점프하라는 명령어를 만나지 않는 한 명령어를 차례대로 실행합니다.

점프 명령어 외에도 하드웨어 인터럽트와 소프트웨어 인터럽트가 미리 정해진 위치로 점프하여 실행 순서를 끊을 수 있으며, 그 위치에서 다시 어디로 점프할지 정할 수 있습니다. 프로세서 코어는 여러 프로그램을 동시에 실행할 수 없지만, 타이머로 인터럽트를 반복해서 발생시키고 커널 코드가 서로 다른 코드 포인터 사이를 전환하도록 하면 이를 흉내 낼 수 있습니다.

프로그램은 자신이 일관되고 격리된 단위로 실행되고 있다고 속아서 믿습니다. 사용자 모드에서는 시스템 자원에 직접 접근할 수 없고, 메모리 공간은 페이징으로 격리되며, 시스템 호출은 실제 실행 환경을 자세히 알지 못해도 일반적인 I/O에 접근할 수 있도록 설계되어 있습니다. 시스템 호출은 CPU에 커널 코드를 실행해 달라고 요청하는 명령어이며, 그 커널 코드의 위치는 시작 시 커널이 설정합니다.

그런데… 프로그램은 어떻게 실행됩니까?

컴퓨터가 부팅된 후 커널은 init 프로세스를 실행합니다. 이것은 더 높은 추상화 수준에서 실행되는 첫 번째 프로그램이며, 이 수준에서는 기계어 코드가 구체적인 시스템 세부 사항을 많이 신경 쓸 필요가 없습니다. init 프로그램은 컴퓨터의 그래픽 환경을 렌더링하고 다른 소프트웨어의 실행을 담당하는 프로그램들을 실행합니다.

프로그램을 실행하기 위해 init은 fork 시스템 호출로 자기 자신을 복제합니다. 모든 메모리 페이지가 COW이므로 물리 RAM 안에서 메모리를 복사할 필요가 없어 이 복제는 효율적입니다. Linux에서는 copy_process 함수가 바로 이 일을 합니다.

두 프로세스는 각각 자신이 fork된 프로세스인지 확인합니다. fork된 프로세스라면 exec 시스템 호출을 사용해 현재 프로세스를 새 프로그램으로 교체해 달라고 커널에 요청합니다.

새 프로그램은 ELF 파일일 가능성이 높으며, 커널은 이 파일을 파싱하여 프로그램을 어떻게 로드할지, 그리고 새 가상 메모리 매핑 안에서 코드와 데이터를 어디에 배치할지에 관한 정보를 찾습니다. 프로그램이 동적으로 링크되어 있다면 커널은 ELF 인터프리터도 준비할 수 있습니다.

그런 다음 커널은 프로그램의 가상 메모리 매핑을 로드하고 프로그램이 실행되는 상태로 유저랜드에 복귀할 수 있는데, 이는 실제로는 CPU의 명령어 포인터를 가상 메모리에 있는 새 프로그램 코드의 시작 지점으로 설정한다는 뜻입니다.

7장으로 계속: 에필로그