프로세스


  • 전통적인 프로세스를 복제하는 방법은 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 
  • 위와 같이 매크로를 같이 빌드하면, 전처리기 분기문 처리가 빌드되지 않는다. 그리고 나서 프로그램을 실행하면 다음과 같은 결과를 얻을 수 있다.

result

  • 총 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를 하기 전에 특정 파일 기술자에 fcntlFD_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.
  • 하지만, 부모 프로세스를 하기 전에 특정 파일 기술자에 fcntlFD_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로 구현하는 것을 추천한다.

참고 문헌

>> Home