[운영체제] Interlude - Process API

서론 보기

더보기

들어가기 전, 이 게시물에서는 여러분들이 흔히 들어보신 부모 프로세스(parent process), 자식 프로세스(child process)가 언급됩니다. 빠른 정리를 위해 parent, child로 축약해 부르겠습니다. 단어에 혼동이 있을 수 있습니다.

 

해당 챕터에서는 프로세스를 만들고 제어하는 방법에 대해 다룬다.

 

코드 실습은 https://github.com/remzi-arpacidusseau/ostep-code/

Get it right. Neither abstraction nor simplicity is a substitute for getting it right.

Process 생성 및 제어

UNIX 시스템에서 제공하는 다음 3가지 시스템 콜을 살펴볼 것이다.

  • fork()
  • exec()
  • wait()

Microsoft Windows의 Win32 API의 경우 CreateProcess() 등을 사용한다.

 

 

fork() 시스템 콜

fork()를 호출할 경우 새로운 프로세스를 실행한다.

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(int argc, char *argv[]) {
    printf("hello (pid:%d)\n", (int) getpid());
    int rc = fork();
    if (rc < 0) {
        // fork failed
        fprintf(stderr, "fork failed\n");
        exit(1);
    } else if (rc == 0) {
        // child (new process)
        printf("child (pid:%d)\n", (int) getpid());
    } else {
        // parent goes down this path (main)
        printf("parent of %d (pid:%d)\n",
        rc, (int) getpid());
    }
    return 0;
}

 

어떤 상황에서 결과값이 어떻게 되는지, 프로세스를 어떤 식으로 생성하는지 이해할 필요가 있다.

 

우선 실행을 직접해보면 if 문 둘 다 출력이 되었다.

우리가 기존에 알고 있던 컴퓨터 프로그래밍 관련된 개념들을 fork()라는 함수가 깨부수고 있다.

 

그림으로 설명하자면 fork()라는 함수 이후로 child 프로세스가 생기고 0을 반환한다.

 

같은 코드 상에서 1개의 프로세스가 아닌 2개의 프로세스가 동작 중인 것이다.

child 프로세스는 0을 반환하고 "hello, I am child" 를 출력한다.

기존 프로세스는 그대로 "hello, I am parent of 29147"을 출력한다.

 

Key points

fork() 함수를 부른 프로세스는 parent process

fork() 함수로 새로 생긴 프로세스는 child process

 

parent process는 address space, register, program counter 등을 복사받기에 main() 함수 처음부터 시작하지 않는다.

다른 건 단 하나, fork()의 리턴 값이 다르다.

child는 0의 리턴 값을 갖게 되고, parent는 child의 PID를 리턴 값으로 갖는다.

 

프로그램을 여러 번 반복하다 보면 결과가 위와 달라질 수 있는데

 

운영체제가 CPU scheduler 우선권에 대한 고려를 안 했을 경우

child가 parent보다 우선권을 가질 확률이 있다.

해당 결과가 결정적이지 않고 실행할 때마다 결과값이 다를 수 있다는 점을 고려해야 한다.

 

 

wait() 시스템콜

parent process가 child process의 종료를 기다리기 위한 함수이다.

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

int
main(int argc, char *argv[])
{
    printf("hello world (pid:%d)\n", (int) getpid());
    int rc = fork();
    if (rc < 0) {
        // fork failed; exit
        fprintf(stderr, "fork failed\n");
        exit(1);
    } else if (rc == 0) {
        // child (new process)
        printf("hello, I am child (pid:%d)\n", (int) getpid());
	sleep(1);
    } else {
        // parent goes down this path (original process)
        int wc = wait(NULL);
        printf("hello, I am parent of %d (wc:%d) (pid:%d)\n",
	       rc, wc, (int) getpid());
    }
    return 0;
}

wc는 wait call

 

실행결과는 다음과 같다.

여기서는 child가 먼저 나오는데, 매우 특이한 운영체제가 아니라면 무조건 child 먼저 출력된다.

 

설령 parent가 먼저 실행되더라도 wait()를 실행했기에 child이 종료되기 전엔 parent를 진행하지 않습니다.

(child가 cpu scheduling 우선권을 갖습니다.)

 

 

주의점
wait()가 child 종료 전에 return 되는 제한적인 상황들이 존재한다.

교수님께서는 이 책에서 증명 불가능하거나 잘못된 표현들(주관이 들어간)이 자주 등장한다고 주의하시라 했다.
"자식은 언제나 먼저 출력된다.", "UNIX는 세상에서 가장 좋다"등 
다른 사람들이 모두 다른 방법으로 구현했기에 운영체제별로 다 다르다. 그러기에 정답은 이 책이 아니라
"man page"라 하셨다.

 

exec() 시스템콜

fork() 또한 프로세스를 만드는 함수이지만, 부모랑 자식이 똑같은 프로세스를 진행한다.

exec()는 조금 다른 개념인데 현재 프로세스에 새로운 프로그램을 load 한다고 생각하면 된다.

 

리눅스에는 exec()의 6개 버전이 있다.

execl(), execlp(), execle(), execv(), execvp(), execvpe()

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

int
main(int argc, char *argv[])
{
    printf("hello world (pid:%d)\n", (int) getpid());
    int rc = fork();
    if (rc < 0) {
        // fork failed; exit
        fprintf(stderr, "fork failed\n");
        exit(1);
    } else if (rc == 0) {
        // child (new process)
        printf("hello, I am child (pid:%d)\n", (int) getpid());
        char *myargs[3];
        myargs[0] = strdup("wc");   // program: "wc" (word count)
        myargs[1] = strdup("p3.c"); // argument: file to count
        myargs[2] = NULL;           // marks end of array
        execvp(myargs[0], myargs);  // runs word count
        printf("this shouldn't print out");
    } else {
        // parent goes down this path (original process)
        int wc = wait(NULL);
        printf("hello, I am parent of %d (wc:%d) (pid:%d)\n",
	       rc, wc, (int) getpid());
    }
    return 0;
}

 

 

배열의 type이 char *인 상황이다.

문자열을 만들어서 공간까지 잡아주는 함수가 strdup이다.

이렇게 작성된 myargs를 execvp()를 통해 실행시킨다.

어떤 프로그램을 실행시킬 수 있게 만드는 코드임을 짐작할 수 있다.

wc부터는 argv로 생각하면 된다.

 

 

        execvp(myargs[0], myargs);  // runs word count
        printf("this shouldn't print out");

execvp() 뒤에 printf함수로 실행되면 안 된다는 멘트가 있는데 

execvp()가 성공하게 된다면 새로운 프로그램이 child process에 적재되기 때문에 실행이 제대로 된다면 printf 함수가 실행되지 않는다. 

 

실행을 해보면 결과는 다음과 같다.

line 1. parent

line 2. fork()된 child

line 3. child가 호출한 execvp(), wc를 호출하여 child는 종료가 되었다.

line 4. parent가 child 종료될 때까지 wait 되다가 자신의 출력을 마치고 종료된다.

 

 

Details

  • exec()는 새로운 실행 파일의 이름과 인자를 통해 코드와 static data를 load 하고 code segment를 교체한다.
  • exec()가 호출되면 heap, stack이 초기화된다.
  • OS는 단순히 그 프로그램을 main() 함수가 실행 것처럼 새로운 프로그램을 실행한다.
  • exec()가 성공한다면 기존 프로그램 뒷부분은 실행하지 않는다.
  • 하지만 종료될 경우 프로그램 뒷부분이 실행된다.

 

fork()과 exec()의 기능을 분리한 이유

프로세스를 제어하고 생성하기 위해 왜 이러한 이상한 interface를 만들었냐?

-> shell programming을 한다면 fork(), exec() 분리는 필수적이다.

 

 

 

Getting it Right (Lampson's Law)
올바르게 생각하라. 추상화도 단순함도 올바른 선택을 대체할 수 없다.

 

운영체제는 시스템 측면에서 연구하는 사람들에겐 아주 중요하다.

한번 운영체제가 만들어지면 거기에 있는 함수들을 바꾸는 작업은 매우 어렵다.

API를 잘 설계하지 못한다면

 

프로세스를 생성하는 API를 제작하는 방법은 아주 많았지만 fork()와 exec()만큼 강력하고 단순한 방법은 없었기 때문에 UNIX 제작자들이 올바른 길을 걸었다고 표현이 되어있다.

 

교수님의 피드백

다른 책들은 중립적으로 써져 있지만, 이 책은 매우 주관적이다. 이러한 관점을 다 외우는 것들은 면접장이나 시험에서 가서는 도움이 될 수 있겠지만, 실무로 가면 모두 쓸모 없어진다. 교재에 대해 특정한 사람의 주장과 근거에 대해 철저하게 파악하는 작업을 할 수 있으면 좋겠다.

 

 

 

Shell

그러면 운영체제 관점에서 shell을 다시 살펴보자.

 

만약 우리가 shell에 명령어를 입력한다면

1. shell은 file system에 실행가능한 파일을 찾아내고 fork()를 통해 새로운 프로세스를 실행할 준비 한다.

2. shell에 있는 code를 그대로 복제한 child process를 생성한다.

3. exec()를 통해 명령어를 실행한다.

4. parent는 child가 종료될 때까지 wait()한다.

 

지금까지의 내용만으로도 shell을 제작할 수 있다.

 

 

 

Redirection

마지막으로 Redirection (p4.c)를 확인해 보자

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <fcntl.h>
#include <assert.h>
#include <sys/wait.h>

int
main(int argc, char *argv[])
{
    int rc = fork();
    if (rc < 0) {
        // fork failed; exit
        fprintf(stderr, "fork failed\n");
        exit(1);
    } else if (rc == 0) {
	// child: redirect standard output to a file
	close(STDOUT_FILENO); 
	open("./p4.output", O_CREAT|O_WRONLY|O_TRUNC, S_IRWXU);

	// now exec "wc"...
        char *myargs[3];
        myargs[0] = strdup("wc");   // program: "wc" (word count)
        myargs[1] = strdup("p4.c"); // argument: file to count
        myargs[2] = NULL;           // marks end of array
        execvp(myargs[0], myargs);  // runs word count
    } else {
        // parent goes down this path (original process)
        int wc = wait(NULL);
	assert(wc >= 0);
    }
    return 0;
}

 

p3와 비슷하지만 가장 중요한 부분을 짚고 가자.

close, open은 운영체제의 상태

 

현재 close() 함수로 Standard Output (STDOUT)을 닫았다.

기존엔 file descriptor가 STDOUT_FILENO가 되었지만 close() 후 open()을 통해./p4.output이 되었다

	close(STDOUT_FILENO); 
	open("./p4.output", O_CREAT|O_WRONLY|O_TRUNC, S_IRWXU);

Redirection이 성립되는 건 UNIX에서 새로운 file descriptor를 찾을 때 0부터 시작하지만

STDOUT FILENO은 내가 닫은 자리에 file descriptor가 새로 할당된다. (open())

 

wc라는 프로그램은 exec를 실행 전 redirection 했기에 file에다가 보내는 결과가 발생한다.

 

프로그램을 실행해 보면 아무런 출력 결과가 없고, cat을 이용해 파일을 확인해 보면 결과를 확인할 수 있다.

 

 

pipe()와 같은 경우도 비슷한데, 실행될 때 입력과 출력을 상호적으로 연결해준다.

개념적으로 queue이다.

example : grep -o foo file | wc -l

 

 

프로세스 상호작용 UNIX Interface

kill() 시스템콜

프로세스에 signals을 보낸다. 다음과 같은 방법이 있다.

  • Ctrl + C : SIGINT (interrupt) 
  • Ctrl + Z : SIGTST (stop) 프로그램 일시정지 (보통 shell에서 bulit-in command)

signal() 시스템콜

interrupt상황에 대해 어떠한 함수를 동작하게 한다던지 signal을 처리할 수 있다.

즉, Ctrl + C를 눌렀을 때 프로그램이 죽지 않게 하기 위해 사용할 수 있다.

 

프로세스 제어 사용자

여러 사용자들이 동시에 사용하는 걸 가정하고 제작되어 있다.

만약 프로세스 상호작용이 사용자별로 제한이 되어있지 않다면, 한 사람이 sigint를 다른 사용자에게 전송하는 등의 보안 위험이 있을 수 있다.

 

  • 시스템 자원에 access 할 권한을 logs in을 통해 얻는다.
  • 사용자는 하나 이상의 프로세스를 실행할 수 있고 그 프로그램에 대해 full control을 갖는다.
  • 일반적으로 자신이 소유하고 있지 않는 프로세스는 제어하지 않는다.

운영체제에서 관리되는 자원들(CPU, Memory, Disk 등)은 운영체제들에게 요청하고 받는 형태이기 때문이다.

 

Useful tools

  • ps : 어떤 프로세스가 동작 중인지 확인할 수 있다.
  • top : 각 프로세스가 시스템 자원을 얼마나 소모하고 있는지 확인할 수 있다.
  • kill : 프로세스에 임의의 신호를 보낼 수 있다. (friendly killall)

 

Summary

  • fork(), exec(), wait() 등의 간단한 APIs를 확인했다.
  • 더 자세하게 공부하고 싶다면 Advanced Programming in the UNIX® Environment(By W. Richard Stevens, Stephen A. Rago)의 Process Control, Process Relationships, Signals 위주로 확인
  • fork() 함수는 어느 정도 문제가 제기되었다.

https://www.microsoft.com/en-us/research/uploads/prod/2019/04/fork-hotos19.pdf

UNIX의 문제점을 살펴보고 싶다면 참고할 것.

 

보통의 경우 역사적으로 매우 중요하고 가치가 있기에 배우는 것이지만, 단순하게 좋다고 외우면 안 된다.
보안 측면이나 연구 측면에서는 어느 정도 비판적으로 생각할 필요성이 있다.