Introduction

fork()는 Unix/Linux 시스템에서 프로세스를 생성하는 핵심 시스템 호출입니다. 이 호출은 한 번 호출되지만 두 프로세스에서 모두 반환되는 독특한 특성을 가지고 있습니다. 이 글에서는 fork()의 동작 원리, 커널 수준의 구현, 그리고 Copy-on-Write 최적화를 살펴봅니다. 프로세스시스템 호출에 대한 기본 이해가 도움이 될 것입니다.

fork() 시스템 호출

fork()를 호출하면 현재 프로세스에서 파생된 새로운 프로세스가 생긴다. 함수는 일반적으로 한 번 호출하면 한 번 반환되지만, fork()는 새로운 프로세스를 만들기 때문에 이후 코드는 각 프로세스에서 독립적으로 실행된다. 따라서 fork()한 번 호출하고 두 번 반환된다고 표현한다.1

#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

프로세스 수가 증가하면 이들을 그룹으로 분류하는데, 이때 사용하는 것이 프로세스 그룹 IDProcess Group ID, PGID다. 같은 PGID를 가진 프로세스들은 한 그룹을 이루며, PID와 PGID가 같은 프로세스를 루트로 하는 서브트리를 구성한다.

새로운 프로세스가 생성되면 부모와 자식 프로세스 간의 관계는 다음 관점에서 규정된다:

  • 자원 공유Resource Sharing Option: 부모와 자식 프로세스가 모든 자원을 공유하거나, 일부 자원만 공유하거나, 자원을 공유하지 않을 수 있다.
  • 실행Execution Option: 부모와 자식이 동시에 실행되거나, 자식이 종료될 때까지 부모가 기다릴 수 있다.
  • 주소 공간Address Space Option: 자식이 부모와 동일한 데이터를 가지거나 새로운 데이터를 가질 수 있다.

fork()의 반환값

fork()pid_t 타입의 값을 반환한다. 한 번의 호출이 두 프로세스에서 반환되기 때문에, 부모 프로세스에서는 자식 프로세스의 PID를 받고, 자식 프로세스에서는 0을 받는다. 오류 발생 시에는 음수가 반환된다.1

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 구조체에는 부모, 자식, 형제 프로세스를 가리키는 멤버들이 있다. parentreal_parent 멤버는 부모 프로세스를 가리키고, children은 자식 프로세스 리스트를, sibling은 형제 프로세스를 가리킨다. group_leader 멤버는 프로세스 그룹의 최상위 프로세스를 참조한다. 이 계층 구조 덕분에 시스템의 어떤 프로세스에서도 다른 특정 프로세스를 찾아갈 수 있다. Process Control Block에서 task_struct에 대해 더 자세히 알아볼 수 있다.

fork()의 구현

fork()의 인터페이스는 간단하지만, 커널 수준의 구현은 복잡하다. 사용자 프로그램이 fork()를 호출하면 GNU C 라이브러리(glibc)가 이를 받아 내부적으로 clone() 시스템 호출을 수행한다.2 커널에 진입하면 sys_fork()kernel_clone() 경로를 따른다. 한편 clone3()clone()의 확장 버전으로, 사용자 공간에서 struct clone_args를 통해 더 정밀한 제어를 할 수 있다. clone3() 경로는 sys_clone3()kernel_clone()으로 이어지며, 결국 두 경로 모두 kernel_clone()에서 합류한다.

kernel_clone()

그래서 실제 프로세스 생성의 핵심은 kernel_clone()에서 이루어진다:

/*
 *  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_structflags 항목을 정리한다. 관리자 권한을 의미하는 PF_SUPERPRIV 플래그를 초기화하고, 아직 exec()를 호출하지 않았음을 뜻하는 PF_FORKNOEXEC 플래그를 설정한다.

  6. 새 PID 할당: alloc_pid() 함수를 호출해 자식 프로세스에 새로운 PID를 할당한다.

  7. 자원 복제 또는 공유: clone() 함수에 전달된 플래그 값에 따라 열린 파일, 파일시스템 정보, 시그널 핸들러, 프로세스 주소 공간, 네임스페이스 등을 복제하거나 공유한다. 스레드는 보통 이런 자원을 공유하며, 다른 경우에는 개별적으로 사용한다.

다음은 copy_process() 함수의 주요 로직을 나타낸 의사코드다:

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-Write3다.

Copy-on-Write는 기록 시 복사를 의미하며, 프로세스의 주소 공간에 실제 쓰기 작업이 일어날 때까지 데이터 복사를 지연하는 최적화 기법이다. 프로세스는 각자의 가상 메모리를 가지면서도 물리 메모리를 공유한다. Copy-on-Write로 지정된 데이터에 쓰기 작업이 발생하면, 그 시점에서만 필요한 메모리 페이지를 복사하고 기록한다.

과거 Linux 커널(v2.6.32 이전)은 의도적으로 자식 프로세스를 먼저 실행했다. 부모 프로세스가 먼저 실행되어 주소 공간에 쓰기 작업이 발생할 경우, 불필요한 Copy-on-Write가 일어나는 것을 방지하기 위함이었다. 그러나 현대 커널에서는 sysctl_sched_child_runs_first의 기본값이 0으로, 부모 프로세스가 먼저 실행된다.4 이는 대부분의 워크로드에서 부모가 먼저 실행되는 것이 캐시 지역성 측면에서 유리하기 때문이다. 특히 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

Footnotes

  1. 이를 통해 부모와 자식 프로세스는 반환값으로 자신이 어떤 프로세스인지를 구분할 수 있다. 부모 프로세스는 반환값이 양수(자식의 PID)이고, 자식 프로세스는 0이다. 2

  2. glibc 2.3.3부터 fork() 래퍼는 커널의 fork() 시스템 호출 대신 clone(SIGCHLD) 시스템 호출을 사용한다. glibc는 clone3()의 래퍼를 제공하지 않으며, clone3()를 사용하려면 syscall(2)을 직접 호출해야 한다. fork(2) - Linux manual page 참고.

  3. Copy-on-Write는 자원 복사를 실제 수정 시점까지 지연하는 최적화 전략이다. 자식 프로세스가 부모의 페이지 테이블을 공유하다가, 쓰기 작업이 발생하는 시점에 해당 페이지만 복사한다.

  4. /proc/sys/kernel/sched_child_runs_first로 확인할 수 있으며, 기본값은 0(부모 우선 실행)이다. CFS 스케줄러의 task_fork_fair() 함수에서 이 값이 설정되어 있을 때만 자식의 vruntime을 부모보다 작게 조정하여 자식이 먼저 스케줄링되도록 한다.