[c언어] 멀티 프로세스 프로그래밍
멀티프로세스의 이해
멀티프로세스는 운영 체제에서 하나의 프로그램이 여러 개의 프로세스를 생성하여 병렬 작업을 수행하는 기법입니다.
CPU는 여러 프로세스 또는 스레드가 마치 '동시에' 실행되는 것 처럼 행동합니다.
메모리 분리: 각 프로세스는 독립적인 메모리 공간을 할당받습니다. CPU는 메모리 상에 각각의 프로세스를 실행하는데 필요한 코드와 데이터를 로드합니다. 프로세스 간 메모리 영역은 운영체제에 의해 보호되며, 서로 접근할 수 없습니다.
싱글 코어 CPU의 경우 (중요!)
1. CPU
- 멀티 프로세스 실행: 실제로 싱글 코어 CPU는 하나의 프로세스만 실행할 수 있습니다. 따라서 CPU는 시간 분할(Time Slice) 방식으로 각 프로세스에 CPU 시간을 할당하고 빠르게 전환합니다. 이는 멀티태스킹처럼 보이지만, 실제로는 CPU가 각 프로세스를 번갈아 가며 실행하는 것입니다.
- 컨텍스트 스위칭: CPU는 한 프로세스를 실행하는 도중 다른 프로세스를 실행해야 할 경우, 현재 실행 중인 프로세스의 상태(State)를 저장하고, 새로 실행할 프로세스의 상태를 복원하는 컨텍스트 스위칭을 수행합니다. 이는 CPU의 오버헤드를 증가시킬 수 있습니다.
2. 레지스터
- 레지스터 저장: 각 프로세스는 실행 중에 CPU 레지스터를 사용하여 데이터를 처리합니다. 프로세스가 중단되고 다른 프로세스로 전환될 때, 해당 프로세스의 레지스터 값은 컨텍스트와 함께 저장됩니다. 새로운 프로세스가 실행될 때는 이전에 저장된 레지스터 값이 복원됩니다.
- CPU 상태 관리: 레지스터는 CPU의 가장 빠른 메모리로, 현재 실행 중인 프로세스의 상태와 관련된 중요한 정보를 저장합니다. 컨텍스트 스위칭 시 레지스터 값이 정확하게 저장되고 복원되지 않으면, 프로세스가 올바르게 실행되지 않을 수 있습니다.
싱글 코어 CPU에서 멀티 프로세스를 실행할 때, CPU는 각 프로세스에 할당된 시간을 번갈아 가며 처리하고, 프로세스 간에 메모리와 레지스터 상태를 관리하여 멀티태스킹을 구현합니다. 이때 메모리 보호와 레지스터 상태 저장/복원 등의 오버헤드가 발생할 수 있습니다.
멀티 코어 CPU의 경우 (예: 2 core)
1. CPU
- 멀티 코어 활용: 2코어 CPU에서는 두 개의 프로세스를 동시에 실행할 수 있습니다. 즉, 하나의 프로세스는 코어 1에서 실행되고, 다른 프로세스는 코어 2에서 실행되는 방식입니다. 이때, 멀티 프로세싱이 가능해지므로 병렬 처리가 이루어집니다.
- 작업 분배: 운영체제는 두 코어를 활용해 각 프로세스를 나누어 실행할 수 있습니다. 각 코어는 독립적으로 프로세스를 실행할 수 있지만, 부하 분배나 프로세스 간 동기화가 필요할 수 있습니다. 예를 들어, 특정 프로세스가 두 코어를 모두 활용하려면 스레드나 프로세스 간 통신(IPC)이 필요합니다.
- 컨텍스트 스위칭: 2코어 CPU에서도 컨텍스트 스위칭은 여전히 발생합니다. 단, 하나의 프로세스가 두 코어 중 하나에서 실행될 때, 다른 프로세스는 나머지 코어에서 실행되며, 각 프로세스가 번갈아 가며 코어를 할당받게 됩니다. 이 과정에서, 한 코어에서 실행 중인 프로세스는 다른 코어의 프로세스와 상호작용할 수 없습니다.
2. 레지스터
- 레지스터 상태: 각 CPU 코어에는 자체적인 레지스터 세트가 있습니다. 즉, 코어 1에서 실행되는 프로세스와 코어 2에서 실행되는 프로세스는 각각 별도의 레지스터 세트를 사용합니다. 이 경우, 두 코어가 독립적으로 실행되므로 서로의 레지스터를 공유하지 않습니다.
- 컨텍스트 스위칭 시 레지스터 저장: 각 프로세스의 레지스터 값은 해당 프로세스가 CPU에서 실행되는 동안 그 프로세스의 상태를 나타냅니다. 두 코어에서 각각 프로세스를 실행할 때, 각 코어는 해당 프로세스의 레지스터 값을 저장하고 복원합니다. 컨텍스트 스위칭이 발생하면, CPU는 프로세스의 레지스터 값을 저장하고 다른 프로세스의 레지스터 값을 복원합니다. 이때, 코어마다 다른 레지스터가 관리되므로 코어 간 컨텍스트 스위칭도 고려해야 합니다.
멀티코어 시스템에서 중요한 점은 부하 분배와 동기화로, 이는 운영체제의 스케줄링 및 프로세스 간 통신(IPC) 기법에 의해 처리됩니다.
프로세스와 시스템 호출
프로세스는 실행 중인 프로그램의 인스턴스입니다. 멀티프로세스를 구현하기 위해서는 프로세스를 생성하거나 관리할 수 있는 시스템 호출이 필요합니다. 아래는 주요 시스템 호출입니다
- fork(): 현재 프로세스(부모 프로세스)의 코드를 복제하여 자식 프로세스를 생성합니다. 반환값은 부모와 자식에서 다르게 동작합니다:
- 부모: 생성된 자식 프로세스의 PID 반환
- 자식: 0 반환
- exit(int status): 현재 프로세스를 종료하고 상태 코드를 반환합니다.
- getpid(): 현재 프로세스의 PID를 반환합니다.
- getppid(): 현재 프로세스의 부모 프로세스 PID를 반환합니다.
fork() 예제
void fork_example() {
pid_t pid = fork();
if (pid == 0) {
printf("This is the child process.\n");
} else {
printf("This is the parent process.\n");
}
}
프로세스 종료와 정리
프로세스 종료 시, 운영 체제는 프로세스가 점유한 자원을 반환해야 합니다. 이를 위한 함수와 메커니즘을 이해해야 합니다.
- atexit(handler): 프로세스 종료 시 실행할 함수를 등록합니다. 부모와 자식 모두 별도로 실행됩니다.
- 좀비 프로세스: 자식 프로세스가 종료되었지만, 부모가 이를 정리하지 않아 프로세스 정보가 남아 있는 상태입니다. wait()와 waitpid()를 사용하여 좀비 프로세스를 방지할 수 있습니다.
wait()와 waitpid()
- wait(): 하나의 자식 프로세스가 종료될 때까지 대기합니다.
- waitpid(pid, *status, options): 특정 자식 프로세스를 기다립니다. 옵션으로 WNOHANG을 설정하면 대기하지 않고 즉시 반환할 수 있습니다.
void wait_example() {
pid_t pid = fork();
if (pid == 0) {
exit(42);
} else {
int status;
wait(&status);
if (WIFEXITED(status)) {
printf("Child exited with status %d\n", WEXITSTATUS(status));
}
}
}
좀비 프로세스 예제
void zombie_example() {
pid_t pid = fork();
if (pid == 0) {
printf("Child process terminating. PID: %d\n", getpid());
exit(0);
} else {
printf("Parent process running. PID: %d\n", getpid());
while (1) {
sleep(1);
}
}
}
위 코드에서 부모 프로세스가 종료되지 않고 자식 프로세스의 종료를 처리하지 않으면 자식은 좀비 상태로 남게 됩니다. 이를 방지하려면 부모가 wait() 또는 waitpid()를 호출하여 자식의 종료를 처리해야 합니다.
고아 프로세스와 좀비 프로세스
고아 프로세스
고아 프로세스는 부모 프로세스가 먼저 종료되어 더 이상 부모가 없는 상태가 되는 프로세스입니다. 이 경우 운영 체제의 init 프로세스가 해당 고아 프로세스의 부모 역할을 대신하며, 자식 프로세스가 종료되면 init이 이를 수거하여 자원을 반환합니다. 고아 프로세스는 일반적으로 시스템에 문제를 일으키지 않으나, 프로세스 관리 구조가 복잡해질 수 있습니다.
좀비 프로세스
좀비 프로세스는 자식 프로세스가 종료된 후에도 부모가 자식의 종료 상태를 수거하지 않아 프로세스 테이블에 남아 있는 상태입니다. 좀비 프로세스는 시스템 자원을 소비하며, 지속적으로 남아 있으면 프로세스 테이블의 공간을 소모하여 새로운 프로세스 생성을 방해할 수 있습니다.
자원 수거와 wait()
운영 체제에서 좀비 프로세스를 방지하려면 부모 프로세스가 wait() 또는 waitpid()를 호출하여 자식 프로세스의 종료 상태를 확인하고, 이를 수거(reap)해야 합니다. 이 과정을 통해 자식 프로세스의 종료 상태가 부모에게 전달되며, 운영 체제가 프로세스 테이블에서 해당 정보를 제거합니다.
void prevent_zombie() {
pid_t pid = fork();
if (pid == 0) {
printf("Child process exiting.\n");
exit(0);
} else {
int status;
wait(&status); // 자식 프로세스 종료 상태를 수거
printf("Child process reaped.\n");
}
}
고아 및 좀비 방지
- 부모가 자식 종료를 wait() 또는 waitpid()로 처리해야 합니다.
- 자식 프로세스가 SIGCHLD 신호를 통해 부모에게 자신의 종료를 알리도록 설정할 수 있습니다.
- 부모가 비동기로 자식 종료를 처리하려면 signal(SIGCHLD, handler)와 같은 방식으로 핸들러를 등록하면 됩니다.
비동기 종료 처리 예제
void sigchld_handler(int signum) {
while (waitpid(-1, NULL, WNOHANG) > 0);
}
void async_wait_example() {
signal(SIGCHLD, sigchld_handler);
if (fork() == 0) {
printf("Child process exiting.\n");
exit(0);
}
printf("Parent process continuing without waiting.\n");
sleep(5); // 부모는 다른 작업 수행 가능
}
exec 패밀리 함수
exec 함수는 현재 프로세스를 새로운 프로그램으로 대체합니다. 반환값은 없으며, 호출이 성공하면 기존 코드의 실행은 종료됩니다.
- execl(): 인자를 리스트로 전달합니다.
- execv(): 인자를 배열로 전달합니다.
- execlp(): 파일명을 경로 없이 전달 가능합니다.
- execle(): 환경 변수를 명시적으로 설정 가능합니다.
- execvp(): 배열 인자와 경로 없이 파일명 사용 가능합니다.
- execvpe(): 배열 인자와 환경 변수를 사용할 수 있습니다.
exec 예제
void exec_example() {
char *argv[] = {"ls", "-l", NULL};
execvp("ls", argv);
perror("exec failed"); // exec 호출 실패 시 실행
}
종합 예제: 간단한 쉘 구현
아래는 execve를 활용하여 간단한 쉘을 구현한 예제입니다:
void eval(char* cmdLine) {
char* argv[100];
pid_t pid;
if (parse_command(cmdLine, argv) == 0) {
if ((pid = fork()) == 0) {
if (execve(argv[0], argv, environ) < 0) {
perror("exec failed");
exit(1);
}
}
wait(NULL);
}
}