프로세스 관련 API

Soyun Park·2023년 10월 31일
0
post-thumbnail

1. 기본적인 프로세스 API

1-1. 프로세스 기본 동작

  • 셸에서 프로그램을 기동시키면 그것이 프로세스가 된다.
  • 먼저 셸이 프로그램을 실행할 때 프로세스를 다루는 기본 API는 다음과 같다.
    • fork(2)
    • exec(2)
    • wait(2)

1-2. fork(2)

#include <unistd.h>
pid_t fork(void);
  • fork()을 호출하면 커널은 그 프로세스를 복제해서 프로세스를 두 개로 분리한다.
  • 기존의 프로세스를 부모 프로세스, 복제해서 만들어진 프로세스를 자식 프로세스라고 한다.
  • 분리된 시점에서 모두 fork() 호출이 끝난 상태로 이후의 코드가 실행된다.
  • 성공 시 자식 프로세스는 0을, 부모 프로세스는 자식 프로세스의 PID를 반환한다. 실패 시 부모 프로세스가 -1을 반환한다.

1-3. exec

#include <unistd.h>
int execl(const char *path, const char *args, ... /* NULL */);
int execlp(const char *program, const char *args, ... /* NULL */);
int execle(const char *path, const char *arg, ..., /* NULL, */ char * const envp[]);
int execv(const char *path, char * const argv[]);
int execvp(const char *program, char * const argv[]);
int execve(const char *path, char * const argvs[], char * const envp[]);
  • exec를 실행하면 현재 실행 중인 프로그램이 소멸하고 새로운 프로그램을 로드하여 실행한다.
  • 사용 예는 fork()하고 즉시 exec하는 것이다. 이것으로 새 프로그램을 실행한다.
  • 인자와 환경변수, 전달 방법에 따라 차이가 있는데 보통 이를 합쳐서 exec 또는 exec 계열이라고 부른다.
  • API 이름 뒷 부분에 l 이 붙은 경우는 실행할 프로그램의 실행 인자를 함수의 가변 인자로 전달한다. 마지막에는 명시적으로 NULL을 넣어야 한다.
    // execl() 사용 예
    execl("/bin/cat", "cat", "hello.c", NULL);
  • v 가 붙은 경우는 실행 인자를 문자열 배열로 전달한다. 마지막에는 명시적으로 NULL을 넣어야 한다.
    // execv() 사용 예
    char *argv[3] = {"cat", "hello.c", NULL};
    execv("/bin/cat", argv);
  • e 가 붙은 경우 마지막 인자로 환경 변수인 envp가 추가된다. 붙지 않은 경우는 현재 프로세스의 환경 변수를 그대로 사용한다.
  • p 가 붙은 경우 셸과 같이 첫 번째 인자인 program을 환경 변수 PATH에서 찾는다. 붙지 않은 경우 첫 번째 인자인 path를 절대 경로 또는 상대 경로로 지정한다.
  • 성공 시 호출이 돌아오지 않는다. 실패 시 -1을 반환하고 errno에 에러 번호가 설정된다.

1-4. wait(2)

#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);
  • wait()는 자식 프로세스 중 어느 하나가 끝나는 것을 기다린다.

  • waitpid()는 첫 번째 인자로 지정한 pid에 해당하는 프로세스가 끝나길 기다린다.

  • status는 자식 프로세스의 종료 상태를 얻기 위해 사용한다.

  • 종료 상태란 종료 방법을 나타내는 플래그와 exit()의 종료 코드를 합성한 값으로 매르로를 사용하여 개별 값을 얻을 수 있다.

    매크로의미
    WIFEXITED(status)exit로 종료했으면 0이 아닌 값
    WEXITSTATUS(status)exit로 종료한 경우, 종료 코드를 반환
    WIFSIGNALED(status)시그널로 종료했으면 0이 아닌 값
    WTERMSIG(status)시그널로 종료했으면 시그널 번호를 반환

1-5. 프로그램 실행

  • 프로그램 실행하고 결과를 기다리는 작업을 수행해보자.
    1. fork()한다.
    2. 자식 프로세스에서 새로운 프로그램을 exec한다.
    3. 부모 프로세스는 자식 프로세스를 wait한다.

1-6. 프로그램 만들기

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

int main(int argc, char *argv[]){
    pid_t pid;

    if (argc != 3) {
        fprintf(stderr, "Usage: %s <command> <arg>\n", argv[0]);
        exit(1);
    }
    pid = fork();
    if (pid < 0) {
        fprintf(stderr, "fork(2) failed\n");
        exit(1);
    }
    if (pid == 0) { /* 자식 프로세스 */
        execl(argv[1], argv[1], argv[2], NULL);
        /* execl()는 성공하면 반환되지 않으므로, 함수가 반환된 경우는 전부 실패 */
        perror(argv[1]);
        exit(99);
    }
    else {          /* 부모 프로세스 */
        int status;

        waitpid(pid, &status, 0);
        printf("child (PID=%d) finished; ", pid);
        if (WIFEXITED(status))
            printf("exit, status=%d\n", WEXITSTATUS(status));
        else if (WIFSIGNALED(status))
            printf("signal, sig=%d\n", WTERMSIG(status));
        else
            printf("abnormal exit\n");
        exit(0);
    }
}
  • 첫 번째 인자는 실행할 프로그램의 경로, 두 번째 인자를 실행할 프로그램에 전달한 인자로 해석하여 실행한다.
  • 프로그램이 종료하면 그 종료 방법을 출력한다.
  • fork()는 자식 프로세스와 부모 프로세스 양쪽 모두에서 출력이 반환된다.

1-7. 작성한 프로그램 실행 예

  • 존재하지 않는 프로그램을 지정하면 다음과 같이 exec가 실패한다.



2. 프로세스의 생애

2-1. _exit(2)

#include <unistd.h>
void _exit(int status);
  • _exit()는 인자로 지정한 status를 자발적으로 종료한다.
  • 절대 실패하지 않는다.

2-2. exit(3)

#include <stdlib.h>
void exit(int status);
  • exit()는 인자로 지정한 status를 종료 상태로 하여 프로세스를 종료한다.
  • 절대 실패하지 않는다.

2-3. _exit(2)와 exit(3)의 차이

  • exit()는 stdio의 버퍼를 전부 해제한다.
  • exit()는 atexit()로 등록된 처리를 실행한다.
  • exit()의 경우 libc의 함수이기 때문에 libc와 관련한 각종 뒤처리를 수행한다.
  • _exit()는 시스템 콜이기 때문에 libc에 대한 뒤처리를 할 수 없다.

2-4. 종료 상태

  • exit(0)이나 exit(1)처럼 상숫값을 직접 넣어서 사용하는데 리눅스에선 가능하지만 그 외의 시스템에서는 다를 수도 있다.
  • 성공과 실패를 표현한다면 EXIT_SUCCESSEXIT_FAILURE 라는 매크로를 사용하는게 좋다.

2-5. 프로세스의 생애

  1. fork()로 시작하여 exec하면 자식 프로세스가 수행된다.
  2. 자식 프로세스가 _exit()로 종료되면 그 종료 상태는 부모 프로세스의 wait()가 받아들인다.


2-6. 좀비 프로세스

  • 커널은 자식 프로세스의 실행이 끝닜어도 부모 프로세스가 종료되거나 wait()를 호출할 때까지 상태 코드를 보관한다.
  • 자식 프로세스의 실행이 종료되었지만 부모 프로세스가 wait()를 호출하지 않았을 때 커널이 상태 코드를 보관하고 있는 자식 프로세스를 좀비 프로세스라고 한다.
  • ps 명령어에 zombie 또는 defunct 라고 표시된다.

2-7. 좀비 프로세스의 대안

  • 데몬 프로세스처럼 장시간 작동하는 프로세스의 자식 프로세스가 좀비가 되면 커널의 부담이 커지므로 시스템에 좋지 않다.

  • 데몬 프로세스와 같이 자식 프로세스가 좀비가 되지 않도록 하는 방법은 다음과 같다.

    1. fork()하면 wait()함
    • 정석적으로 부모 프로세스가 wait()하는 책임을 가진다.
    1. 이중 fork
    • 도중에 여분의 fork()를 끼워 넣는다.
    • 이중 fork 상태가 되면 손자 프로세스의 부모 프로세스자식 프로세스가 없어지면서 wait()을 기다릴 필요가 없어진다.
    • 따라서 손자 프로세스가 좀비 프로세스가 되지 않고 종료하면 즉시 정리된다.

    1. sigaction()을 사용
    • sigaction()이라는 API를 사용해서 wait()하지 않도록 커널에 알리는 방법이다.


3. 파이프

3-1. 파이프

  • 리눅스 셸은 여러 프로세스 사이를 파이프로 연결할 수 있다.
  • 파이프는 프로세스와 프로세스 간에 연결된 스트림이다.
  • 파일에 연결된 스트림처럼 파이프도 파일 디스크립터를 사용하여 표현된다.
  • 파이프는 단방향이라서 하나의 파일 디스크립터에 대해서는 읽기나 쓰기 둘 중 하나만 할 수 있다.


3-2. pipe(2)

#include <unistd.h>
int pipe(int fds[2]);
  • pipe()는 호출한 프로세스에 연결된 스트림을 만들고 양쪽의 파일 디스크립터 두 개를 인자로 전달한 fds에 써넣고 반환한다.
  • fds[0]은 읽기 전용, fds[1]은 쓰기 전용이다.


3-3. 부모/자식 프로세스 사이를 파이프로 연결하기

  • fork()를 하면 프로세스가 복제되면서 스트림도 모두 복제된다. 즉 pipe()는 fork()와 결합해야 비로소 의미가 있다.

  • 부모가 읽는 측을 close()하고 자식이 쓰는 측을 close()하면 아래와 같이 연결된 파이프가 구성된다.


3-4. dup(2), dup2(2)

#include <unistd.h>
int dup(int oldfd);
int dup2(int oldfd, int newfd);
  • 파일 디스크립터로 파이프를 연결하기 위해 표준 입력과 표준 출력을 파이프로 연결할 필요가 있다.
  • dup()와 dup2()는 파일 디스크립터 oldfd를 복제한다.
  • dup()는 아직 사용하지 않은 제일 작은 값의 파일 디스크립터에 oldfd를 복제한다.
  • dup2()는 파일 디스크럽터 oldfd를 지정한 파일 디스크립터 newfd에 복제한다.
  • 복제한다는 것은 하나의 스트림을 커널 안에서 두 개로 분기하는 것을 의미한다.

  • 위와 같이 구성하면 파일 디스크립터 6에 작업을 하면 파일 디스크립터 5에 작업을 한 것과 동일한 효과가 있다.
  • lseek()로 파일 오프셋 작업을 하는 경우에도 양쪽에 동일하게 적용된다.
  • close()할 때는 양쪽 모두를 close()해야 비로소 정리된다.
  • 파일 디스크립터에 파이프를 연결하는 예시는 다음과 같다. 3번에 연결된 파이프를 0번으로 옮기고 싶다면 다음과 같은 순서로 API를 사용한다.
    1. close(0);
    2. dup2(3,0);
    3. close(3);

3-5. popen(3)

#include <stdio.h>
FILE *popen(const char *command, const char *mode);
  • popen()은 command로 지정한 프로그램을 기동하고 거기에 파이프를 연결하여 해당 스트림을 반환한다. 실행 시 NULL을 반환한다.
  • mode는 문자열 r은 읽기용, w 쓰기용이 있다. 동시에 할 수는 없다.
  • popen()에서는 프로그램이 셸을 거쳐 실행되므로 첫 번째 인자로 지정하는 command는 PATH에서 찾을 수 있고 리디렉션이나 파이프도 사용할 수 있다.

3-6. pclose(3)

#include <stdio.h>
int pclose(FILE *stream);
  • pclose()는 popen()으로 fork()한 자식 프로세스를 wait()하고 그 후에 스트림을 닫는다.



4.프로세스 관계

4-1. 부모/자식 관계

  • 리눅스에서는 어떠한 프로세스도 fork()나 이와 비슷한 API로 생성된다.
  • pstree 명령어를 사용하여 부모/자식 관계를 하나의 트리 구조로 표시할 수 있다.

  • 왼쪽 끝에 systemd는 부팅 시 커널이 직접 실행시키는 프로그램이자 모든 프로세스의 시작이다.

4-2. getpid(2), getppid(2)

#include <sys/types.h>
#include <unistd.h>
pid_t getpid(void);
pid_t getppid(void);
  • getpid()는 자신의 PID를 반환하고 getppid()는 부모 프로세스의 PID를 반환한다.

4-3. 다른 프로세스 정보

  • 리눅스의 경우 프로세스 파일 시스템 /proc 에서 얻을 수 있다.
  • ps 명령어나 pstree 명령어도 프로세스 파일 시스템을 사용해서 정보를 모은다.


4-4. 프로세스 그룹과 세션

  • 리눅스에는 프로세스를 체계적으로 관리할 수 있는 프로세스 그룹세션이라는 개념이 있다.
  • 모든 프로세스는 하나의 프로세스 그룹과 세션에 소속되어있다.
  • 셸을 사용해 여러 개의 명령어를 파이프로 연결해서 기동한다. 이 때 한 명령어에 문제가 있어 Ctrl+c로 중단한다면 모든 프로세스가 멈춰야 한다.
  • 프로세스 그룹은 파이프로 연결된 프로세스 집단을 하나의 프로세스 그룹으로 통합하여 그룹에 속한 모든 프로세스에 시그널을 통합하여 보낼 수 있다.
  • 세션은 로그인 셸을 기점으로 사용자가 동일 단말로부터 기동한 프로세스를 하나로 통합 관리하기 위한 개념이다. 이 때 세션과 연결된 단말을 프로세스의 제어 단말이라고 한다.
  • 그 결과 하나의 세션은 복수의 프로세스 그룹을 통합하는 형태가 된다.


4-5. 프로세스 그룹과 세션 리더

  • ps 명령어에 j 옵션으로 실행하면 프로세스 그룹이나 세션을 볼 수 있다.
  • PID와 PGID가 같은 프로세스는 프로세스 그룹 리더이다. 처음으로 그 프로세스 그룹을 만든 프로세스가 리더가 된다.
  • PID와 SID가 같은 프로세스는 세션 리더이다. 마찬가지로 처음 그 세션을 만든 프로세스가 세션 리더가 된다.
  • 프로세스 그룹 리더와 세션 리더는 새로운 프로세스 그룹이나 세션을 만들 수 없다는 제한이 있다.

4-6. 데몬 프로세스

  • ps 명령어에 -ef 옵션으로 실행하면 시스템에서 작동하고 있는 모든 프로세스를 표시한다.
  • TTY 항목이 ? 로 되어 있는 프로세스는 제어 단말이 없는 프로세스이고 이를 데몬 프로세스 또는 데몬이라고 한다.
  • 데몬 프로세스는 서버, 즉 다른 프로세스에 어떤 서비스를 제공하기 위해 존재한다. 서버는 컴퓨터가 동작하는 동안 항상 작동하고 있어야 한다.
  • 일반적인 프로세스는 만든 사람이 로그아웃하면 종료된다. 따라서 로그아웃한 단말이 제어 단말로 지정된 모든 프로세스가 멈춘다.
  • 서버 프로세스를 기동한 사람은 로그아웃하지 말아야하는 제약이 생기는데 이를 데몬 프로세스가 해결한다.
  • 프로세스가 어떠한 단말과도 관계를 맺지 않고 동작하여 서버를 기동한 사람이 자유롭게 로그아웃할 수 있고 서버를 종료하는 일도 없어진다.

4-7. setpgid(2)

#include <unisted.h>
int setpgid(pid_t pid, pid_t pgid);
  • setpgid()는 첫 번째 인자 pid에 해당하는 프로세스의 PGID를 두 번째 인자 pgid로 변경한다.
  • pid를 0으로 지정한 경우 현재 프로세스가 대상이다.
  • 또한 두 번째 인자 pgid를 0으로 지정하면 현재 PID가 PGID로 사용된다. 즉 자신이 리더가 되어 새로운 프로세스 그룹을 만들고 싶은 경우 두 인자를 모두 0으로 설정한다.

4-8. setsid(2)

#include <unistd.h>
pid_t setsid(void);
  • setsid()는 새로운 세션을 만들고 스스로 세션 리더가 된다. 또한 세션에서 최초의 프로세스 그룹을 작성하고 그 그룹의 리더가 된다. setsid()로 작성한 새로운 세션은 제어 단말을 가지지 않는다.
  • 즉 세션 리더가 되는 동시에 데몬이 된다.

0개의 댓글