Home

Published

- 19 min read

[ OS ] 1. 프로세스의 생성

img of [ OS ] 1. 프로세스의 생성

운영체제가 프로그램을 실행하는 방식(프로세스가 생성되는 방식)

image

결론부터 이야기하면

  1. 프로그램 코드, 정적 데이터 프로세스의 주소공간에 탑재(load)
  2. 특정 크기의 메모리 공간이 프로그램에 스택 용도로 할당
  3. 프로그램의 힙을 위한 메모리 영역 할당
  4. 입출력과 관계된 초기화 작업
  5. 프로그램의 시작지점(entry point), 즉 main()에서부터 프로그램 실행

이 프로그램이 프로세스로 변형되는 과정이다.

프로그램 실행을 위해서 운영체제가 하는 첫 번째 작업은 프로그램 코드와 정적 데이터(static data, 예를 들어 초기값을 가지는 변수)를 메모리, 프로세스의 주소 공간에 탑재(load) 하는 것이다. 프로그램은 디스크 또는 요즘 시스템에서는 플래시 기반 SSD에 특정 실행 파일 형식으로 존재한다. 코드와 정적 데이터를 메모리에 탑재하기 위해서 운영체제는 디스크의 해당 바이트를 읽어서 메모리의 어딘가에 저장해야 한다

현대의 운영체제는 프로그램을 실행하면서 코드나 데이터가 필요할 때 필요한 부분만 메모리에 탑재한다.(일종의 캐싱) 자세한 방법은 이후 페이징(paging), 스와핑(swapping) 동작의 이해가 필요하다.

코드와 정적 데이터가 메모리로 탑재된 후, 프로세스를 실행시키기 전에 운영체제가 해야할 몇가지 있다. 특정 크기의 메모리 공간이 프로그램에 스택(run-time stack, or just stack) 용도로 할당되어야 한다. 이미 알고 있겠지만 C 프로그램은 지역 변수, 함수 인자, 리턴 주소 등을 저장하기 위해 스택을 사용한다. 운영체제는 스택을 주어진 인자로 초기화한다. 특히, main() 함수의 인자인 argc와 argv 벡터를 사용하여 스택을 초기화한다.

운영체제는 프로그램의 힙(heap)을 위한 메모리 영역을 할당한다. C 프로그램에서 힙은 동적으로 할당된 데이터를 저장하기 위해 사용된다. 프로그램은 malloc()을 호출하여 필요한 공간을 요청하고 free()를 호출하여 사용했던 공간을 반환하여 다른 프로그램이 사용할 수 있도록 한다. 힙은 연결 리스트, 해시 테이블, 트리 등 크기가 가변적인 자료 구조를 위해 사용된다. 프로그램이 실행되면 malloc() 라이브러리 API를 호출하여 메모리를 요청하고, 운영체제가 이를 충족하도록 메모리를 할당한다.

운영체제는 또 입출력과 관계된 초기화 작업을 수행한다. 예를 들어, Unix 시스템에서 각 프로세스는 기본적으로 표준 입력(STDIN), 표준 출력(STDOUT), 표준 에러(STDERR) 장치에 해당하는 세 개의 파일 디스크립터(file descriptor)를 갖는다. 이 디스크립터들을 사용하여 프로그램은 터미널로부터 입력을 읽고 화면에 출력을 프린트하는 작업을 쉽게 할 수 있다.

코드와 정적 데이터를 메모리에 탑재하고, 스택과 힙을 생성하고 초기화하고, 입출력 셋업과 관계된 다른 작업을 마치게 되면, 운영체제는 프로그램 실행을 위한 준비를 마치게 된다. 프로그램의 시작 지점(entry point), 즉 main()에서부터 프로그램 실행을 시작하는 마지막 작업만이 남는다. main() 루틴으로 분기함으로써 운영체제는 CPU를 새로 생성된 프로세스에게 넘기게 되고 프로그램 실행이 시작된다.

프로세스 API

  1. 프로그램 코드, 정적 데이터 프로세스의 주소공간에 탑재(load)
  2. 특정 크기의 메모리 공간이 프로그램에 스택 용도로 할당
  3. 프로그램의 힙을 위한 메모리 영역 할당
  4. 입출력과 관계된 초기화 작업
  5. 프로그램의 시작지점(entry point), 즉 main()에서부터 프로그램 실행

앞서 말한 5개의 과정 중에서 프로세스 API는 5번에 관여한다. 다시 말해, 프로세스 API는 프로세스의 생성, 실행, 종료, 통신에 관여한다.

Unix는 프로세스를 생성하기 위해서 fork()와 exec() 시스템 콜을 사용한다. wait()는 프로세스가 자신이 생성한 프로세스가 종료되기를 기다리기 원할 때 사용된다.

**시스템 콜(System Call)?**시스템 콜(System Call)은 유저 모드(User Mode)와 커널 모드(kernel Mode)간의 인터페이스로 작동한다. 여기서 유저 모드는 응용 프로그램이 실행되는 환경이고, 커널 모드는 운영체제의 핵심인 커널이 실행되는 환경이다.유저 모드 응용 프로그램이 실행되는 환경이다. 응용 프로그램은 시스템 콜을 사용하여 운영체제의 서비스에 접근한다. 이를 통해 파일 읽기/쓰기, 메모리 할당, 네트워크 통신 등과 같은 다양한 작업을 할 수 있다. 유저 모드에서는 프로세스가 직접 실행되고, 커널의 기능에 접근할 수 없다.커널 모드(Kernel Mode) 운영체제의 핵심인 커널이 실행되는 환경이다. 시스템 콜은 유저 모드에서 실행중인 프로세스의 요청에 응답하여 커널 모드로 전환된다. 커널 모드에서는 시스템 콜에 의해 요청된 작업을 처리하고, 시스템 자원을 관리하며, 프로세스 간의 보호와 권한 관리를 수행한다. 커널 모드는 운영 체제의 핵심 기능을 실행하기 위해 더 높은 권한을 가지며, 직접적으로 하드웨어와 상호 작용할 수 있다.

fork() 시스템 콜

운영체제는 프로세스 생성을 위해 fork 시스템 콜을 제공한다.

   // p1.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
	printf("hello world (pid: %d)\n", (int) getpid());
	int rc = fork();
	if (rc < 0){
		fprintf(stderr, "fork failed\n");
		exit(1);
	} else if (rc == 0){
		printf("hello, I am child (pid:%d)\n", (int) getpid());
	} else {
		(main)
		printf("hello, I am parent of %d (pid:%d)\n", rc, (int) getpid());
	}
	return 0;
}

실행 결과

   prompt> ./p1
hello world (pid: 29416)
hello, I am parent of 29147 (pid:29146)
hello, I am child(pid:29147)
prompt>

코드와 함께 직접적으로 설명하려 한다.

프로세스는 fork() 시스템 콜을 호출한다. 생성된 프로세스는 호출한 프로세스의 복사본이다. fork() 호출 직후를 보자.

운영체제 입장에서 보면 프로그램 p1이 2개가 존재한다. 두 프로세스가 모두 fork()에서 리턴하기 직전이다. 새로 생성된 프로세스는 부모 프로세스라 불린다.(일반적으로 자식 프로세스가 존재하고, 생성된 프로세스를 부모 프로세스라 한다.) 부모 프로세스는 main()함수 첫 부분부터 시작하지 않았다. 자식 프로세스는 fork()를 호출하면서부터 시작되었다

그리고 보면 출력 순서가 이상한 걸 알 수 있다. 코드로는 child가 먼저 나오는게 맞는데 왜 이런 결과가 나올까? fork() 실행시점부터 프로세스의 분기가 일어났기때문에 누가 스케줄링이 될지는 아무도 모른다. 따라서 순서가 뒤죽박죽이다. wait라던가 다른 시스템콜이 있거나, 프로세스의 함수실행부분에서 다르게 처리하지 않는 이상 결과는 계속 섞여서 나온다.

PID는 프로세스의 실행이나 중단과 같이 특정 프로세스를 대상으로 작업을 해야 할 경우 프로세스를 지칭하기 위해 사용한다.

자식 프로세스와 부모 프로세스는 동일하지 않다. 자식 프로세스는 자신의 주소 공간, 자신의 레지스터, 자신의 PC값을 갖는다. 그리고 자식 프로세스와 부모 프로세스는 fork() 시스템의 반환 값이 다르다. fork()로부터 부모 프로세스는 생성된 자식 프로세스의 PID를 반환받고, 자식 프로세스는 0을 반환받는다. 이 반환값의 차이로 인해, 부모와 자식 프로세스가 서로 다른 코드를 실행하는 프로그램을 쉽게 작성할 수 있다.

CPU 스케줄러(scheduler)는 실행할 프로세스를 선택한다. 스케줄러의 동작은 일반적으로 상당히 복잡하고 상황에 따라 다른 선택이 이루어지기 때문에, 어느 프로세스가 먼저 실행된다라고 단정하는 것은 매우 어렵다. 이 비결정성으로 인해 멀티 쓰레드 프로그램 실행 시 다양한 문제가 발생한다.

wait() 시스템 콜

   //p2.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main(int argc, char *argv[])
{
	printf("hello world (pid: %d) \n", (int) getpid());
	int rc = fork();
	if (rc < 0) {
		fprintf(stderr, "fork failed\n");
		exit(1);
	} else if (rc == 0) {
		printf("hello, I am child (pid: %d)\n", (int) getpid());
	} else {
		(main)
		int rc_wait = wait(NULL);
		printf("hello, I am parent of %d (rc_wait: %d) (pid: %d)\n", rc, rc_wait, (int) getpid());
	}
	return 0;
}
   prompt> ./p1
hello world (pid:29146)
hello, I am child (pid:29147)
hello, I am parent of 29147 (pid:29146)
prompt>

부모 프로세스가 자식 프로세스의 종료를 대기해야 하는 경우도 발생할 수 있다. 이러한 작업을 위해 wait() 시스템 콜이 있다. 이 코드에서 부모 프로세스는 wait() 시스템 콜을 호출하여 자식 프로세스 종료 시점까지 자신의 실행을 잠시 중지시킨다. 자식 프로세스가 종료되면 wait() 은 리턴한다.

exec() 시스템 콜

   // p3.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>

int main(int argc, char *argv[])
{
	printf("hello world (pid:%d)\n", (int) getpid());
	int rc = fork();
	if (rc < 0){
		fprintf(stderr, "fork failed\n");
		exit(1);
	} else if (rc == 0) { // 자식(새 프로세스)
		printf("hello. I am child (pid:%d)\n", (int) getpid());
		char *myargs[3];
		myargs[0] = strdup("wc"); // 프로그램: "wc" (단어 세기)
		myargs[1] = strdup("p3.c"); // 인자: 단어 셀 파일
		myargs[2] = NULL;          // 배열의 끝 표시
		execvp(myargs[0], myargs); // "wc" 실행
		printf("this shouldn't print out");
	} else { // 부모 프로세스는 이 경로를 따라 실행한다
		int rc_wait = wait(NULL);
		printf("hello, I am parent of %d (rc_wait:%d) (pid:%d) \n", rc, wc, (int) getpid());
	}
	return 0;
}
  • 실행 결과
   prompt> ./p3
hello world (pid:29384)
hello, I am child(pid:29384)
	29    107    1030    p3.c
hello, I am parent of 29384 (rc_wait:29384) (pid:29383)
prompt>

exec() 시스템 콜은 자기 자신이 아닌 다른 프로그램을 실행해야 할 때 사용한다. p2.c 의 fork() 시스템 콜은 자신의 복사본을 생성하여 실행한다. 자신의 복사본이 아닌 다른 프로그램을 실행해야 하는 경우에는 바로 exec() 시스템 콜이 그 일을 한다.

이 예에서 자식 프로세스는 wc 프로그램을 실행하기 위해 execvp() 시스템 콜을 호출한다. wc 프로그램은 단어의 개수를 세는 프로그램이다. 사실 이 예제 프로그램은 자신의 소스 파일인 p3.c를 인자로 하여 wc를 실행하고 소스 코드의 행 개수, 단어의 개수, 바이트의 개수를 알려준다.

exec() 시스템 콜은 다음과 같은 과정으로 수행된다. 실행 파일의 이름과 약간의 인자가 주어지면 해당 실행 파일의 코드와 정적 데이터를 읽어 들여 현재 실행중인 프로세스의 코드 세그멘트와 정적 데이터 부분을 덮어 쓴다. 힙과 스택 및 프로그램 다른 주소 공간들로 새로운 프로그램의 실행을 위해 다시 초기화된다. 그런 다음 운영체제는 프로세스의 argv와 같은 인자를 전달하여 프로그램을 실행시킨다. 새로운 프로세스를 생성하지는 않는다. 현재 실행중인 프로그램을 다른 실행중인 프로그램으로 대체하는 것이다. 자식 프로세스가 exec()을 호출한 후에는 p3.c는 전혀 실행되지 않은 것처럼 보인다. exec() 시스템 콜이 성공하게 되면 p3.c는 절대로 리턴하지 않는다.

   // p4.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <fcntl.h>
#include <sys/wait.h>

int main(int argc, char *argv[])
{
	int rc = fork();
	if (rc < 0){
		fprintf(stderr, "fork failed\n");
		exit(1);
	} else if (rc == 0) {
		close(STDOUT_FILENO); // 표준 출력 파일이 닫혔기 때문에 첫번째 사용가능 파일 디스크립터
		open("./p4.output", O_CREATE|O_WRONLY|O_TRUNC, S_IRWXU);

		char *myargs[3];
		myargs[0] = strdup("wc");
		myargs[1] = strdup("p4.c");
		myargs[2] = NULL;
		execvp(myargs[0], myargs);
	} else {
		int rc_wait = wait(NULL);
	}
	return 0;
}
  • 실행 결과
   prompt> ./p4
prompt> cat p4.output
	32    109    846    p4.c
prompt>

파일 디스크럽터(File Descriptor)는 운영 체제에서 파일이나 소켓, 파이프 등의 I/O 리소스를 식별하는 데 사용되는 정수이다. 대부분의 운영 체제는 파일 디스크럽터를 사용하여 파일을 열고, 읽고, 쓰고, 닫는다. 주로 세 개의 표준 파일 디스크럽터가 사용되며, 다음과 같이 정의된다stdin (Standard Input): 프로그램에 입력을 제공하는 파일 디스크럽터이다. 일반적으로 키보드 입력이 이에 해당한다. 파일 디스크럽터 번호는 보통 0이다. stdout (Standard Output): 프로그램에서 출력하는 파일 디스크럽터이다. 일반적으로 터미널에 출력된다. 파일 디스크럽터 번호는 보통 1이다. stderr (Standard Error): 프로그램에서 오류를 출력하는 파일 디스크럽터이다. 일반적으로 터미널에 출력된다. 파일 디스크럽터 번호는 보통 2이다.이러한 표준 파일 디스크럽터 외에도, 프로그램이 파일을 열 때마다 새로운 파일 디스크럽터가 할당된다. 이 파일 디스크럽터를 통해 프로그램은 파일에 대한 읽기, 쓰기, 닫기 등의 작업을 수행할 수 있다. 파일 디스크럽터는 정수 값으로 표현되며, 0 이상의 값으로 할당된다.