> [!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`을 부모보다 작게 조정하여 자식이 먼저 스케줄링되도록 한다.