[유혁 교수의 운영체제] 6.IPC

IPC

프로세스간 통신이란?

  • Interprocess Communication(IPC)
    • 원래는 Protection Domain으로 인해 서로 다른 프로세스들이 직접 데이터를 주고 받을 수가 없다.
    • 프로세스들 간에 데이터 및 정보를 주고 받기 위한 메커니즘
    • 커널에서 IPC를 위한 도구를 제공
      • 시스템 콜의 형태로 프로세스에게 제공됨
  • IPC의 필요성
    • 프로세스 협력 모델(cooperting process model)을 구현하기 위해 IPC가 반드시 필요함
      • 서로 데이터를 교환하기 위해

IPC의 첫번째 모델 - 메모리 공유

  • Shared memory
    • 프로세스의 특정 메모리 영역을 공유
    • 공유한 메모리 영역에 읽기/쓰기를 통해서 프로세스간 통신을 수행 응용
      • 이 메모리 영역을 통해서 각각의 프로세스의 일부를 서로가 읽거나 쓸수가 있다.
    • 커널 관여 없음
      • 공유 메모리가 설정되면 (여기는 커널 관여)
        • 그 이후의 통신은 프로세스간에 진행됨
        • 마치 자신에게 할당된 메모리에 정보를 읽고 쓰듯이 자연스럽게 사용이 가능하다.
      • 활용: 데이터베이스
      • 구현: shared memory IPC

IPC의 두번째 모델 - 메시지 교환

  • Message passing
    • 프로세스간 메모리 공유 없이 동작 가능
    • 고정길이 메시지, 가변길이 메시지를 송/수신자끼리 주고받음
    • 활용: 클라이언트-서버 방식의 통신
      • 인공지능 학습(parameter Server 구조) - Worker와 Parmeter Server가 데이터를 주고 받으면서 통신
    • 구현:
      • Pipe
      • Message Queue
      • Socket
    • 구현이 가능한 이유 : 각각 프로세스가 서로를 모르더라도 커널은 모든 프로세스와 연결되어있기 때문이다. 따라서 커널이 프로세스들 통신의 중간다리가 되어준다.
      • 모놀리틱 커널 : 각각의 프로세스에서는 어플리케이션 영역과 커널 영역이 존재했다. 그런데 이 커널이 각각의 커널 서버로 분리된 것이 아니라, 하나의 통일된 커널로서 존재했기 때문에 이 커널이 프로세스들 간의 연결 지점이 될 수 있는 것이다.

IPC의 기본 동작

  • 메모리 공유 방식
    • Read and Write
    • 프로세스의 주소 공간의 일부를 공유 메모리 영역으로 설정해서
      • 프로세스에서 바로 접근이 가능
    • 속도가 빠르다.
    • 두 개의 프로세스가 같은 메모리 주소에 임의로 데이터를 쓸 수가 있다. 어떤 프로세스가 작성한 것을 다른 프로세스가 덮어쓰면, 해당 데이터는 사라짐으로써 동기화가 깨질 수 있다.
  • 메시지 교환 방식
    • Send and Receive
    • 커널을 경유하여 메시지 전송
    • 커널에서 데이터를 버퍼링
    • 문맥 전환이 발생함
      • 프로세스가 메시지를 기다리는 과정에서 발생

IPC의 두가지 모델 - 그림

  • MBox - 자기자신 프로세스가 보낸 메시지나, 다른 프로세스로부터 받은 메시지를 저장하는 버퍼, 커널에서도 존재

Q : process A는 메시지가 process B에게 도착했는지를 read를 통해서만 만들 수 있다고 하셨는데 이때 read의 과정을 조금만 더 설명해주실 수 있으실까요? 마치 polling처럼 계속 process B의 해당 내용이 A가 전달한 값으로 바뀌었는지 확인하는 과정인건지 궁금합니다 A : 메세지 패싱의 경우에는, polling을 통해서 메세지가 들어왔는지 주기적으로 확인하면서 들어왔을 경우에 receive를 수행할 수가 있다. 그러나 이 메세지 패싱 과정 자체가 복잡하고, 시간이 오래걸리기 때문에, polling을 하면 효율이 떨어지므로 polling 방법은 잘 사용하지 않는다. 만약 GPU를 사용하여 빠르게 동작시킬 수 있다면 polling을 충분히 사용할 수가 있다. 한편, shared memory에서는 값이 바뀌었는지 알기가 쉽지 않다. 두가지 방법이 있다. 한가지는 polling, 한가지는 프로세스가 값을 바꿀 때마다 signal등을 통해 notification을 두는 것이다. 동기화 기법 중 하나인 세마포를 통해서 업데이트가 되었는 지를 확인할 수가 있다.

IPC에서 동기화의 필요성

  • IPC에서 동기화의 의미 : 프로세스가 주고 받는 메시지의 순서와 내용이 훼손되지 않고 전달되는 것

  • 메시지 전달 방식 - 커널이 동기화 제공
    • send와 receive와 같은 연산에 대해서는 커널이 동기화의 역할을 수행해준다.
      • mbox를 통해 커널이 메시지를 순차적으로 각각의 프로세스들에게 전송한다.
    • send와 receive를 수행할 때에는 프로그램에서 동기화에 대한 고려 없이 사용 가능
  • 메모리 공유 방식 - 사용자가 제공
    • 두 개 이상의 프로세스가 같은 주소에 동시에 무언가를 작성하면 overwrite이 발생한다.
      • 이전에 작성된 값이 사라진다.
    • 메모리 영역에 대한 동시적인 접근을 제어하기 위한 방법이 필요
      • 읽고 씀에 있어서 순서 등을 정하여 공유되어야할 메시지가 훼손되지 않도록 해야한다.
    • 접근 제어 방식은 locking이나 세마포어(semaphore)방법이 있다.

Q : 모놀리틱 커널은 파일시스템도 커널에 있는것 아닌가요? A : 그렇다. 파일 시스템은 커널에 있고, 데이터 자체는 스토리지에 존재한다.

Q : Process A는 message가 process B에게 도착했는지 확인할 수 없나요? A : Read라는 동작 자체가 송신측 프로세스에게 리턴을 한다.

IPC의 구체적인 사례

Pipe

  • 하나의 프로세스가 다른 프로세스로 데이터를 직접 전달하는 기법
    • 데이터는 한 쪽 방향으로만 이동함(half duplex)
      • 양방향 통신을 위해서는 두 개의 파이프가 필요(full duplex)
    • 1:1 의사소통만 가능
      • 하나의 파이프 양쪽 끝에는 각각 하나의 프로세스만 존재한다.
    • 보내어진 순서대로만 가능(ordering)
      • 다른 메시지 패싱 기법에서는 보내는 순서와 받는 순서가 반드시 일치하지 않는다.
    • 용량제한이 있기 때문에 파이프가 꽉 차면 더 이상 쓸 수 없음
      • 만약 송신측에서 메시지를 계속 보내는데 수신측에서 읽지 않아 파이프의 용량이 꽉차면, 더 이상 메시지를 보낼 수가 없게 된다.
      • 파이프는 커널 내의 메모리이다.
    • pipe는 invisible하다. - 제 3자의 프로세스에서는 이 pipe에 접근할 수가 없다. 긴밀한 통신이 가능

Signal

  • 특정 프로세스에게 이벤트를 전달하는 기법 - signal.h를 살펴보면 종류를 확인할 수 있음
    • ex) null-pointer나 divide by zero - trap에 의해서 발생하는 signal 다른 signal은 trap 없이도 서로 통신이 가능하다.
    • 송신 프로세스 : 여러 신호 중 하나를 특정 프로세스에 보냄. 이 동작은 수신할 프로세스의 상태에 무관
      • 시그널은 프로세스가 직접 생성하는 것이다.
    • 수신 프로세스 : Signal Handler가 신호 종류에 따라 신호 처리 방법을 지정 가능 - trap handler, interrupt handler와 같은 역할을 수행
      • 무시
      • 단순히 신호를 붙잡아 둠
      • 특정 처리 루틴 수행
        • 특정 alarm에 대한 signal handler가 없는 상태에서 시그널을 받게 되면 프로세스는 죽는다. ex) kill signal을 처리하는 signal handler는 없으므로 이를 받은 프로세스는 죽는다.
    • 비동기적인 동작
      • Process A가 시그널을 Process B에게 보내더라도, 시그널 처리는 Process B가 스케줄링되어야 가능함
    • data 교환보다는 특정 프로세스의 상태를 다른 프로세스에게 알리는(notification) 용도로 많이 사용됨
    • signal 기법의 구현 방법 - 관련 시스템 콜 이용
      • 프로세스가 signal 시스템 콜을 호출한다. 이 때 아규먼트는 수신측 프로세스 정보와, 시그널 내용이 될 것이다.
      • 커널이 수신 측 프로세스 PCB에 signal에 해당하는 내용을 mark한다.
      • context switch가 발생하면서 수신측 프로세스가 PCB를 읽을 때, signal을 확인한다.
      • 커널이 해당 signal에 해당하는 handler로 dispatch를 해준다.

Q : Signal에도 우선순위가 있습니까? A : interrupt는 하드웨어에 의해서 발생하는 것이므로 종류가 많지 않은 반면, signal은 소프트웨어인 프로세스에 의해 발생하므로 종류가 굉장히 많다. 따라서 우선순위를 두는 것이 어렵고, FCFS(발생한 순서대로)로 delivery한다.

Q : signal과 interrupt의 차이점은 무엇입니까? A : signal은 프로세스 혹은 커널 등 소프트웨어에 의해서 발생하고, interrupt는 하드웨어에 의해서 발생한다.

Shared Memory

  • 두 개 이상의 프로세스들이 하나의 메모리 영역을 공유하여 통신을 하는 기법
    • “두 프로세스에 Shared memory를 attach한다”
    • 메모리의 직접 사용으로 빠르고 자유로운 통신 가능
    • 하나의 메모리에 접근하는 프로세스 개수에 제약이 없음
    • 둘 이상의 프로세스가 동시에 메모리를 변경하지 않도록 프로세스 간의 동기화가 필요함
      • 메시지 교환의 올바른 순서를 보장하지 않는다.

Message Queue

  • 고정된 크기를 갖는 메시지 큐를 이용하는 기법
    • shared memory와 pipe의 중간 지점
      • 1:1 통신이 아니다.
      • 메시지가 전송되는 순서대로 queue에 저장되고, 저장된 순서대로 다른 프로세스들에 수신된다.
      • 메시지 큐에 메시지를 보내는 프로세스와 메시지를 받는 프로세스가 정해져있다.
    • 메시지 단위의 통신 - 메시지의 형태는 통신하고자 하는 프로세스간의(사전) 약속이 필요
      • 누가 메시지를 보내고 읽을 것인지에 대한 정의도 필요
      • 메시지의 형태는 사용자가 정의하여 사용
    • 여러 프로세스가 동시에 접근 가능 - 동기화 필요
      • 두 개 이상의 프로세스가 메시지를 보내거나 받을 수 있으므로 동시 접근 제어 필요

소켓(socket)

지금까지 배운 IPC 구현 방법들은 하나의 호스트 안에서만 가능한 통신 기술들이었다.

  • 소켓의 정의
    • endpoint for communication
    • Bound to a port
  • 포트(port)를 이용하여 통신하는데 사용됨
    • 포트는 운영체제가 제공하는 abstraction임
    • 포트 번호를 이용하여 통신하려는 상대 프로세스의 소켓을 찾아감
      • 예) Webserver의 80번 포트 -> 서버 컴퓨터에서 실행중인 Webserver 프로세스가 사용하는 소켓 identifier
  • 다른 IPC와 달리 프로세스의 위치에 Independent
    • Local 또는 romote - 다른 호스트에 있는 프로세스와도 통신이 가능
    • Port의 도움으로 가능
      • Local의 경우 포트 번호 만으로 식별
      • Remote의 경우 IP주소 + 포트 번호 조합으로 식별
    • 연결(connection)의 semantics를 정할 수 있음
      • Reliable(TCP) / Unreliable(UDP)

소켓의 특성

  • Machine independent
    • port를 사용하기 때문에 machine boundary와 관계 없음
      • Remote machine은 local machine의 port만 보임 (socket은 보이지 않음)
      • 비록 remote machine이라도 port와 port를 연결시켜준다면, 프로세스는 소켓을 통하여 패킷을 보내고 받음
        • 커널이 패킷을 받으면 수신측의 포트 번호를 확인하고, 커널 네트워킹 기술을 통해서 그 포트 번호에 대응된 소켓을 찾아준다.

Port examples

  • Well-known, Registered, Dynamic port
    • Well-known ports : 0 ~1023
    • Registered ports :1024 ~ 49151
    • Dynamic ports : 49152 ~ 65535
  • Well-known ports

Q : 인터럽트가 발생하여 이를 처리하는 ISR 중에 시그널을 보내는 것이 가능한가요? A : 불가능할 것이다. 인터럽트가 발생하는 것 자체가 하드웨어에 관한 특수한 이벤트이고, 시그널은 소프트웨어에 관한 일이기 때문에 목적이 조금 다르다.

Q : signal을 통해서 악의적으로 프로세스를 죽이는 일이 가능한가요? A : 가능하다. 해킹의 도구로도 쓰이기도 한다. 해당 시그널에 대한 handler가 없을 때 ignore를 하도록 설정되지 않은 이유는 termination이 꼭 필요할 때 ignore되는 경우가 있으므로, ignore이 꼭 필요한 상황에서 해당 signal handler를 만들어야만 ignore된다는 제약을 일부러 정한 것이다.

IPC 사용예제

Pipe 예제

  • Pipe은 1:1 통신 기술이므로 두 개의 프로세스가 pipe 하나를 통해 연결될 것이다. 우리는 parent process와 child process를 서로 연결할 것이다.
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#define MAXLINE 4096

int main(void)
{
  int n, fd[2]; // 두 개의 int 값을 갖는 file descriptor
  pid_t pid;
  char line[MAXLINE];
  
  if (pipe(fd) < 0) { // 파이프 생성 실패
    perror("pipe error");
    exit(-1);
  }
  
  if ((pid = fork()) < 0) { // fork 실패
    perror("fork error");
    exit(-1);
  } else if (pid > 0) { // 부모 프로세스
    /*parent*/
    close(fd[0]);
    write(fd[1]. "hello, world\n", 14);
  } else { // 자식 프로세스
    /*child*/
    close(fd[1]);
    n = read(fd[0], line, MAXLINE);
    write(STDOUT_FILENO, lien, n);
    waitpid(pid, NULL, NULL);
  }
  exit(0);
}
  • 두 개의 int 값을 갖는 file descriptor를 생성한다.
    • 첫 번째 값은 읽기 위한 것이고, 두 번째 값은 쓰기 위한 것이다.
  • pipe 시스템 콜을 호출한다.
  • fork 시스템 콜을 호출한다.
    • 이로써 두 개의 프로세스에 모두 file descriptor가 존재한다.
    • pipe는 두 file descriptors를 연결하는 buffered stream이다.
  • fork 리턴값으로 프로세스와 자식 프로세스를 구별한다.
    • 부모 프로세스 : fd[0]을 close하고, fd[1]에 “hello, world”를 작성한다.
    • 자식 프로세스 : fd[1]을 close하고, fd[0]에 쓰인 문자열을 읽는다.

파일 디스크립터(file descriptor) 리눅스 혹은 유닉스 계열의 시스템에서 프로세스(process)가 파일(file)을 다룰 때 사용하는 개념으로 프로세스에서 특정 파일에 접근할 때 사용하는 추상적인 값

Signal 예제

#include <stdio.h>
#include <stdbool.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

void SignalHandlerChild(int signo)
{
  printf("signal handler\n");
  fflush(stdout);
}

int main(void)
{
  pid_t pid;
  struct sigaction act_child;
  
  act_child.sa_handler = SignalHandlerChild;
  act_child.sa_flags = 0;
  sigemptyset(&act_child.sa_mask);
  sigaction(SIGUSR1, &act_child, 0);
  switch(pid = fork())
  {
    case -1: // fork 실패
      perror("fork failed");
      exit(-1);
      
    case 0: // 자식
      sigpause(SIGUSR1);
      return 0;
      
    default: // 부모
      sleep(3);
      kill(pid, SIGUSR1);
      waitpid(pid, 0, 0);
  }
  return 0;
}
  • SignalHandler 역할을 수행하는 별도의 함수를 정의
  • 시그널에 의해서 정의되는 structure 변수 act_child로, 다음과 같은 필드에 값을 저장한다.
    • sa_handler : SignalHandler(방금 전 정의한 별도의 함수)를 할당
    • sa_flags : 기본 값 할당
    • act_child.sa_mask : 시그널들을 모두 mask한 것
  • SIGUSR1 : 유저가 직접 정의하는 시그널
  • signal handler install : SIGUSR1이 도착하면 sigaction을 통해 act_child의 SignalHandler를 호출하게끔 정의한다.
  • fork를 한 후, 부모 프로세스와 자식 프로세스를 구별한다.
    • 부모 프로세스 : 3초 후에 SIGUSR1을 자식 프로세스에게 보낸다. (kill()은 시그널을 보내는 함수) 그리고 자식 프로세스의 종료를 기다린다.
    • 자식 프로세스 : SIGUSR1을 기다렸다가 도착하면 SignalHandler를 호출한 후 리턴한다.

이 예제는 가장 기본적인 예제로, 시그널과 시그널 핸들러를 여러개 만들어서 각각의 시그널에 대해서 다른 시그널핸들러를 정의하여 더 다채로운 프로그램을 만들 수가 있다.

위에서 본 것처럼 시그널은 그냥 정의해둔 시그널만을 보내고 이에 따라 어떤 행동을 하도록 정의할 수 있을 뿐, 프로세스 사이에서 어떤 값을 동적으로 주고 받기는 어렵다.

Shared Memory

#include <stdil.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/wait.h>

#define SHM_SIZE			1024

void ChildRun(int shmid);

int main(int argc, char **argv)
{
  int shmId, pid;
  char *ptrShm;
  
  if ( (shmId = shmget(IPC_PRIVATE, SHM_SIZE, SHM_R | SHM_W)) < 0) {
    perror("shmget error");
    exit(-1);
  }
  
  if ( (ptrShm = shmat(shmId, 0, 0)) == (void *)-1) {
    perror("shmat error");
    exit(-1);
  }
  
  ptrShm[0] = 11;
  ptrShm[1] = 22;
  
  printf("Parent : %d, %d\n", ptrShm[0], ptrShm[1]);
  switch (pid = fork())
  {
    case 0: // 자식 프로세스
      ChildRun(shmId);
      return 0;
    case -1: // fork 실패
      perror("fork error");
      exit(-1);
    default: // 부모 프로세스
      break;
  }
  
  // 부모 프로세스
  waitpid(pid, NULL, 0);
  printf("Parent : %d, %d\n", ptrShm[0], ptrShm[1]); // 33, 44
  
  if (shmdt(ptrShm) < 0) {
    perror("shmct1 error");
    exit(-1);
  }
  
  if (shmct1(shmId, IPC_RMID, 0) < 0) {
    perror("shmct1 error");
    exit(-1);
  }
  return 0;
}

void ChildRun(int shmid)
{
  int shmId;
  char *ptrShm;
  
  shmId = shmid;
  if (ptrShm = shmat(shmId, 0, 0) == (void *)-1) {
    perror("shmat error");
    exit(-1);
  }
  
  printf("Child : %d, %d\n", ptrShm[0], ptrShm[1]); // 11, 22
  printf("Child : Modify value\n");
  ptrShm[0] = 33;
  ptrShm[1] = 44;
  
  if (shmdt(ptrShm) < 0) {
    perror("shmct1 error");
    exit(-1);
  }
}
  • shmget을 통해 shared memory를 만든다. 이 함수의 리턴 값은 해당 메모리의 id 값이다.
  • shmat을 통해 shared memory를 프로세스에 붙인다. 이 때 리턴 값은 Shmat을 호출하는 프로세스에서 메모리가 붙는 주소이며 커널이 정해준다. 사용자가 아규먼트를 통해서 특정 주소에 붙여달라고 요청할 수는 있으나, 결국 이를 결정하는 것은 커널로, 사용자가 원하는 주소에 못 붙을 수도 있다.
    • 이 주소가 -1이면 attach에 실패한 것이다.
  • 프로세스에 붙은 shared memory에 11과 22라는 값을 쓴다.
  • fork를 하고, 자식 프로세스와 부모 프로세스를 구별한다.
    • 자식 프로세스 : ChildRun() 함수를 호출한다. 이때 아규먼트로 Shared memory의 식별자를 넘긴다.
      • shared memory가 붙은 주소 값을 확인하면서 자식 프로세스에도 shared memory가 정상적으로 attach되었는지 확인한 후 overwrite를 한다.
    • 부모 프로세스 : shared memory에 저장된 값을 확인한다. 자식 프로세스가 overwrite한 뒤이므로, 변경된 값이 출력된다.
    • shmdt() : 프로세스로부터 shared memory를 분리시키는 함수이다.
    • shmct1() : shared memory를 해제하는 함수이다.

Q : fork를 통해서 프로세스가 복제가 되면 두 프로세스가 가지고 있는 가상메모리가 똑같은 물리 메모리를 가르키게 되나요? A : 이것은 memory management 파트에서 좀 더 구체적으로 설명할 것이다. 지금 간단하게 설명하자면, fork를 하면 둘이 같은 물리 주소를 가리키고 있다가 어떤 프로세스가 write을 하면, cow(copy-on-write)를 한다. 즉, write를 하지 않고 read만 하면 같은 물리 주소를 가리키지만, write를 하는 순간부터 두 메모리가 분리된다.

Q : ptrShm에 리턴된 주소는 커널이 제안한 주소일수도 있고 아닐수도 있다는 말씀이신가요? A : 아니다. 사용자 프로세스가 제안한 것을 커널이 채택하여 정해줄 수도 있고, 아닐 수도 있다는 뜻이다.

Q : fork로 만든 child process가 아닌 별개의 process와 shared memory를 사용하려고하면 shmId값을 어떻게 아나요? A : 다른 IPC 기법으로 Id를 넘긴 뒤에 shared memory를 사용할 것이다. 이 때 사용되는 IPC 기법은 서로가 서로의 변수 정보를 전혀 몰라도 사용이 가능해야 하므로 signal(시그널 핸들러 호출 시 아규먼트로 들어갈 signo를 모른다.)이나, pipe같은 기법도 힘들다. 서로를 전혀 모르는 임의의 프로세스에서 IPC를 하기 가장 적합한 기법은 Socket이다. 특히 임의의 프로세스가 서로 shared memory와 같은 IPC를 하기 위해서는 shared memory의 ID를 주고 받는 것에 앞서 authentication 과정이 필수이다. 이 과정도 Socket을 통해서 가능하다.

Message Queue 예제

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <stdbool.h>
#include <errno.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <sys/wait.h>

typedef struct _MSG {
  long type;
  char message[256];
} MSG, *PMSG, **PPMSG;
#define MSG_ sizeof(MSG)

int main(void)
{
  pid_t pid;
  key_t msg_id;
  MSG msg;
  
  if ( -1 == (msg_id = msgget(IPC_PRIVATE, 0660 | IPC_CREAT)) ) { // 메세지 큐 생성 실패
    perror( "msgget failed" );
    exit(1);
  }
  
  switch ( pid = fork() )
  {
    case -1: // fork 실패
      perror( "fork failed " );
      exit(-1);
    case 0: // 자식 프로세스
      msg.type = 1;
      strcpy( msg.message, "Hello, world.");
      msgsnd( msg_id, &msg, MSG_-sizeof(long), 0 );
      return 0;
    default: // 부모 프로세스
      waitpid(pid, 0, 0);
      memset( &msg, 0, MSG_ );
      msgrcv( msg_id, &msg, MSG_-sizeof(long), 1, 0 );
      printf("PARENT - message from child : %s\n", msg.message);
      fflush(stdout);
  }
  if ( -1 == msgctl(msg_id, IPC_RMID, 0) ) {
    perror( "msgctl failed" );
    exit(-1);
  }
  return 0;
}
  • msgget을 통해 메세지 큐를 생성하여 ID를 리턴받는다.
  • fork를 한후, 자식 프로세스와 부모 프로세스를 구별한다.

Socket 예제(Server)

Socket은 Port로 동작하며 데이터를 주고 받아야하므로 클라이언트-서버 구조로 되어있다.

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define PORT_NUM 53003 // 임의의 포트 번호 설정

int main() {
  int serverSocketFd = 0;
  int clientSocketFd = 0;
  socklen_t clientAddrSize = 0;
  char ch[3];
  
  // 서버 주소에 관한 구조체
  struct sockaddr_in serverAddress;
  struct sockaddr_in clientAddress;
  
  // 소켓 생성
  serverSocketFd = socket(AF_INET, SOCK_STREAM, 0); 
  // AF_INET - 인터넷 소켓
  // SOCK_STREAM - TCP 프로토콜 사용
  
  memset(&serverAddress, 0, sizeof(serverAddress));
  serverAddress.sin_family = AF_INET;
  serverAddress.sin_port = htons(PORT_NUM); // 포트번호 변환(HBO -> NBO)
  serverAddress.sin_addr.s_addr = INADDR_ANY;
  
  // 소켓의 이름 지정 bind([socket 함수가 생성한 소켓 번호], [소켓의 이름을 표현하는 구조체], [name의 크기])
  // 성공시 0, 실패시 -1 리턴
  // 소켓과 연결할 IP주소와 포트번호를 지정하는 것을 의미
  bind(serverSocketFd, (struct sockaddr *)&serverAddress, sizeof(struct sockaddr)); 

  
  // 서버 소켓을 열어 클라이언트 기다리기
  // 최대 허용 클라이언트 개수 1
  listen(serverSocketFd, 1);
  printf("waiting connection from client.\n");
  
  memset(&clientAddress, 0, sizeof(clientAddress));
  clientAddrSize = sizeof(clientAddress);
  clientSocketFd = accept(serverSocketFd, (structsockaddr *)&clientAddress, &clientAddrSize);
  printf("connected with client.\n");
  
  // 루프를 돌리며 클라이언트에서 보낸 데이터 읽기
  while(1){
    int rtn;
    rtn = read(clientSocketFd, &ch, sizeof(ch)); // 데이터 한 단위씩 읽어 ch에 저장
    if(rtn <= 0){
      break;
    }
    
    // 받은 데이터 읽기 
    printf("received data : %s \n", ch);
  }
  // 서버 소켓 닫기
  close(serverSocketFd);
  printf("disconnected with client.\n");
  
  return 0;
}
  • 소켓은 프로세스가 포트를 바라볼 때 사용되는 창이라고 할 수 있다.
    • 소켓을 해당 포트에 bind를 시킨 후, listen을 통해서 포트를 지켜볼 수 있도록 한다.
      • 단, listen에서 아규먼트로 소켓의 ID와 최대 허용 클라이언트 수를 정한다.
    • accept() 함수를 통해서 클라이언트의 요청을 받으면 리턴 값으로 클라이언트 소켓의 번호를 받을 수 있다.
      • 클라이언트에서 보낸 데이터를 read한다.

Socket 예제 (Client)

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define PORT_NUM 53003

int main() {
  int socketFd = 0;
  struct sockaddr_in serverAddress;
  int result = 0;
  char ch[3] = "HI";
  int i;
  
  // 소켓 생성
  socketFd = socket(AF_INET, SOCK_STREAM, 0);
  // AF_INET - 인터넷 소켓
  // SOCK_STREAM - TCP 프로토콜 사용
  
  memset(&serverAddress,0, sizeof(serverAddress));
  serverAddress.sin_family = AF_INET;
  serverAddress.sin_port = htons(PORT_NUM);
  inet_pton(AF_INET, "127.0.0.1",&serverAddress.sin_addr.s_addr);
  
  // 서버와 연결되기 위한 시도, 이때 접속할 서버의 IP주소와 포트번호가 필요하다.
  connect(socketFd, (struct sockaddr *) &serverAddress,sizeof(serverAddress));
  
  for(i = 0; i < 5; i++){
    // write "HI" to server 5 times
    printf("Send Data to Server. \n");
    write(socketFd, &ch, sizeof(ch));
    sleep(1);
  }
  close(socketFd);
}
  • Socket을 통해 데이터를 주고 받으려면 클라이언트와 서버가 포트를 공유해야한다.
    • 따라서 well known들처럼 자주 사용되는 포트 번호가 표준으로 정해져있는 것이다.

포트와 소켓, 프로세스의 관계

한 포트 번호를 갖고 여러 개의 프로세스가 서로 읽고 쓸 수가 있다. 따라서 소켓과 포트를 구별하는 것이다. 소켓은 프로세스가 만들고, 포트는 커널이 만든다.

socket() 시스템 콜을 통해서 프로세스가 소켓을 만드므로, 소켓은 프로세스 안에서 존재하는 것이다. 그러나 포트는 53003은 커널이 recognize를 해준다. 그래서 포트는 OS가 제공하는 abstraction이라고 할 수 있는 것이다.

어떤 프로세스도 원하는 포트에 소켓을 bind할 수가 있다. 즉, 여러 프로세스가 하나의 포트에 자신의 소켓을 bind할 수 있다는 뜻이다. 예를 들자면, 수많은 사용자들의 브라우저 프로그램들이 웹서버가 사용하고 있는 하나의 포트 번호만을 갖고 웹 서버와 통신하는 것이다.

따라서 프로세스가 종료되면, 소켓도 사라지겠지만, 포트 번호는 사라지지 않는다. 포트는 커널이 crash될 때만 사라진다.

Q : pipe 코드를 다시 리뷰해주시면 감사하겠습니다. A : pipe 이라는 시스템 콜에서 가장 중요한 것은 half-duplex에 따라 데이터를 보내는 곳의 위치와 받는 곳의 위치가 정해져있다는 점이다. Parent 프로세스에서 fd[0]를 close하고 Child 프로세스에서는 fd[1]을 close했는데, fd[0]은 읽는 용, fd[1]는 쓰는 용이기 때문에 이로써 Parent에서 보내고 Child에서 받도록 하는 pipe를 만들었다고 볼 수 있다.

Q : 코드는 다 자식과 연결되어있는 경우만 본 것 같은데 다른 프로세스랑 연결되는 것은 어려운건가요? A : 많은 경우에 Socket을 사용한다. 왜냐하면 서버가 사용하는 포트 번호만 알아도 연결이 가능하기 때문이다.

Q : 전에 shared memory의 id를 공유하기 위해서 socket을 이용한다고 말씀하셨는데, 그러면 굳이 다른 기법을 사용하지 않고, socket만 사용하는게 더 이득이 아닌가요? A : 사실은 그렇기는 하다. 그래서 많은 상황에서 socket이 사용된다. 원격으로 넘어갈 수 있는 IPC 기법은 socket뿐이기도 해서 더 많이 사용되기도 한다. 그렇지만 socket은 authentication 등 좀 더 복잡한 과정들이 필요하기 때문에 local 안에서 좀 더 가볍게 IPC를 하기 위해서는 pipe, signal, shared memory가 더 나은 상황이 있을 수 있는 것이다.

Q : 과제를 진행하면서 찾다보니까 시스템 콜이나 익셉션을 소프트웨어 인터럽트라고도 부르던데 트랩이랑 소프트웨어 인터럽트가 같은건가요? A : 인터럽트는 두단계로 구성이된다. 인터럽트가 들어오면 인터럽트 핸들러가 굉장히 짧은 시간내에 처리하고 나머지 처리는 별도의 태스크를 만든다. 이 두 과정을 top half, bottom half라고 나눠서 부른다. 이bottom half 과정을 소프트 인터럽트라고 부른다. 트랩을 소프트웨어 인터럽트라고 부르는 것은 틀린 것이다.

Comments