> [!abstract] Introduction > `fork()`는 Unix/Linux 시스템에서 **프로세스를 생성하는 핵심 시스템 호출**입니다. 이 호출은 한 번 호출되지만 두 프로세스에서 모두 반환되는 독특한 특성을 가지고 있습니다. 이 글에서는 `fork()`의 동작 원리, 커널 수준의 구현, 그리고 Copy-on-Write 최적화를 살펴봅니다. [[Process|프로세스]]와 [[System Call|시스템 호출]]에 대한 기본 이해가 도움이 될 것입니다. ## `fork()` 시스템 호출 `fork()`를 호출하면 현재 프로세스에서 파생된 새로운 프로세스가 생긴다. 함수는 일반적으로 한 번 호출하면 한 번 반환되지만, `fork()`는 새로운 프로세스를 만들기 때문에 이후 코드는 각 프로세스에서 독립적으로 실행된다. 따라서 `fork()`는 **한 번 호출하고 두 번 반환된다**고 표현한다.[^fork-return-value] ```C #include <unistd.h> pid_t fork(void); ``` 반환되는 값의 타입은 `pid_t`이며, 이를 이해하려면 Unix/Linux 시스템이 여러 프로세스를 관리하는 방식을 알아야 한다. ## 프로세스 ID와 프로세스 그룹 ID Unix 운영체제는 생성된 각 프로세스에 고유한 번호 **프로세스 식별 번호*Process Identification Number, PID***를 부여하여 관리한다. `fork()` 시스템 호출로 프로세스를 생성할 때, 실행 중인 현재 프로세스를 **부모 프로세스*Parent Process***라 하고, 새로 생성되는 프로세스를 **자식 프로세스*Child Process***라 한다. 부모-자식 관계로 인해 프로세스의 계층은 트리 구조를 이룬다. Unix 기반 시스템의 모든 프로세스는 PID 1인 `init` 프로세스를 최상단 조상으로 하는 계층 구조를 형성한다. 시스템의 모든 프로세스는 정확히 하나의 부모 프로세스를 가지며, 하나 이상의 자식 프로세스를 가질 수 있다. 같은 부모를 가지는 자식 프로세스들을 **형제 프로세스*Sibling Process***라 부른다. ![[processGroup.png|Bryant and O'Hallaron, 2016, p. 796]] 프로세스 수가 증가하면 이들을 그룹으로 분류하는데, 이때 사용하는 것이 **프로세스 그룹 ID*Process Group ID, PGID***다. 같은 PGID를 가진 프로세스들은 한 그룹을 이루며, PID와 PGID가 같은 프로세스를 루트로 하는 서브트리를 구성한다. 새로운 프로세스가 생성되면 부모와 자식 프로세스 간의 관계는 다음 관점에서 규정된다: - **자원 공유*Resource Sharing Option***: 부모와 자식 프로세스가 모든 자원을 공유하거나, 일부 자원만 공유하거나, 자원을 공유하지 않을 수 있다. - **실행*Execution Option***: 부모와 자식이 동시에 실행되거나, 자식이 종료될 때까지 부모가 기다릴 수 있다. - **주소 공간*Address Space Option***: 자식이 부모와 동일한 데이터를 가지거나 새로운 데이터를 가질 수 있다. ### `fork()`의 반환값 `fork()`는 `pid_t` 타입의 값을 반환한다. 한 번의 호출이 두 프로세스에서 반환되기 때문에, 부모 프로세스에서는 자식 프로세스의 PID를 받고, 자식 프로세스에서는 0을 받는다. 오류 발생 시에는 음수가 반환된다.[^fork-return-value] ```C struct task_struct { // ... /* * Pointers to the (original) parent process, youngest child, younger sibling, * older sibling, respectively. (p->father can be replaced with * p->real_parent->pid) */ /* Real parent process: */ struct task_struct __rcu *real_parent; /* Recipient of SIGCHLD, wait4() reports: */ struct task_struct __rcu *parent; /* * Children/sibling form the list of natural children: */ struct list_head children; struct list_head sibling; struct task_struct *group_leader; // ... ``` 리눅스 커널에서 관리하는 각 프로세스의 `task_struct` 구조체에는 부모, 자식, 형제 프로세스를 가리키는 멤버들이 있다. `parent`와 `real_parent` 멤버는 부모 프로세스를 가리키고, `children`은 자식 프로세스 리스트를, `sibling`은 형제 프로세스를 가리킨다. `group_leader` 멤버는 프로세스 그룹의 최상위 프로세스를 참조한다. 이 계층 구조 덕분에 시스템의 어떤 프로세스에서도 다른 특정 프로세스를 찾아갈 수 있다. [[Process Control Block]]에서 `task_struct`에 대해 더 자세히 알아볼 수 있다. ## `fork()`의 구현 `fork()`의 인터페이스는 간단하지만, 커널 수준의 구현은 복잡하다. 사용자 프로그램이 `fork()`를 호출하면 GNU C 라이브러리(glibc)가 이를 받아 내부적으로 `clone()` 시스템 호출을 수행한다.[^glibc-fork-clone] 커널에 진입하면 `sys_fork()` → `kernel_clone()` 경로를 따른다. 한편 `clone3()`는 `clone()`의 확장 버전으로, 사용자 공간에서 `struct clone_args`를 통해 더 정밀한 제어를 할 수 있다. `clone3()` 경로는 `sys_clone3()` → `kernel_clone()`으로 이어지며, 결국 두 경로 모두 `kernel_clone()`에서 합류한다. ### `kernel_clone()` 그래서 실제 프로세스 생성의 핵심은 `kernel_clone()`에서 이루어진다: ```C /* * Ok, this is the main fork-routine. * * It copies the process, and if successful kick-starts * it and waits for it to finish using the VM if required. * * args->exit_signal is expected to be checked for sanity by the caller. */ pid_t kernel_clone(struct kernel_clone_args *args) { u64 clone_flags = args->flags; struct completion vfork; struct pid *pid; struct task_struct *p; int trace = 0; pid_t nr; /* * For legacy clone() calls, CLONE_PIDFD uses the parent_tid argument * to return the pidfd. Hence, CLONE_PIDFD and CLONE_PARENT_SETTID are * mutually exclusive. With clone3() CLONE_PIDFD has grown a separate * field in struct clone_args and it still doesn't make sense to have * them both point at the same memory location. Performing this check * here has the advantage that we don't need to have a separate helper * to check for legacy clone(). */ if ((clone_flags & CLONE_PIDFD) && (clone_flags & CLONE_PARENT_SETTID) && (args->pidfd == args->parent_tid)) return -EINVAL; /* * Determine whether and which event to report to ptracer. When * called from kernel_thread or CLONE_UNTRACED is explicitly * requested, no event is reported; otherwise, report if the event * for the type of forking is enabled. */ if (!(clone_flags & CLONE_UNTRACED)) { if (clone_flags & CLONE_VFORK) trace = PTRACE_EVENT_VFORK; else if (args->exit_signal != SIGCHLD) trace = PTRACE_EVENT_CLONE; else trace = PTRACE_EVENT_FORK; if (likely(!ptrace_event_enabled(current, trace))) trace = 0; } p = copy_process(NULL, trace, NUMA_NO_NODE, args); add_latent_entropy(); if (IS_ERR(p)) return PTR_ERR(p); /* * Do this prior waking up the new thread - the thread pointer * might get invalid after that point, if the thread exits quickly. */ trace_sched_process_fork(current, p); pid = get_task_pid(p, PIDTYPE_PID); nr = pid_vnr(pid); if (clone_flags & CLONE_PARENT_SETTID) put_user(nr, args->parent_tid); if (clone_flags & CLONE_VFORK) { p->vfork_done = &vfork; init_completion(&vfork); get_task_struct(p); } if (IS_ENABLED(CONFIG_LRU_GEN_WALKS_MMU) && !(clone_flags & CLONE_VM)) { /* lock the task to synchronize with memcg migration */ task_lock(p); lru_gen_add_mm(p->mm); task_unlock(p); } wake_up_new_task(p); /* forking complete and child started to run, tell ptracer */ if (unlikely(trace)) ptrace_event_pid(trace, pid); if (clone_flags & CLONE_VFORK) { if (!wait_for_vfork_done(p, &vfork)) ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid); } put_pid(pid); return nr; } ``` `kernel_clone()`의 핵심은 `copy_process()` 함수를 호출하는 것이다. 이 함수가 성공하면, 새로운 자식 프로세스를 깨워(`wake_up_new_task()`) 실행을 시작한다. ### `copy_process()` 함수 `copy_process()` 함수는 실제 프로세스 복사 작업을 수행한다. 복잡한 구현을 핵심만 요약하면 다음 단계를 거친다: 1. **커널 스택 및 프로세스 구조체 생성**: `dup_task_struct()` 함수를 호출해 새로운 커널 스택, `thread_info` 구조체, `task_struct` 구조체를 만든다. 이 시점에서 새로 만들어진 데이터는 현재 프로세스와 동일하므로, 부모와 자식이 동일한 프로세스 서술자를 공유한다. 2. **사용자 프로세스 개수 제한 확인**: 새로운 자식 프로세스가 시스템의 사용자 프로세스 개수 제한을 초과하지 않는지 검증한다. 3. **프로세스 서술자 초기화**: 자식 프로세스를 부모와 구별하기 위해 `task_struct`의 다양한 항목을 초기화한다. 통계 정보 등 부모로부터 물려받지 않아야 할 항목들을 정리한다. 4. **프로세스 상태 설정**: 자식 프로세스의 상태를 `TASK_UNINTERRUPTIBLE`로 설정하여 아직 실행되지 않게 한다. 5. **플래그 정리**: `copy_flags()` 함수로 `task_struct`의 `flags` 항목을 정리한다. 관리자 권한을 의미하는 `PF_SUPERPRIV` 플래그를 초기화하고, 아직 `exec()`를 호출하지 않았음을 뜻하는 `PF_FORKNOEXEC` 플래그를 설정한다. 6. **새 PID 할당**: `alloc_pid()` 함수를 호출해 자식 프로세스에 새로운 PID를 할당한다. 7. **자원 복제 또는 공유**: `clone()` 함수에 전달된 플래그 값에 따라 열린 파일, 파일시스템 정보, 시그널 핸들러, 프로세스 주소 공간, 네임스페이스 등을 복제하거나 공유한다. 스레드는 보통 이런 자원을 공유하며, 다른 경우에는 개별적으로 사용한다. 다음은 `copy_process()` 함수의 주요 로직을 나타낸 의사코드다: ```C struct task_struct *copy_process(struct pid *pid, int trace, int node, struct kernel_clone_args *args) { struct task_struct *p; const u64 clone_flags = args->flags; int retval; /* 1. Validate clone flags combinations */ if ((clone_flags & CLONE_THREAD) && !(clone_flags & CLONE_SIGHAND)) return ERR_PTR(-EINVAL); // Threads must share signals if ((clone_flags & CLONE_SIGHAND) && !(clone_flags & CLONE_VM)) return ERR_PTR(-EINVAL); // Shared signals require shared VM /* 2. Duplicate task_struct from parent */ p = dup_task_struct(current, node); if (!p) goto fork_out; /* 3. Initialize basic task properties */ p->flags |= PF_FORKNOEXEC; // Haven't called exec yet p->pid = pid_nr(pid); p->start_time = ktime_get_ns(); /* 4. Copy credentials and check resource limits */ retval = copy_creds(p, clone_flags); if (is_rlimit_overlimit(RLIMIT_NPROC)) // Process count limit goto bad_fork_cleanup_count; /* 5. Initialize task subsystems */ sched_fork(clone_flags, p); // Scheduler setup, assign CPU audit_alloc(p); // Audit subsystem security_task_alloc(p); // LSM hooks /* 6. Copy or share parent resources based on clone flags */ retval = copy_files(clone_flags, p); // File descriptors retval = copy_fs(clone_flags, p); // Filesystem info retval = copy_sighand(clone_flags, p); // Signal handlers retval = copy_signal(clone_flags, p); // Signal state retval = copy_mm(clone_flags, p); // Memory mappings retval = copy_namespaces(clone_flags, p); // Namespaces retval = copy_thread(p, args); // Architecture-specific setup /* 7. Allocate PID for the new process */ if (pid != &init_struct_pid) { pid = alloc_pid(p->nsproxy->pid_ns_for_children); if (IS_ERR(pid)) goto bad_fork_cleanup_thread; } /* 8. Handle CLONE_PIDFD if requested */ if (clone_flags & CLONE_PIDFD) { retval = pidfd_prepare(pid, flags, &pidfile); pidfd = retval; put_user(pidfd, args->pidfd); // Return fd to userspace } /* 9. Set up thread group relationships */ if (clone_flags & CLONE_THREAD) { p->group_leader = current->group_leader; p->tgid = current->tgid; // Same thread group ID } else { p->group_leader = p; p->tgid = p->pid; // New process group } /* 10. Make process visible to the system */ write_lock_irq(&tasklist_lock); /* Set parent relationships */ if (clone_flags & CLONE_PARENT) p->real_parent = current->real_parent; // Share parent else p->real_parent = current; // Current is parent /* Add to process lists */ if (thread_group_leader(p)) { list_add_tail(&p->sibling, &p->real_parent->children); list_add_tail_rcu(&p->tasks, &init_task.tasks); attach_pid(p, PIDTYPE_TGID); __this_cpu_inc(process_counts); } else { current->signal->nr_threads++; list_add_tail_rcu(&p->thread_node, &p->signal->thread_head); } attach_pid(p, PIDTYPE_PID); nr_threads++; total_forks++; write_unlock_irq(&tasklist_lock); /* 11. Post-fork notifications */ proc_fork_connector(p); // Process events connector cgroup_post_fork(p, args); // Cgroup setup perf_event_fork(p); // Performance monitoring return p; /* Error cleanup paths - unwind everything in reverse order */ bad_fork_cleanup_mm: if (p->mm) mmput(p->mm); bad_fork_cleanup_signal: if (!(clone_flags & CLONE_THREAD)) free_signal_struct(p->signal); bad_fork_cleanup_thread: exit_thread(p); bad_fork_free: put_task_stack(p); delayed_free_task(p); fork_out: return ERR_PTR(retval); } ``` ## Copy-on-Write 오늘날 `fork()`는 부모 프로세스의 모든 자원을 자식 프로세스로 복사하지 않는다. 이는 공유 가능한 데이터를 최대한 복사하지 않으면서도 독립적인 프로세스를 제공하기 위한 것이다. 이때 핵심 개념이 **Copy-on-Write**[^copy-on-write]다. Copy-on-Write는 **기록 시 복사**를 의미하며, 프로세스의 주소 공간에 실제 쓰기 작업이 일어날 때까지 데이터 복사를 지연하는 최적화 기법이다. 프로세스는 각자의 가상 메모리를 가지면서도 물리 메모리를 공유한다. Copy-on-Write로 지정된 데이터에 쓰기 작업이 발생하면, 그 시점에서만 필요한 메모리 페이지를 복사하고 기록한다. ![[copyOnWrite.png]] 과거 Linux 커널(v2.6.32 이전)은 의도적으로 자식 프로세스를 먼저 실행했다. 부모 프로세스가 먼저 실행되어 주소 공간에 쓰기 작업이 발생할 경우, 불필요한 Copy-on-Write가 일어나는 것을 방지하기 위함이었다. 그러나 현대 커널에서는 `sysctl_sched_child_runs_first`의 기본값이 0으로, **부모 프로세스가 먼저 실행**된다.[^child-runs-first] 이는 대부분의 워크로드에서 부모가 먼저 실행되는 것이 캐시 지역성 측면에서 유리하기 때문이다. 특히 `fork()` 직후 즉시 `exec()`을 호출하는 패턴에서는 자식 프로세스의 주소 공간이 완전히 교체되므로 Copy-on-Write로 인한 페이지 복사가 발생하지 않는다. 결국 `fork()`가 실제로 하는 일은 부모 프로세스의 페이지 테이블을 복사하고 자식 프로세스를 위한 프로세스 서술자를 만드는 것뿐이다. [[exec()]]에서 프로세스의 주소 공간을 완전히 교체하는 방식을 자세히 알아볼 수 있다. --- ## 출처 + Silberschatz, A., Galvin, P. B. and Gagne, G. (2018) *Operating System Concepts*. 10th edn. Hoboken: John Wiley & Sons. + Bryant, R. E. and O'Hallaron, D. R. (2016) *Computer Systems: A Programmer's Perspective*. 3rd edn. Boston: Pearson. + Kerrisk, M. (2018) *The Linux Programming Interface*. 9th printing. San Francisco: No Starch Press. + Amini, K. (2019) *Extreme C: Taking you to the limit in Concurrency, OOP, and the most advanced capabilities of C*. 1st edn. Birmingham: Packt Publishing. + Love, R. (2010) *Linux Kernel Development*. 3rd edn. Upper Saddle River: Addison-Wesley Professional. + Linux kernel source code: `kernel/fork.c` --- [^fork-return-value]: 이를 통해 부모와 자식 프로세스는 반환값으로 자신이 어떤 프로세스인지를 구분할 수 있다. 부모 프로세스는 반환값이 양수(자식의 PID)이고, 자식 프로세스는 0이다. [^copy-on-write]: Copy-on-Write는 자원 복사를 실제 수정 시점까지 지연하는 최적화 전략이다. 자식 프로세스가 부모의 페이지 테이블을 공유하다가, 쓰기 작업이 발생하는 시점에 해당 페이지만 복사한다. [^glibc-fork-clone]: glibc 2.3.3부터 `fork()` 래퍼는 커널의 `fork()` 시스템 호출 대신 `clone(SIGCHLD)` 시스템 호출을 사용한다. glibc는 `clone3()`의 래퍼를 제공하지 않으며, `clone3()`를 사용하려면 `syscall(2)`을 직접 호출해야 한다. [fork(2) - Linux manual page](https://www.man7.org/linux/man-pages/man2/fork.2.html) 참고. [^child-runs-first]: `/proc/sys/kernel/sched_child_runs_first`로 확인할 수 있으며, 기본값은 0(부모 우선 실행)이다. CFS 스케줄러의 `task_fork_fair()` 함수에서 이 값이 설정되어 있을 때만 자식의 `vruntime`을 부모보다 작게 조정하여 자식이 먼저 스케줄링되도록 한다.