프로세스
-
전통적인 프로세스를 복제하는 방법은
fork
를 사용하는 방법이다. 이때 복제할 프로세스를 부모 프로세스라고 하고, 새롭게 복제된 프로세스를 자식 프로세스라고 한다. -
프로세스를 복제하는 이유는 멀티 태스킹을 위해서이다. 싱글 스레드에서, 3개의 태스크,
A, B, C
를 실행하는 구조를 멀티 프로세스 구조로 바꾸면 3개의 복제된 자식 프로세스에 일임하는 형식으로 분리할 수 있다. -
복제된 프로세스는 부모 프로세스와 독립적으로 작동하기 때문에, 복수개의 CPU가 설치된 경우에는 매우 뛰어난 응답성과 성능을 보여줄 가능성이 크다.
-
하지만, 복제된 프로세스 사이에 데이터를 주고 받는 구조이고, 데이터 통신 처리에 비용이 크다면 오히려 성능 하락이 발생할 수 있다.
-
프로세스 복제가 많이 쓰이는 경우로, 셸(
SHELL
)이 있다. 셸에서ls
명령을 실행한다고 가정할 때, 셸은 명령어를 받아들인 후에,fork
를 하여 자식 프로세스를 만든다. 그 후에 바로exec
를 호출하여bin/ls
프로그램 이미지로 교체하게 된다.
확장된 프로세스 실행 방법
-
새로운 프로세스 실행 방법이 있는데, 이 방법은 기존의
fork-exec
를 대체할 수 있는 기능으로서 더 가볍고 빠른 실행을 위해서 제안되었다. -
기존의
fork-exec
구조에서는fork
에서 부모 프로세스를 복제할 때, 모든 정적 정보를 복제한다. 예를 들어서 부모 프로세스의 힙 메모리, 정적 메모리, IPC 자원 ID, 열린 파일, 시그널 마스크 등이 포함된다. -
하지만
fork
를 하고 나서, 곧바로exec
를 호출하는 경우에는 대부분 부모 프로세스의 열린 파일이나,IPC
자원을 사용하지 않는 경우가 많다. 따라서 사용하지 않는 자원을 복제하는 오버헤드가 존재한다는 것이다. -
물론 한두 개의 프로세스가 저런 오버헤드를 가진다고 해도 시스템에 큰 문제가 없지만, 대형 시스템에서 엄청난 수의 프로세스가 실행되거나, 실시간 처리가 중요한 서비스라면 더더욱 큰 문제가 될 수 있다. 따라서
posix_spawn
에서는 부모 프로세스의 자원 중 6가지(열린 파일, 프로세스 그룹 ID, 유저 및 그룹 ID, 시그널 마스크, 스케줄링
)의 자원을 선택적으로 복제 및 관리할 수 있도록 디자인 되었다.
fork()
-
fork()
호출이 성공하면 프로세스가 복제되어 2개가 되고, 리턴값으로 정수인pid_t
타입을 리턴한다. -
이러한 리턴 값은 3가지의 반환 형태를 가지며 각각에 따라서 처리 방법을 다르게 코딩해야 한다.
0 -> 자식 프로세스에게 리턴되는 값
양수 -> 부모 프로세스에게 리턴되며, 자식 프로세스의 PID를 의미한다.
-1 -> 에러, 복제 실패
-
따라서 다음과 같이 0인 경우에는, 자식 프로세스가 실행할 부분을 코딩하고 양수인 부분은 부모 프로세스가 실행할 부분으로 코딩한다.
-
이렇게
fork()
를 사용하면 하나의 소스코드에 부모와 자식 프로세스의 코드가 같이 들어가게 된다. 그리고 부모 프로세스에는 자식 프로세스의 종료를 기다리기 위해서wait
,waitpid
를 이용할 수 있다.
switch (ret = fork()) {
case 0:
do_child(); /* 자식 프로세스인 경우에 실행될 코드 */
break;
case -1: /* 에러가 난 경우 */
do_errorcatc();
break;
default: /* 양수는 부모 프로세스이며 ret에 자식 프로세스의 PID가 저장됨 */
do_parent();
break;
}
-
일반적으로 위와 같이 부모 프로세스가 실행할 부분과, 자식 프로세스가 실행할 부분을 나누어서 코딩할 수 있다.
-
주의할 점은
fork()
를통해서 자식 프로세스가 분기하는 구조를 제대로 만들지 않으면 이상한 현상이 발생할 수 있다는 것이다.
#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
int main() {
int i = 0;
pid_t ret;
for (i = 0; i < 3; i++) {
ret = fork();
printf("[%d] PID(%d) PPID(%d)\n", i, getpid(), getppid());
#ifndef OMIT_SWITCH
switch (ret) {
case 0:
pause();
return 0;
case -1:
break;
default:
break;
}
#endif
}
wait(NULL);
return 0;
}
- 위의 코드를 보면, 전처리기 분기문 처리가 되어있어,
OMIT_SWITCH
매크로가 정의되어 있지 않는다면, 같이 빌드되는 부분이다.
gcc -DOMIT_SWITCH _Wall -o fork_omit_swich fork_process.c
- 위와 같이 매크로를 같이 빌드하면, 전처리기 분기문 처리가 빌드되지 않는다. 그리고 나서 프로그램을 실행하면 다음과 같은 결과를 얻을 수 있다.
- 총 7개의 프로세스가 실행되는 것을 확인할 수 있는데, 부모 프로세스에서 총 3개의 자식 프로세스를 생성하고, 또한 그 자식 프로세스들이 자식 프로세스를 생성하는 구조로 이루어진다.
exec(3) 계열 함수
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ..., char * const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execve(const char *path, char *const argv[], char *const envp[]);
-
exec
계열 함수는 현재 실행중인 프로세스의 이미지를 새로운 프로세스 이미지로 대체한다. 즉 쉽게 이야기하면 현재 프로세스에 실행중인 프로그램 파일을 로딩한다는 의미이다. -
프로세스 이미지가 대체되면 프로세스의 실행코드는 교체되지만, 기본적인
PID
,PPID
, 파일 기술자등 프로세스의 정보는 유지된다. -
exec
계열의 첫 번째 인수는 실행되어야 하는 프로그램 파일로서절대 경로나 상대 경로를 사용할 수 있다. 만일 경로가 생략되고 파일 명만 넣으면execl, execle, execv, execve
는 현재 작업 디렉터리에서 실행되어야 하는 프로그램 파일을 찾고,execlp, exevcp
는 환경 변수에 등록된 디렉터리를 검색하여 실행되어야 하는 프로그램 파일을 찾는다. -
execl
로 시작하는 함수는arg
라는 이름을 쓰고,execv
로 시작하는 함수는argv
를 사용하는 것을 알 수 있다. 이들의 차이는 execl계열은 실행할 파일의 인수 목록을 리스트로 받기 때문에 가변 인수 리스트를 가진다. 따라서 인수 리스트의 마지막을 알아내기 위해서 맨 끝은
NULL`로 끝내야 한다.
execl 계열을 사용한 예
execl("/bin/ls", "ls", "-al", NULL);
execlp("ls", "ls", "-al", NULL);
execv 계열을 사용한 예
char *argv_exec[] = {"ls", "-al", NULL};
execv("/bin/ls", argv_exec);
char *argv_exec[] = {"ls", "-al", NULL};
execvp("ls", argv_exec);
-
이 경우에는 기존의 환경변수는 모두 초기화되고 새로 넣은 환경 변수 벡터가 사용된다.
#include <stdio.h>
#include <unistd.h>
int main() {
if (execl("/bin/ls", "ls", "-al", NULL) == -1) {
perror("excel");
}
printf("+ arfter execl\n");
return 0;
}
-
위의 예제는 작동에는 문제가 없지만, 설계상의 의문이 있는 코드이다.
-
왜냐하면,
execl
실행되면서ls
로 프로세스 이미지를 교체하기 때문에 이후+ after execl
메시지는 화면에 출력될 일이 없기 때문이다.
상속되지 않는 파일 기술자
- 기본적으로는
exec
는 부모 프로세스의 파일 기술자를 복제한다. - 하지만, 부모 프로세스가
fork
를 하기 전에 특정 파일 기술자에fcntl
로FD_CLOEXEC
플레그를 지정하면exec
가 실행될 때 해당 파일 기술자는 닫히게 된다. 이를close-on-exec
라고 부른다.
forkexec_parent.c*
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <fcntl.h>
int main() {
pid_t pid_child;
printf("Parent[%d]: Start\n", getpid());
int fd = open("forkexec.log", O_WRONLY | O_CREAT | O_APPEND, 0644);
if (fd == -1) {
perror("FAIL: open");
exit(EXIT_FAILURE);
}
dprintf(fd, "Parent[%d]: Open log file(fd=%d)\n", getpid(), fd);
#ifdef APPLY_FD_CLOEXEC
int ret_fcntl;
if ((ret_fcntl = fcntl(fd, F_SETFD, FD_CLOEXEC)) == -1) {
perror("FAIL: fcntl(F_SETFD, FD_CLOEXEC)");
exit(EXIT_FAILURE);
}
#endif
/* fork-exec code */
char *argv_exec[] = {"fork_exec_child", (char*) NULL};
switch ((pid_child = fork())) {
case 0: /* child process */
execv(argv_exec[0], argv_exec);
break;
case -1: /* error */
perror("FAIL: FORK");
break;
default: /* parent process */
wait(NULL);
break;
}
printf("Parent[%d]: Exit\n", getpid());
return 0;
}
forkexec_child.c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>
int main() {
dprintf(STDOUT_FILENO, "Child[%d]: Start\n", getpid());
dprintf(3, "Child[%d]: fd(3): Test fd.\n", getpid());
close(3);
dprintf(STDOUT_FILENO, "Child[%d]: Exit\n", getpid());
return 0;
}
-
다음은 부모 프로세스에서, 파일을 생성하고 기록하고 있다. 그리고 나서, 자식 프로세스에서 파일 디스크립터를 받고 종료하는 코드이다.
-
생성된 파일을 살펴보면 다음과 같다.
Parent[5935]: Open log file(fd=3)
Child[5936]: fd(3): Test fd.
- 하지만, 부모 프로세스를 하기 전에 특정 파일 기술자에
fcntl
로FD_CLOEXEC
플래그를 지정하면, 해당 파일 기술자는 닫히게 된다. 따라서 이를 위해서APPLY_FD_CLOEXEC
매크로를 정의하고 빌드를 해보자.
gcc -DAPPLY_FD_CLOEXEC -o forkexec_parent_fdcloexec forkexec_parent.c
Parent[6014]: Open log file(fd=3)
-
매크로를 정의하고 나서는, 부모 프로세스가 자식 프로세스에게 파일 기술자를 상속하지 않으므로,
forkexec.log
파일에 자식 프로세스에는 기록되지 않았음을 확인할 수 있다. -
결론적으로는
fork-exec
를 이용할 때, 자식 프로세스가 사용하지 않는 파일이 복제되는 오버헤드를 피하고 싶다면FD_CLOEXEC
플래그 사용을 고려하는 것이 좋다. 하지만 더 근본적인 방법으로는fork-exec
대신에posix_spawn
을 사용하는 것이 더 좋다.
system 함수
-
system
함수는 셸을 실행시켜서 명령어를 실행하는 기능으로서,fork-exec
를 간단하게 구현한 형태이다. -
다만 중요한 차이가 있는데,
system
은 실행 명령어가 작동되는 동안에 부모 프로세스가 잠시 정지되고, 자식 프로세스의 정지, 종료 상태를 통보해주는SIGCHILD
도 블록되고 종료 시그널인SIGINT
,SIGQUIT
시ㅋ그널도 무시된다. -
따라서, 중요 시그널이 블록킹 되어, 종종 부모 프로세스가 무한 대기 상태에 빠지는 경우가 발생할 수 있으므로, 정말로 간단한 경우가 아니라면
fork-exec
로 구현하는 것을 추천한다.