Search
Duplicate

운영체제/ 프로세스

초기 컴퓨터 시스템은 한 번에 하나의 프로그램만을 실행하도록 허용하였다. 반면 오늘날 컴퓨터 시스템들은 메모리에 다수의 프로그램들이 적재되어 병행 실행되는 것을 허용한다. 이러한 발전은 다양한 프로그램을 보다 견고하게 제어하고 보다 구획화할 것을 필요로 했다.
이러한 필요성이 프로세스의 개념을 낳았으며, 프로세스란 실행 중인 프로그램을 말한다. 프로세스는 현대의 시분할 시스템에서 작업의 단위이다.
운영체제의 주 관심은 사용자 프로그램을 실행하는 것이지만, 운영체제는 커널에 포함시키지 않는 편이 더 좋다고 판단된 다양한 시스템 작업들도 처리해야 한다. 그러므로 하나의 시스템은 프로세스들의 집합체이다.
즉 운영체제 프로세스들은 시스템 코드를 실행하고, 사용자 프로세스들은 사용자 코드를 실행한다. 이들 모든 프로세스들은 잠재적으로 병행 실행이 가능하고, CPU는 이들 프로세스들 가운데서 다중화(multiplex) 된다.
CPU를 각 프로세스들 사이에게 전환시킴으로써 운영체제는 컴퓨터를 보다 생산적으로 만든다.

프로세스 개념

운영체제를 논의할 때 CPU 활동을 어떻게 부를 것인가 하는데 의문이 있다. 일괄처리 시스템은 잡(job)들을 실행하는 반면, 시분할 시스템은 사용자 프로그램 또는 태스크(task)들을 가진다.
사용자 단일 사용자 시스템에서조차 동시에 여러 프로그램을 실행할 수 있다. 그리고 다중 태스킹을 지원하지 않는 임베디드 장치 등에서 설혹 사용자가 한 번에 하나의 프로그램만 실행시키더라도 운영체제는 메모리 관리와 같은 운영체제 내부의 예정된 활동들을 지원할 수 있다.
이들 모든 활동들은 여러 면에서 유사하므로 우리는 이들을 모두 프로세스라 부른다. 이 책에서는 잡과 프로세스란 용어를 거의 호환성 있게 사용한다.

프로세스(The Process)

비공식적으로 프로세스란 실행 중인 프로그램이다. 프로세스는 때로는 텍스트 섹션으로 알려진 프로그램 코드 이상의 무엇이다. 프로세스는 또한 프로그램 카운터의 값과 처리기 레지스터의 내용으로 대표되는 현재 활동을 포함한다.
프로세스는 일반적으로 함수의 매개변수, 복귀 주소와 로컬 변수와 같은 임시적인 자료를 가지는 프로세스 스택과 전역 변수들을 수록하는 데이터 섹션을 포함한다.
또한 프로세스는 프로세스 실행 중에 동적으로 할당되는 메모리인 힙을 포함한다. 메모리 상에 존재하는 프로세스의 구조가 그림 3.1에 나와 있다.
우리는 프로그램 그 자체는 프로세스가 아님을 강조한다. 프로그램은 명령어 리스트를 가진 디스크에 저장된 파일(실행 파일이라고 불림)과 같은 수동적인 존재이다.
이와 대조적으로 프로세스는 다음에 실행할 명령어를 지정하는 프로그램 카운터와 관련 자원의 집합을 가진 능동적인 존재이다. 실행 파일이 메모리에 적재될 때 프로그램은 프로세스가 된다.
두 프로세스들이 동일한 프로그램에 연관될 수 있지만, 이들은 두 개의 별도의 실행 순서로 간주된다.
예컨대 여러 사용자가 메일 프로그램의 서로 다른 복사본을 실행하거나 또는 동일 사용자가 웹 브라우저 프로그램의 여러 복사본을 호출할 수 있다. 이들 각각은 별도의 프로세스이며, 텍스트 섹션이 동등하다 할지라도 데이터, 힙 및 스택 섹션은 다를 수 있다. 프로세스가 실행되는 과정에서 많은 프로세스들을 생성하는 것이 보통이다.
프로세스 자체가 다른 개체를 위한 실행 환경으로 동작할 수 있다는 사실에 주목하라. Java 프로그래밍 환경이 좋은 예이다. 대부분의 상황에서 실행 가능한 Java 프로그램은 Java 가상기계(JVM) 안에서 실행된다.
JVM은 적재된 Java 코드를 해석하고 그 코드를 대신하여 원 기계어를 이용하여 행동을 취하는 프로세스로서 프로그램을 실행한다.
예컨대 컴파일된 Java 프로그램 Program.class를 실행하기 위해 다음과 같이 명령을 입력할 것이다.
java program
Shell
java 명령어는 JVM을 보통의 프로세스처럼 실행시키고, JVM은 Java 프로그램 Program을 가상 기계 안에서 실행한다. 이 개념은 다른 기계어로 작성된 프로그램이 아니라 Java 언어로 작성된 프로그램을 실행시킨다는 점을 제외하면 모의실험과 동일한 개념이다.

프로세스 상태(Process State)

프로세스는 실행되면서 그 상태가 변한다. 프로세스의 상태는 부분적으로 그 프로세스의 현재의 활동에 따라 정의된다. 프로세스는 다음 상태들 중 하나에 있게 된다.
새로운(new): 프로세스가 생성 중이다.
실행(running): 명령어들이 실행되고 있다.
대기(waiting): 프로세스가 어떤 사건(입출력 완료 또는 신호의 수신 같은)이 일어나기를 기다린다.
준비 완료(ready): 프로세스가 처리기에 할당되기를 기다린다.
종료(terminated): 프로세스의 실행이 종료되었다.
이들 이름들은 임의적이고 운영체제마다 변하지만 이들이 나타내는 상태들은 모든 시스템에서 찾아볼 수 있다.
또한 어떤 운영체제는 프로세스 상태를 더욱 자세하기 묘사하여 구별한다. 어느 한 순간에 한 처리기 상에서는 오직 하나의 프로세스만이 실행된다는 것을 인식하는 것이 중요하다.
그렇지만 많은 프로세스가 준비완료 및 대기 상태에 있을 수 있다. 그림 3.2는 이들 상태에 해당하는 상태도(state diagram)이다.

프로세스 제어 블록(Process Control Block)

각 프로세스는 운영체제에서 프로세스 제어 블록(PCB, Process Control Block)(태스크 제어 블록이라고도 불린다)에 의해 표현된다. 그림 3.3은 PCB를 나타낸다. 프로세스 제어 블록은 특정 프로세스와 연관된 여러 정보를 수록하며, 다음과 같은 것들을 포함한다.
프로세스 상태
상태는 새로운(new), 준비완료(ready), 실행(running), 대기(waiting) 또는 정지(halted) 상태 등이다.
프로그램 카운터
프로그램 카운터는 이 프로세스가 다음에 실행할 명령어의 주소를 가리킨다.
CPU 레지스터들
CPU 레지스터는 컴퓨터의 구조에 따라 다양한 수와 타입을 가진다. 레지스터에는 누산기(accumlator), 인덱스 레지스터, 스택 레지스터, 범용(general-purpose) 레지스터들과 상태 코드(condition code) 정보가 포함된다.
프로그램 카운터와 함께 이 상태 정보는 나중에 프로세스가 계속 올바르게 실행되도록 하기 위해 인터럽트 발생 시 저장되어야 한다. (그림 3.4)
CPU-스케쥴링 정보
이 정보는 프로세스 우선순위, 스케쥴 큐에 대한 포인터와 다른 스케쥴 매개변수들을 포함한다.
메모리 관리 정보
이 정보는 운영체제에 의해 사용되는 메모리 시스템에 따라 기준(base) 레지스터와 한계(limit) 레지스터의 값, 운영체제가 사용하는 메모리 시스템에 따라 페이지 테이블 또는 세그먼트 테이블과 같은 정보를 포함한다.
회계(accounting) 정보
이 정보는 CPU 사용 시간과 경과된 실시간, 시간 제한, 계정 번호, 잡 또는 프로세스 번호 등을 포함한다.
입출력 상태 정보
이 정보는 이 프로세스에게 할당된 입출력 장치들과 열린 파일의 목록 등을 포함한다.
요약하면 프로세스 제어 블록은 단순하게는 프로세스마다 달라지는 모든 정보를 저장하는 저장소의 역할을 한다.

스레드(Threads)

이제까지 논의한 프로세스 모델은 프로세스가 단일의 실행 스레드를 실행하는 프로그램임을 암시했다. 예컨대 만일 한 스레드가 워드 프로세서 프로그램을 실행 중이면, 실행되는 명령어의 단일 스레드가 존재한다.
이 단일 제어 스레드는 프로세스로 하여금 한 번에 단지 한 가지 일만 실행하도록 허용한다. 예컨대 사용자는 동일한 프로세스 내에서 문자를 입력하면서 동시에 철자 검사기를 실행할 수 없다.
대부분의 현대 운영체제는 프로세스 개념을 확장하여 한 프로세스가 다수의 실행 스레드를 가질 수 있도록 허용한다. 그들은 따라서 프로세스가 한 번에 하나 이상의 일을 수행할 수 있도록 허용한다.
이러한 특성은 특히 다중 처리기 시스템에서 이익을 얻을 수 있는데, 여러 스레드가 병렬로 실행될 수 있다. 스레드를 지원하는 시스템에서는 PCB는 각 스레드에 관한 정보를 포함하도록 확장된다. 스레드를 지원하기 위해서는 시스템 전반에 걸친 다른 수정도 필요하다.

프로세스 스케줄링(Process Scheduling)

다중 프로그래밍의 목적은 CPU 이용을 최대화 하기 위해 항상 어떤 프로세스가 실행되도록 하는데 있다. 시분할의 목적은 각 프로그램이 실해오디는 동안 사용자가 상호작용할 수 있도록 프로세스들 사이에서 CPU를 빈번하게 교체하는 것이다.
이 목적을 달성하기 위해 프로세스 스케쥴러는 CPU에서 실행 가능한 여러 프로세스들 중에서 하나의 프로세스를 선택한다. 단일 처리기 시스템에서는 실행 중인 프로세스가 한 개 이상 있을 수 없다.
만일 프로세스들이 여러 개가 있다면 나머지 프로세스들은 CPU가 자유로워 다시 스케쥴 될 때까지 대기해야 한다.

Linux의 프로세스 표현

Linux 운영체제의 프로세스 제어 블록은 C 구조체 task_struct로 표현되는데, 이에 대한 정의는 커널 소스코드 디렉터리에 존재하는 <linux.sched.h> 헤더 파일에 있다.
이 구조체는 프로세스의 상태, 스케줄링과 메모리 간리 정보, 열린 파일 및 부모 프로세스와 자식과 형제 프로세스의 리스트를 가리키는 포인터 등의 프로세스를 나타내기 위해 모든 필요한 정보를 가지고 있다. (프로세스의 부모 프로세스는 그 프로세스를 생성한 프로세스이고 자식 프로세스는그 프로세스가 만든 모든 프로세스를 의미한다.
이 필드의 일부분은 다음과 같다.
long state; /* 프로세스 상태 */ struct sched_entity se; /* 스케쥴링 정보 */ struct task_struct *parent; /* 이 프로세스의 부모 */ struct list_head children; /* 이 프로세스의 자식들 */ struct files_struc *files; /* 오픈 파일 */ struct mm_struct *mm; /* 이 프로세스의 주소 공간 */
C
예컨대 프로세스의 상태는 이 구조체의 필드 long state에 의해 표시된다. Linux 커널 안에서 모든 활성 프로세스들은 task_struct의 이중 연결 리스트로 표현된다. 다음 그림에서 보이는 것처럼 커널은 현재 실행 중인 프로세스를 가리키는 포인터를 유지한다 (current)
명시된 프로세스에 상응하는 task_struct의 필드를 조작하는 방법을 설명하기 위해 시스템이 현재 실행 중인 프로세스의 상태를 new_state로 바꾸려고 한다고 가정하자. current가 현재 실행 중인 프로세스를 가리키고 있다면 다음 명령문에 의해 상태가 바뀌게 된다.
current->state = new_state;
C

스케줄링 큐(Scheduling Queues)

프로세스가 시스템에 들어오면 이들은 잡 큐에 놓여진다. 이 큐는 시스템 안의 모든 프로세스로 구성된다. 주 메모리에 존재하며, 준비 완료 상태에서 실행을 대기하는 프로세스들은 준비 완료 큐(ready queue)라 불리는 리스트 상에 유지된다. 이 큐는 일반적으로 연결 리스트로 저장된다.
준비 완료 큐의 헤더는 리스트의 첫번째와 마지막 PCB를 가리키는 포인터를 포함한다. 각 PCB는 준비 완료 큐에 있는 다음 프로세스를 가리키는 포인터 필드를 가진다.
시스템에는 또한 다른 큐들도 있다. 프로세스가 CPU를 할당 받으면, 어느 정도 실행을 하고 결국에는 그만두거나, 인터럽트되거나, 입출력 요청이 완료되는 것 같은 특별한 사건의 발생을 기다리게 된다.
프로세스가 디스크 같은 공유 장치에 입출력 요청을 했다고 가정하자. 시스템에는 많은 프로세스들이 있기 때문에 디스크가 다른 프로세스들의 입출력 요청으로 바쁠 수가 있다. 그러므로 프로세스는 디스크를 대기해야 할 수도 있다.
특정 입출력 장치를 대기하는 프로세스들의 리스트를 장치 큐(device queue)라고 한다. 각 장치는 그 자신의 장치 큐를 가진다. (그림 3.5)
프로세스 스케쥴링의 공통적인 표현 방식은 그림 3.6과 같은 큐잉 도표(queueing diagram)이다. 각 사각형은 하나의 큐를 나타낸다. 두 가지 타입의 큐(준비 완료 큐와 장치 큐들의 집합)가 존재한다.
원은 큐를 서비스하는 자원이며, 화살표는 시스템에서 프로세스들의 흐름을 표현한다.
새로운 프로세스는 처음에 준비 완료 큐에 놓인다. 프로세스는 실행을 위하여 선택될 떄 즉 CPU를 할당받을(dispatch) 때까지 준비 완료 큐에서 대기한다. 일단 프로세스에 CPU가 할당되어 실행되면 여러 가지 사건들 중의 하나가 발생할 수 있다.
프로세스가 입출력 요청을 하여 입출력 큐에 넣어질 수 있다.
프로세스가 새로운 자식 프로세스를 생성하고 자식 프로세스의 종료를 기다릴 수 있다.
프로세스가 인터럽트의 결과에 의해 강제로 CPU로부터 제거되고, 준비 완료 큐에 다시 놓일 수 있다.
처음 두 경우에서 프로세스는 결국 대기 상태에서 준비 완료 생태로 전환되고, 다시 준비 완료 큐에 넣어지게 된다. 프로세스는 종료될 떄까지 이 주기를 계속하며, 종료되면 모든 큐에서 삭제되고 그 자신의 PCB와 자원을 반납(deallocate)한다.

스케줄러(Schedulers)

프로세스는 일생 동안에 다양한 스케줄링 큐들 사이를 이주한다. 운영체제는 어떤 방식으로든지 스케줄링 목적을 위해 프로세스들을 이들 큐에서 반드시 선택해야 한다. 선택 절차는 적절한 스케줄러에 의해 수행된다.
일괄처리 시스템에서는 즉시 실행될 수 있는 것보다 더 많은 프로세스들이 종종 제출된다. 이들 프로세스들은 대용량 메모리(전형적으로 디스크)에 저장되어 나중에 실행될 때까지 그곳에 유지된다.
장기 스케줄러(또는 잡 스케줄러)는 이 풀에서 프로세스들을 선택하여 실행하기 위해 메모리로 적재한다.
단기 스케줄러(또는 CPU 스케줄러)는 실행 준비가 완료되어 있는 프로세스들 중에서 선택하여 이들 중 하나에게 CPU를 할당한다.
이들 두 스케줄러 사이의 주요한 차이점은 이들의 실행 빈도에 있다. 단기 스케줄러는 PCU를 위해 반드시 자주 새로운 프로세스를 선택해야 한다.
프로세스는 입출력 요청을 위해 대기하기 전까지 겨우 수 밀리초 동안 실행될 수 있다. 종종 단기 스케줄러는 매번 백 밀리초마다 최소한 한 번씩 실행된다. 실행 간격이 짧기 때문에 단기 스케줄러는 반드시 매우 빨라야 한다.
만일 한 프로세스를 백 밀리초 동안 실행하기 위해 십 밀리초를 소모한다면 ‘10 / (100 + 10) = 9%’의 CPU를 단순히 작업을 스케줄링 하기 위하여 사용(낭비)하게 된다.
장기 스케줄러는 실행 빈도수가 훨씬 ㅈ거다. 시스템에서 새로운 프로세스를 생성하는 간격은 수 분이 될 수 있다. 장기 스케줄러는 다중 프로그램의 정도(메모리에 있는 프로세스들의 수)를 제어한다.
다중 프로그래밍의 정도가 안정적이면, 평균 프로세스 생성률이 시스템을 떠나는 평균 프로세스 이탈률(average departure rate)과 반드시 동일해야 한다.
그러므로 장기 스케줄러는 프로세스가 시스템을 떠날 때만 호출될 필요가 있을 수도 있다. 실행 간격이 비교적 크기 때문에 장기 스케줄러는 실행할 프로세스를 선택하는데 시간을 더 사용해도 된다.
장기 스케줄러가 신중한 선택을 하는 것이 중요하다. 일반적으로 대부분의 프로세스들은 입출력 중심 또는 CPU 중심으로 묘사된다. 입출력 중심 프로세스는 연산보다 입출력 실행에 더 많은 시간을 소요하는 프로세스이다.
반면에 CPU 중심 프로세스는 입출력 중심 프로세스보다 연산에 시간을 더 소요하여, 입출력 요청을 드물게 발생시키는 프로세스이다.
장기 스케줄러는 입출력 중심과 CPU 중심 프로세스들의 적절한 프로세스 혼합(mix)을 선택하느 것이 중요하다. 만일 모든 프로세스들이 입출력 중심이라면, 준비 완료 큐는 항상 비게되고, 단기 스케줄러는 할 일이 없게 된다.
모든 프로세스들이 CPU 중심이라면 입출력 대기 큐는 항상 비어 있는 상태가 되고, 장치들이 사용되지 않을 것이고, 재차 시스템이 균형을 잃게 된다. 최선의 성능을 가진 시스템은 CPU 중심과 입출력 중심 프로세스들을 적절히 혼합한다.
어떤 시스템에서는 장기 스케줄러가 없거나 기능이 적다. 예컨대 UNIX와 Microsoft Windows 같은 시분할 시스템들은 장기 스케줄러가 없으며, 모든 새로운 프로세스를 단기 스케줄러를 위하여 단순히 메모리에 넣는다.
이들 시스템의 안정성은 물리적인 제한(이를테면 가용 단말기의 수)이나 사용자들의 자체 조정 본능에 의존한다. 성능이 수용할 수 없을 정도로 저하되면 어떤 사용자들은 단순히 그만 둘 것이다.
시분할 시스템과 같은 일부 운영체제들은 추가로 중간 수준의 스케줄링을 도입한다. 이와 같은 중기 스케줄러(medium-term scheduler)를 그림 3.7에 보였다.
중기 스케줄러의 핵심 아이디어는 메모리에서(CPU를 위해 적극적으로 경쟁하는) 프로세스들을 제거함으로써 다중 프로그래밍의 정도를 완화하는 것이 가끔 바람직할 수 있다는 것이다.
차후에 다시 프로세스를 메모리로 불러와서 중단되었던 지점에서부터 실행을 재개한다. 이러한 기법을 스와핑(swapping)이라고 한다. 프로세스는 중기 스케줄러에 의해 스왑되어 나가고 후에 다시 스왑되어 들어온다.
스와핑은 프로세스 혼합 상태를 개선하기 위해 필요하기도 하며, 메모리 요구에 대한 변화가 가용 메모리에 비해 너무 많은 요구를 수용하여, 메모리를 자유화 시키기 위하여 필요하기도 한다.

문맥 교환(Context Switch)

1.2.1절에서 언급한 것처럼 인터럽트는 운영체제가 CPU를 현재 작업에서 빼앗아 커널 루틴을 실행할 수 있게 한다. 이러한 연산은 범용 시스템에서는 자주 발생한다.
인터럽트가 발생하면 시스템은 인터럽트 처리가 끝난 후에 문맥을 복구할 수 있도록 현재 실행 중인 프로세스의 현재 문맥을 저장할 필요가 있다. 이는 결국 프로세스를 중단했다가 재개하는 작업이다.
문맥은 프로세스의 PCB에 표현된다. 문맥은 CPU 레지스터의 값, 프로세스 상태(그림 3.2 참조), 메모리 관리 정보 등을 포함한다.
일반적으로 커널 모드이건 사용자 모드이건 CPU의 현재 상태를 저장하는 작업을 수행하고(state save), 나중에 연산을 재개하기 위하여 상태 복구 작업을 수행한다(state restore)
CPU를 다른 프로세스로 교환하려면 이전의 프로세스의 상태를 보관하고 새로운 프로세스의 보관된 상태를 복구하는 작업이 필요하다. 이 작업은 문맥 교환(context switch)이라고 알려져 있다.
문맥 교환이 일어나면 커널은 과거 프로세스의 문맥을 PCB에 저장하고, 실행이 스케줄된 새로운 프로세스의 저장된 문맥을 복구한다. 문맥 교환이 진행될 동안 시스템이 아무런 유용한 일을 못하기 때문에 문맥 교환 시간은 순수한 오버헤드이다.
교환 속도는 메모리의 속도, 반드시 복사 되어야 하는 레지스터의 수, 특수 명령어(모든 레지스터들을 하나의 명령어로 보관하고 적재하는 것 같은)의 존재에 좌우되므로 기계마다 다르다. 전형적인 속도는 수 밀리 초까지 분포되어 있다.
문맥 교환 시간은 하드웨어의 지원에 크게 좌우된다. 예컨대 어떤 처리기들은 (예컨대 Sun Ultra SPARC) 여러 개의 레지스터 집합을 제공한다. 문맥 교환은 단순히 현행 레지스터 집합에 대한 포인터를 변경하는 것을 포함한다.
물론 레지스터 집합들보다도 활성 프로세스들이 더 많다면, 시스템은 전처럼 레지스터 자료를 메모리로 또는 메모리에서 복사해야 한다.
또한 운영체제가 복잡할 수록 문맥 교환 시 해야할 작업의 양이 더 많아진다. 나중에 8장에서 살펴보겠지만, 복잡한 고급 메모리 관리 기법을 사용하면 문맥 교환 시 더 많은 자료들을 교환해야 한다.
예컨대 문맥 교환 시 현재 프로세스의 주소 공간은 다음 태스크의 공간이 사용 준비되는 동안 반드시 보존되어야 한다. 주소 공간이 어떤 식으로 보존되고 보존하기 위해 수행해야 할 작업의 양은 운영체제의 메모리 관리 기법에 따라 달라진다.

프로세스에 대한 연산(Operation on Process)

프로세스 생성(Precess Creation)

실행되는 동안 프로세스는 여러 개의 새로운 프로세스들을 생성할 수 있다. 앞서 언급한 것과 같이 생성하는 프로세스를 부모 프로세스라고 하고, 새로운 프로세스는 자식 프로세스라고 한다.
이 새로운 프로세스들은 각기 다른 프로세스를 생성할 수 있으며, 그 결과 프로세스의 트리를 형성한다.
UNIX, Linux 및 Windows와 같은 대부분의 현대 운영체제들은 유일한 프로세스 식별자(pid)를 이용하여 프로세스를 구분하는데 이 식별자는 보통 정수이다. pid는 시스템의 각 프로세스에게 고유한 값을 가지도록 할당된다. 이 식별자를 통하여 커널이 유지하고 있는 프로세스의 다양한 속성에 접근하기 위한 찾아보기(index)로 사용된다.
그림 3.8이 Linux 운영체제의 전형적인 프로세스 트리를 보여주고 있으며 프로세스 이름과 pid를 보이고 있다 (Linux는 태스크라는 용어를 더 선호하기 때문에 여기서는 프로세스라는 용어를 다소 엄밀하지 않게 사용한다).
언제나 pid가 1인 init 프로세스는 다양한 사용자 프로세스를 생성한다. 이러한 프로세스에는 웹 또는 프린트 서버, ssh 서버 등이 포함된다.
그림 3.8에서 init의 두 자식 프로세스 kthread와 sshd 프로세스를 볼 수 있다. kthread 프로세스는 커널을 대신하여 작업을 수행할 추가적인 프로세스를 생성하는 책임을 진다. 이 예제에서는 추가 프로세스는 khelper와 pdflush 프로세스이다.
sshd 프로세스는 ssh를(secure shell의 줄임말) 사용하여 시스템에 접속하는 클라이언트를 관리하는 책임이 있다. login 프로세스는 시스템에 직접 로그인하는 클라이언트를 관리하는 책임을 진다.
이 예제에서 클라이언트는 로그인을 한 후 bash 셸을 사용하고 있는데, 셸의 pid는 8416이 할당되었다. bash 명령어 라인 인터페이스를 사용하여 이 사용자는 ps 프로세스와 emacs 편집기 프로세스를 생성하였다.
UNIX와 Linux 시스템에서는 ps 명령어를 이용하여 프로세스들의 목록을 얻으 ㄹ수 있다. 예컨대 다음과 같은 명령은 현재 시스템에 활성화 되어 있는 모든 프로세스들의 정보를 나열해 줄 것이다.
프로세스들의 부모 프로세스를 init이 나올 때까지 재귀적으로 추적하면 그림 3.8에 나와 있는 것과 유사한 프로세스 트리를 구축하는 것은 쉽다.
ps -el
Shell
일반적으로 프로세스가 자식 프로세스를 생성할 떄, 그 자식 프로세스는 자신의 임무를 달성하기 위하여 어떤 자원(CPU 시간, 메모리, 파일, 입출력 장치)이 필요하다.
자식 프로세스는 이 자원을 운영체제로부터 직접 얻거나 부모 프로세스가 가진 자원의 부분 집함만을 사용하도록 제한될 수 있다.
부모 프로세스는 자식 프로세스들에게 나누어 주거나 메모리나 파일과 같은 몇몇 자원들은 자식 프로세스들이 같이 사용하게 할 수도 있다.
부모 프로세스 자원의 일부분만을 사용하도록 자식 프로세스가 쓸 수 있게 제한하며, 자식 프로세스들을 많이 생성하여 시스템을 과부하 상태로 만드는 프로세스를 방지할 수 있다.
물리적, 논리적 자원을 제공하는 것 이외에 부모 프로세스는 자식 프로세스에게 초기화 데이터(입력)를 전달할 수 있다. 예컨대 img.jpg라는 파일의 내용을 단말기의 화면에서 나타내는 기능을 하는 프로세스를 생각해 보자.
이 프로세스가 생성될 때, 부모 프로세스로부터 입력 데이터로 img.jpg라는 파일 이름을 얻을 수 있다. 이 파일 이름을 사용하여 파일을 열고 내용을 출력할 수 있다. 프로세스는 또한 출력 장치의 이름도 전달받을 수 있을 것이다.
대체 방안으로 어떤 운영체제는 자식 프로세스에게 자원을 전달한다. 이와 같은 시스템에서는 새로운 프로세스는 img.jpg와 터미널 장치에 해당하는 두 개의 열린 파일을 전달받고 단순히 두 장치 사이에서 데이터를 전송하는 작업만 하면 된다.
프로세스가 새로운 프로세스를 생성할 떄, 두 프로세스를 실행시키는 데 두 가지 가능한 방법이 존재한다.
1.
부모는 자식과 병행하게 실행을 계속한다.
2.
부모는 일부 또는 모든 자식이 실행을 종료할 때까지 기다린다.
새로운 프로세스들의 주소 공간 측면에서 볼 때 다음과 같은 두 가지 가능성이 있다.
1.
자식 프로세스는 부모 프로세스의 복사본이다. (자식 프로세스는 부모와 똑같은 프로그램과 데이터를 가진다)
2.
자식 프로세스가 자신에게 적재될 새로운 프로그램을 갖고 있다.
이들의 차이점을 설명하기 위해 우선 UNIX 운영체제를 고려해 보자. UNIX에서 각 프로세스는 프로세스 식별자(identifier)로 확인되는데 이것은 유일한 정수이다. 새로운 프로세스는 fork() 시스템 호출로 생성된다. 새로운 프로세스는 원래 프로세스의 주소 공간의 복사본으로 구성된다.
이 기법은 부모 프로세스가 쉽게 자식 프로세스와 통신할 수 있게 한다. 두 개의 프로세스들(부모와 자식)은 fork() 후의 명령어에서부터 실행을 계속하며, 이 때 한 가지 다른 점은 fork() 복귀 코드가 서로 다르다는 것이다. 자식 프로세스의(0이 아닌) 식별자가 부모로 복귀되는데 반해, 새로운(자식) 프로세스는 ‘0’이 복귀된다.
fork() 시스템 호출 다음에 두 프로세스 중 한 프로세스가 exec() 시스템 호출을 사용하여 자신의 메모리 공간을 새로운 프로그램으로 교체한다. exec() 시스템 호출은 이진 파일을 메모리로 적재(load)하고 —이때 exec() 시스템 호출을 포함하는 원래의 프로그램의 메모리 이미지를 파괴한다— 그 프로그램의 실행을 시작한다.
이와 같은 방법으로 두 프로세스는 통신을 할 수 있으며, 그리고 이어 그들 각자의 길을 간다. 그 후 부모는 더 많은 자식을 생성할 수 있으며, 또는 자식이 실행하는 동안 할 일이 없으면, 자식이 종료될 때까지 준비 완료 큐에서 자신을 제거하기 위해 wait() 시스템 호출을 한다.
exec()을 호출하면 프로세스의 주소 공간을 새 프로그램으로 덮어 쓰기 때문에 exec() 시스템 호출은 오류가 발생하지 않는 한 제어를 반환하지 않는다.
#include <sys/types.h> #include <unistd.h> main(int argc, char *argv[]) { int pid; /* 새 프로세스를 생성한다(fork) */ pid = fork(); if (pid < 0) { /* 오류가 발생했음 */ fprintf(stderr, "Fork Failed"); return 1; } else if (pid == 0) { /* 자식 프로세스 */ execlp("/bins/ls", "ls", NULL); } else { /* 부모 프로세스 */ /* 부모가 자식이 완료되기를 기다림 */ wait(NULL); printf("Child Complete"); } return 0; }
C
위 코드의 C 프로그램은 앞서 설명한 UNIX 시스템 호출을 보이고 있다. 우리는 이제 동일한 프로그램의 복사본을 실행하는 두 개의 서로 다른 프로세스를 갖는다. 유일한 차이점은 자식 프로세스에게 보이는 pid의 값은 0이고 반면 부모 프로세스에게 보이는 pid 값은 0보다 큰 정수 값(사실 이 값은 자식 프로세스의 pid)이라는 것이다.
자식 프로세스는 열린 파일과 같은 자원 뿐 아니라 특권과 스케줄링 속성을 부모 프로세스로부터 상속받는다. 그런 후에 자식 프로세스는 execlp() 시스템 호출(exec() 시스템 호출의 한 버전)을 사용하여 자신의 주소 공간을 UNIX 명령 /bin/ls(디렉터리 목록을 얻기 위해 사용된다)로 덮어 쓴다.
부모는 wait() 시스템 호출로 자식 프로세스가 끝나기를 기다린다. 자식 프로세스가 끝나면 (암시적 또는 명시적으로 exit()를 사용하여), 부모 프로세스는 wait() 호출로부터 재개하여, exit() 시스템 호출을 사용하여 끝낸다. 이 작업이 3.10에 묘사되어 있다.
물론 자식 프로세스가 exec()를 호출하지 않고 부모 프로세스의 복사본을 계속 실행하는 것을 막을 방법은 없다. 이 시나리오에서 부모와 자식은 같은 코드를 실행하는 병행 실행 프로세스이다. 자식은 부모의 복사본이기 떄문에 각 프로세스는 모든 데이터에 대해 자신만의 복사본을 가지고 있다.
다른 예로 Windows에서의 프로세스 생성에 대해 고려한다. 프로세스는 Windows API의 CreateProcess() 함수를 이용하여 생성되는데 이 함수는 부모 프로세스가 새로운 자식 프로세스를 생성한다는 점에서 fork()와 유사하다.
그러나 fork()가 부모 프로세스로부터 주소 공간을 상속받는 자식 프로세스를 생성하는 반면에 CreateProcess()는 자식 프로세스가 생성될 떄 주소 공간에 명시된 프로그램을 적재한다.
더욱이 fork()가 아무런 인자가 전달되지 않는 반면 CreateProcess()는 10개 이상의 매개변수를 요구한다.
#include <stdio.h> #include <window.h> int main(VOID) { STARTUPINFO si; PROCESS_INFORMATION pi; // 메모리 할당 ZeroMemory(&si, sizeof(si)); si.cb = sizeof(si); ZeroMemory(&pi, sizeof(pi)); // 자식 프로세스 생성 if (!CreateProcess(NULL, // 명령어 라인 사용 "C:\\WINDOWS\\system32\mspaint.exe", // 명령어 라인 NULL, // 프로세스를 상속하지 말 것 NULL, // 스레드 핸들을 상속하지 말 것 FALSE, // 핸들 상속 disable 0, // 생성 플래그 없음 NULL, // 부모 환경 블록 사용 NULL, // 부모 프로세스가 존재하는 디렉터리 사용 &si, &pi)) { fprintf(stderror, "Create Process Failed"); return -1; } // 부모 프로세스가 자식 프로세스가 끝나기를 기다림 WaitForSingleObject(pi.hProcess, INFINITE); printf("Child Complete"); // 핸들 닫기 CloseHandle(pi.hProcess); CloseHandle(pi.hThread); }
C
위 C 프로그램은 mspaint.exe를 적재하는 자식 프로세스를 생성하는 CreateProcess()를 설명하고 있다. 우리는 CreateProcess()에게 전달되는 10개의 매개변수 중에서 많은 수를 디폴트 값으로 선택하였다.
CreateProces() 함수에게 전달된 두 개의 매개변수는 STARTUPINFO와 PROCESS_INFORMATION 구조체의 인스턴스이다.
STARTUPINFO는 윈도우 크기와 모양 표준 입력과 출력 파일에 대한 핸들과 같은 프로세스의 특성을 지정한다. PROCESS_INFORMATION 구조체는 새로 생성된 프로세스와 스레드에 대한 핸들과 식별자들을 포함하고 있다.
CreateProcess()를 진행하기 전에 각각의 구조체가 쓸 메모리를 할당하기 위하여 ZeroMemory() 함수를 호출하였다.
CreateProcess()에게 전달된 첫 2개의 변수는 응용의 이름과 명령어 라인 매개변수이다. 만일 응용의 이름이 NULL이면 명령어 라인 매개변수가 적재할 응용을 지정한다. 이 예제에서는 mspain.exe 응용을 적재하고 있다.
이 이후의 매개변수에 대해서는 디폴트 값을 사용하여 프로세스와 스레드 핸들을 상속받고 생성 플래그가 없다고 명시한다.
또한 부모 프로세스의 기존 환경 블록을 사용하고 시작 디렉터리로 부모의 시작 디렉터리를 사용하였다. 마지막으로 프로그램 초기에 생성된 STARTUPINFO와 PROCSS_INFORMATION 구조체를 가리키는 포인터를 매개변수로 전달한다.
앞선 코드에서 부모 프로세스는 wait() 시스템 호출을 불러 자식 프로세스가 끝나기를 기다린다. 이와 동일한 Windows API의 함수는 WaitForSingleObject()이고 이 함수는 자식 프로세스의 핸들(pi.hProcess)를 전달받고 이 프로세스가 종료되기를 기다린다. 자식 프로세스가 종료하면 제어는 부모 프로세스의 WaitForSingleObject()에서 반환된다.

프로세스 종료(Process Termination)

프로세스가 마지막 문장의 실행을 끝내고, exit 시스템 호출을 사용하여 운영체제에게 자신의 삭제를 요청하면 종료한다. 이 시점에서 프로세스는 자신의 부모 프로세스에게(wait 시스템을 통해) 상태 값을 반환할 수 있다. 물리 메모리와 가상 메모리, 열린 파일, 입출력 버퍼를 포함한 프로세스의 모든 자원이 운영체제로 반납된다.
프로세스 종료가 발생하는 다른 경우가 있다. 한 프로세스는 적당한 시스템 호출(예컨대 Windows의 TerminateProcess())을 통해서 다른 프로세스의 종료를 유발할 수 있다.
통상적으로 그런 시스템 호출은 단지 종료될 프로세스의 부모만이 호출할 수 있다. 그렇지않으면 사용자가 서로의 다른 작업을 임의적으로 중단(kill) 시킬 수 있을 것이다.
부모가 자식을 종료시키기 위해서는 자식의 pid를 알아야 한다는 것에 유의하자. 그러므로 한 프로세스가 새로운 프로세스를 만들 때 새로 만들어진 프로세스의 신원(identity)이 부모에게 전달된다.
부모는 다음과 같이 여러 가지 이유로 자식들 중 하나의 실행을 종료할 수 있다.
자식이 자신에게 할당된 자원을 초과하여 사용할 때. 이때는 부모가 자식들의 상태를 검사할 수 있는 방편이 주어져야 한다.
자식에게 할당된 태스크(task)가 더 이상 필요 없을 때
부모가 exit을 하는데, 운영체제는 부모가 exit한 후 자식이 실행을 계속하는 것을 허용하지 않는 경우
몇몇 시스템에서는 부모 프로세스가 종료한 이후에 자식 프로세스가 존재할 수 없다. 그러한 시스템에서는 프로세스가 종료되면(정상적이든 비정상적이든) 그로부터 비롯된 모든 자식 프로세스들도 종료되어야 한다.
이것을 연쇄식 종료(cascading termination)라고 부르며 이 작업은 운영체제가 시행한다.
프로세스 실행과 종료를 설명하기 위해 Linux와 UNIX 시스템에서 exit() 시스템 호출을 사용하여 프로세스를 종료시키는 것을 고려해 보자. exit() 시스템 호출은 종료 상태를 나타내는 인자를 전달 받는다.
/* 1인 상태로 exit */ exit(1);
C
사실 정상적인 종료에서 exit()는 위와 같이 직접 또는 main() 함수의 return 문에 의해서와 같이 간접적으로 호출될 수 있다.
부모 프로세스는 wait() 시스템 호출을 사용해서 자식 프로세스가 종료할 때를 기다릴 수 있다. wait() 시스템 호출은 부모가 자식의 종료 상태를 얻어낼 수 있도록 하나의 인자를 전달 받는다. 이 시스템 호출은 부모가 어느 자식이 종료되었는지 구별할 수 있도록 종료된 자식의 프로세스 식별자를 반환한다.
pid_t pid; int status; pid = wait(&status);
C
프로세스가 종료하면 사용하던 자원은 운영체제가 되찾아 간다. 그러나 프로세스의 종료 상태가 저장되는 프로세스 테이블의 해당 항목은 부모 프로세스가 wait()를 호출할 떄까지 남아있게 된다.
종료되었지만 부모 프로세스가 아직 wait() 호출을 하지 않은 프로세스를 좀비(zombie) 프로세스라고 한다. 종료하게 되면 모든 프로세스는 좀비 상태가 되지만 아주 짧은 시간 동안만 머무른다.
부모가 wait()를 호출하면 좀비 프로세스의 프로세스 식별자와 프로세스 테이블의 해당 항목이 운영체제에 반환된다.
부모 프로세스가 wait()를 호출하는 대신 종료한다면 무슨 일이 벌어지는지 살펴보자. 이 상황에 처한 자식 프로세스를 고아(orphan) 프로세스라고 부른다.
Linux와 UNIX는 고아 프로세스의 새로운 부모 프로세스로 init 프로세스를 지정함으로써 이 문제를 해결한다. —init 프로세스는 UNIX와 Linux 시스템에서 프로세스 계층 구조의 루트에 위치한다.
init 프로세스는 주기적으로 wait()를 호출하여 고아 프로세스의 종료 상태를 수집하고 프로세스 식별자와 프로세스 테이블 항목을 반환한다.

프로세스간 통신

운영체제 내에서 실행되는 병행 프로세스들은 독립적이거나 협력적인 프로세스들일 수 있다. 프로세스가 시스템에서 실행 중인 다른 프로세스들에게 영향을 주거나 받지 않는다면 독립적인 프로세스라고 말하고 다른 프로세스들에게 영향을 주거나 받으면 협력적인 프로세스라고 한다.
프로세스 협력을 허용하는 환경을 제공하는데는 몇 가지 이유가 있다.
정보 공유 (Information sharing)
여러 사용자가 동일한 정보에 흥미를 가질 수 있으므로 그러한 정보를 병행적으로 접근할 수 있는 환경을 제공해야 한다.
계산 가속화 (Computation speedup)
만일 우리가 특정 태스크(task)를 빨리 실행하고자 한다면 그것을 서브태스크로 나누어 이들 각각이 다른 서브태스크들과 병렬로 실행되게 해야 한다. 이러한 가속화는 복수 개의 처리 코어를 가진 경우에만 달성할 수 있음에 유의하라.
모듈성 (Modularity)
우리가 2장에서 논의했던 것과 같이 우리는 시스템 기능을 별도의 프로세스들 또는 스레드들로 나누어, 모듈식 형태로 시스템을 구성하기를 원할 수 있다.
편의성 (Convenience)
개별 사용자들이 한 순간에 작업할 많은 태스크(task)를 가질 수도 있다. 예컨대 한 사용자가 편집, 음악 듣기 및 컴파일 작업을 병렬로 할 수 있다.
협력적 프로세스들은 데이터와 정보를 교환할 수 있는 프로세스간 통신(interprocess communication, IPC) 기법을 필요로 한다.
프로세스간 통신에는 기본적으로 공유 메모리(shared memory)와 메시지 전달(message passing)의 두 가지 모델이 있다.
공유 모델에서는 협력 프로세스들에 의해 공유되는 메모리의 영역이 구축된다. 프로세스들은 그 영역에 데이터를 읽고 쓰고 함으로써 정보를 교환할 수 있다.
메시지 전달 모델에서는 통신이 협력 프로세스들 사이에 교환되는 메시지를 통하여 이루어진다. 이 두 모델이 그림 3.12에 대비되어 있다.
언급된 두 모델은 운영체제에서는 통상적인 것이며 많은 시스템들이 두 가지를 모두 구현한다.
메시지 전달 모델을 충돌을 회피할 필요가 없기 때문에 적은 양의 데이터를 교환하는데 유용하다. 메시지 전달은 또한 분산 시스템에서 공유메모리 보다 구현하기 쉽다.
메시지 전달 시스템은 통상 시스템 호출을 사용하여 구현되므로 커널 간섭 등의 부가적인 시간 소비 작업들이 필요하기 때문에 공유 메모리 모델이 메시지 전달보다 더 빠르다.
공유 메모리 시스템에서는 공유 메모리 영역을 구축할 때만 시스템 호출이 필요하다. 공유 메모리 영역이 구축되면 모든 접근은 일반적인 메모리 접근으로 취급되어 커널의 도움이 필요없다.
많은 처리 코어를 가진 시스템 상에서의 연구에 의하면 메시지 전달이 공유 메모리보다 더 나은 성능을 보인다. 공유 메모리는 공유 데이터가 여러 캐시 사이에서 이주하기 때문에 발생하는 캐시 일관성 문제로 인해 성능 저하가 발생한다. 시스템의 처리 코어 수가 증가하면 할수록 IPC로 메시지 전달이 더 선호되는 것을 볼 가능성이 있다.

공유 메모리 시스템

공유 메모리를 사용하는 프로세스간 통신에서는 통신하는 프로세스들이 공유 메모리 영역을 구축해야 한다.
통상 공유 메모리 영역은 공유 메모리 세그먼트를 생성하는 프로세스의 주소 공간에 위치한다. 이 공유 메모리 세그먼트를 이용하여 통신하고자 하는 다른 프로세스들은 이 세그먼트를 자신의 주소 공간에 추가해야 한다.
일반적으로 운영체제는 한 프로세스가 다른 프로세스의 메모리를 접근하는 것을 금지한다는 것을 기억하자. 공유 메모리는 둘 이상의 프로세스가 이 제약 조건을 제거하는 것에 동의하는 것을 필요로 한다. 그런 후에 프로세스들은 공유 역역에 읽고 씀으로써 정보를 교환할 수 있다.
데이터의 형식과 위치는 이들 프로세스들에 의해 결정되며 운영체제의 소관이 아니다. 또한 프로세스들은 동시에 동일한 위치에 쓰지 않도록 책임져야 한다.
협력하는 프로세스의 개념을 설명하기 위해 협력하는 프로세스의 일반적인 패러다임인 생산자-소비자 문제를 생각해 보자. 생산자 프로세스는 정보를 생산하고 소비자 프로세스는 정보를 소비한다.
예컨대 컴파일러는 어셈블리 코드를 생산하고, 어셈블러는 이것을 소비한다. 어셈블러는 이어 목적 모듈(object module)을 생산할 수 있고, 적재기(loader)는 이들을 소비한다.
생산자 소비자 문제는 클라이언트 서버 패러다임을 위한 유용한 은유를 제공한다. 일반적으로 우리는 서버를 생산자로 클라이언트를 소비자로 생각한다. 예컨대 웹 서버는 HTML 파일과 이미지를 생산하고(즉, 제공하고) 이 자원들을 요청한 클라이언트 웹 브라우저가 소비하게 된다. (즉, 읽는다)
생산자-소비자 문제의 하나의 해결책은 공유 메모리를 사용하는 것이다. 생산자와 소비자 프로세스들이 병행으로 실행되도록 하려면 생산자가 정보를 채워 넣고 소비자가 소모할 수 있는 항목들의 버퍼가 반드시 사용 가능해야 한다.
이 버퍼는 생산자와 소비자가 공유하는 메모리 영역에 존재하게 된다. 생산자가 한 항목을 생산하고, 그 동안 소비자는 다른 항목을 소비할 수 있다. 생산자와 소비자가 반드시 동기화 되어야 생산되지도 않은 항목을 소비자가 소비하려고 하지 않을 것이다.
두 가지 유형의 버퍼가 사용된다.
무한 버퍼(unbounded buffer)의 생산자 소비자 문제에서는 버퍼의 크기에 실질적인 한계가 없다. 소비자는 새로운 항목을 기다려야만 할 수도 있지만, 생산자는 항상 새로운 항목을 생산할 수 있다.
유한 버퍼(bounded buffer)는 버퍼의 크기가 고정되어 있다고 가정한다. 이 경우 버퍼가 비어 있으면 소비자는 반드시 대기해야 하며, 모든 버퍼가 채워져 있으면 생산자가 대기해야 한다.
유한 버퍼가 공유 메모리를 사용한 프로세스간 통신을 어떻게 분명하게 하는지 살펴보자. 다음 변수들은 생산자와 소비자 프로세스가 공유하는 메모리 영역에 존재한다.
#define BUFFER_SIZE 10 typedef struct { ... } item; item buffer[BUFFER_SIZE]; int in = 0; int out = 0;
C
공유 버퍼는 두 개의 논리 포인터 in과 out을 갖는 원형 배열로 구현된다. 변수 in은 버퍼 내에서 다음으로 비어 있는 위치를 가리키며, out은 버퍼 내에서 첫 번째 차있는 위치를 가리킨다.
in == out; 일 때, 버퍼는 비어 있고 ((int + 1) % BUFFER_SIZE) == out) 이면 버퍼는 가득 차 있다.
생산자와 소비자의 코드가 아래 각각 나와 있다. 생산자 프로세스는 next_Produced라는 지역 변수에 다음 번 생산되는 item을 저장하고 있다. 보시자 코드는 next_Consumed라는 지역 변수에 다음 번 소비되는 item을 저장하고 있다.
이 방법은 최대 BUFFER_SIZE - 1까지 만을 버퍼에 수용할 수 있다. 버퍼에 최대 BUFFER_SIZE 개까지 넣을 수 있는 해결책은 연습문제로 남겨둔다.
// 공유 메모리를 사용한 생산자 프로세스 item_next_Produced; while (true) { /* next_Produced에 생산된 item 저장 */ while (((in + 1) % BUFFER_SIZE == out) ; /* 아무 일도 안 함 */ buffer[in] = next_Produced; in = (in + 1) % BUFFER_SIZE; }
C
// 공유 메모리를 사용한 소비자 프로세스 item_next_Consumed; while (true) { while (in == out) ; /* 아무 일도 안 함 */ next_Consumed = buffer[out]; out = (out + 1) % BUFFER_SIZE; /* next_Consumed에 있는 item을 소비 */ }
C

메시지 전달 시스템(Message-Passing Systems)

앞서 서로 협력하는 프로세스가 공유 메모리 환경에서 어떻게 상호 통신을 행하는지 알아보았다. 그 기법은 프로세스들이 메모리 영역을 공유할 것을 필요로 하며, 공유 메모리를 접근하고 조작하는 코드가 응용 프로그래머에 의해 명시적으로 작성되어야 했다.
동일한 효과를 얻는 또 다른 방법으로 운영체제가 메시지 전달 설비를 통하여 서로 협력하는 프로세스간의 통신 수단을 제공해 주는 방법이 있다.
메시지 전달 방식은 동일한 주소 공간을 공유하지 않고도 프로세스들이 통신을 하고, 그들의 동작을 동기화할 수 있도록 허용하는 기법을 제공한다. 메시지 전달 방식은 통신하는 프로세스들이 네트워크에 의해 연결된 다른 컴퓨터들에 존재할 수 있는 분산 환경에서 특히 유용하다.
한 예로 월드 와이드 웹에 사용되는 chat 프로그램은 서로 메시지를 교환하여 통신하도록 설계될 수 있다.
메시지 전달 시스템은 최소한 2가지 연산을 제공한다.
send(message), receive(message)
프로세스가 보낸 메시지는 고정 길이일 수도 있고 가변 길이일 수도 있다. 고정 길이 메시지만 보낼 수 있다면 시스템 수준의 구현은 직선적이다. 그렇지만 이러한 제한은 프로그래밍 작업을 더욱 힘들게 한다. 반면 가변 길이 메시지는 보다 복잡한 시스템 수준의 구현을 필요로 하지만 프로그래밍 작업은 더 간단해진다. 이러한 일은 운영체제 설계 전반에 걸쳐 흔히 볼 수 있는 교환이다.
만약 프로세스 P와 Q가 통신을 원하면, 반드시 서로 메시지를 보내고 받아야 한다. 이들 사이에 통신 연결(communication link)이 설정되어야 한다. 이 연결은 다양한 방법으로 구현할 수 있다. 우리는 연결의 물리적인 구현에 관심이 있는 것이 아니라 논리적인 구현에 관심이 있다. 하나의 링크와 send()/receive() 연산을 논리적으로 구현하는 다수의 방법들은 다음과 같다.
직접 또는 간접 통신
동기식 또는 비동기식 통신
자동 또는 명시적 버퍼링
다음으로 이 특성과 관련된 쟁점들을 살펴보자.

명명(Naming)

통신을 원하는 프로세스들은 서로를 가리킬 수 있는 방법이 있어야 한다. 이들은 간접 통신 또는 직접 통신을 사용할 수 있다.
직접 통신 하에서 통신을 원하는 각 프로세스는 통신의 수신자 또는 송신자의 이름을 명시해야 한다. 이 기법에서 send, receive 프리미티브들은 다음과 같이 정의한다.
send(P, message) - 프로세스 P에게 메시지를 전송한다.
receive(Q, message) - 프로세스 Q로부터 메시지를 수신한다.
이 기법에서 통신 연결은 다음의 특성을 가진다.
통신을 원하는 각 프로세스의 쌍들 사이에 연결이 자동적으로 구축된다. 프로세스들은 통신하기 위해 서로 상대반의 신원(identify)만 알면 된다.
연결은 정확히 두 프로세스들 사이에만 연관된다.
통신하는 프로세스들의 각 쌍 사이에는 정확하게 하나의 연결이 존재해야 한다.
이 기법은 주소 방식에서 대칭성을 보인다. 즉 송신자와 수신자 프로세스가 모두 통신하려면 상대방의 이름을 제시해야 한다. 이 기법의 변형으로서 주소 지정 시에 비대칭을 사용할 수도 있다. 송신자만 수신자 이름을 지명하며, 수신자는 송신자의 이름을 제시할 필요가 없다. 이 기법에서 send와 receive 프리미티브들을 다음과 같이 정의한다.
send(P, message) - 메시지를 프로세스 P에 전송한다.
receive(id, message) - 임의의 프로세스로부터 메시지를 수신한다. 변수 id는 통신을 발생시킨 프로세스의 이름으로 설정된다.
이들 기법(대칭적 그리고 비대칭적) 모두의 단점은 결과로 얻어지는 프로세스 정의의 제한된 모듈성에 있다. 프로세스의 이름을 바꾸면 모든 다른 프로세스 정의를 검사할 필요가 있을 수 있다.
옛 이름들에 대한 모든 참조를 반드시 찾아서 새로운 이름으로 변경해야 할 것이다. 일반적으로 이러한 하드 코딩(hard-coding) 기법은, 이 상황에서는 신원을 명시적으로 표시해야 한다. 다음에 설명할 간접적인 방식에 비해 바람직하지 않다.
간접 통신에서 메시지들은 메일박스(mailbox) 또는 포트(port)로 송신되고 그것으로부터 수신된다. 메일박스는 추상적으로 프로세스들에 의해 메시지들이 넣어지고, 메시지들이 제거될 수 있는 객체라고도 볼 수 있다.
각 메일박스는 고유의 id를 가진다. 예컨대 POSIX 메시지 큐는 메일박스를 식별하기 위해 정수 값을 사용한다. 이 기법에서 프로세스는 다수의 상이한 메일박스를 통해 다른 프로세스들과 통신할 수 있다.
두 프로세스들이 공유 메일박스를 가질 때만 이들 프로세스가 통신할 수 있다. send와 receive 프리미티브들은 다음과 같이 정의할 수 있다.
send(A, message) - 메시지를 메일박스 A로 송신한다.
receive(A, message) - 메시지를 메일박스 A로부터 수신한다.
이 방법에서 통신 연결은 다음의 성질을 갖는다.
한 쌍의 프로세스들 사이의 연결은 이들 프로세스가 공유 메일박스를 가질 때만 구축된다.
연결은 두 개 이상의 프로세스들과 연관될 수 있다.
통신하고 있는 각 프로세스들 사이에는 다수의 서로 다른 연결이 존재할 수 있고, 각 연결은 하나의 메일박스에 대응된다.
프로세스 P1, P2, P3가 모두 메일박스 A를 공유한다고 가정하자. 프로세스 P1은 메시지를 A에 송신하고, P2, P3은 각각 A로부터 receive를 실행한다. 어느 프로세스가 P1이 보낸 메시지를 수신하는가? 이 문제에 대한 답은 우리가 선택할 기법에 좌우된다.
하나의 링크는 최대 두 개의 프로세스와 연관되도록 허용한다.
한 순간에 최대로 하나의 프로세스가 receive 연산을 실행하도록 허용한다.
어느 프로세스가 메시지를 수신할 것인지 시스템이 임의로 선택하도록 한다(즉 두 프로세스 모두는 아니고 P2나 P3 중 하나가 메시지를 수신한다) 시스템이 어느 프로세스가 수신할 것인지 선택하는 알고리즘을 정의할 수 있다. (예컨대 라운드 로빈 방식에서 는 프로세스가 돌아가면서 메시지를 수신한다) 시스템은 송신자에게 수신자를 알려 줄 수 있다.
메일박스는 한 프로세스 또는 운영체제에 의해 소유될 수 있다. 메일박스가 한 프로세스에 의해 소유된다면(즉 메일박스가 프로세스의 주소 공간의 일부이다.) 우리는 소유자(이 메일박스로부터 메시지를 수신만 가능한 프로세스)와 메일박스의 사용자를(메일박스에 메시지를 송신만 할 수 있는 프로세스) 구분할 수 있다.
각 메일박스가 고유한 소유자를 가지고 있기 때문에 이 메일박스로 보내진 메시지를 어느 프로세스가 수신할 지에 대한 혼란이 있을 수 없다. 메일박스를 소유하고 있는 프로세스가 종료할 때, 메일박스는 사라진다.
그 후에 이 메일박스로 메시지를 송신하는 모든 프로세스는 더는 메일박스가 존재하지 않는다는 사실을 반드시 통보 받아야 한다.
반면에 운영체제가 소유한 메일박슨느 자체적으로 존재한다. 이것은 독립적인 것으로 어떤 특정한 프로세스에 예속되지 않는다. 운영체제는 한 프로세스에게 다음을 할 수 있도록 허용하는 기법을 반드시 제공해야 한다.
새로운 메일박스를 생성한다.
메일박스를 통해 메시지를 송신하고 수신한다.
메일박스를 삭제한다.
새로운 메일박스를 생성하는 프로세스는 디폴트로 메일박스의 소유자가 된다. 초기에는 소유자만이 이 메일박스를 통해 메시지를 수신할 수 있는 유일한 프로세스이다.
그러나 소유권과 수신 특권은 적절한 시스템 호출을 통해 다른 프로세스에게 전달될 수 있다. 물론 이런 규칙으로 인해 각 메일박스마다 복수의 수신자들을 낳을 수 있다.

동기화(Synchronization)

프로세스간의 통신은 send와 receive 프리미티브에 대한 호출에 의해 발생한다. 각 프리미티브를 구현하기 위한 서로 다른 설계 옵션이 있다. 메시지 전달은 봉쇄형(blocking)이거나 비봉쇄형(non-blocking) 방식으로 전달된다. 이 두 방식은 각각 동기식, 비동기식이라고도 알려져 있다.
봉쇄형 보내기
송신하는 프로세스는 메시지가 수신 프로세스 또는 메일 박스에 의해 수신될 때까지 봉쇄된다.
비봉쇄형 보내기
송신하는 프로세스가 메시지를 보내고 작업을 재시작한다.
봉쇄형 받기
메시지가 이용 가능할 때까지 수신 프로세스가 봉쇄된다.
비봉쇄형 받기
송신하는 프로세스가 유효한 메시지 또는 널(null)을 받는다.
send()와 receive()의 다른 조합도 가능하다. send()와 receive()가 모두 봉쇄형일 때, 우리는 송신자와 수신자간에 랑데뷰(rendezvous)를 갖게 된다.
봉쇄형 send()와 receive()를 사용한다면 생산자와 소비자 문제에 대한 해결책은 사소한 문제가 된다. 생산자는 단순히 봉쇄형 send()를 호출하고, 메시지가 수신자 또는 메일박스에 전달 될 때까지 기다린다.
유사하게 소비자가 receive()를 호출하면 메시지가 전달될 떄까지 봉쇄된다. 아래 코드가 이 동작을 설명하고 있다.
// 메시지 전달을 사용하는 생산자 프로세스 message next produced; while(true) { /* next_produced에 생산된 item 저장 */ send(ext produced); }
C
// 메시지 전달을 사용하는 소비자 프로세스 message next consumed; while (true) { receive(next consumed); /* next_consumed에 있는 item 소비 */ }
C

버퍼링(Buffering)

통신이 직접적이든 간접적이든 간에 통신하는 프로세스들에 의해 교환되는 메시지는 임시큐에 들어 있다. 기본적으로 이러한 큐를 구현하는 방식은 세 가지가 있다.
무용량(zero capacity)
큐의 최대 길이가 0이다. 즉 링크는 장체 안에 대기하는 메시지들을 가질 수 없다. 이 경우에 송신자는 수신자가 메시지를 수신할 때까지 기다려야 한다.
유한 용량(bounded capacity)
큐는 유한한 길이 n을 가진다. 즉 최대 n개의 메시지가 그 안에 들어 있을 수 있다. 새로운 메시지가 전송될 때 큐가 만원이 아니라면, 메시지는 큐에 놓이며(메시지가 복사되든지 또는 메시지에 대한 포인터가 유지된다) 송신자는 대기하지 않고 실행을 계속한다. 그렇지만 링크는 유한한 용량을 가진다.
링크가 만원이면 송신자는 큐 안에 공간이 이용 가능할 때까지 반드시 봉쇄되어야 한다.
무한 용량(unbounded capacity)
큐는 잠재먹으로 무한한 길이를 가진다. 따라서 메시지들이 얼마든지 큐 안에서 대기할 수 있다. 송신자는 결코 봉쇄되지 않는다.
무용량의 경우는 떄떄로 버퍼가 없는 메시지 시스템이라고 부른다. 다른 경우들은 자동 버퍼링이라 불린다.

IPC 시스템의 사례

(생략)

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

소켓(Socket)

소켓(Socket)은 통신의 극점(endpoint)를 뜻한다. 두 프로세스가 네트워크상에서 통신을 하려면 양 프로세스마다 하나씩 총 두 개의 소켓이 필요해진다. 각 소켓은 IP 주소와 포트 번호 두 가지를 접합(concatenate)해서 구별한다.
일반적으로 소켓은 클라이언트-서버 구조를 사용한다. 서버는 지정된 port에 클라이언트 요청 메시지가 도착하기를 기다리게 된다. 요청이 수신되면 서버는 클라이언트 소켓으로부터 연결 요청을 수락함으로써 연결이 완성된다.
Telnet, ftp, http 등의 특정 서비스를 구현하는 서버는 well-known 포트로부터 메시지를 기다린다 (well-known이란 전 세계에서 표준으로 사용하는 포트 번호라는 의미) 예컨대 telnet은 23번, ftp는 21번, http는 80번 포트를 사용한다. 1024 미만의 모든 포트들은 well-known 포트로 간주되며 표준 서비스를 구현하는데 사용할 수 있다.
클라이언트 프로세스가 연결을 요청하면 호스트 컴퓨터가 포트 번호를 부여한다. 이 번호는 1024보다 큰 임의의 정수가 된다.
예컨대 IP 주소가 146.86.5.20인 호스트 X에 있는 클라이언트가 IP 주소 161.25.19.8의 웹 서버에 접속하려고 한다면 호스트 X는 클라이언트에게 포트 1625를 부여한다.
연결은 한 쌍의 소켓, 호스트 x의 146.86.5.20:1625와 웹서버의 161.25.19.8:80으로 구성된다.
이 상황이 그림 3.20에 나와 있다. 두 호스트 사이에 패킷들이 오갈 때 그 패킷들은 이 목적지 포트 번호가 지정하는데 따라 적절한 프로세스에게로 배달된다.
모든 연결은 유일해야 한다. 따라서 호스트 X에 있는 다른 클라이언트 프로세스가 위와 동일한 웹서버와 통신을 하면 그 클라이언트는 1024보다 크고 1625가 아닌 다른 포트번호를 부여받게 된다. 이것은 모든 연결이 유일한 소켓 쌍으로 구성되는 것을 보장한다.
Java는 세 가지 종류의 소켓을 제공한다. 연결 기반(TCP) 소켓은 Socket 클래스로 구현된다. 비연결성(UDP) 소켓은 DatagramSocket을 사용한다. 마지막으로 MulticastSocket 클래스는 DatagramSocket의 서브클래스이다. Mulicast 소켓은 데이터를 여러 수신자들에게 보낼 수 있다.
예제 프로그램은 연결 기반 TCP 소켓을 사용하는 date 서버를 설명한다. 클라이언트는 이 서버로부터 현재 날짜와 시간을 알아볼 수 있다.
1024보다 큰 임의의 번호를 가질 수 있지만 예제에서는 서버가 포트 6013을 listen하고 있다. 연결이 수신되면 서버는 클라이언트에게 현재 날짜와 시간을 보내준다.
Date 서버가 아래 코드에 나와 있다.
서버는 포트 6013을 listen 한다는 것을 지정하는 ServerSocket을 생성한다. 그런 후에 accept() 메서드를 이용하여 listen 하게 된다. 서버는 accept() 메서드에서 클라이언트가 연결을 요청할 때까지 봉쇄된다.
연결 요청이 들어오면 accept()는 클라이언트와 통신하기 위해 사용할 수 있는 소켓을 반환한다.
// Date 서버 import java.net.*; import java.io.*; public class DateServer { public static void main(String[] args) { try { ServerSocket sock = new ServerSocket(6013); // 연결을 listen() 한다 while (true) { Socket client = sock.accept(); PrintWriter pout = new PrintWriter(client.getOutputStream(), true); // Date를 소켓에 쓴다. pout.println(new java.util.Date().toString()); // 소켓을 닫고 다시 시작한다. client.close(); } } catch (IOException ioe) { System.err.println(ioe); } } }
Java
서버가 소켓과 통신하는 자세한 방법은 아래와 같다.
서버는 먼저 PrintWriter 객체를 만들고 이 객체는 추후 클라이언트와 통신하는데 사용된다. PrintWriter 객체는 서버가 print나 println과 같은 루틴을 써서 소켓에 데이터를 쓸 수 있게 한다.
서버는 println 메서드를 호출하여 클라이언트에게 날짜를 보낸다. 날짜를 소켓에 쓰면 서버는 이 클라이언트와의 소켓을 닫고 다른 요청을 기다리게 된다.
클라이언트는 소켓을 생성하고 서버가 listen 하는 포트와 연결함으로써 서버와 통신을 시작한다. 아래 코드는 이러한 클라이언트를 Java 프로그램으로 구현하였다.
클라이언트는 Socket을 생성하고 IP 주소 127.0.0.1에 있는 6013의 서버와 연결해 주기를 요청한다. 연결이 되면 소켓은 일반적인 스트림 입출력 명령문을 사용하여 그 소켓으로부터 읽을 수 있다. 서버로부터 날짜를 받은 후 클라이언트는 소켓을 닫고 종료한다.
IP 주소 127.0.0.1은 루프백(loopback)을 나타내는 특별한 IP 주소이다. 컴퓨터가 IP 주소 127.0.0.1을 참조하면 그 자신 기계를 지칭하는 것이다. 이처럼 하면 같은 기계에 있는 클라이언트와 서버가 TCP/IP 프로토콜을 사용하여 통신하게 된다.
IP 주소 127.0.0.1 대신 Date 서버를 실행하고 있는 원격지 호스트의 IP 주소를 사용할 수도 있다. IP 주소 뿐만 아니라 www.westminstercollege.edu와 같은 실제 호스트 이름을 사용할 수도 있다.
// Date 클라이언트 import java.net.*; import java.io.*; public class DateClient { public static void main(String[] args) { try { // 서버 소켓과의 연결을 만든다. Socket sock = new Socket("127.0.0.1", 6013); InputStream in = sock.getInputStream(); BufferedReader bin = new BufferedReader(new InputStreamReader(in)); // 소켓으로부터 날짜를 읽는다. String line; while ( (line = bine.readLine()) != null) System.out.println(line); // 소켓 연결을 닫는다. } catch (IOException ioe) { System.err.println(ioe); } } }
Java

원격 프로시저 호출(Remote Procedure Calls, RPC)

원격 서비스와 관련된 가장 보편적인 형태 중 하나는 앞서 소개한 RPC 패러다임이다. RPC는 네트워크에 연결되어 있는 두 시스템 사이의 통신을 프로시저 호출을 추상화하기 위한 방편으로 설계되었다.
이것은 IPC와 많은 측면에서 유사하며 사실 그러한 IPC 기반 위에 만들어진다. 그러나 여기서는 프로세스들이 서로 다른 시스템 위에서 돌아가기 때문에 원격 서비스를 제공하기 위해서는 메시지 기반 통신을 해야 한다.
IPC 방식과는 달리 RPC 통신에서 전달되는 메시지는 구조화 되어 있고 따라서 데이터의 패킷 수준을 넘어서게 된다. 각 메시지에는 원격지 포트에서 listen 중인 RPC 디먼의 주소가 지정되어 있고 실행되어야 할 함수의 식별자 그리고 그 함수에게 전달되어야 할 매개변수가 포함된다.
그런 후에 요청된 함수가 실행되고 어떤 출력이든지 별도의 메시지를 통해 요청자에게 반환된다.
포트는 단순히 메시지 패킷의 시작부분에 포함되는 정수이다. 시스템은 일반적으로 네트워크 주소는 하나씩 가지지만 그 시스템에서 지원되는 여러 서비스를 구별하기 위해 포트를 여러 개 가질 수 있다.
원격 프로세스가 어떤 서비스를 받고자 하면 그 서비스에 대응되는 적절한 포트 주소로 메시지를 보내야 한다. 예컨대 한 시스템이 자신의 사용자 목록을 다른 시스템에서 알 수 있도록 원하면 관련 (그러한 RPC 서비스를 제공해 주는) 디먼을 port 3027과 같은 곳에 등록시켜 놓는다.
그러면 원격 시스템은 서버의 포트 3027로 RPC 메시지를 보내면 필요한 정보를(이 시스템의 사용자 목록) 얻을 수 있다. 이 데이터는 응답 메시지 형태로 받게 된다.
RPC는 클라이언트가 원격 호스트의 프로시저 호출하는 것을 마치 자기의 프로시저 호출하는 것처럼 해준다. RPC 시스템은 클라이언트 쪽에 stub을 제공하여 통신을 하는데 필요한 자세한 사항들을 숨겨 준다. 보통 원격 프로시저 마다 다른 stub가 존재한다.
클라이언트가 원격 프로시저를 호출하면 RPC는 그에 대응하는 stub를 호출하고 원격 프로시저가 필요로 하는 매개변수를 건네준다. 그러면 stub가 원격 서버의 포트를 찾고 매개변수를 정돈(marshall)한다.
매개변수 정돈(parameter marshalling)이란 프로시저에게 갈 매개변수를 네트워크로 전송하기 위해 적절한 형태로 재구성하는 작업을 말한다.
그 후 stub은 메시지 전달 기법을 사용하여 서버에게 메시지를 전송한다. 이에 대응되는 stub가 서버에도 존재하여 서버 측 stub가 메시지를 수신한 후 적절한 서버의 프로시저를 호출한다.
필요한 경우 반환 값들도 동일한 방식으로 다시 되돌려준다. Windows 시스템에서는 stub 코드 Microsoft Interface Definition Language(MIDL)로 작성된 명세로부터 컴파일 된다. 이 언어는 클라이언트와 서버 프로그램 사이의 인터페이스를 정의하는데 사용된다.
한 가지 짚고 넘어가야 할 문제는 클라이언트와 서버의 데이터 표현 방식이 다를 경우이다. 32비트 정수를 예로 들어보자. 어떤 기계는 최상위 바이트(most-significant byte)를 먼저 저장하고(big-endia), 어떤 기계는 최하위 바이트(least-significant byte)를 먼저 저장한다.
이와 같은 차이를 해결하기 위해 대부분의 RPC 시스템은 기종 중립적인 데이터 표현 방식을 정의한다. 이러한 표현 방식 중 하나가 XDR(external data representation)이다.
클라이언트 측에서는 서버에게 데이터를 보내기 전 매개변수 정돈 작업의 일환으로 전송할 데이터를 기종 중립적인 XDR 형태로 바꾸어서 보낸다. 수신측 기계에서는 XDR 데이터를 받으면 매개변수를 풀어내면서 자기 기종의 형태로 데이터를 바꾼 후 서버에게로 넘겨준다.
또 다른 중요한 문제는 호출의 의미(semantic)에 관한 것이다. 지역 프로시저 호출의 경우 극단적인 경우에만 실패하지만 RPC의 경우는 네트워크 오류 때문에 실패할 수도 있고, 메시지가 중복되어 호출이 여러 번 실행될 수도 있다.
이 문제를 해결하는 방법은 운영체제로 하여금 메시지가 ‘최대 한 번’ 실행되는 것이 아니라 정확히 한 번 처리되도록 보장하게 하는 것이다. 대부분의 지역 프로시저 호출은 ‘정확히 한 번’의 기능성을 가지고 있으나 이를 구현하는 것은 더 어렵다.
우선 ‘최대 한 번’을 고려하자. 이 의미는 각 메시지에 타임스탬프를 매기는 것으로 보장할 수 있다. 서버는 이미 처리한 모든 메시지의 타임스탬프 기록을 가지거나 중복된 메시지를 검사해 낼 수 있을 만큼의 기록을 가져야 한다.
기록에 보관된 타임스탬프를 가진 메시지가 도착하면 그 메시지는 무시된다. 이렇게 하면 클라이언트는 한 번 이상 메시지를 보낼 수 있고 메시지에 대한 실행이 단 한 번 실행된다는 것을 보장 받을 수 있다.
‘정확히 한 번’의 의미를 가지려면 서버가 요청을 받지 못하는 위험을 제거할 필요가 있다. 이를 완수하려면 서버는 위에서 설명한 ‘최대 한 번’ 프로토콜을 구현하고 추가로 RPC 요청이 수신되었고 실행됐다는 응낙(acknowledgement) 메시지를 보내야만 한다.
이 ACK 메시지는 네트워킹에서 일반적이다. 클라이언트는 해당 호출에 대한 ACK를 받을 때까지 주기적으로 각 RPC 호출을 재전송해야 한다.
또 하나 다루어야 할 중요한 문제는 클라이언트와 서버 간의 통신 문제이다. 일반적인 프로시저 호출의 경우, 바인딩(binding)이라는 작업이 링킹/적재/실행 시점에 행해진다. 이 때 프로시저의 이름이 프로시저의 메모리 주소로 변환된다.
이와 마찬가지로 RPC도 클라리언트와 서버의 포트를 바인딩 해야 하는데, 클라이언트는 서버의 포트 번호를 어떻게 알 수 있는가? 두 시스템에는 모두 상대방에 대한 완전한 정보가 없다.
이를 위해 보통 두 가지 방법이 사용된다. 한 가지 방법은 고정된 포트 주소 형태로 미리 정해 놓는 방법이다. 컴파일 할 때 RPC에게는 이 고정된 포트 번호가 주어지게 된다. 컴파일 되고 나면 그 후에는 서버가 그 포트 번호를 임의로 바꿀 수 없다.
두 번째는 랑데부 방식에 의해 동적으로 바인딩하는 방법이다. 보통 운영체제는 미리 정해져 있는 고정 RPC 포트를 통해 랑데부용 디먼(matchmaker라고 불림)을 제공한다.
그러면 클라이언트가 자신이 실행하기를 원하는 RPC 이름을 담고 있는 메시지를 랑데부 디먼에게 보내서 RPC 이름에 대응하는 포트 번호가 무엇인지 알려달라고 요청한다. 그러면 포트 번호가 클라이언트에게 반환되고, 클라이언트는 그 포트 번호로 RPC 요청을 계속 보낸다(시스템이 crash 되거나 그 프로세스가 종료되지 않는 한)
이 방식은 통신 초기에 오버헤드가 좀 들기는 하지만 첫 번째 방식보다 더 유연하다. 그림 3.23이 본보기 상호작용을 보이고 있다.

파이프(Pipes)

파이프는 두 프로세스가 통신할 수 있게 하는 전달자로서 동작한다. 파이프는 초가 UNIX 시스템에서 제공하는 IPC 기법 중 하나였다. 파이프는 통상 프로세스들 간에 통신하는 더 간단한 방법 중 하나이지만 통신할 때 여러 제약을 가진다. 파이프를 구현하기 위해 다음 4가지 문제를 고려해야 한다.
1.
파이프가 단방향 통신 또는 양방향 통신을 허용하는가?
2.
양방향 통신이 허용된다면 반이중(half duplex) 방식인가, 전이중(full duplex) 방식인가? 반이중 방식은 한 순간에 한 방향 전송만 가능하고 전이중 방식은 동시에 양방향 데이터 전송이 가능하다.
3.
통신하는 두 프로세스 간에 부모-자식과 같은 특정 관계가 존재해야만 하는가?
4.
파이프는 네트워크를 통하여 통신이 가능한가, 아니면 동일한 기계 안에 존재하는 두 프로세스끼리만 통신할 수 있는가?

일반 파이프

일반 파이프는 생산자-소비자 형태로 두 프로세스 간의 통신을 허용한다. 생산자 파이프의 한 종단(쓰기 종단)에 쓰고, 소비자는 다른 종단(읽기 종단)에서 읽는다.
결과적으로 일반 파이프는 한쪽으로만 데이터를 전송할 수 있으며 오직 단방형 통신만을 가능하게 한다. 만일 양방향 통신이 필요하다면 각각 다른 방향으로 데이터를 전송할 수 있는 두 개의 파이플 사용해야 한다.
다음으로 UNIX와 Windows 시스템에서 일반 파이프를 구축하는 예를 설명한다. 두 개의 프로그램 예제에서 한 프로세스는 Greetings라는 메시지를 파이프에 쓰고, 다른 프로세스는 이 메시지를 파이프로부터 읽는다. UNIX 시스템에서 일반 파이프는 다음 함수를 사용하여 구축된다.
pipe(inf fd[]);
Java
이 함수는 fd[] 파일 설명자를 통해 접근되는 파이프를 생성한다. fd[0]는 파이프의 읽기 종단이고 fd[1]은 파이프의 쓰기 종단으로 동작한다. UNIX는 파이프를 파일의 특수한 유형으로 취급한다. 따라서 파이프는 일반적인 read()와 write() 시스템 호출을 사용하여 접근될 수 있다.
일반 파이프는 파이프를 생성한 프로세스 이외에는 접근할 수 없다. 따라서 통상 부모 프로세스가 파이프를 생성하고 fork()로 생성한 자식 프로세스와 통신하기 위해 사용한다.
자식 프로세스는 열린 파일을 부모로부터 상속받는다는 것을 기억하라. 파이프는 파일의 특수한 유형이기 때문에 자식 프로세스는 부모로부터 파이프를 상속받는다. 그림 3.24는 파일 설명자와 부모와 자식 프로세스의 관계를 도시하고 있다.
아래 코드에 보인 UNIX 프로그램에서 부모 프로세스는 파이프를 생성하고 자식 프로세스를 생성하기 위해 fork()를 호출한다. fork() 후에 일어나는 작업은 파이프를 통해 데이터가 어떻게 흘러가느냐에 따라 달라진다.
이 예제에서는 부모 프로세스가 파이프에 쓰고 자식 프로세스가 파이프로부터 읽는다. 부모와 자식 프로세스 모두 처음에 자신들이 사용하지 않는 파이프의 종단을 닫는 것으 ㄹ주의하라.
아래 코드에 보인 프로그램은 이러한 작업을 하지 않지만 writer가 파이프의 종단을 닫았을 때 파이프로부터 읽는 프로세스가 end-of-file(read()가 0을 반환)을 탐지하는 것을 보장하기 때문에 이 작업은 매우 중요한 절차이다.
#include <sys/types.h> #include <stdio.h> #include <string.h> #include <unistd.h> #define BUFFER_SIZE 25 #define READ_END 0 #define WRITE_END 1 int main(void) { char write_msg[BUFFER_SIZE] = "Greetings"; char read_msg[BUFFER_SIZE]; int fd[2]; pid_t pid; /* 파이프 생성 */ if (pipe(fd) == 1) { fprintf(stderr, "Pipe failed"); return 1; } /* 자식 프로세스 생성 */ pid = fork(); if (pid < 0) { fprintf(stderr, "Fork Failed"); return 1; } if (pid > 0 { /* 부모 프로세스 */ /* 파이프의 사용하지 않는 쪽을 닫는다 */ close(fd[READ_END]); /* 파이프에 쓴다 */ write(fd[WRITE_END], write_msg, strlen(write_msg)+1); /* 파이프의 쓰기 쪽을 닫는다 */ close(fd[WRITE_END]); } else { /* 자식 프로세스 */ /* 파이프의 사용하지 않는 쪽을 닫는다 */ close(fd[WRITE_END]); /* 파이프로부터 읽는다 */ read(fd[READ_END], read_msg, BUFFER_SIZE); printf("child read %s\n", read_msg); /* 파이프의 읽기 쪽을 닫는다 */ close(fd[READ_END]); } return 0; }
C
Windows 시스템의 일반 파이프는 익명 파이프(anonymous pipe)라고 불리며 UNIX의 대응되는 파이프와 유사하게 동작한다. 이 파이프는 단방향이고 통신하는 두 프로세스는 부모-자식 관계여야 한다.
게다가 파이프의 읽기와 쓰긴느 보통의 ReadFile()과 WriteFile()을 사용하여 이루어진다. 파이프를 생성하기 위한 Windows API는 CreatePipe() 함수로서 4개의 매개변수를 전달받는다.
매개변수는 (1) 읽기 (2) 쓰기 (3) 자식 프로세스가 파이프의 핸들을 상속받는다는 것을 명시하기 위해 사요오디는 STARTUPINFO 구조체의 인스턴스를 위한 각각의 핸들을 전달받는다. 그리고 (4) 바이트 단위의 파이프의 크기가 지정될 수 있다.
아래 코드는 자식 프로세스와 통신하기 위하여 익명 파이프를 생성하는 부모 프로세스를 설명하고 있다. 자식 프로세스가 부모 프로세스가 생성한 파이프를 자동적으로 상속받는 UNIX 시스템과 다르게 Windows에서는 프로그래머가 어떤 속성을 상속받는지를 명시해야 한다.
우선 핸들을 상속받을 수 있도록 SECURITY_ATTRIBUTES 구조를 초기화하고 다음에 자식 프로세스의 표준 입력 또는 표준 출력 핸들을 파이프의 읽기 또는 쓰기 핸들로 지정해야 한다.
자식 프로세스가 파이프로부터 읽도록 만들어야 하기 때문에 부모 프로세스는 자식의 표준 입력을 파이프의 읽기 핸들로 재지정해야만 한다.
더욱이 파이프는 반이중 방식이므로 자식이 파이프의 쓰기 종단을 상속받는 것을 금지시켜야 한다. 자식 프로세스를 생성하는 것은 앞선 코드와 한 가지만 뺴놓고 유사하다. 한 가지 차이점은 자식 프로세스가 지정된 핸들을 부모로부터 상속받을 수 있도록 5번째 매개변수를 TRUE로 지정해 주는 것이다.
파이프에 쓰기 전에 부모는 먼저 파이프의 사용하지 않을 읽기 종단을 닫는다. 아피으포부터 읽는 자식 프로세스는 아래 코드에 나와 있다. 파이프로부터 읽기 전에 리프로그램은 GetStdHandle() 함수를 호출하여 파이프의 읽기 핸들을 획득한다.
// Windows 익명 파이프 - 부모 프로세스 #include <stdio.h> #include <stdlib.h> #include <windows.h> #define BUFFER_SIZE 25 int main(VOID) { HANDLE ReadHandle, WriteHandle; STARTUPINFO si; PROCESS_INFORMATION pi; char message[BUFFER_SIZE] = "Greetings"; DWORD written; /* 파이프가 상속받을 보안 속성을 설정한다 */ SECURITY_ATTRIBUTES sa = { sizeof(SECURITY_ATTRIBUTES), NULL, TRUE }; /* 메모리 할당 */ ZeroMemory(&pi, sizeof(pi)); /* 파이프 생성 */ if (!CreatePipe(&ReadHandle, &WriteHandle, &sa, 0)) { fprintf(stderr, "Create Pipe Failed\n"); return 1; } /* 자식 프로세스를 위한 START_INFO 구조를 구축한다 */ GetStartupInfo(&si); si.hStdOutput = GetStdHandle(STD_OUTPUT_HANDLE); /* 표준 입력을 파이프의 읽기 쪽으로 재지정한다 */ si.hStdInput = ReadHandle; si.dwFlags = STARTF_USETDHANDLES; /* 자식 프로세스가 파이프의 쓰기 쪽을 상속 받지 않도록 한다 */ SetHandleInformation(WriteHandle, HANDLE_FLAG_INHERIT, 0); /* 자식 프로세스 생성 */ CreateProcess(NULL, "child.exe", NULL, NULL, TRUE, /* inherit handles */ 0, NULL, NULL, &si, &pi)); /* 파이프의 사용하지 않는 쪽을 닫는다 */ CloseHandle(ReadHandle); /* 부모 프로세스가 파이프에 쓴다 */ if(!WriteFile(WriteHandle, message, BUFFER_SIE &wirtten, NULL)) fprintf(stderr, "Error writing to pipe\n"); /* 파이프의 쓰기 쪽을 닫는다 */ CloseHandle(WriteHandle); /* 자식이 종료하기를 기다린다 */ WaitForSingleObject(pi.hProcess, INFINITE); CloseHandle(pi.hProcess); CloseHandle(pi.hThread); return 0; }
C
// Windows 익명 파이프 - 자식 프로세스 #include <stdio.h> #include <windows.h> #define BUFFER_SIZE 25 int main(VOID) { HANDLE ReadHandle; CHAR buffer[BUFFER_SIZE]; DWORD read; /* 파이프의 읽기 핸들을 얻는다 */ ReadHandle = GetStdHandle(STD_INPUT_HANDLE); /* 부모 프로세스가 파이프에 쓴다 */ if(ReadFile(ReadHandle, buffer, BUFFER_SIE &read, NULL)) printf("child: >%s<", buffer); else fprintf(stderr, "Child: Error reading from pipe\n"); return 0; }
C
UNIX와 Windows 시스템 모두에서 통신하는 두 프로세스는 부모-자식 관계를 가져야 한다는 것을 명심하라. 이 유형의 파이프는 동일한 기계 상의 두 프로세스끼리만 통신이 가능하다는 것을 의미한다.

지명 파이프(Named Pipes)

일반 파이프는 한 쌍의 프로세스가 통신할 수 있는 간단한 기법을 제공한다. 그러나 일반 파이프는 오로지 프로세스들이 서로 통신하는 동안에만 존재한다. UNIX와 Windows 시스템 모두에서 프로세스들이 통신을 마치고 종료하면 일반 파이프는 없어지게 된다.
지명 파이프(named pipes)는 좀 더 강력한 통신 도구를 제공한다. 통신은 양방향으로 가능하며 부모-자식 관계도 필요로 하지 않는다. 지명 파이프라 구축되면 여러 프로세스들이 이를 사용하여 통신할 수 있다.
실제 통상의 시나리오에서 지명 파이프는 다수의 writer를 가진다. 추가적으로 통신 프로세스가 종료하더라도 지명 파이프는 계속 존재하게 된다. UNIX와 Windows 시스템은 구현상에 차이점은 있지만 모두 지명 파이프를 지원한다.
지명 파이프를 UNIX에서는 FIFO라고 부른다. 생성되면 지명 파이프는 파일 시스템의 보통 파일처럼 존재한다.
FIFO는 mkfifo() 시스템 호출을 이용하여 생성되고 일반적인 open(), read(), write() 및 close() 시스템 호출로 조작된다. 명시적으로 파일 시스템에서 삭제될 때까지 존재하게 된다.
FIFO가 양방향 통신을 허용하긴 하지만 반이중 전송만이 가능하다. 데이터가 양방향으로 전송될 필요가 있다면 보통 2개의 FIFO가 사용된다.
부가적으로 통신하는 두 프로세스는 동일한 기계 내에 존재해야 한다. 서로 다른 기계에 존재하는 프로세스 사잉에 통신이 필요하다면 소켓을 사용해야 한다.
Windows 시스템의 지명 파이프는 UNIX의 상승 파이프보다 훨씬 풍부한 통신 기법을 제공한다. 전이중 통신을 허용하며, 통신하는 두 프로세스는 같은 기계 또는 다른 기계 상에 존재할 수 있다.
추가적으로 UNIX FIFO가 바이트-단위 통신만을 허용하는데 비해 Windows 시스템 파이프는 바이트-단위 또는 메시지-단위 데이터의 전송을 허용한다.
지명 파이프는 CreatenamePipe() 함수를 사용하여 생성되고 클라이언트는 ConnectNamePipe() 함수를 사용하여 지명 파이프에 연결할 수 있다. 지명 파이프를 통한 통신을 ReadFile()과 WriteFile() 함수를 사용하여 실행된다.