챕터 6:
Fork와 Cow에 대해 이야기해봅시다 GitHub에서 수정

마지막 질문: 우리는 어떻게 여기까지 왔을까요? 첫 번째 프로세스는 어디에서 왔을까요?

이 글은 거의 끝났습니다. 우리는 마지막 단계에 있습니다. 홈런을 칠 준비가 되었습니다. 더 푸른 목장으로 이동하고 있습니다. 그리고 당신이 15,000단어짜리 CPU 아키텍처에 관한 글을 읽지 않을 때 하는 잔디 만지기나 다른 것으로부터 6장의 길이 거리에 있다는 것을 의미하는 다양한 다른 끔찍한 관용구들.

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

답은 또 다른 시스템 콜입니다: 모든 멀티프로세싱의 기본이 되는 시스템 콜인 fork입니다. fork는 실제로 꽤 간단합니다 — 현재 프로세스와 메모리를 복제하고, 저장된 명령어 포인터를 정확히 그 위치에 두고, 두 프로세스가 평소대로 진행하도록 합니다. 개입 없이, 프로그램은 서로 독립적으로 계속 실행되며 모든 계산이 두 배가 됩니다.

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

동일한 코드를 무작정 두 배로 만드는 것은 꽤 쓸모가 없으므로, fork는 부모와 자식에서 다른 값을 반환합니다. 부모에서는 새 자식 프로세스의 PID를 반환하고, 자식에서는 0을 반환합니다. 이것은 새 프로세스에서 다른 작업을 수행하는 것을 가능하게 하여 포크하는 것이 실제로 유용하게 만듭니다.

main.c
pid_t pid = fork();

// 코드는 평소처럼 이 지점에서 계속되지만, 이제
// 두 개의 "동일한" 프로세스에 걸쳐 있습니다.
//
// 동일합니다... fork에서 반환된 PID를 제외하고!
//
// 이것은 어느 프로그램에게나 그들이 유일하지 않다는
// 유일한 지표입니다.

if (pid == 0) {
	// 우리는 자식에 있습니다.
	// 일부 계산을 수행하고 부모에게 결과를 제공합니다!
} else {
	// 우리는 부모에 있습니다.
	// 아마도 전에 하던 일을 계속합니다.
}

프로세스 포크는 머리를 감싸기가 조금 어려울 수 있습니다. 이 시점부터 당신이 그것을 파악했다고 가정하겠습니다; 그렇지 않다면, 꽤 좋은 설명을 위해 이 보기 흉한 웹사이트를 확인하세요.

어쨌든, Unix 프로그램은 fork를 호출한 다음 자식 프로세스에서 즉시 execve를 실행하여 새 프로그램을 시작합니다. 이것을 fork-exec 패턴이라고 합니다. 프로그램을 실행하면, 컴퓨터는 다음과 유사한 코드를 실행합니다:

launcher.c
pid_t pid = fork();

if (pid == 0) {
	// 자식 프로세스를 새 프로그램으로 즉시 교체합니다.
	execve(...);
}

// 여기까지 왔으므로, 프로세스가 교체되지 않았습니다. 우리는 부모에 있습니다!
// 유용하게도, 우리는 이제 PID 변수에 새 자식 프로세스의 PID도 가지고 있으며,
// 죽여야 할 경우를 대비해서입니다.

// 부모 프로그램은 여기서 계속됩니다...

음메!

프로세스의 메모리를 복제한 다음 다른 프로그램을 로드할 때 모두 버리는 것은 조금 비효율적으로 들릴 수 있습니다. 다행히, 우리에게는 MMU가 있습니다. 물리 메모리에서 데이터를 복제하는 것이 느린 부분이지, 페이지 테이블을 복제하는 것이 아니므로, 우리는 단순히 RAM을 복제하지 않습니다: 새 프로세스를 위해 이전 프로세스의 페이지 테이블의 복사본을 만들고 매핑을 동일한 기본 물리 메모리를 가리키도록 유지합니다.

하지만 자식 프로세스는 부모로부터 독립적이고 격리되어 있어야 합니다! 자식이 부모의 메모리에 쓰거나 그 반대의 경우는 괜찮지 않습니다!

COW (copy on write) 페이지를 소개합니다. COW 페이지를 사용하면, 두 프로세스가 메모리에 쓰려고 시도하지 않는 한 동일한 물리 주소에서 읽습니다. 그들 중 하나가 메모리에 쓰려고 하는 즉시, 해당 페이지가 RAM에 복사됩니다. COW 페이지는 전체 메모리 공간을 복제하는 초기 비용 없이 두 프로세스가 메모리 격리를 가질 수 있게 합니다. 이것이 fork-exec 패턴이 효율적인 이유입니다; 이전 프로세스의 메모리 중 어느 것도 새 바이너리를 로드하기 전에 쓰여지지 않으므로, 메모리 복사가 필요하지 않습니다.

COW는 많은 재미있는 것들과 마찬가지로 페이징 해킹과 하드웨어 인터럽트 처리로 구현됩니다. fork가 부모를 복제한 후, 두 프로세스의 모든 페이지를 읽기 전용으로 플래그를 지정합니다. 프로그램이 메모리에 쓸 때, 메모리가 읽기 전용이므로 쓰기가 실패합니다. 이것은 커널에 의해 처리되는 segfault (하드웨어 인터럽트 종류)를 트리거합니다. 커널은 메모리를 복제하고, 페이지를 쓰기를 허용하도록 업데이트한 다음, 쓰기를 재시도하기 위해 인터럽트에서 복귀합니다.

A: 똑똑! B: 누구세요? A: 인터럽팅 소. B: 인터럽팅 소 — A: 음메!

태초에 (창세기 1:1이 아님)

컴퓨터의 모든 프로세스는 하나를 제외하고 부모 프로그램에 의해 fork-exec되었습니다: init 프로세스. init 프로세스는 커널에 의해 직접 수동으로 설정됩니다. 이것은 실행될 첫 번째 사용자 공간 프로그램이며 종료 시 마지막으로 죽습니다.

멋진 즉석 블랙스크린을 보고 싶으신가요? macOS나 Linux를 사용 중이라면, 작업을 저장하고 터미널을 열어 init 프로세스 (PID 1)를 종료하세요:

Shell session
$ sudo kill 1

저자의 메모: init 프로세스에 대한 지식은 불행히도 macOS 및 Linux와 같은 Unix 계열 시스템에만 적용됩니다. 지금부터 배우는 대부분은 매우 다른 커널 아키텍처를 가진 Windows를 이해하는 데 적용되지 않습니다.

execve에 대한 섹션과 마찬가지로, 저는 이것을 명시적으로 언급하고 있습니다 — NT 커널에 대해 완전히 다른 글을 쓸 수 있지만, 그렇게 하지 않도록 자제하고 있습니다. (지금은.)

init 프로세스는 운영 체제를 구성하는 모든 프로그램과 서비스를 생성하는 책임이 있습니다. 그들 중 많은 것들은 차례로 자체 서비스와 프로그램을 생성합니다.

프로세스 트리. 루트 노드는 "init"으로 레이블이 지정되어 있습니다. 모든 자식 노드는 레이블이 없지만 init 프로세스에 의해 생성된 것으로 암시됩니다.

init 프로세스를 종료하면 모든 자식과 그들의 모든 자식이 종료되어 OS 환경이 종료됩니다.

커널로 돌아가기

우리는 3장에서 Linux 커널 코드를 보면서 많은 재미를 느꼈으므로, 조금 더 해봅시다! 이번에는 커널이 init 프로세스를 시작하는 방법을 살펴보겠습니다.

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

  1. 마더보드는 연결된 디스크에서 부트로더라는 프로그램을 검색하는 작은 소프트웨어와 함께 번들로 제공됩니다. 부트로더를 선택하고, 그 기계어를 RAM에 로드하고, 실행합니다.

    우리는 아직 실행 중인 OS의 세계에 있지 않다는 것을 명심하세요. OS 커널이 init 프로세스를 시작할 때까지, 멀티프로세싱과 시스템 콜은 실제로 존재하지 않습니다. init 전 컨텍스트에서 프로그램을 “실행”한다는 것은 복귀 예상 없이 RAM의 기계어로 직접 점프하는 것을 의미합니다.

  2. 부트로더는 커널을 찾아 RAM에 로드하고 실행하는 책임이 있습니다. 일부 부트로더는 GRUB와 같이 구성 가능하거나 여러 운영 체제 중에서 선택할 수 있게 합니다. BootX와 Windows Boot Manager는 각각 macOS 및 Windows의 내장 부트로더입니다.

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

  4. 우리는 마침내 운영 체제의 사용자 공간에 있습니다! init 프로그램은 init 스크립트를 실행하고, 서비스를 시작하고, 셸/UI와 같은 프로그램을 실행하기 시작합니다.

Linux 초기화

Linux에서 3단계(커널 초기화)의 대부분은 init/main.cstart_kernel 함수에서 발생합니다. 이 함수는 200줄이 넘는 다양한 다른 init 함수에 대한 호출이므로, 전체를 이 글에 포함하지는 않겠지만, 훑어보는 것을 권장합니다! 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 매크로는 일반적인 .text 섹션 대신 .init.text라는 섹션에 코드를 배치하는 컴파일러 지시문인 __section(".init.text")로 확장됩니다. 다른 매크로는 __initdata와 같이 데이터와 상수를 특수 init 섹션에 배치할 수 있게 하며, 이것은 __section(".init.data")로 확장됩니다.

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 메모리 매핑

Linux 커널이 프로세스를 포크할 때 메모리의 하위 절반을 어떻게 다시 매핑하는지 궁금해서, 조금 살펴봤습니다. 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_mappinginclude/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 프로그램은 컴퓨터의 그래픽 환경을 렌더링하고 다른 소프트웨어를 시작하는 책임이 있는 프로그램을 시작합니다.

프로그램을 시작하기 위해, fork 시스템 콜로 자신을 복제합니다. 이 복제는 모든 메모리 페이지가 COW이고 메모리가 물리 RAM 내에서 복사될 필요가 없기 때문에 효율적입니다. Linux에서 이것은 copy_process 함수가 작동하는 것입니다.

두 프로세스는 자신이 포크된 프로세스인지 확인합니다. 만약 그렇다면, exec 시스템 콜을 사용하여 커널에 현재 프로세스를 새 프로그램으로 교체하도록 요청합니다.

새 프로그램은 아마도 ELF 파일일 것이며, 커널은 프로그램을 로드하는 방법과 코드와 데이터를 새 가상 메모리 매핑 내에 배치할 위치에 대한 정보를 찾기 위해 파싱합니다. 커널은 또한 프로그램이 동적으로 링크된 경우 ELF 인터프리터를 준비할 수 있습니다.

그런 다음 커널은 프로그램의 가상 메모리 매핑을 로드하고 프로그램이 실행되는 사용자 공간으로 복귀할 수 있으며, 이것은 실제로 CPU의 명령어 포인터를 가상 메모리에서 새 프로그램 코드의 시작으로 설정하는 것을 의미합니다.

챕터 7로 계속: 에필로그