왜 프로세스를 생성할 때 fork()를 사용할까?

date
Sep 6, 2022
thumbnail
slug
fork-exec
author
status
Published
tags
OS
summary
POSIX계열에서 프로세스를 생성 시 왜 새로 만들지 않고 fork()를 사용하는 이유를 알아보자!
type
Post
updatedAt
Feb 1, 2023 12:34 PM

학습 배경

LINUX/UNIX 계열에서 쉘에서 명령어를 실행하는 등의 프로세스를 생성할 때, fork()시스템콜로 현재 프로세스를 복사한 후, exec()시스템콜로 해당 내용을 변경하는 식으로 프로세스를 생성한다고 알고 있다.
하지만 fork()를 통해 기존 프로세스를 복사하지않고 바로 프로세스를 생성하는게 훨씬 효율적일거 같은데, 왜 번거롭게 복사 후 내용을 바꾸는 것인지 의문이 들었다.

fork()

프로세스를 copy하여 자식 프로세스를 생성하는 시스템콜이다.
POSIX 표준에 따르면, fork()의 용도는 두 가지이다.
  • 같은 프로그램에서 새로운 제어 흐름을 만드는 용도
  • 다른 프로그램을 실행하는 새 프로세스를 만드는 용도. 예를 들면 쉘에서 명령어를 통해 프로세스를 실행하면 쉘은 fork()를 통해 자식 프로세스를 만들고, exec()를 통해 주어진 명령에 해당하는 프로그램을 실행한다.
개인적으로 첫 번째 이유로 fork를 사용하는 경우는 많이 줄었을 것으로 생각된다. 대개 새로운 제어 흐름은 스레드를 통해 만들기 때문이다. 물론 스레드를 만드는 것보다 프로세스를 만드는 것이 안정성측면에서 더 나을 수 있기때문에 절대적으로 우월한 것은 아니다.
프로세스가 갖는 가상메모리 공간
프로세스가 갖는 가상메모리 공간
자식 프로세스가 생성되면서 부모 프로세스의 Stack, Heap, Data 뿐만 아니라 부모 프로세스의 PCB(Process Control Block)도 그대로 복사한다. 텍스트(text, 프로그램 코드)영역은 공유한다.

fork()를 사용하여 자식 프로세스를 생성하는 예제

아래 코드를 해석하면서 fork()의 대한 원리를 이해해보자.
#include <stdio.h>
#include <unistd.h>
#include <errno.h>

int global_count = 0; // Data영역에 할당

int main(int argc, const char * argv[]) {
	int local_count = 0; // Stack영역에 할당
	int pid = fork();

	if (pid == 0) {
		// 자식 프로세스에서 실행되는 영역
		global_count++;
		local_count++;
		printf("[Child] global count: %d(%p)\n", global_count, &global_count);
		printf("[Child] local count: %d(%p)\n", local_count, &local_count);
	} else {
		// 부모 프로세스에서 실행되는 영역
		printf("[Parent] global count: %d(%p)\n", global_count, &global_count);
		printf("[Parent] local count: %d(%p)\n", local_count, &local_count);
	}

	return 0;
}
// 실행결과
// [Child] global count: 1(0x10ddc5028)
// [Child] local count: 1(0x7ffee1e4283c)
// [Parent] global count: 1(0x10ddc5028)
// [Parent] local count: 0(0x7ffee1e4283c)
실행 결과를 보면 변수의 값은 다른데, 가리키는 메모리 주소는 동일하다. 왜 이렇게 되는지 알아보자.
  • fork가 성공하면 child 프로세스를 생성 → parent 프로세스에게는 child 프로세스의 PID 반환 / child 프로세스에게는 0 반환
위와 같은 과정을 거치고 각각의 프로세스에서 main함수가 실행되게 된다. (어느 프로세스가 먼저 실행될지는 알수 없다.)
그런데 child 프로세스도 같은 copy된 main프로세스를 실행하게 되니 pid_t pid = fork(); 가 또 실행되고 이게 무한히 반복되는 것은 아닌가? 하는 의문이 들었다. 하지만 위에서 적은 것처럼 부모의 PCB도 fork()를 통해 자식에게 복사된다(PPID와 PID만 다르다). 그리고 PCB에는 CPU에서 수행되던 레지스터의 값들이 저장되어 있고, 여기에는 Program Counter의 정보도 존재한다.따라서 child 프로세스에서도 fork 이후부터 코드가 실행된다.

fork()와 COW(Copy On Write) 정책

위 코드의 실행 결과를 이해하기 위해서는 우선 가상메모리와 COW(Copy On Write) 에 대한 지식이 필요하다.
실제로는 자식 프로세스가 생성될 때부터 Stack, Data, Heap 영역들의 데이터들에 대한 복사본을 갖게 되는 것은 아니다. fork()를 통해 생성된 자식 프로세스는 처음에는 동일한 내용을 가리키는 별개의 가상메모리 공간을 갖는다. 즉, 페이지테이블을 그대로 복사하고 실제 물리메모리의 같은 부분을 참조하고 있다는 것이다.
사실 가상메모리라는 것 자체가 모든 개별 프로세스가 상대 주소를 갖고, 실제 물리 주소를 페이지테이블을 이용해 운영체제가 관리하는 것이므로 위 내용은 당연한 내용이다.
fork() 후 부모 프로세스와 자식 프로세스가 갖는 가상메모리 공간
fork() 후 부모 프로세스와 자식 프로세스가 갖는 가상메모리 공간
이후 부모 프로세스나 자식 프로세스의 Stack, Data, Heap 중 어느 한 곳이라도 수정하게 되는 순간 새로운 메모리 공간을 할당하여 내용을 복사하는 COW(Copy On Write) 정책을 사용한다.

COW(Copy On Write)란?

영어 뜻 그래도 쓰기 작업 시 복사 가 일어나는 최적화 기술이다. 평소에는 Resource를 공유하다가, Resource를 수정할 경우가 발생할 때가 되서야 이전 Resource의 데이터를 실제로 복사하여 사용한다.
COW - process1에서 fork()가 호출된 후
COW - process1에서 fork()가 호출된 후
COW - Process1의 특정 페이지에서 수정이 발생한 경우
COW - Process1의 특정 페이지에서 수정이 발생한 경우

fork 코드의 해석과 비용 계산

이제 위 코드의 실행 결과에서 메모리 주소(가상 주소)는 동일한데, 값이 다른 이유를 이제 알 수 있게 되었다.
당연히 페이지 테이블 자체를 복사했으므로, 두 프로세스에서 변수가 갖는 상대 주소 자체는 동일하게 유지된다. 그리고 초기에는 두 상대 주소가 갖는 물리 메모리 주소도 동일할 것이다. 이후 한 프로세스의 변수에서 변경이 일어나면, 해당 프로세스의 페이지 테이블은 새로운 물리 메모리 주소를 가리키게 될 것이다. (COW) 변경된 변수에 대해서 두 페이지 테이블이 실제로 가리키는 물리 메모리 주소는 달라졌지만, 페이지 테이블의 주소(상대 주소)는 그대로 유지된 상태이다.
위 내용들을 바탕으로 최종적인 fork()의 비용은
  • 부모 프로세스의 페이지 테이블을 복사하는 비용
  • 자식 프로세스를 기술하기 위한 PCB를 할당받는 비용
뿐인 것을 알 수 있다. 처음부터 부모 프로세스의 모든 데이터들를 실제로 복사하는 게 아닌 것이다.

결론

이제 프로세스를 생성할거면 그냥 새로 만들면 되지, 왜 굳이 있는걸 복사해와서 다른걸로 바꿔주는 거야?에 대한 답을 할 수 있을 것 같다.
  1. 복제라는 작업 자체가 빈 메모리 공간에 새로운 공간을 할당하고, 새 프로그램을 로드하는 것보다 간단하다(템플릿을 가져다가 쓰는 것과 비슷한 느낌이다).
  1. Copy On Write 로 인해서 복제의 비용이 거의 들지 않는다.
  1. 부모 프로세스를 종료하면 자식 프로세스도 정리되는 것처럼, 관리의 용이성 측면도 있다.

참고