OS - "운영체제 아주 쉬운 세 가지 이야기" 정리노트 - 2. 가상화 - 2. 프로세스 API

송준섭 Junseop Song·2023년 7월 3일
0

운영체제

목록 보기
3/5
post-thumbnail

⛳️ 목적

이 글은 "운영체제 아주 쉬운 세 가지 이야기" 책을 읽고 공부한 내용들을 두고두고 보기 위해 정리하는 글이다.
읽을 때마다 그 날의 내용들을 꾸준히 이어서 업데이트 할 예정이다.


🗓️ 2023.07.04 작성 ▽

2. 가상화

운영체제의 세 주제중 첫 번째인 가상화

2-2. 프로세스 API

UNIX 시스템의 프로세스 생성에 관해 논의
프로세스 생성을 위해 fork()와 exec() 시스템 콜 사용
wait()은 프로세스가 자신이 생성한 프로세스가 종료되길 원할 때 사용

❓ 핵심 질문: 프로세스를 생성하고 제어하는 방법
프로세스를 생성하고 제어하려면 운영체제가 어떤 인터페이스를 제공해야 하는가?
유용성, 편리성, 그리고 성능을 위해서는 어떻게 인터페이스를 설계해야 하는가?

📌  fork() 시스템 콜

// p1.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(int argc, char *argv[]) {
    printf("hello world (pid: %d)\n", (int) getpid());
    int rc = fork();
    if (rc < 0) {  // fork가 실패하면 종료
        fprintf(stderr, "fork failed\n");
        exit(1);
    } else if (rc == 0) {  // 자식(fork()로 생긴 새로운 프로세스)
        printf("hello, I am child (pid: %d)\n", (int) getpid());
    } else {  // 부모 프로세스(원래의 프로세스)는 이 경로를 따라 실행 (main)
        printf("hello, I am parent of %d (pid: %d)\n", rc, (int) getpid());
    }

    return 0;
}
// 결과
hello world (pid: 42909)
hello, I am parent of 42910 (pid: 42909)
hello, I am child (pid: 42910)

프로세스가 rc = fork();로 시스템 콜을 호출
운영체제는 프로세스 생성을 위해 이 시스템 콜을 제공
생성된 프로세스는 호출한 프로세스의 복사본
그러나 새로 생긴 프로세스(자식 프로세스)가 첫줄 hello world부터 시작한게 아닌 바로 hello, I am child를 출력함
-> 자식 프로세스는 fork()를 호출하면서부터 시작
자식 프로세스는 부모 프로세스와 완전 동일한 것은 아님(복사본)
자식 프로세스는 자신의 주소 공간, 자신의 레지스터, 자신의 PC 값을 가짐
fork() 반환값에도 차이가 있음
부모 프로세스는 자식 프로세스의 pid를, 자식 프로세스는 0을 반환받음

부모, 자식 프로세스의 출력 순서는 다를 수 있음
CPU 스케줄러가 실행할 프로세스를 선택
어느 프로세스가 먼저 실행된다라고 단정하는 것은 어려움
-> 이러한 비결정성으로 인해 멀티 쓰레드 프로그램 실행 시 다양한 문제 발생 (추후 병행성 부분에서 다룰 예정)

📌  wait() 시스템 콜

// p2.c
#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가 실패하면 종료
        fprintf(stderr, "fork failed\n");
        exit(1);
    } else if (rc == 0) {  // 자식(새로운 프로세스)
        printf("hello, I am child (pid: %d)\n", (int) getpid());
    } else {  // 부모 프로세스는 이 경로를 따라 실행
        int rc_wait = wait(NULL);
        printf("hello, I am parent of %d (rc_wait: %d) (pid: %d)\n", rc, rc_wait, (int) getpid());
    }

    return 0;
}
// 결과
hello world (pid: 42967)
hello, I am child (pid: 42968)
hello, I am parent of 42968 (rc_wait: 42968) (pid: 42967)

부모 프로세스가 자식 프로세스의 종료를 대기해야 하는 경우 wait() 시스템 콜을 사용
그러면 앞의 p1.c에서와 다르게 출력 순서가 보장이 됨(항상 자식 프로세스 먼저 수행)

📌  exec() 시스템 콜

fork() 시스템 콜은 자신의 복사본을 생성하여 실행
자신의 복사본이 아닌 다른 프로그램을 실행해야 할 경우에는 exec() 시스템 콜 사용

// p3.c
#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가 실패하면 종료
        fprintf(stderr, "fork failed\n");
        exit(1);
    } else if (rc == 0) {  // 자식(새로운 프로세스)
        printf("hello, I am child (pid: %d)\n", (int) getpid());
        char *myargs[3];
        myargs[0] = strdup("wc");  // 프로그램: "wc" (단어 세기)
        myargs[1] = strdup("p3.c");  // 인자: 단어 셀 파일
        myargs[2] = NULL;  // 배열의 끝 표시
        execvp(myargs[0], myargs);  // "wc" 실행
        printf("this shouldn't print out");
    } else {  // 부모 프로세스는 이 경로를 따라 실행
        int rc_wait = wait(NULL);
        printf("hello, I am parent of %d (rc_wait: %d) (pid: %d)\n", rc, rc_wait, (int) getpid());
    }

    return 0;
}
// 결과
hello world (pid: 43452)
hello, I am child (pid: 43453)
      26     125     981 p3.c
hello, I am parent of 43453 (rc_wait: 43453) (pid: 43452)

exec() 시스템 콜은 다음과 같은 과정으로 수행됨
1. 실행 파일의 이름(예, wc)과 약간의 인자(예, p3.c)가 주어지면 해당 실행 파일의 코드와 정적 데이터를 읽어 들여 현재 실행 중인 프로세스의 코드 세그멘트와 정적 데이터 부분을 덮어 씀
2. 힙과 스택 및 프로그램 다른 주소 공간들로 새로운 프로그램의 실행을 위해 다시 초기화 됨
3. 운영체제는 프로세스의 argv와 같은 인자를 전달하여 프로그램을 실행

새로운 프로세스를 생성하지는 않음
현재 실행 중인 프로그램(p3)을 다른 실행 중인 프로그램(wc)로 대체하는 것
자식 프로세스가 exec()을 호출한 후에는 p3.c는 실행되지 않은 것처럼 보임
exec() 시스템 콜이 성공하게 되면 p3.c는 절대로 리턴하지 않음


🗓️ 2023.07.05 작성 ▽

📌  이러한 API를 사용하는 이유

새로운 프로세스를 생성하는 것은 간단한 작업 같은데 왜 이러한 인터페이스들을 사용할까?
그 이유는 UNIX의 쉘을 수현하기 위해 fork()와 exec()를 분리해야 하기 때문
그래야만 쉘이 fork()를 호출하고 exec()를 호출하기 전에 코드를 실행할 수 있음
이때 실행하는 코드에서 프로그램을 설정하고, 다양한 기능을 준비

쉘은 프롬프트를 표시해 사용자의 입력을 받고 명령어를 실행
이 때 대부분의 경우 쉘은 파일 시스템에서 실행 파일의 위치를 찾고 명령어를 실행하기 위하여 fork()를 호출해 새로운 자식 프로세스를 만듦
그 후 exec()의 변형 중 하나를 호출하여 프로그램을 실행시킨 후 wait()을 호출하여 명령어가 끝나기를 기다림
자식 프로세스가 종료되면 쉘은 wait()으로부터 리턴하고 다시 프롬프트를 출력하고 다음 명령어를 기다림

fork()와 exec()를 분리함으로써 쉘은 많은 유용한 일을 조금 쉽게 할 수 있음

prompt> wc p3.c > newfile.txt

위의 예제에서 wc 프로그램의 출력은 newfile.txt라는 출력 파일로 방향이 재지정('>' 표시가 재지정 의미)
자식이 생성되고 exec()이 호출되기 전에 표준 출력파일을 닫고 newfile.txt 파일을 열어 출력이 화면이 아닌 파일로 보내지게 함

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

int main(int argc, char *argv[]) {
    int rc = fork();
    if (rc < 0) {  // fork에 실패했을 경우 종료
        fprintf(stderr, "fork failed\n");
        exit(1);
    } else if (rc == 0) {  // 자식 프로세스: 표준 출력 파일로 재지정
        close(STDOUT_FILENO);
        open("./p4.output", O_CREAT|O_WRONLY|O_TRUNC, S_IRWXU);

        char *myargs[3];
        myargs[0] = strdup("wc");  // 프로그램: "wc"(단어 세기 프로그램)
        myargs[1] = strdup("p4.c");  // 인자: 단어 셀 파일
        myargs[2] = NULL;  // 배열의 끝
        execvp(myargs[0], myargs);  // wc 실행
    } else {  // 부모 프로세스는 이 경로를 따라 실행 (main)
        int rc_wait = wait(NULL);
    }

    return 0;
}

예제의 작업을 수행하는 프로그램
UNIX 시스템이 0번부터 차례로 사용중이 아닌 파일 디스크립터를 찾음
위의 경우 표준 출력을 닫았기 때문에 STDOUT_FILENO가 첫 번째 사용 가능 파일 디스크립터로 탐색(open()이 호출될 때 할당)
이후 자식 프로세스가 표준 출력 파일 디스크립터를 대상으로 하는 모든 쓰기(예를 들어 printf()에 의한 쓰기)는 화면이 아닌 새로 열린 파일로 향함

prompt> ./p4.c
prompt> cat p4.output
      26     106     873 p4.c

위의 첫 줄과 같이 p4를 실행해도 화면에 아무런 출력도 일어나지 않음
하지만 실제로는 프로그램 p4는 fork()를 호출하여 새로운 자식 프로세스를 생성하고 execvp()를 호출하여 wc 프로그램을 실행시킴
그 출력은 p4.output 파일로 재지정되었기 때문에 화면에는 아무 것도 출력되지 않았음
그러나 파일을 열어보았을 때 원하던 출력이 저장되어 있는 것을 발견할 수 있음

UNIX 파이프가 이와 유사한 방식으로 구현됨(대신 pipe() 시스템 콜을 통하여 생성)
이 경우 한 프로세스의 출력과 다른 프로세스의 입력이 동일한 파이프에 연결
한 프로세스의 출력이 자연스럽게 다음 프로세스의 입력으로 사용되고, 명령어 체인이 형성됨


📌  프로세스 제어와 사용자

UNIX 시스템에는 앞선 3가지 외에도 많은 프로세스 관련 인터페이스가 있음
예를 들어 kill() 시스템 콜은 프로세스에게 멈추거나 끝내기와 같은 시그널을 보내는데 사용

편의를 위해 대부분의 UNIX 쉘은 현재 실행중인 프로세스에 특정 시그널을 보내는 단축키가 설정
control-c키는 SIGINT(인터럽트) 시그널을 보내 종료시킬 수 있음
control-z키는 SIGSTP(멈춤) 시그널을 보내 실행 도중에 프로세스를 잠시 멈출 수 있음
멈춘 프로세스를 재개하기 위해서는 fg명령어 사용

시그널은 외부 사건을 프로세스에게 전달하는 토대
개별 프로세스 또는 프로세스 그룹 단위로 시그널을 받거나 처리 가능
이와 같은 통신이 가능하기 위해서 프로세스는 signal() 시스템 콜을 사용하여 여러 시그널을 잡아야 함

이러한 시그널을 아무나 보낼 수 있게 하면 안되기 때문에 현대 시스템에는 사용자라는 아주 강력한 개념을 도입
사용자가 비밀번호를 입력하여 인증을 획득하면 시스템의 자원에 접근할 수 있는 권한을 얻음(프로세스 시작하는 권한, 중단 및 종료의 제어권 등)
일반적으로 사용자는 자기 프로세스들에 한해서 제어권을 가짐

+) 슈퍼사용자(ROOT)
시스템을 관리하기 위한 관리자
사용자들과 달리 제한을 받지 않음
보안성을 높이기 위해, 실수를 하지 않기 위해 일반 사용자로 접속하는 것이 좋고, 슈퍼사용자를 사용해야 한다면 조심해서 사용해야 함

운영체제는 CPU, 메모리와 디스크 같은 자원을 각 사용자와 프로세스들에 할당하여 전체적인 시스템의 목적에 도달하도록 만드는 역할 수행


📌  유용한 도구들

ps 명령어: 어떤 프로세스가 실행 중인지
top 명령어: 시스템에 존재하는 프로세스와 그 프로세스가 CPU 및 다른 자원들을 얼마나 사용하고 있는지
kill, killall 명령어: 프로세스에 시그널을 보내어 종료


📌  요약) 주요 프로세스 API 용어들

각 프로세스는 이름이 있고 대부분의 시스템에서 이름은 프로세스 ID(PID)

  • fork() 시스템 콜
    새로운 프로세스 생성
    생성의 주체가 부모 프로세스, 새롭게 생성된 프로세스가 자식 프로ㅔㅅ스

  • wait() 시스템 콜
    자식 프로세스의 실행이 종료할 때까지 부모가 기다리도록 함

  • exec() 시스템 콜
    다른 프로그램을 실행할 수 있게 함
    자식 프로세스가 부모와의 연관성을 완전히 끊어서 완전히 새로운 프로그램을 실행할 수 있도록 해줌

UNIX 쉘은 보통 fork(), wait(), exec()를 사용하여 사용자의 명령 시작
fork()와 exec()의 분리로 실행 중인 프로그램을 조작하지 않고도 입/출력 재지정, 파이프, 그 외 다른 기능들을 처리하는 것이 가능

프로세스 제어는 시그널이라는 형태로 제공
이를 활용하여 작업을 멈추고, 계속 실행하고, 종료시킬 수 있음

누가 어떤 프로세스를 제어 할 수 있냐는 사용자라는 개념 속에 포함
어떤 사용자가 시스템을 사용할 수 있는지, 사용자가 어떠한 프로세스에 접근할 수 있는지 정하는 것은 운영체제 책임

슈퍼사용자는 모든 프로세스들을 제어(그 외에도 많은 일 가능)
이 역할은 간헐적으로 맡아야 하며, 보안 등의 이유로 조심스럽게 대처해야 함

0개의 댓글