운영체제 공룡책 3단원 정리

목차

운영체제 3단원, 프로세스 관련 내용을 정리한다.

1. 프로세스의 개념

프로세스는 현재 실행중인 프로그램을 의미한다. 프로그램은 디스크에 존재하는 것이며 이것이 메모리에 로드되어 프로그램 카운터를 가지고 실행되면 프로세스가 된다. 이런 프로세스는 컴퓨팅 시스템에서 작업 단위로도 쓰인다.

메모리에는 여러 프로그램이 로드될 수 있고 하나의 프로그램에 의해 여러 프로세스가 만들어질 수도 있다.

하나의 프로세스는 개별 메모리와 프로그램 카운터를 가지고 있으며, 이것은 프로세스가 독립적으로 실행되는 것을 의미한다. 그리고 프로세스의 메모리 배치는 코드(텍스트), 데이터, 스택, 힙 영역으로 구분된다.

이때 텍스트, 데이터 섹션은 프로그램 실행 동안 크기가 불변이고 스택, 힙 섹션은 실행 중에 동적으로 크기가 변경될 수 있다. 이때 힙 영역에는 유저가 동적으로 할당하는 메모리가 들어가고 스택 영역에는 함수가 호출될 때마다 그 activation record가 들어가게 된다. 자세한 내용은 일반적으로 시스템이나 PL 과목에 있다.

1.1. 프로세스의 상태

프로세스는 실행되면서 그 상태가 변한다. 프로세스의 상태는 크게 5가지로 나눌 수 있다. new, ready, running, waiting, terminated가 그것이다.

  • new : 프로세스가 생성 중이다. 여기서 프로세스는 프로세스 테이블에 등록되지 않은 상태이며 승인되면 ready 상태로 넘어간다.
  • ready : 프로세스가 cpu에 할당되기를 기다리고 있다. cpu에 프로세스가 할당되어 스케줄링되면 running 상태로 넘어간다.
  • running : 프로세스가 cpu에 할당되어 실행 중이다. 프로세스가 I/O로 넘어가거나 특정 이벤트를 기다리는 상태가 되면 waiting 상태로 넘어간다. 그리고 프로세스가 종료되면 terminated 상태로 넘어간다. 또 인터럽트가 걸리면 ready 상태로 넘어간다.
  • Waiting : 프로세스가 I/O를 기다리거나 특정 이벤트를 기다리는 상태이다. I/O가 완료되거나 특정 이벤트가 발생하면 ready 상태로 넘어간다.
  • Terminated : 프로세스가 종료되었다. 프로세스는 종료되면 프로세스 테이블에서 제거된다.

한 코어에서는 한 프로세스만이 실행 중에 있을 수 있다.

1.2 Process Control Block

각 프로세스는 os에서 Process Control Block에 의해 표현된다. 이 PCBs는 프로세스 생성시 만들어지며 프로세스 관리자에 의해 관리된다. 다음과 같은 정보를 갖고 있다.

  • Process state
  • Process number
  • Program counter
  • CPU registers
  • CPU scheduling information
  • Memory management information
  • Accounting information (CPU 사용 시간, 경과된 시간, 시간 제한 등)
  • I/O 상태 정보

2. 프로세스 스케줄링

멀티프로그래밍의 목적은 CPU가 언제나 어떤 프로세스를 실행하고 있도록 하는 데에 있다. time-sharing은 사용자가 여러 프로그램을 쓸 수 있도록 CPU가 실행하고 있는 프로세스를 빈번하게 교체한다.

하나의 프로세서는 하나의 프로세스만 실행할 수 있으므로, 여러 프로세스를 실행하기 위해서는 프로세스를 스케줄링해야 한다. 이때 현재 메모리에 있는 프로세스의 수를 Degree of Multiprogramming이라고 한다.

이를 위해 프로세스를 2가지로 분류하자.

  • I/O bound 프로세스 : 계산보다 입출력에 소비하는 시간이 많다. 즉 프로세스에서 입출력 연산이 지배적이다.
  • CPU bound 프로세스 : 입출력보다 계산에 소비하는 시간이 많다. 즉 프로세스에서 계산 연산이 지배적이다.

2.1 스케줄링 큐

메인 메모리에 있으면서 실행되길 기다리는 ready 상태 프로세스들은 연결 리스트 형태로 저장되며 준비 큐라고 한다. 이 준비 큐에는 PCB들이 연결되어 있다.

I/O장치의 완료를 기다리고 있는 프로세스들은 I/O Wait 큐(디바이스 큐라고도 한다)에 저장된다. 혹은 핀토스 프로젝트에서 볼 수 있듯이 자식 프로세스의 종료를 기다리는 sleep 큐도 존재한다.

2.2 CPU 스케줄링

cpu 스케줄러는 레디 큐의 프로세스 중 어떤 프로세스가 실행될지 선택하고 선택된 프로세스에 CPU를 할당한다. 스케줄러는 ms단위 정도로 매우 자주 실행된다.

스케줄러는 스와핑이라 불리는 동작을 하기도 한다. 핵심 아이디어는 메모리에서 프로세스를 제거하고 나중에 다시 불러와서 실행하는 것이 더 좋을 수도 있다는 데에서 온다. 스와핑에 대해서는 9장에서 자세히 다룬다.

2.3 컨텍스트 스위칭(문맥 교환)

프로그램 카운터, 프로세스 상태 등 프로세스의 상태를 나타내는 정보를 프로세스의 컨텍스트라 한다.

CPU가 실행하고 있는 프로세스를 다른 프로세스로 교환하는 작업을 컨텍스트 스위칭이라 한다. 이때 기존 프로세스의 컨텍스트를 저장하고 후에 다시 되돌려야 한다. 즉 기존에 실행되고 있는 프로세스의 PCB를 저장하고 새로운 프로세스를 실행해야 한다(컨텍스트는 PCB내에 표현되기 때문이다). 새 프로세스의 실행이 끝나면 기존 프로세스의 PCB를 불러와서 그 프로세스의 실행으로 돌아와야 하기 때문이다.

이 컨텍스트 스위칭 도중에는 CPU가 아무런 일도 하지 못하므로 컨텍스트 스위칭이 발생하는 시간은 순수한 오버헤드이다. 이 스위칭 속도는 디바이스마다 다르다.

3. 프로세스 연산

프로세스 생성 기법에 대해 살펴보자. 시스템 내 프로세스들은 대부분 병행 실행 가능하며 반드시 동적으로 생성/제거해야 한다. 따라서 운영체제에 프로세스 생성 및 종료를 위한 기능은 필수적이다.

3.1 프로세스 생성

프로세스는 pid(process identifier)라는 고유한 식별자를 통해 관리되고 식별된다. 그리고 각 프로세스는 여러 자식 프로세스를 만들 수 있고 이런 관계에 따라 트리를 형성한다. 언제나 pid가 1인 systemd(혹은 init, 운영체제마다 다르다) 프로세스가 최상위 프로세스이다.

이 pid 1인 루트 프로세스는 모든 프로세스의 부모가 되는 프로세스이고 시스템이 부트될 때 생성된다.

프로세스가 자식 프로세스를 생성할 때 정할 수 있는 옵션이 있다.

자식 프로세스가 자원을 어디서 가져오게 할 것인가?

  • 부모 프로세스의 자원을 모두 공유한다.
  • 부모 프로세스 자원의 일부를 공유한다.
  • 부모 프로세스 자원을 전혀 공유하지 않는다.(이 경우 자식 프로세스에 따로 자원을 제공해야 한다)

자식 프로세스의 실행은?

  • 부모 프로세스가 자식과 병행해서 실행을 계속한다.
  • 부모 프로세스가 자식 프로세스가 끝날 때까지 기다린다.(보통 이걸 쓴다)

자식 프로세스의 주소 공간은?

  • 자식 프로세스가 부모 프로세스의 주소 공간을 그대로 사용한다. 즉 같은 프로그램과 데이터를 가진다.
  • 자식 프로세스가 자신에게 로드될 새로운 프로그램을 가지고 있다.

3.2 실제 UNIX 운영체제의 예시

유닉스에서 새로운 프로세스는 fork() 시스템 콜로 생성된다. 이 함수를 호출시 부모 프로세스는 자신과 똑같은 자식 프로세스를 생성한다.

이 자식 프로세스는 부모 프로세스의 주소 공간의 복사본이다. 그리고 이 2개의 프로세스는 fork시스템 콜 다음의 명령어에서부터 각자 실행을 계속한다.

이때 부모 프로세스와 자식 프로세스의 차이점은 fork가 반환하는 pid값의 차이이다. 부모 프로세스에서의 fork는 자식 프로세스의 pid를 반환하고, 자식 프로세스에서의 fork는 0을 반환한다. 이 두 프로세스는 동시에 작동한다.

자식 프로세스에서는 exec()를 통해 자신만의 프로그램을 로드할 수 있다. 그러면 자식 프로세스는 원래 프로그램의 메모리 이미지를 파괴하고 exec()를 통해 로드된 프로그램을 실행한다.

이러한 프로그램 코드를 보면 다음과 같다.

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
 
int main(void) {
    pid_t pid;
    pid = fork();
    if(pid < 0){
        fprintf(stderr, "Fork Failed");
        exit(-1);
    }
    else if(pid == 0){ // 자식 프로세스는 fork가 0을 반환한다.
    // 자식 프로세스가 새로운 프로그램을 실행한다.
    // getpid 함수를 사용하면 이 자식 프로세스의 진짜 pid를 얻을 수 있다.
        execlp("/bin/ls", "ls", NULL);
    }
    else{
        wait(NULL); // 부모는 자식 프로세스가 끝날 때까지 기다린다.
        printf("Child Complete");
        exit(0);
    }
    return 0;
}

부모 프로세스는 자식 프로세스가 끝날 때까지 기다린다. 자식 프로세스가 exec()를 호출했다면 프로세스 주소 공간을 새 프로그램으로 덮어쓰기 때문에 오류가 발생하지 않는 한 제어를 넘기지 않는다.

물론 자식 프로세스가 exec()를 호출하지 않고 부모 프로세스의 복사본을 계속 실행할 수도 있다.

3.3. 프로세스 종료

프로세스가 마지막 명령을 실행하고 나서 exit() 시스템 콜을 사용해서 운영체제에 이 프로세스 삭제 요청을 한다. 그리고 자신을 기다리고 있는 부모 프로세스에 wait(&status) 시스템 콜을 통해 상태 값을 반환한다. 또한 자식 프로세스의 모든 자원이 할당 해제되어 운영체제에 반환된다.

혹은 부모나 사용자가 kill 시스템 콜을 통해 자식 프로세스를 임의로 종료시킬 수 있다. 이런 kill을 하는 이유는 다음과 같다.

  • 자식 프로세스가 할당된 자원 이상을 사용한다.
  • 자식 프로세스의 작업이 더 이상 필요없다.
  • 부모 프로세스가 종료되었으며(exit) 운영체제가 부모 exit 이후 자식 프로세스가 계속 실행되는 걸 허용하지 않음(몇몇 os는 프로세스가 종료되면 운영체제가 그 프로세스의 자식 프로세스들을 모두 종료시킨다. 이를 cascading termination이라고 한다.)

이때 kill(pid) 와 같이 부모가 자식 프로세스를 종료시키기 위해서는 자식 프로세스의 pid가 필요하다.

3.4 고아 프로세스, 좀비 프로세스

좀비 프로세스는 종료되었지만 부모 프로세스가 아직 wait 호출을 하지 않은 프로세스이다. 따라서 모든 프로세스는 종료하게 되면 잠깐 동안은 좀비 프로세스가 된다. 그리고 부모가 wait을 호출하면 좀비 상태였던 자식 프로세스는 종료된다. 즉 이미 종료되었지만 아직 프로세스 테이블에는 올라가 있는 프로세스가 좀비 프로세스이다.(부모는 아직 실행 중)

반면 고아 프로세스는 부모가 죽었지만 자식이 아직 종료되지 않은 프로세스이다. 유닉스 같은 경우 init 프로세스가 고아 프로세스를 자신의 자식 프로세스로 만들어서 관리함으로써 고아 프로세스를 해결한다. 그리고 init 프로세스는 주기적으로 wait을 호출하여 자식 프로세스의 종료 상태를 수집한다.

이런 고아 프로세스는 부모 프로세스가 wait을 호출하지 않고 종료되었을 때 생긴다.

4. 프로세스 주소 공간

각 프로세스는 가상 주소 공간을 가진다. 이 가상 주소 공간의 메모리는 실제 물리 메모리와 매핑되어 있다. 흔히 말하는 스택, 힙, 데이터, 코드 영역이 프로세스의 가상 주소 공간에 해당한다. 이런 가상 주소 공간은 보통 연속된 주소 공간(0~MAX)으로 구성되어 있다. 하지만 실제 이 가상 주소 공간의 메모리와 매핑된 물리 메모리 공간들은 꼭 연속된 주소 공간일 필요는 없다.

그럼 이런 가상 주소 공간을 어떻게 구성하고, 어떻게 물리 메모리와 매핑하는가? 거기에는 여러 기법과 페이징이라는 중요한 개념이 나오는데 이는 이후 단원에서 자세히 나올 것이다.

5. 프로세스 간 통신

프로세스가 실행 중인 다른 프로세스들과 영향을 주고받는다면 cooperating process(협력적인 프로세스)이다. 다른 프로세스들과 영향을 주고받지 않는 프로세스는 독립 프로세스(independent process)라고 한다. 이렇게 협력 프로세스를 쓰는 것의 장점은 다음과 같다.

  • 여러 프로세스가 같은 정보에 접근하는 경우 정보에 같이 접근할 수 있다
  • 특정 작업을 여러 프로세스가 병렬로 실행하게 하기
  • 시스템 기능을 별도의 프로세스/스레드로 나눠서 모듈식으로 시스템 구성
  • 편의성 증가

이때 프로세스 간 협력을 위해서는 프로세스 간 통신(interprocess communication, IPC)이 필요하다. 이 IPC에는 크게 두 가지 방법이 있다.

  • 공유 메모리(shared memory)
  • 메시지 전달(message passing)

5.1 공유 메모리 방식

공유 메모리 방식에서는 협력 프로세스들이 공유하는 메모리 영역이 구축된다. 이 메모리 영역에는 프로세스들이 공유하는 데이터가 저장된다. 프로세스들은 그 영역을 읽고 씀으로써 정보를 교환할 수 있다. 이때 각 프로세스는 공유 메모리 세그먼트를 자신의 주소 공간에 추가해야 한다.

공유 메모리 방식은 공유 메모리 영역을 구축할 때만 시스템 콜을 사용하며 일단 이 영역이 구축되면 모든 접근은 일반 메모리 접근으로 취급되어 커널의 도움이 필요 없어진다. 따라서 메시지 접근 방식보다 빠르다. 단 메모리에 동시 접근하는 것을 막기 위한 구현이 필요하다.(동시에 동일한 위치에 쓰게 되면 데이터가 꼬일 수 있다)

5.1.1 생산자-소비자 문제

공유 메모리 방식은 생산자-소비자 문제의 해결책이 될 수 있다. 두 프로세스가 동시에 동작할 때 일어나는 이슈인데, 정보의 생산 속도가 소비 속도보다 보통 빠르기 때문에 일어나는 동기화 문제이다. 생산자와 소비자 프로세스가 공유하는 메모리 영역에 버퍼를 만드는 것으로 이를 해결할 수 있다.

생산자가 생산한 정보는 버퍼에 저장되고 소비자는 버퍼에서 정보를 꺼내서 소비한다. 이때 버퍼에 저장된 정보가 없으면 소비자는 대기하고, 버퍼가 가득 차면 생산자는 대기한다. 이렇게 생산자와 소비자가 동시에 동작할 때 생기는 동기화 문제를 해결할 수 있다.

5.2 메시지 전달 방식

메시지 전달 방식에서는 프로세스간 통신이 프로세스들 간에 교환되는 메시지를 통해서 이루어진다. 이 방식에선 메모리를 프로세스간에 공유할 필요가 없다.

메시지 전달 방식은 최소 2가지 연산을 제공한다.

  • send: 메시지를 전송. 메시지 길이는 고정 길이일 수도 가변 길이일 수도 있다.
  • receive : 메시지를 수신. 메시지를 수신할 때까지 대기한다.

이 send/receive를 통해 프로세스간 메시지를 주고받기 위해서는 communication link가 설정되어 있어야 한다. 그리고 그 링크에서 send, receive를 이용해 메시지를 주고받는다. 이런 메시지 전달 방식 설계에서 고려해야 할 것은 다음과 같다.

  • Naming : 통신할 프로세스들이 어떻게 서로를 식별할 것인가?
  • Syncronization : 메시지를 주고받는 프로세스들이 어떻게 동기화할 것인가?
  • Buffering : 프로세스들 간의 메시지 큐를 어떻게 관리할 것인가?

5.2.1 Naming

직접 통신의 경우 프로세스는 식별을 위해 상대방의 주소를 알고 있어야 한다. 즉 P에게 msg를 보내려면 send(P, msg)를 쓰고 Q에서 메시지를 수신하는 것은 receive(Q, msg)를 쓰는 식이다.

mailbox(or port)를 통해서 통신하는 간접 통신의 방법도 있다. 이 방식의 경우 메시지들은 mailbox로 송신되고 거기로부터 수신된다. 즉 프로세스 - 메일박스 - 프로세스의 구조이다.

각 메일박스는 고유 id를 가진다. 그리고 두 프로세스가 통신하기 위해서는 서로가 공유하는 메일박스가 있어야 한다. send(A, msg), receive(A, msg) 를 통해 메일박스 A와 메시지를 송수신할 수 있다. 이 경우 다수 프로세스간 통신도 가능하다. 단 메시지를 저장할 메일박스가 따로 있어야 한다는 단점이 있다.

5.2.2 Syncronization

프로세스간 통신은 블로킹, 논블로킹이 있다. 블로킹=동기=synchronous, 논블로킹=비동기=asynchronous. 각각의 특징은 다음과 같다.

blocking send : 송신한 메시지를 수신자(혹은 mailbox)가 받을 때까지 새로운 송신을 할 수 없다. non-blocking send : 송신한 메시지를 수신자가 받을 때까지 기다리지 않고 송신 과정만 끝나면 송신자는 바로 새로운 송신을 할 수 있다. blocking receive : 메시지가 이용 가능할 때까지 수신 프로세스가 블락된다. non-blocking receive : 송신하는 프로세스가 유효한 메시지 혹은 null을 받는다.

5.2.3 Buffering

통신하는 프로세스들이 교환하는 메시지는 큐에 들어 있다. 이 큐의 방식은 3가지가 있다.

  1. zero capacity : 큐 최대 길이 0. 메시지를 따로 보관할 곳이 없으므로 송신자는 수신자가 메시지를 수신할 때까지 기다려야만 한다.
  2. bounded capacity : 큐 최대 길이가 정해져 있다. 큐가 꽉 차 있다면 송신자는 큐가 꽉 차지 않을 때까지 기다려야 한다.
  3. unbounded capacity : 큐 최대 길이가 없으며 송신자는 절대 기다리지 않는다.

5.3 비교

대부분 운영체제에서는 2가지 방식을 모두 구현한다. 메시지 전달 방식은 충돌을 회피할 필요가 없다. 그래서 적은 양의 데이터를 공유하는 데 유용하고 분산 시스템에서 구현하기 쉽다.

그러나 공유 메모리 방식은 빠르다. 메시지 전달 방식은 일반적으로 시스템 콜을 이용해 구현하므로 커널 간섭 등 때문에 느리다. 하지만 공유 메모리 방식은 공유 메모리 영역을 구축할 때만 시스템 콜이 필요하고 그 이후에는 커널의 도움이 필요 없다.

6. 실제 IPC 기법

6.1 파이프

파이프는 두 프로세스가 통신할 수 있게 하는 전달자 역할을 한다. 일반적인 파이프는 단방향 통신만 가능하다. 한쪽에서는 데이터를 쓰고 한쪽에서는 읽는다. 만약 양방향 통신이 필요하다면 각각 다른 방향의 파이프 2개를 써야 한다.

이는 일반적으로 커맨드라인에서 명령을 연결할 때 사용한다. 예를 들어 ls | grep 명령을 실행하면 ls 명령의 결과가 grep 명령의 입력으로 들어간다.

또한 파이프는 구조화된 통신이 없기 때문에 파이프에 포함된 데이터의 크기, 송신자와 수신자를 알 수 없다.

일반 익명 파이프의 제한은 조상 프로세스와만 통신이 가능하다는 것이다.. 따라서 파이프를 사용하려면 부모 프로세스가 파이프를 생성하고 자식 프로세스에게 fork를 이용해서 파이프를 복사해야 한다.

익명 파이프를 만드는 건 pipe 함수를 통해 가능하다. pipe(fd) 함수는 파이프를 생성하고 fd[0]과 fd[1]에 각각 읽기와 쓰기를 위한 파일 디스크립터를 저장한다.

  • fd[0] : 읽기 전용 파일 디스크립터
  • fd[1] : 쓰기 전용 파일 디스크립터

실제 코드를 통해 보면 다음과 같다.

#include <stdio.h>
#include <unistd.h>
 
int main(void){
  int n, fd[2], pid;
  char line[100];
 
  if(pipe(fd) < 0){
    fprintf(stderr, "pipe error\n");
    exit(-1);
  }
  if((pid=fork()) < 0){exit(-1);}
  else if(pid > 0){ // parent
    close(fd[0]); // 읽기 종료
    write(fd[1], "hello world\n", 12); // 부모 프로세스에서 쓴다.
    wait(NULL); // 자식 프로세스 기다리기
  }
  else{ // child
    close(fd[1]); // 쓰기 종료
    n = read(fd[0], line, 100); // 자식 프로세스에서 읽는다
    write(STDOUT_FILENO, line, n); // 받은 문자열을 표준 입출력에 출력
  }
}

6.2 지명 파이프

지명 파이프는 일반 파이프의 제한들을 완화시킨다. 지명 파이프는 통신을 양방향으로 가능하게 하고 부모 프로세스와 자식 프로세스가 아닌 다른 프로세스와도 통신이 가능하다.

또 파일 시스템에 존재하여 통신 프로세스가 종료되어도 사라지지 않는다. 실제로 파일처럼 존재하여 open, read, write, close 시스템 콜로 조작할 수 있다.

6.3 소켓

소켓은 통신의 endpoint이다. 각 소켓은 IP 주소와 포트 번호를 가지고 있다. 소켓은 네트워크를 통해 통신을 하기 위한 인터페이스로 정의된다. 그리고 일반적으로 서버-클라이언트가 통신하는 방식이다.

IP주소와 포트 번호로 이루어진 소켓은 일종의 주소 같은 것이라고 생각하면 된다(146.86.5.20:1625와 같이 나타난다). 서버와 클라이언트가 통신을 하기 위해서는 서로의 소켓 주소를 알고 있어야 한다. 그래서 서버는 자신의 소켓 주소를 알려주고 클라이언트는 서버의 소켓 주소를 알아야 한다.

그렇게 클라이언트와 서버가 서로의 소켓 주소를 알고 있으면 TCP, UDP 등의 기법으로 통신을 할 수 있다. 그런데 이때 소켓을 생성한 두 프로세스가 다른 네트워크에 있는 게 아니라 같은 컴퓨터의 같은 운영체제 상에서 실행되고 있다면 소켓을 통한 프로세스 간 통신도 가능하다.