운영체제::Process

안준성·2023년 6월 9일
0

OperatingSystem

목록 보기
4/22

초기의 컴퓨터 시스템은 한번에 하나의 프로그램만을 실행하도록 허용하였다.
이 프로그램이 시스템을 완전히 제어하고, 시스템의 모든 자원에 접근할 수 있었다.
반면 오늘날은 메모리에 다수으 ㅣ프로그램이 적재되어 병행실행된다.
따라서 다양한 프로그램을 제어하고 구획화할 필요성을 낳았고, 이러한 배경이 프로세스라는 개념을 탄생시켰다.

운영체제는 커널이 아닌 사용자 공간에서 다양한 시스템 작업을 처리할 필요가 있다.
시스템은, 일부는 사용자 코드를 실행하고, 일부는 운영체제 코드를 실행하는 프로세스의 집합체이다.
이 모든 프로세스는 잠재적으로 병행 실행 가능하고, cpu들은 프로세스 가운데서 multiplex된다.
프로세스가 무엇인지, 운영체제에서 어떻게 표현되는지, 또 어떻게 작동하는지에 대해 알아보자.

1. 프로세스 개념

1.1 프로세스

비공식적으로, 프로세스란 실행 중인 프로그램이다.
프로세스의 현재 활동 상태는 프로그램 카운터 값과 프로세서 레지스터의 내용으로 나타낸다.

텍스트 및 데이터 섹션의 크기는 고정되기 때문에 실행중에 변하지 않는다.
스택영역은 함수가 호출될 때마다 매개변수, 지역변수, 복귀 주소를 포함하는 activation record가 스택에 푸시된다.
함수에서 제어가 되돌아오면 스택에서 활성화 레코드가 팝 된다.
힙 영역은 메모리가 할당됨에 따라 커지고 반환되면 축소된다.

프로그램 그 자체는 명령어 리스트를 가진 디스크에 저장된 파일(실행파일)과 같은 수동적인 존재이다.
반면 프로세스는 다음에 실행할 명령어를 지정하는 프로그램 카운터와 관련 자원의 집합을 가진 능동적인 존재다.
실행 파일이 메모리에 적재될 때 프로세스가 된다.
./a.out을 입력하면 메모리에 적재되는 것이다.

프로세스 자체가 다른 개체를 위한 실행 환경으로 동작할 수 있다.
JVM은 적재된 Java 코드를 해석하고, 원 기계어를 이용하여 프로세스로서 프로그램을 실행한다.
예를 들어, 컴파일된 Java 프로그램 Program.class를 실행하기 위해선 java Program과 같이 입력한다.

java 명령어는 JVM을 보통의 프로세스처럼 실행시키고,
JVM은 Java 프로그램 Program을 가상머신 안에서 실행한다.

1.2 Process State

프로세스는 실행되면서 그 상태가 변한다.

  • new : 프로세스 생성 중
  • running : 명령어들이 실행 중인 상태
  • waiting : 어떤 이벤트(Ex. 입출력 완료 || 신호의 수신)가 일어나기를 기다린다.
  • ready : 처리기에 할당되기를 기다리는 상태
  • terminated : 프로세스의 실행이 종료된 상태

어느 한순간에 한 처리기 코어에서는 오직 하나의 프로세스만이 실행된다.

1.3 Process Control Block

각 프로세스는 운영체제에서 PCB에 의해 표현된다.
PCB는 특정 프로세스와 연관된 여러 정보를 수록하며 다음을 포함한다.

  • 프로세스 상태
  • 프로그램 카운터 : 프로세스가 다음에 실행할 명령어의 주소를 가리킨다.
  • CPU 레지스터들 : cpu 레지스터는 컴퓨터의 구조에 따라 다양한 수와 유형을 가진다.
    레지스터에는 accumulator, 인덱스 레지스터, 스택 레지스터, 범용 레지스터, 상태코드 정보가 포함된다. 이 상태 정보는 프로그램 카운터와 함께, 나중에 프로세스가 다시 스케줄 될 때 계속 올바르게 실행되도록 하기 위해, 인터럽트 발생 시 저장되어야 한다.
  • CPU-스케줄링 정보 : 프로세스 우선순위, 스케줄 큐에 대한 포인터, 다른 스케줄 매개변수를 포함한다.
  • 메모리 관리 정보 : 페이지 테이블 또는 세그먼트 테이블 등과 같은 정보를 포함한다.
  • accounting 정보 : cpu 사용시간과 경과된 실시간, 시간 제한, 계정 번호, 프로세스 번호 등을 포함한다.
  • 입출력 상태 정보 : 프로세스에 할당된 입출력 장치들과 열린 파일의 목록 등을 포함한다.

요약하자면 PCB는 프로세스를 시작시키거나 다시 시작시키는 데 필요한 모든 데이터를 저장한다.

1.4 Threads

단일 제어 스레드는 프로세스가 한 번에 한 가지 일만 실행하도록 허용한다.
대부분의 현대 운영체제는 프로세스 개념을 확장하여 한 프로세스가 다수의 실행 스레드를 가질 수 있도록 허용한다.
이러한 특성은 특히 다중 처리기 시스템에서 유용한데, 여러 스레드가 병렬로 실행될 수 있다.
스레드를 지원하는 시스템에서는 PCB는 각 스레드에 관한 정보를 포함하도록 확장된다.

2. Process Scheduling

다중 프로그래밍의 목적은 cpu 이용을 최대화하기 위해 항상 어떤 프로세스가 실행되도록 하는 데 있다.
시분할의 목적은 각 프로그램이 실행되는 동안 사용자가 상호 작용할 수 있도록,
프로세스들 사이에서 cpu 코어를 빈번하게 교체하는 것이다.
이를 위해 프로세스 스케줄러는 코어에서 실행 가능한 여러 프로세스 중에서 하나를 선택한다.
현재 메모리에 있는 프로세스 수를 다중 프로그래밍 정도라고 한다.

일반적으로 프로세스는 I/O 바운드 또는 CPU 바운드로 설명할 수 있다.
I/O 바운드 프로세스는 계산보다 I/O에 더 많은 시간을 소비하는 프로세스이다.
CPU 바운드 프로세스는 그 반대이다.

2.1 Scheduling Queue

프로세스가 시스템에 들어가면 Ready Queue에 들어가서 ready상태가 되어 cpu 코어에서 실행되기를 기다린다.
이 큐는 일반적으로 연결 리스트로 저장된다.
레디 큐 헤더에는 리스트의 첫 번째 PCB에 대한 포인터가 저장되고,
각 PCB에는 레디 큐의 다음 PCB를 가리키는 포인터 필드가 포함된다.

시스템에는 다른 큐도 존재한다.
프로세스는 실행되면 종료되거나, 인터럽트 되거나, I/O 요청 완료등의 이벤트가 발생할 때까지 기다린다.
I/O 완료와 같은 이벤트가 발생하기를 기다리는 프로세스는 wait queue에 삽입된다.

프로세스 스케줄링의 일반적인 표현은 큐잉 다이어그램이다.

  • 큐잉 다이어그램

    레디 큐와 wait 큐의 집합의 두 가지 유형의 큐가 있다. 원은 큐에 서비스를 제공하는 자원을 나타내고 화살표는 프로세스의 흐름을 나타낸다.

새 프로세스는 처음에 레디 큐에 놓인다.
프로세스는 실행을 위해 선택되거나 dispatch 될 때까지 기다린다.
프로세스에 cpu 코어가 할당되고 실행 상태가 되면, 여러 이벤트 중 하나가 발생할 수 있다.

  • 프로세스가 I/O 요청을 한 다음 I/O 대기 큐에 놓일 수 있다.
  • 프로세스는 새 자식 프로세스를 만든 다음, 자식의 종료를 기다리는 동안 대기 큐에 놓일 수 있다.
  • 인터럽트 또는 타임 슬라이스가 만료되어 프로세스가 코어에서 강제로 제거되어 레디 큐로 돌아갈 수 있다.

처음 두 경우에는 프로세스가 대기상태에서 준비 상태로 전환된 다음 레디 큐에 다시 들어간다.
프로세스는 이 주기를 반복하다 종료 시점에 모든 큐에서 제거되고 PCB 및 자원이 반환된다.

2.2 CPU Scheduling

프로세스는 생명주기 동안 레디 큐와 다양한 대기 큐를 이주한다.
CPU Scheduler는 레디 큐에 있는 프로세스 중에서 하나를 선택해 cpu 코어를 할당한다.
cpu 스케줄러는 일반적으로 적어도 100밀리초마다 한 번 이상 실행된다.

일부 운영체제는 스와핑으로 알려진 중간 형태의 스케줄링을 가지고 있는데,
핵심 아이디어는 때로는 메모리에서 프로세스를 제거하여 다중 프로그래밍 정도를 감소시키는 것이 유리할 수 있다는 것이다.
프로세를 메모리에서 디스크로 swap out하고 현재 상태를 저장하고,
이후 디스크에서 메모리로 swap in하여 상태를 복원할 수 있다.
스와핑은 일반적으로 메모리가 초과 사용되어 가용공간을 확보해야 할 때만 필요하다.

2.3 Context Switch

  • 프로세스에서 프로세스로의 문맥 교환

인터럽트는 운영체제가 cpu 코어를 현재 작업에서 뺏어 커널 루틴을 실행할 수 있게 한다.
인터럽트가 발생하면 시스템은 인터럽트 처리 후 문맥을 복구할 수 있도록, 현재 실행 중인 프로세스의 문맥을 저장할 필요가 있다.
문맥은 CPU 레지스터의 값, 프로세스 상태, 메모리 관리 정보 등을 포함하며 PCB에 표현된다.

cpu 코어를 다른 프로세스로 교환하려면, 이전의 프로세스의 상태를 보관하고
새로운 프로세스의 보관된 상태를 복구하는 작업이 필요하다.
이것이 context switch다.

문맥 교환이 일어나면 커널은 과거 프로세스의 문맥을 PCB에 저장하고,
실행이 스케줄된 새로운 프로세스의 저장된 문맥을 복구한다.
문맥 교환이 진행될 동안은 아무런 유용한 일을 못하기 때문에 문맥 교환 시간은 순수한 오버헤드다.

3. 프로세스에 대한 연산

대부분 시스템 내의 프로세스들은 병행 실행될 수 있으며, 반드시 동적으로 생성되고, 제거되어야 한다.
운영체제는 프로세스 생성 및 종료를 위한 기법을 제공해야 한다.

3.1 프로세스 생성

실행되는 동안 프로세스는 여러 개의 새로운 프로세스들을 생성할 수 있다.
그 결과 프로세스의 트리를 형성한다.

대부분의 현대 운영체제들은 유일한 프로세스 식별자(pid)를 사용하여 프로세스를 구분하는데,
이 식별자를 통하여 커널이 유지하고 있는 프로세스의 index로 사용된다.

  • Linux 시스템의 프로세스 트리

Linux에서 언제나 pid가 1인 systemd 프로세스가 모든 사용자 프로세스의 루트 부모 프로세스 역할을 수행하고 시스템이 부트될 때 생성되는 첫 번째 사용자 프로세스이다.
시스템이 부팅되면 systemd 프로세스는 다양한 사용자 프로세스를 생성한다. (ex. 웹, ssh서버)
logind 프로세스는 시스템에 직접 로그인하는 클라이언트를 관리하는 책임을 진다.
이 예제에서 클라이언트는 로그인 후 bash CLI를 사용하여 ps 프로세스와 vim 프로세스를 생성하였다.
sshd 프로세스는 ssh를 사용하여 시스템에 접속하는 클라이언트의 관리를 책임진다.

init과 systemd 프로세스
기존의 UNIX 시스템은 init 프로세스를 루트로 식별한다.
Linux 시스템은 처음에는 System V init 방식을 채택했지만 최근 배포판에서는 이를 systemd로 대체하였다.
systemd는 System V init과 매우 유사하지만 init보다 훨씬 융통성있고, 더 많은 서비스를 제공할 수 있다.

일반적으로 프로세스가 자식 프로세스를 생성할 때,
자식 프로세스는 어떤 자원이 필요하다.
자식 프로세스는 이 자원을 운영체제로부터 직접 얻거나,
부모 프로세스가 가진 자원의 일부만 사용하도록 제한될 수 있다.

자원을 제공하는 것 이외에 부모 프로세스는 자식 프로세스에 초기화 데이터를 전달할 수 있다.

프로세스가 새로운 프로세스를 생성할 때,
두 프로세스를 실행시키는 데 두 가지 방법이 존재한다.

  1. 부모는 자식과 병행하게 실행을 계속한다.
  2. 부모는 일부 또는 모든 자식이 실행을 종료할 때까지 기다린다.

새로운 프로세스들의 주소 공간 측면에서 볼 때 다음과 같은 두 가지 가능성이 있다.

  1. 자식 프로세스는 부모 프로세스의 복사본이다
  2. 자식 프로세스가 자신에게 적재될 새로운 프로그램을 가지고 있다.

이들의 차이점을 설명하기 위해,
우선 UNIX 운영체제를 고려해보자 .
UNIX에서 각 프로세스는 pid로 식별된다.
새로운 프로세스는 fork() 시스템 콜로 생성된다.
새로운 프로세스는 원래 프로세스의 주소 공간의 복사본으로 구성된다.
이 기법은 부모 프로세스가 쉽게 자식 프로세스와 통신할 수 있게 한다.
두 개의 프로세스들(부모와 자식)은 fork() 후의 명령어에서부터 실행을 계속하며,
이때 한 가지 다른 점은 fork()의 복귀 코드가 서로 다르다는 것이다.
자식 프로세스의 식별자가 부모로 복귀되는 데 반해,
새로운(자식) 프로세스는 '0'이 복귀된다.

fork() 시스템 콜 다음에 두 프로세스 중 한 프로세스가 exec() 시스템 콜을 사용하여 자신의 메모리 공간을 새로운 프로그램으로 교체한다.
exec() 시스템 콜은 이진 파일을 메모리로 적재하고(이때 exec() 시스템 콜을 포함하는 원래의 프로그램의 메모리 이미지를 파괴한다) 그 프로그램의 실행을 시작한다.
이와 같은 방법으로 두 프로세스는 통신할 수 있으며, 이어 각자의 길을 간다.
그 후 부모는 더 많은 자식을 생성할 수 있으며,
또는 자식이 실행하는 동안 할 일이 없으면,
자식이 종료될 때까지 준비 큐에서 자신을 제거하기 위해 wait() 시스템 콜을 한다.
exec()을 호출하면 프로세스의 주소 공간을 새 프로그램으로 덮어쓰기 때문에 exec() 시스템 콜은 오류가 발생하지 않는 한 제어를 반환하지 않는다.

우리는 이제 동일한 프로그램의 복사본을 실행하는 두 개의 서로 다른 프로세스를 갖는다.
자식 프로세스는 자원뿐 아니라 특권과 스케줄링 속성을 부모 프로세스로부터 상속받는다.
그런 후에 자식프로세스는 exec() 시스템 콜을 사용하여 자신의 주소 공간을 bin/ls로 덮어쓴다.
부모는 wait() 시스템 콜로 자식 프로세스가 끝나기를 기다린다.
자식 프로세스가 끝나면(암시적 또는 exit()를 사용하여),
부모 프로세스는 wait() 호출로부터 재개하여, exit() 시스템 콜을 사용하여 끝낸다.

물론 자식 프로세스가 exec()를 호출하지 않고 부모 프로세스의 복사본을 계속 실행하는 것을 막을 방법은 없다.
이 시나리오에서 부모와 자식은 같은 코드를 실행하는 병행 실행 프로세스이다.
자식은 부모의 복사본이기 때문에 각 프로세스는 모든 데이터에 대해 자신만의 복사본을 가지고 있다.

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

/* fork() 시스템 콜을 사용하여 별도의 프로세스를 생성 */
int main()
{
pid_t pid;

	// 새 프로세스를 생성한다 
    pid = fork();
    
    // 오류 발생
    if (pid < 0)
    {
    	fprintf(stderr, "Fork Failed");
        return 1;
    }
    // 자식 프로세스
    else if (pid == 0)
    {
    	execlp("/bin/ls", "ls", NULL);
    }
    // 부모 프로세스
    else
    {
    	wait(NULL);
        printf("Child Complete");
    }
    
    return 0;
}

다른 예로 Windows에서의 프로세스 생성은 Windows API의 CreateProcess() 함수를 사용한다.
CreateProcess()는 fork()와 유사하지만 자식 프로세스가 생성될 때 주소 공간에 명시된 프로그램을 적재한다.
또한 fork()가 아무런 인자가 전달되지 않는 반면에 CreateProcess()는 10개 이상의 매개변수를 요구한다.

3.2 프로세스 종료

프로세스가 마지막 문장의 실행을 끝내고,
exit 시스템 콜을 사용하여 운영체제에게 자신의 삭제를 요청하면 종료한다.
이 시점에 프로세스는 자신을 기다리고 있는 부모 프로세스에 상태 값을 반환할 수 있다.
프로세르의 모든 자원이 할당 해제되고 운영체제로 반납된다.

한 프로세스가 시스템 콜을 이용해 다른 프로세스의 종료를 유발할 수도 있다.
통상적으로 그런 시스템 콜은 부모만이 호출할 수 있다.
부모가 자식을 종료시키기 위해서는 자식의 pid를 알아야 한다.
그러므로 자식 프로세스를 만들 때, 자식 프로세스의 identity가 부모에게 전달된다.

부모는 다음과 같은 여러 이유로 인해 자식 프로세스를 종료할 수 있다.

  • 자식이 자신에게 할당된 자원을 초과하여 사용할 때
  • 자식에게 할당된 태스크가 더 이상 필요 없을 때
  • 부모가 exit를 하는데, 운영체제가 부모가 exit 한 후에 자식이 실행을 계속하는 것을 허용하지 않는 경우

몇몇 시스템에서는 부모 프로세스가 종료되면,
모든 자식 프로세스들도 종료되어야 한다.
이것을 연쇄식 종료(cascading termination)라고 부르며 이 작업은 운영체제가 시행한다.

Linux와 UNIX 시스템에서 exit() 시스템 콜을 사용하여 프로세스를 종료시키는 것을 생각해보자.
exit() 시스템 콜은 종료 상태를 나타내는 인자를 전달받는다.

exit(1);

정상적인 종료에서 exit()는 위와 같이 직접적으로 호출되거나
UNIX 실행파일에 추가되는 C 런타임 라이브러리가 디폴트로 exit() 호출을 추가한다.

wait() 시스템 콜은 부모가 자식의 종료 상태를 얻어낼 수 있도록 하나의 인자를 전달받는다.
이 시스템 콜은 부모가 어느 자식이 종료되었는지 구별할 수 있도록
종료된 자식의 프로세스 식별자를 반환한다.

pid_t pid;
int status;

pid = wait(&status);

프로세스가 종료하면 사용하던 자원은 운영체제가 되찾아 간다.
그러나 프로세스의 종료 상태가 저장되는 프로세스 테이블의 해당 항목은
부모 프로세스가 wait()를 호출할 때까지 남아 있게 된다.
종료되었지만 부모 프로세스가 아직 wait() 호출을 하지 않은 프로세스를 좀비 프로세스라고 한다.**
종료하게 되면 모든 프로세스는 아주 짧은 시간동안 좀비 상태가 된다.
부모가 wait()를 호출하면 좀비 프로세스의 pid와 프로세스 테이블의 해당 항목이 운영체제에 반환된다.

부모 프로세스가 wait()를 호출하는 대신 종료한다면 어떤 일이 벌어질까?
이 상황에 부닥친 자식 프로세스를 고아(orphan) 프로세스라고 부른다.
전통적인 UNIX는 고아 프로세스의 새로운 부모로 init 프로세스를 지정함으로써 이 문제를 해결한다.
init 프로세스는 주기적으로 wait()를 호출하여 고아 프로세스의 종료 상태를 수집하고 pid와 프로세스 테이블 항목을 반환한다.

3.2.1 Android 프로세스 계층

제한된 메모리와 같은 자원의 제약 때문에 모바일 운영체제는
자원을 회수하기 위해 프로세스를 종료해야 할 수도 있다.
이를 위해 프로세스간 중요도를 구분해 중요도가 낮은 프로세스부터 종료시킨다.

4. Interprocess Communication

운영체제 내에서 실행되는 병행 프로세스들은 독립적이거나 또는 협력적인 프로세스들일 수 있다.
프로세스 협력을 허용하는 환경을 제공하는 데는 몇 가지 이유가 있다.

  • 정보 공유
  • 계산 가속화 : 특정 태스크를 빨리 실행하고 싶다면 서브태스크로 나누어 병령로 실행되게 해야한다.
  • 모듈성 : 우리는 시스템 기능을 나누어 모듈식 형태로 구성하기를 원할 수도 있다.

협력적 프로세스들은 데이터를 교환할 수 있는 IPC 기법이 필요하다.
IPC에는 기본적으로 shared memorymessage passing의 두 가지 모델이 있다.

공유 메모리 모델에서는 협력 프로세스들에 의해 공유 되는 메모리의 영역이 구축된다.

메시지 전달 모델에서는 협력 프로세스들 사이에 교환되는 메시지를 통하여 통신이 이루어진다.

메시지 전달 모델은 충돌을 회피할 필요가 없기 때문에 적은 양의 데이터를 교환하는 데 유용하다.
또한 분산 시스템에서 공유 메모리보다 구현하기 쉽다.
메시지 전달 시스템은 통상 시스템 콜을 사용하여 구현되므로
커널 간섭 등의 부가적인 시간 소비 작업이 필요하기 때문에
공유 메모리 모델이 메시지 전달보다 더 빠르다.

공유 메모리 시스템에서는 공유 메모리 영역을 구축할 때만 시스템 콜이 필요하다.
공유 메모리 영역이 구축되면 모든 접근은 일반적인 메모리 접근으로 취급되어 커널의 도움이 필요 없다.

5 공유 메모리 시스템에서의 IPC

공유 메모리를 사용하는 IPC에서는 통신하는 프로세스들이 공유 메모리 영역을 구축해야 한다.
통상 공유 메모리 영역은 공유 메모리 세그먼트를 생성하는 프로세스의 주소 공간에 위치한다.
이 공유 메모리 세그먼트를 이용하여 통신하고자 하는 다른 프로세스들은
이 세그먼트를 자신의 주소 공간에 추가하여야 한다.
프로세스들은 동시에 동일한 위치에 쓰지 않도록 책임져야 한다.

협력하는 프로세스의 일반적인 패러다임인 생산자-소비자 문제를 알아보자.
생산자 프로세스는 정보를 생산하고 소비자 프로세스는 정보를 소비한다.

생산자-소비자 문제의 하나의 해결책은 공유 메모리를 사용하는 것이다.
생산자가 정보를 채워 넣고 소비자가 소모할 수 있는 항목들의 버퍼가 필요하다.
이 버퍼는 공유 메모리 영역에 존재하게 된다.

두 가지 유형의 버퍼가 사용된다.
무한 버퍼(unbounded buffer)의 생산자 소비자 문제에서는 버퍼의 크기에 실질적인 한계가 없다.
소비자는 새로운 항목을 기다려야만 할 수도 있지만,
생산자는 항상 새로운 항목을 생산할 수 있다.
유한 버퍼(bounded buffer)는 버퍼의 크기가 고정되어 있다고 가정한다.
이 경우 버퍼가 비어 있으면 소비자는 반드시 대기해야 하며,
모든 버퍼가 채워져 있으면 생산자가 대기해야 한다.

유한 버퍼가 공유 메모리를 사용한 IPC를 어떻게 분명하게 하는지 살펴보자.

#define BUFFER_SIZE 10

typedef struct {
	...
} item;

item buffer[BUFFER_SIZE];
int in = 0;
int out = 0;

공유 버퍼는 두 개의 논리 포인터 in과 out을 갖는 원형 배열로 구현된다.
변수 in은 버퍼 내에서 다음으로 비어 있는 위치를 가리키며,
out은 버퍼 내에서 첫 번째로 채워져 있는 위치를 가리킨다.
in == out 일때 버퍼는 비어있고,
((in + 1) % BUFFER_SIZE) == out이면 버퍼는 가득 차 있다.

// 생산자 프로세스
item next_produced;

while (true)
{
// produce an item in next_produced
	while (((in + 1) % BUFFER_SIZE) == out)
    	; // do nothing
        
    buffer[in] = next_produced;
    in = (in + 1) % BUFFER_SIZE;
}
// 소비자 프로세스
item next_consumed;

while (true)
{
	while (in == out)
    	; // do nothing
        
    next_consumed = buffer[out];
    out = (out + 1) % BUFFER_SIZE;
    
    // consume the item in next_consumed
}

6. IPC in Message-Passing Systems

운영체제가 메시지 전달 설비를 통하여 프로세스 간의 통신 수단을 제공해 주는 방법.

동일한 주소 공간을 공유하지 않고도 프로세스들이 통신을 하고, 프로세스의 동작을 동기화할 수 있도록 허용하는 기법을 제공한다.
프로세스들이 다른 컴퓨터에 존재하는 분산 환경에서 특히 유용하다.

메시지 전달 시스템은 최소 두 가지 연산을 제공한다.

send(message)
receive(message)

만약 프로세스가 메시지를 주고 받으려면 통신 연결(communication link)이 설정되어야 한다.
하나의 링크와 send()/receive() 연산을 논리적으로 구현하는 다수의 방법은 다음과 같다.

  • 직접 또는 간접 통신
  • 동기식 또는 비동기식 통신
  • 자동 또는 명시적 버퍼링

다음으로 이 특성과 관련된 쟁점들을 살펴본다.

6.1 Naming

통신을 원하는 프로세스들은 서로를 가리킬 방법이 있어야 한다.
이들은 간접 통신 또는 직접 통신을 사용할 수 있다.

직접 통신하에서, 각 프로세스는 수신자 또는 송신자의 이름을 명시해야 한다.
이 기법에서는 다음과 같이 정의한다.

  • send(P, message) : 프로세스 P에 메시지를 전송한다.
  • receive(Q, message) : 프로세스 Q로부터 메시지를 수신한다.

또한 이 기법에서 통신 연결은 다음의 특성을 가진다.

  • 각 프로세스 사이의 연결이 자동으로 구축된다. 프로세스들은 통신을 위해 상대방의 신원만 알면 된다.
  • 연결은 정확히 두 프로세스 사이에만 연관된다.
  • 통신하는 프로세스 쌍 사이에는 정확하게 하나의 연결이 존재해야 한다.

이 기법은 주소 방식에서 대칭성을 보인다.
즉, 송신자와 수신자 모두 상대방의 이름을 제시해야 한다.
이 기법의 변형으로 주소 지정 시에 비대칭을 사용할 수도 있다.
송신자만 수신자 이름을 지명하며 send()와 receive()는 다음과 같이 정의한다.

  • send(P, message) : 메시지를 프로세스 P에 전송한다.
  • receive(id, message) : 임의의 프로세스로부터 메시지를 수신한다. id는 통신을 발생시킨 프로세스의 이름으로 설정된다.

직접 통신 기법(대칭, 비대칭 모두)은 프로세스를 지정하는 방식 때문에 모듈성을 제한한다는 단점이 있다.

메시지 시스템의 주요 문제점은 송신자 포트에서 수신자 포트로 메시지를 복사해야 하므로 발생하는 성능 저하이다.
Mach 메시지 시스템은 가상 메모리 관리 기술을 사용하여,
송신자의 메시지가 포함된 주소 공간을 수신자의 주소 공간에 매핑한다.
따라서 송신자와 수신자 모두 동일한 메모리에 액세스 한다.
하지만 이는 같은 시스템 내 메시지에만 작동한다.

3.7 IPC 시스템의 사례

3.7.4 Pipes

파이프는 두 프로세스가 통신할 수 있게 하는 전달자
파이프는 프로세스 간에 통신하는 간단한 방법이지만 통신 시 여러 제약을 한다.

일반 파이프는 생산자-소비자 형태로 두 프로세스 간의 통신을 허용한다.
생산자는 파이프의 한 종단에 쓰고,
소비자는 다른 종단에서 읽는다.
결과적으로 일반 파이프는 단방형 통신만 가능하다.

pipe(int fd[])

이 함수는 fd를 통해 접근되는 파이프를 생성한다.
fd[0]는 파이프의 읽기 종단이고,
fd[1]는 파이프의 쓰기 종단으로 동작한다.

일반 파이프는 파이프를 생성한 프로세스 이외에는 접근할 수 없다.
따라서 통상 부모 프로세스가 파이프를 생성하고
fork()로 생성한 자식 프로세스와 통신하기 위해 사용한다.
(자식 프로세스는 열린 파일을 부모로부터 상속받는다)
파이프는 파일의 특수한 유형이기 때문에
자식 프로세스는 부모로부터 파이프를 상속받는다.

부모와 자식 프로세스 모두 처음에 자신들이 사용하지 않는 파이프의 종단을 닫는다.
writer가 파이프의 종단을 닫았을 때,
파이프로부터 읽는 프로세스가 eof(read()가 0을 반환)를 탐지하는 것을 보장하기 때문에
이 작업은 매우 중요한 절차이다.

Windows에서 일반 파이프는 익명 파이프라고 불리며 UNIX와 유사하게 동작한다.
이 파이프는 단방향이고,
통신하는 두 프로세스는 부모-자식 관계여야 한다.
파이프의 읽기와 쓰기는 보통 ReadFile()과 WriteFile()을 통해 이루어진다.
CreatePipe()를 통해 파이프를 생성한다.

UNIX와 다르게 자식이 어떤걸 상속받는지 명시해줘야 한다.

UNIX와 Windows 모두 통신하는 두 프로세스는 부모-자식 관계임을 명심하라.
이 유형의 파이프는 동일한 기계상의 두 프로세스끼리만 통신이 가능하다는 것을 의미한다.

Named Pipes(지명 파이프)
일반 파이프는 한 쌍의 프로세스가 통신할 수 있는 간단한 기법을 제공한다.
그러나 일반 파이프는 오로지 프로세스들이 서로 통신하는 동안에만 존재한다.

지명 파이프는 좀 더 강력한 통신 도구를 제공한다.
통신은 양방향,
부모-자식 관계도 필요하지 않다.
지명 파이프가 구축되면 여러 프로세스들이 이를 사용하여 통신할 수 있다. (다수의 writer를 가진다.)
또한 통신 프로세스가 종료돼도 지명 파이프는 계속 존재한다.

UNIX에서는 지명 파이프를 FIFO라고 부른다.
지명 파이프는 생성되면 파일 시스템의 보통 파일처럼 존재한다.
mkfifo()를 통해 생성되고,
open(), read(), write(), close()로 조작된다.
명시적으로 삭제할 때까지 존재한다.
FIFO는 양방향 통신을 지원하지만 반이중 전송만이 가능하다.
또한 통신하는 두 프로세스는 동일한 기계 내에 존재해야 한다.
다른 기계에 존재하는 프로세스 사이에 통신이 필요하다면 소켓 통신을 해야한다.

Windows에서 지명 파이프는 UNIX보다 훨씬 풍부한 통신 기법을 제공한다.
전이중 통신을 허용하며,
통신하는 두 프로세스는 다른 기계상에 존재할 수 있다.
추가로 UNIX는 바이트 단위 통신만을 혀용하는데에 반해
바이트단위 + 메시지 단위 데이터의 전송을 허용한다.

8. 클라이언트 서버 환경에서 통신

클라이언트 서버에서 사용할 수 있는 두 가지 통신 전략에 대해 설명한다.
이 두 가지는 소켓, 원격 프로시저 호출이다.

8.1 Socket

소켓은 통신의 endpoint를 뜻한다.
두 프로세스가 네트워크상에서 통신하려면 각각 하나씩, 총 두개의 소켓이 필요하다.
각 소켓은 IP와 포트번호를 통해 구별한다.
일반적으로 소켓은 클라이언트-서버 구조를 사용한다.
서버는 지정된 포트에 클라이언트 요청 메시지가 도착하기를 기다린다.
요청이 오면 서버는 클라이언트 소켓으로부터 연결 요청을 수락하면서 연결이 완성된다.
Telnet, ftp, http등의 서비스를 구현하는 서버는
well-known 포트로부터 메시지를 기다린다.
(ex. ssh 서버는 22번, ftp 서버는 21번, HTTP 서버는 80번)
1024 미만의 모든 포트는 well-known 포트로 간주되며 표준 서비스를 구현하는 데 사용된다.

클라이언트 프로세스가 연결을 요청하면 호스트 컴퓨터가 포트 번호를 부여한다.
이 번호는 1024보다 큰 임의의 정수가 된다.
두 호스트 사이에 패킷들이 오갈 때
그 패킷들은 이 목적지 포트 번호가 지정하는 데 따라 적절한 프로세스로 배달된다.

모든 연결은 유일한 소켓 쌍으로 구성된다.

자바는 세 가지 종류의 소켓을 제공한다.
연결 기반(TCP) 소켓은 Socket 클래스로 구현된다.
비연결성(UDP) 소켓은 DatagramSocket 클래스를 사용한다.
마지막으로 MulticastSocket 클래스는 UDP 클래스의 서브 클래스이다.

클라이언트는 소켓을 생성하고
서버가 listen 하는 포트와 연결함으로써 서버와 통신을 시작한다.
클라이언트는 소켓을 생성하고 IP 127.0.0.1에 있는 포트 6013의 서버와 연결해 주기를 요청한다.
서버로부터 데이터를 받고 클라이언트는 소켓을 닫고 종료한다.
127.0.0.1은 Loopback을 나타내는 특별한 주소다. (자기 자신을 나타낸다)
이를 통해 같은 기계에 있는 클라이언트와 서버가 TCP/IP 프로토콜을 사용하여 통신하게 된다.
물론 루프백 주소말고 실제 호스트의 이름이나 IP주소를 사용할 수도 있다.

소켓을 이용한 통신은 분산된 프로세스 간에 널리 사용되고 효율적이지만
너무 낮은 수준이다.
소켓은 스레드 간에 구조화되지 않은 바이트 스트림만을 통신하도록 하기 때문이다.
이러한 원시적인 바이트 스트림 데이터를 구조화하여 해석하는 것은
클라이언트와 서버의 책임이 된다.
이에 대한 대안으로 더 높은 수준의 통신 기법인 원격 프로시저 호출, RPC를 살펴보자.

8.2 원격 프로시저 호출

원격 서비스와 관련한 가장 보편적인 형태 중 하나는 RPC 패러다임이다.
IPC와 많은 측면에서 유사하며 실제 IPC 기반 위에 만들어진다.
그러나 RPC에서는 프로세스들이 서로 다른 시스템 위에서 돌아가기 때문에
메시지 기반 통신을 해야 한다.

IPC 방식과는 달리 전달되는 메시지는 구조화되어 있고,
따라서 데이터의 패킷 수준을 넘어서게 된다.

시스템은 지원하는 여러 서비스를 구별하기 위해 포트를 여러 개 가질 수 있다.
원격 프로세스가 어떤 서비스를 받고자 하면 그에 대응되는 포트 주소로 메시지를 보내야 한다.
시스템이 port 3027과 같은 곳에 등록시켜 놓으면
원격 시스템은 서버의 포트 3027로 RPC 메시지를 보내 필요한 정보를 얻을 수 있다.

RPC는 클라이언트가 원격 호스트의 프로시저를 호출하는 것을
마치 자기의 프로시저를 호출하는 것처럼 해준다.
RPC 시스템은 클라이언트 쪽에 Stub을 제공하여 통신하는 데 필요한 자세한 사항들을 숨겨준다.
보통 원격 프로시저마다 다른 스텁이 존재한다.
클라이언트가 원격 프로시저를 호출하면 RPC는 그에 대응하는 스텁을 호출하고 원격 프로시저가 필요로 하는 매개변수를 건네준다.
그러면 스텁이 원격 서버의 포트를 찾고 매개변수를 정돈(marshall)한다.
그 후 스텁은 메시지 전달 기법을 사용하여 서버에게 메시지를 전송한다.
이에 대응되는 스텁이 서버에도 존재하여 서버 측 스텁이 메시지를 수신한 후
적절한 서버의 프로시저를 호출한다.

매개변수 정돈은 클라이언트와 서버 기기의 데이터 표현 방식의 차이 문제를 해결한다.
대부분의 RPC 시스템은 기종 중립적인 데이터 표현 방식을 정의한다.
이러한 표현 방식 중 하나가 XDR(eXternal Data Representation)이다.

클라이언트 측에서는 데이터를 보내기 전 데이터를 XDR 형태로 바꾸어서 보낸다.

또 다른 중요한 문제는 호출에 관한 것이다.
RPC에서 호출은 정확히 한번 처리되도록 보장돼야 한다.
이를 위해 서버는 타임스탬프를 이용해 호출이 최대 한번 실행되게 프로토콜을 구현하고,
추가로 ACK 메시지를 통해 RPC 요청이 수신되었고 실행됐다는 것을 알려야 한다.
클라이언트는 해당 호출에 대한 ACK를 받을 때까지 주기적으로 각 RPC 호출을 재전송해야 한다.

또 하나 다루어야 할 중요한 문제는 클라이언트와 서버 간의 통신 문제이다.
일반적인 프로시저 호출의 경우, binding 작업이 실행 시점에 행해진다.
RPC도 클라이언트와 서버의 포트를 바인딩해야 하는데,
클라이언트는 서버의 포트 번호를 어떻게 알 수 있을까?

이를 위해 보통 두 가지 방법이 사용된다.
한 가지 방법은 고정된 포트를 정해 놓는 것이다.
두 번째는 랑데부 방식에 의해 동적으로 바인딩 하는 방법이다.
보통 운영체제는 미리 정해져 있는 고정 RPC 포트를 통해
랑데부용 디먼(matchmaker라고 한다)을 제공한다.
그러면 클라이언트가 자신이 실행하기를 원하는 RPC 이름을 담고 있는 메시지를
랑데부 디먼에게 보내서,
RPC 이름에 대응하는 포트 번호가 무엇인지 알려달라고 요청한다.

RPC는 일반적으로 분산 시스템과 관련되어 있지만
동일한 시스템에서 실행되는 IPC의 형태로 사용될 수도 있다.

앱은 여러 응용 프로그램 구성요소를 결합하여 필요한 기능을 구현할 수 있다.
이러한 응용 프로그램 구성요소 중 하나는 서비스이다.
서비스의 예로는 백그라운드에서 음악을 재생하고 다른 프로세스 대신 네트워크 연결을 통해 데이터를 검색하여 데이터를 다운로드 할 때 다른 프로세스가 실행 중단되는 것을 방지할 수 있다.


스터디 요약

일반 파이프
pipe는 부모에서 자식으로 상속되어야 하기 때문에
fork() 전에 만들어야 한다.

pipe() 시스템 콜을 통해 파이프를 만든다.
매개변수로는 입구와 출구를 나타내는 fd 2개가 배열의 형태로 들어간다.

fork()를 쓰면 fd가 상속된다
그러고 자기가 안쓰는 fd는 close 한다.

안 닫으면 문제가 발생한다.

네임드 파이프
네임드 파이프는 프로세스에 종속되지 않고
fd를 가지기 때문에 부모-자식 사이가 아니더라도 사용이 가능하다.

소켓
소켓도 파일이다.
소켓은 tcp를 추상화한 인터페이스이다.

RPC
프로시저는 반환 값이 있는 함수와 달리
어떠한 실행 흐름만을 뜻한다.

말 그대로 원격에 있는 프로시저를 호출하는 것이다.

HTTP3가 이 방식을 많이 쓴다.

RPC에 대해 좀 더 알아보기

dup2(fd[1], 1)을 하면 fd 1(표준 출력)의 역할에
원래 fd가 닫히고 fd[1]의 값으로 대체된다.
즉, 표준 출력이 fd[1]로 연결된다.

프로세스가 종료되면 fd가 다 닫힌다.


wait는 자식프로세스를 기다려 사망신고를 해주어 좀비 프로세스를 방지한다.

충돌을 회피할 필요가 없다 :

profile
안녕하세요 준갑습니성

2개의 댓글

comment-user-thumbnail
2023년 6월 10일

캭 퉤

1개의 답글