프로그래밍공부(Programming Study)/CS-운영체제(OS)

OSTEP: 5. Interlude: Process API

Chann._.y 2025. 10. 8.
728x90

이 글은 OSTEP(Operating Systems: Three Easy Pieces)의 ‘Interlude: Process API’ 문서를 직접 읽고 정리한 내용이다. Opinion(의견) 은 명시적으로 표시하고, 나머지는 설명 위주로 구성했다.
DevOps 관점(왜 중요한가/적용 방식)은 글 맨 마지막 별도 섹션에 모았다.

 

1) 프로세스 API가 다루는 것

  • 프로그램을 프로세스로 실행·중단·종료하고, 자식 프로세스를 만들고 관리하는 시스템 콜 집합.
  • 핵심 호출군: fork(), exec*(), wait()/waitpid(), exit(), getpid()/getppid(), sleep(), kill(), signal() 등.
  • 표준 입출력/파일 서술자와 결합해 리다이렉션·파이프라인까지 구성.

2) 프로세스 기본 수명주기

1) 생성fork()로 부모의 주소공간/레지스터/파일서술자를 복제(Copy-on-Write).
2) 프로그램 교체(선택) — 자식에서 exec*()로 현재 프로세스를 다른 프로그램으로 치환.
3) 실행 — OS 스케줄러가 CPU를 배분.
4) 종료exit(status) 또는 main() return.
5) 회수(Reap) — 부모가 wait()/waitpid()로 종료 상태를 수거하지 않으면 좀비(zombie) 로 남는다.


3) 핵심 시스템 콜

3.1 fork()

  • 부모를 복제해 자식 프로세스를 하나 만든다.
  • 반환값: 부모에선 자식 PID(>0), 자식에선 0, 실패 시 -1.
  • 부모/자식은 같은 코드로 동시에 계속 실행되므로, 분기 로직이 필요.
// fork 기본 패턴
pid_t pid = fork();
if (pid < 0) {
  // error
} else if (pid == 0) {
  // child
} else {
  // parent (pid is child's pid)
}

3.2 exec*()

  • 현재 프로세스 이미지 전체를 교체하고 지정한 프로그램으로 점프.
  • execl, execv, execle, execve, execlp, execvp 등: 인자/환경/경로 검색 차이.
  • 성공 시 복귀하지 않음(그 지점 이후 코드는 실행되지 않는다). 실패 시에만 -1 반환.
// PATH 검색 + argv 배열 전달
char *argv[] = {"ls", "-l", "/tmp", NULL};
execvp("ls", argv);   // 성공하면 여기로 돌아오지 않음

3.3 wait() / waitpid()

  • 자식 프로세스 종료 대기상태 수거(reap).
  • wait(&status): 임의의 자식 1개, waitpid(pid, &status, flags): 특정 자식 또는 비차단(WNOHANG) 가능.
  • 매크로: WIFEXITED(status), WEXITSTATUS(status), WIFSIGNALED(status) 등.
int status;
pid_t done = waitpid(-1, &status, 0);  // 아무 자식이나 하나 대기
if (WIFEXITED(status)) {
  int code = WEXITSTATUS(status);
}

3.4 exit(status) / return

  • 프로세스 종료, 상태 코드는 부모가 wait*로 조회.
  • main()에서 return n;은 내부적으로 exit(n)과 동등하게 처리.

3.5 유틸 함수들

  • getpid() / getppid() — 현재/부모 PID 조회.
  • sleep(sec) — 대기.
  • kill(pid, sig) — 대상 프로세스에 시그널 전달(종료·중단·사용자 정의 등).
  • signal(sig, handler) 또는 sigaction()시그널 핸들러 등록(권장: sigaction).

4) 표준 입출력과 파일 서술자

  • 시작 시 보통 0:stdin, 1:stdout, 2:stderr 세 개의 FD가 열린다.
  • fork()열린 파일 서술자도 복제 → 부모/자식이 같은 파일 커서를 공유할 수 있음.
  • dup2(newfd, oldfd)로 서술자 복제/치환 → 리다이렉션의 핵심.
// stdout(1)을 파일로 리다이렉트
int fd = open("out.txt", O_CREAT|O_WRONLY|O_TRUNC, 0644);
dup2(fd, 1);   // 이후 printf/puts는 out.txt로 간다
close(fd);

5) 자주 쓰는 패턴

5.1 fork-then-exec (자식에서 새 프로그램 실행)

  • 부모는 제어권을 유지하고, 자식만 다른 프로그램으로 바꿔 감시/수거가 쉽다.
pid_t pid = fork();
if (pid == 0) {
  char *argv[] = {"grep", "-n", "main", "app.c", NULL};
  execvp("grep", argv);
  _exit(127);  // exec 실패 시 안전 종료
} else {
  int status;
  waitpid(pid, &status, 0);
}

5.2 파이프라인: pipe() + dup2() + execvp()

  • 한 프로세스의 stdout → 파이프 → 다른 프로세스 stdin 으로 연결.
int p[2]; pipe(p);                // p[0]: read, p[1]: write

pid_t c1 = fork();
if (c1 == 0) {                    // child1: "cat file"
  dup2(p[1], 1); close(p[0]);     // stdout → p[1]
  char *a[] = {"cat", "file.txt", NULL};
  execvp("cat", a);
  _exit(127);
}

pid_t c2 = fork();
if (c2 == 0) {                    // child2: "grep error"
  dup2(p[0], 0); close(p[1]);     // stdin ← p[0]
  char *b[] = {"grep", "error", NULL};
  execvp("grep", b);
  _exit(127);
}

close(p[0]); close(p[1]);         // 부모는 파이프 닫기
waitpid(c1, NULL, 0);             // 자식 회수
waitpid(c2, NULL, 0);

5.3 리다이렉션: cmd > out.txt 2>&1

  • 쉘 동작을 직접 코드로: dup2()로 stdout(1)·stderr(2)를 파일로 치환.
int fd = open("out.txt", O_CREAT|O_WRONLY|O_TRUNC, 0644);
dup2(fd, 1);   // stdout -> out.txt
dup2(fd, 2);   // stderr -> out.txt
close(fd);
// 이후 execvp("cmd", argv) 실행

5.4 자식 회수(좀비 방지)

  • 자식 종료 후 wait*반드시 회수. 여러 자식이면 루프 또는 SIGCHLD 핸들러에서 비차단 회수.
void reap_all_children(void) {
  int status;
  while (waitpid(-1, &status, WNOHANG) > 0) {
    // reaped
  }
}

6) 시그널과 안전한 처리

  • 시그널: 비동기 알림(예: SIGINT, SIGTERM, SIGCHLD).
  • 핸들러 설치: sigaction()으로 재진입 안전한 최소 작업만 수행(플래그 세팅 등).
  • SIGCHLD: 자식 종료 알림. 핸들러에서 waitpid(-1, &status, WNOHANG)루프로 호출해 누락 없이 회수.
  • 주의: 핸들러에서 non-async-signal-safe 함수(printf, malloc 등) 호출 금지.
static volatile sig_atomic_t got_sigchld = 0;

void on_sigchld(int sig) { got_sigchld = 1; }

int main() {
  struct sigaction sa = {.sa_handler = on_sigchld};
  sigemptyset(&sa.sa_mask);
  sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
  sigaction(SIGCHLD, &sa, NULL);

  // 메인 루프
  for (;;) {
    if (got_sigchld) {
      got_sigchld = 0;
      int status;
      while (waitpid(-1, &status, WNOHANG) > 0) { /* reap */ }
    }
    // ...
  }
}

7) 오류 처리와 errno

  • 대부분의 시스템 콜은 실패 시 -1 을 반환하고, 전역 변수 errno 를 설정한다.
  • 검사 습관: 실패 시 즉시 분기하고, 필요 시 perror() 또는 strerror(errno)로 원인 로깅.
int fd = open("data.txt", O_RDONLY);
if (fd < 0) { perror("open"); /* handle */ }

8) 좀비와 고아(Orphan)

  • 좀비: 종료했지만 부모가 아직 wait*로 회수하지 않아 프로세스 테이블 엔트리가 남은 상태.
  • 고아: 부모가 먼저 종료되어 init/systemd입양(re-parenting). 보통 즉시 회수되어 좀비로 남지 않는다.
  • 대응: 항상 자식을 회수하는 루틴/핸들러를 갖추고, 장기 실행 데몬은 자식 관리를 체계화.

9) 보안·운영 실무 포인트

  • execvp/execlpPATH 검색을 사용 → 의도치 않은 바이너리 실행 위험. 필요 시 절대 경로 사용 또는 환경 정제(execveenvp).
  • 파일 서술자 누수 방지: 자식에게 전달되면 안 되는 FD는 FD_CLOEXEC/O_CLOEXEC.
  • 리소스 한도: ulimit/rlimit(예: RLIMIT_NOFILE)으로 자원 소진 방지.
  • 신호 사용 규칙: 종료는 SIGTERM → 유예 후 SIGKILL 순. 헬스체크·그레이스풀 셧다운을 표준화.

10) 요약

  • fork()복제, 자식에서 exec*()프로그램 교체, 부모는 wait*()회수.
  • 표준 입출력/dup2/pipe리다이렉션·파이프라인 구현.
  • SIGCHLD 기반 회수와 FD/경로/자원 한도 관리로 운영 친화성을 확보.

(부록) DevOps 관점 — 왜 중요하고, 어떻게 적용하나 (Opinion)

A) 왜 중요한가

  • 프로세스 생애주기 통제는 CI 러너/에이전트/워커의 안정성·관측성 핵심.
  • 좀비/FD 누수/비정상 종료는 장기 서비스에서 자원 고갈·SLO 위반으로 직결.

B) 적용 방식

  • 자식 관리 표준화: fork-exec-waitpid 패턴 + SIGCHLD 핸들러에서 비차단 회수 루프.
  • 클린 리다이렉션: dup2 + O_CLOEXEC로 로그/아티팩트를 분리, FD 누수 차단.
  • 보안 가드레일: 절대 경로 exec, PATH/env 정제, RLIMIT_* 설정.
  • 종료 절차: TERM → (유예) → KILL 표준화, 종료 훅에서 정리 작업 수행.
  • 관측 지표: 좀비 수, FD 사용량, 파이프 백업률, wait 실패율, 자식 실행시간 P50/P95.
728x90

댓글