[운영체제] IPC: 프로세스 간 통신(Inter Process Communication)

참고도서: Operating System Concepts (10/E) Abraham Silberschatz, Peter B. Galvin, Greg Gagne

Process Communication

컴퓨터 시스템이 동작하면서 내부적으로 다양한 프로세스가 실행되고 종료된다. 그리고 프로세스는 다른 프로세스와 통신을 수행하기도 한다. 어떤 프로세스가 시스템에 있는 다른 프로세스와 데이터를 공유하고 서로 영향을 준다면, 그 프로세스는 cooperating 하다고 할 수 있다. 그리고 다른 프로세스와 데이터를 공유하지 않고 자기만의 일을 하는 프로세스는 independent 하다고 말할 수 있을 것이다.

프로세스간의 통신이 필요한 이유는 다음과 같이 설명할 수 있다.

  1. Information Sharing : 여러 프로그램이 동일한 정보에 대해 작업을 원한다면 동시에 접근이 가능하도록 하기 위해
  2. Computation Speedup : 어떤 작업을 여러개의 substask 각각의 서브테스크가 병렬하게 작업될 수 있도록 하기 위해
  3. Modulity : 시스템의 기능을 프로세스나 스레드로 나누어서 모듈식 시스템을 구성하기 위해

이런 통신을 지원하기 위한 기법들을 통틀어 Interprocess Communication (IPC) 기법이라고 부른다. 이 기법은 크게 두가지로 나뉘어 지는데, shared memory 방식과, message passing 방식이다. 이제 하나식 살펴보자.

IPC in Shared-Memory Systems

공유 메모리를 사용하는 방식은 통신을 원하는 각 프로세스들이 공유 메모리 영역을 구축하게 된다. 그리고 이 공유 메모리 영역을 통신을 하고자하는 다른 프로세스들이 자신의 주소공간에 추가하는 방법으로 서로를 연결한다. 일반적인 상황에서 운영체제는 어떤 프로세스가 다른 프로세스의 메모리에 접근하는 것을 허용하지 않는다. 따라서 공유 메모리 방식은 통신을 시도하는 두 개 이상의 프로세스가 서로 간의 접근을 허용해야한다. 공유 메모리를 사용할 때의 가장 큰 장점은 통신속도가 빠르다는 점, 커널의 도움이 필요하지 않다는 점이 있다.

IPC의 필요성을 논하기 위해 producer-consumer 문제를 생각해보자. producer process는 정보를 계속 만들어내는 일을 하고, consumer process는 만들어진 정보를 사용하는 일을 한다. 예를 들어, 컴파일러(producer process)가 어셈블리 코드를 생산하게 되면 loader(consumer process)는 이 코드를 메모리에 적재하는 데 소비하게 될 것이다.

두 종류의 프로세스 사이에 적절하게 자원이 오고가게 하려면 효과적인 방법이 공유 메모리를 사용하는 방법이 될 것이다. producer 와 consumer 프로세스가 병행하게 실행될 수 있도록 하려면 둘 사이에 버퍼가 필요하게 되는데 이 버퍼는 producer에 의해 item들이 들어가고 consumer에 의해 버퍼 내의 item들이 사용된다. 그리고 producer 와 consumer는 항상 동기화 되어서 아직 만들어지지 않은 item이 사용된는 것을 막아야 할 필요도 있다.

위에서 데이터의 공유를 위해 버퍼를 사용해야 된다고 언급했는데, 버퍼는 두 종류가 사용될 수 있다. 한 종류는 크기의 제한이 없는 unbounded buffer 이다. 크기에 제한이 없기 때문에 producer는 계속해서 새로운 정보를 생성해서 버퍼에 넣어줄 수 있다. 또 다른 종류는 bounded buffer 로 크기가 특정한 사이즈로 고정되어 있는 경우를 말한다. 이경우에는 버퍼가 비어있으면 consumer는 어떤 정보가 버퍼에 들어올 때까지 기다려야하고, 버퍼가 가득차있으면 producer가 버퍼에 빈자리가 생길 때까지 대기해야한다.

위 코드를 보면 bounded buffer 에서 producer process와 consumer process가 어떤 일을 하는지 정확히 알 수 있을 것이다. counter 변수는 현재 버퍼에 들어가 있는 아이템의 갯수를 계속 세어준다. producer는 버퍼에 지정된 크기만큼의 데이터가 가득찰 때까지 계속 새로운 정보를 만들어서 넣어준다. 그리고 consumer 는 버퍼에 아무것도 없다면 아무 일도 하지 않고 넘어가고, 버퍼어 어떤 정보가 존재하면 그 정보를 소비하고 버퍼를 하나 비워주게 된다.

IPC in Message-Passing System

Message Passing 방식의 통신은 프로세스가 동일한 주소 공간을 공유하지 않고 프로세스들이 통신하고 동기화할 수 있도록 하는 기법이다. 이런 방식은 여러 컴퓨터 시스템이 네트워크를 통해서 묶여있는 환경에서 특히 더 유용하게 사용된다.

Message Passing 기법은 기본적으로 두 가지 연산은 항상 제공한다. 바로 send(message) 와 recieve(message) 이다. 어떤 두 프로세스가 서로 통신하려면 위 연산을 통해 메세지를 주고 받아야 하고, 이를 위해선 둘 사이에 communication link가 필수적으로 설정되어야 한다. 메세지 전달 방식은 구현이 쉽고 충돌 회피를 고려하지 않아도 되기 때문에 적은 양의 데이터를 교환할 때 더 유용하게 사용될 수 있다는 장점이 있다.

이제 통신이 이루어 지는 방법을 깊에 알아보자. 통신은 크게 세 가지 다른 논리로 만들어질 수 있는데 하나씩 알아보도록 하겠다.

Direct Communication (Naming)

통신을 원하는 프로세스들이 서로를 가르키게 하려면 직접, 혹은 간접적으로 통신해야 한다.

직접 통신은 프로세스가 서로의 이름을 명시해서 통신을 시도하는 것이다.

  • send(P, message)
  • receive(Q, message)

위와 같이 명시적으로 프로세스의 이름을 전달하면 프로세스 P 에게로 message를 보내는 것과 Q 로 부터 message를 받는 것을 요청할 수 있다. 통신을 원하는 프로세스가 서로의 이름(identity)만 알고 있으면 연결이 되기 때문에 정확히 pair 를 이루는 특징을 가지게 된다. 그리고 각 pair는 하나의 연결만을 가지게 된다. 따라서 이런 형태의 연결이 symmetric 하다고도 말 할 수 있을 것이다.

  • send(P, message)
  • receive(id, message)

위의 예시는 assymetric 형태로, sender 가 reciever 의 이름을 명시하고, receiver 는 sender의 이름을 명시하지 않는 형태를 보여준다. receiver에 지정된 변수 id는 통신을 발생시키는 프로세스의 이름으로 설정되게 된다.

이러한 직접 통신은 modulity를 제한한다는 것을 큰 단점으로 가지고 있다. 만약 프로세스의 이름이 바뀌면 모든 다른 프로세스의 이름을 다시 수정해주어야 하고 이런 하드 코딩은 그리 효율성이 좋은 방법이 되지는 못할 것이다.

Indirect Communication (mailbox)

간접 통신은 message 들이 mailbox 나 port로 보내지고 여기서 message들을 받아오는 통신 기법이다. mailbox는 고유한 id를 가지고 있고 각 프로세스에서 들어오는 송수신 요청을 통해 통신을 만들게 된다. 고유한 id를 가진다는 뜻은 다수의 mailbox가 존재할 수 있다는 것과 같다.

  • send(A, message)
  • receive(A, message)

직접 통신과 형태는 비슷하지만, 직접 통신과는 다르게 mailbox의 이름을 지정해준다. mailbox의 사용은 두 개 이상의 프로세스에 대한 통신을 가능하게 만들고 다수의 프로세스간에 연결이 존재할 수 있지만 거기에 되는 mailbox는 하나가 존재하게 된다.

다수의 프로세스가 하나의 mailbox 를 사용할 수 있다면, 하나의 프로세스가 send 를 요청하고 다른 여러개의 프로세스가 receive 를 요청했을 때, 어떻게 대응해야 할지에 대한 고민이 든다. 이를 위해서 우리는 다음 기법들 중 하나를 선택하여 사용할 수 있다.

  1. 하나의 링크에 최대 두 개의 프로세스만 허용한다.
  2. 한순간에 최대 하나의 receive 만 수행하도록 한다.
  3. 임의로 receiver를 하나 선택하고 어떤 receiver 가 선택되었는지 sender에게 알려준다.

mailbox는 프로세스에 의해 소유될 수도, 운영체제에 의해 소유될 수도 있다. 프로세스에 의해 소유되는 경우에는 우리가 해당 mailbox의 소유자와 사용자를 구분할 수 있기 때문에 어떤 프로세스에게 message 를 줄 것인지에 대해 혼란이 생기지 않는다. 그리고 mailbox가 프로세스의 주소공간 안에 존재하기 때문에 프로세스가 종료되면 mailbox도 종료된다. 운영체제가 mailbox를 소유하게 되면 해당 mailbox는 프로세스로부터 독립적으로 운영되게 된다. 이 경우에는 운영체제가 프로세스가 다음과 같은 작업을 할 수 있도록 허용한다.

  1. 새로운 mailbox를 만든다.
  2. mailbox 를 통해 send 와 recieve 를 수행할 수 있도록 한다.
  3. mailbox를 삭제할 수 있게 한다.

프로세스가 새로운 mailbox를 생성하게 되면, 해당 프로세스는 mailbox의 소유자가 되고 message를 수신할 수 있는 유일한 프로세스가 된다. 그러나 system call을 통해 이런 소유권과 수신권이 다른 프로세스에게 전달될 수 있고 이 때문에 한 mailbox가 여러 수신자를 가지게 될 수 있다.

Synchronization

Message passing 에서 메세지의 전달은 Blocking(synchronous) 혹은 Nonblocking(aysnchronous) 방식으로 수행된다.

  • Blocking Send : 메세지를 보내는 프로세스가 해당 메세지가 메세지를 받는 프로세스나 mailbox 에 의해 수신될 때까지 대기하게 된다.
  • Nonblocking Send : 메세지를 보내는 프로세스가 해당 메세지를 보내고 다른 작업을 이어서 수행한다.
  • Blocking Receive : 메세지를 수신하는 프로세스가 메세지를 받을 수 있을 때까지 대기한다.
  • Nonblocking Receive : 메세지를 수신하는 프로세스가 유효한 메시지를 받을 수 없으면 null을 받는다.

만약 sender 와 reciever 가 모두 blocking 타입을 가지게 되면, sender와 reciever 는 rendezvous 상태를 가지게 된다. 이 상태가 되면 producer 는 단순히 send() 를 호출하고 consumer가 수신할 때까지 기다리고, consumer는 recieve()를 호출하고 producer 가 메세지를 전달할 때까지 기다리기 때문에 앞서 정의했던 producer-consumer 문제가 발생하지 않게 된다.

Buffering

어떤 방식으로 message passing 을 구현하던, 메세지는 buffer 라고 불리는 큐에 저장되게 된다. Buffer 는 수용 용량에 따라서 다음과 같은 종류로 나눌 수 있다.

  1. Zero Capacity : 큐의 길이가 0인 경우. 메세지가 큐에서 대기할 수 없기 때문에 sender 는 reciever가 recieve()를 요청할 때까지 기다려야 하고 결국 둘 사이에는 randezvous 가 형성된다.
  2. Bounded Capacity : 큐가 유한한 길이를 가진다. 만약 큐가 메세지들로 가득 찬다면, sender는 큐에 자리가 생길 때까지 Blocking 되는 상황이 발생한다.
  3. Unbounded Capacity : 큐가 무한한 길이를 가진다. 따라서 sender 가 Blocking 되는 일은 발생하지 않고 계속해서 message 를 큐에 넣어줄 수 있게 된다.

Communication in Client-Server Systems

프로세스들이 서로 통신하는 것과 같은 아이디어가 클라이언트 시스템의 통신 사이에서도 사용이 가능하다. 클라이언트-서버에서 우리가 사용할 수 있는 두 가지 통신 전략을 알아보자.

Socket

Socket은 통신의 endpoint 를 의미한다. 따라서 두 프로세스가 네트워크를 통해 서로 통신하려면 각 프로세스가 하나씩의 소켓을 가지고 있어야 한다. 소켓은 IP 주소와 포트번호를 결합해서 이름을 붙이게 된다.

소켓은 클라이언트-서버 구조로 주로 만들어져 있는데, 서버는 지정한 포트에 클라이언트의 요청 메세지가 도착하는 것을 기다리게 된다. 요청이 들어오고 서버에서 이 요청을 수락하게 되면 둘 사이의 연결이 만들어진다.

클라이언트 프로세스가 연결을 요청하게 되면, 호스트 컴퓨터는 포트 번호를 부여하게 된다. 이때 포트 번호는 1024 보다 큰 임의의 정수가 되는데 그 이유는 1024이하의 포트들은 well-known 포트로 표준 서비스를 구성하는 데 사용되는 포트번호이기 때문이다. 이렇게 연결이 완성되면 소켓 호스트와 웹 서버가 연결이 되고 둘 사이에 오가는 패킷은 이 포트 번호에 따라 연결된 프로세스에 연결되는 역할을 하게된다. 이 때문에 모튼 연결을 unique 해야할 필요가 있다. 모든 연결은 쌍으로 구성되어 있고 클라이언트 마다 모두 다른 포트번호를 부여받게 된다.

Java 에서는 세 가지 소켓 종류를 제공한다. Connect-Oriented(TCP)Socket, Connectionless(UDP) Socket, MulticastSocket 이다. 그리고 소켓에서 프로세스는 자기 자신의 컴퓨터를 가르키기 위해 loop back 이라고 불리는 특별한 IP 주소를 사용하고 그 주소는 127.0.0.1 이다.

Socket은 분산되어 있는 프로세스간에 널리 사용되고 또 효율적인 통신방법이긴 하지만, 소켓은 스레드 간에 구조화되지 않은 바이트 스트림을 통해서만 통신을 하기 때문에 이 바이트 스트림을 해석하는 것에 있어서는 그다지 효율적인 방법이라고 할 수 없다.

Remote Procedure Call (RPC)

RPC 는 네트워크 상에 있는 프로세스들의 통신을 IPC의 기법들을 기반으로 설계된 기법이다. RPC는 목적 자체가 서로 다른 시스템에 있는 프로세스들을 연결시키는 것이기 때문에 Message Passing 기법을 사용한다.

RPC 통신은 IPC보다 훨씬 더 구조화된 메세지를 교환하기 때문에, 단순한 데이터의 패킷이 아니게 된다. 메세지는 listen하고 있는 RPC daemon에 대한 주소가 기록되어 있고 RPC daemon은 실행해야할 함수, 파라미터를 포함하고 있다. 요청받은 함수를 실행한 결과는 메세지 형태도 하지 요청한 프로세스에게 반환된다.

Port

RPC 에서 Port는 단순히 메세지 패킷의 시작점을 알려주는 숫자이다. 따라서 일반적으로 시스템은 하나의 네트워크 주소를 가지는 데 포트 번호는 여러개 가질 수 있다. 어떤 프로세스가 어떤 서비스를 받으려고 한다면, 그 서비스에 맞는 포트로 메세지를 보내야 서비스를 받을 수 있다.

Stub

RPC는 프로세스가 다른 시스템에 있는 프로세스와 통신이 가능하도록 해주지만 마치 동일한 시스템 내에서 통신이 일어나는 것 처럼 한다. RPC는 client side 에 stub을 제공해서 통신에 구체적으로 어떤 일이 일어나는지 숨겨버린다.

remote procedure 가 발생할 때마다 각기 다른 stub이 제공된다. 클라이언트에서 remote procedure call 을 요청하면 RPC 시스템은 적절한 stub을 호출하고 procedure에게 파라미터를 넘겨준다. 호출된 stub은 서버의 포트를 찾고 파라미터를 marshal 하는 일을 하게된다. 여기서 marshal 한다는 것은 로컬에 존재하는 파라미터를 네트워크로 전송시킬 수 있는 형태로 만드는 것이다.

클라이언트 RPC 시스템에서 호출된 stub은 가지고 있는 정보와 procedure를 메세지로 서버에게 보내고, 서버에 위치해 있던 stub가 요청을 받아 함수를 호출하게 된다. 만약 반환해야되는 값이 있다면 요청을 전송했던 방법과 같은 기법으로 반환되는 값을 다시 클라이언트에게 전달해준다.

Parameter Marshalling

위에서 언급했던 것 처럼 파아미터를 marshal 한다는 것은 서버가 이해할 수 있는 형태로 파라미터를 가공해서 서버에게 보내주는 것을 의미한다. Big-endian 이라고 불리는 시스템은 MSB를 가장 먼저 저장하지만, 또 liitle-endian 이라고 불리는 시스템은 LSB를 가장 먼저 저장한다. 이렇게 시스템마다 표현방식의 차이가 있기 때문에 많은 RPC 시스템은 중립적인 데이터의 표현을 사용한다.

대표적인 예가 External Data Representation(XDR) 이다. 클라이언트에서 서버로 파라미터를 보내기전에 stub을 통해서 데이터를 XDR형태로 변환하여 보내고, 서버쪽에서는 넘겨받은 XDR 데이터를 서버에서 해석할 수 있는 형태로 다시 변환하게 된다.

Semantics of a Call

네트워크의 상태에 따라서 RPC가 실패하는 경우가 생길 수도 있다. 이때 문제가 되는 경우는 RPC가 중복적으로 작업을 여러번 수행하는 경우다. 그래서 우리는 운영체제가 RPC를 정확히 한 번(“exactly once”) 실행하게 하는 것이 중요하다.

“정확히 한 번”을 위헤 최대 한 번의 경우를 생각해보자. 이것을 구현하려면 모든 메세지에 timestamp를 넣어주면 해결된다. 서버쪽에서 이미 처리한 메세지에 대한 timestamp 기록을 가지고 있다면 오류로 인해 똑같은 메세지가 한번 더 수행되는 일을 막을 수 있을 것이다.

따라서, 정확히 한 번을 위해 서버가 메세자를 받지 못하는 상황을 고려해야 할 필요가 있다. 이를 위해서 서버와 클라이언트 사이에 프로토콜을 설정하고 RPC 요청이 성공적으로 수신되고 실행되었다면 클라이언트에게 acknowledgement 메세지를 보내면 된다. 이런 메세지를 ACK 메세지라고 부르는데, 클라이언트는 어떤 호출에 대한 ACK 메세지를 돌려받지 못하면 주기적으로 같은 호출을 계속 요청하게 된다.

Binding

일반적으로 프로시저가 호출 될 때, binding 이라는 작업이 행해지게 된다. 이때 procedure call의 이름이 procedure call의 주소로 덮어씌워지게 된다. RPC 시스템에서도 동일한 binding 이 이루어져야 하는데, 이때 필요한 클라이언트와 서버의 포트 번호를 어떻게 알아낼 것인지에 대한 문제가 있다.

이 문제를 해결하기 위해 두 가지 방법이 제안되는데, 첫번째 방법은 포트 주소를 고정적으로 미리 정해두는 것이다. 서버는 항상 이 고정된 포트번호를 가지게 되고 임의로 변경할 수 없게 하기 때문에 바인딩이 가능해진다. 두번째 방법은 randezvous 방식으로 바인딩하는 것이다. 운영체제는 고정된 RPC 포트를 통해서 matchmaker 라는 randezvous용 daemon을 제공하고 클라이언트는 이 포트로 계속 요청을 보내서 실행하고자 하는 프로시저의 이름을 전달하면서 해당 프로시저의 포트번호를 계속 요청한다.


Written by@전여훈 (Click Me!)
고민이 담긴 코드를 만들자, 고민하기 위해 공부하자.