본문으로 바로가기
반응형

프로세스간 통신의 기본 개념

프로세스는 완벽하게 독립된 하나의 객체라고 볼 수 있다.

프로세스간 통신이 가능하다는 것은 서로 다른 두 프로세스가 데이터를 주고 받을 수 있다는 의미가 되며, 이렇게 되기 위해서는 두 프로세스가 동시에 접근 가능한 메모리 공간이 있어야한다.

 

 

파이프(PIPE) 기반의 프로세스간 통신

다음 그림은 프로세스간 통신 방법으로 사용되는 파이프기법의 구조적 모델을 보이고 있다.

두 프로세스간 통신을 위해서는 파이프라는 것을 생성해야한다. 이 파이프는 프로세스에 속하는 자원이아니다.

이는 소켓과 마찬가지로 운영체제에 속하는 자원이다.(때문에 fork 함수의 호출에 의한 복사 대상이 아니다.)

즉, 운영체제가 마련해주는 메모리 공간을 통해서 두 프로세스는 통신을 하게된다.

그럼 먼저 파이프 생성에 사용되는 함수를 알아보자.

#include <unistd.h>
int pipe(int filedes[2]);  // 성공 시 0, 실패시 -1 반환

- filedes[0] : 파이프로부터 데이터를 수신하는데 사용되는 파일디스크립터 저장, 즉 filedes[0]은 파이프의 출구가 된다.
- filedes[1] : 파이프로부터 데이터를 전송하는데 사용되는 파일디스크립터 저장, 즉 filedes[1]은 파이프의 입구가 된다.

 

길이가 2인 int형 배열의 주소 값을 인자로 전달하면서 위의 함수를 호출하면 배열에는 두 개의 파일 디스크립터가 담긴다. 그리고 이들 각각은 파이프의 출구와 입구로 사용이 된다. 결국 부모 프로세스가 위의 함수를 호출하면 파이프가 생성되고, 파이프의 입구 및 출구에 해당하는 파일 디스크립터를 동시에 얻게 되는 것이다.

따라서 부모 프로세스 혼자서 파이프 안으로 데이터를 집어넣고 꺼내는 것도 가능하다.

 

그런데 부모 프로세스의 목적은 자식 프로세스와의 데이터 송수신이니, 입구 또는 출구에 해당하는 파일 디스클비터 중 하나를 자식 프로세스에게 전달해야 한다. 이것을 가능하게 하는것이 fork 함수 호출에 있다.

 

일단 예제를 통해 이를 관찰해보자.

// pipe1.c
#include <stdio.h>
#include <unistd.h>
#define BUF_SIZE 30

int main(int argc, char *argv[])
{
   int fds[2];
   char str[] = "who are you?";
   char buf[BUF_SIZE];
   pid_t pid;

   pipe(fds);
   pid = fork();
   if(pid == 0)
   {
      write(fds[1], str, sizeof(str));  // 1번방의 파일 디스크립터를 통해 str을 집어넣음
   }
   else
   {
      read(fds[0], buf, BUF_SIZE);   // 부모는 자식 파일 디스크립터를 읽어와서 puts로 출력
      puts(buf);
   }
   return 0;
}

예제에서 보인 통신의 방법 및 경로를 그림으로 정리하면 다음과 같다.

여기서 중요한 사실은 부모, 자식 프로세스 모두 파이프의 입출력 경로에 접근이 가능하지만, 자식은 입력 경로에만, 부모는 출력 경로에만 접근해서 통신을 했다는 점이다.

이로써 파이프의 기본 원리 및 통신 방법에 대해 알아보았는데, 파이프 활용에 있어서 주의해야할 내용이 조금 더 있기에 이를 양방향 통신을 예로 들면서 설명해보고자한다.

 

 

파이프(PIPE) 기반의 프로세스간 양방향 통신

이번에는 하나의 파이프를 통해서 두 프로세스가 양방향으로 데이터를 주고 받는 예제를 작성해보자.

이 예제에서 보이고자 하는 통신방식은 다음과 같다.

위 그림과 같이 하나의 파이프를 대상으로 양방향으로 통신을 하는것도 물론 가능하다. 하지만 이러한 모델로 구현할 경우 주의해야할 점이 더 많아진다. 일단 예제를 작성해보자.

// pipe2.c
#include <stdio.h>
#include <unistd.h>
#define BUF_SIZE 30

int main(int argc, char *argv[])
{
   int fds[2];
   char str1[] = "Who are you?";
   char str2[] = "Thank you for your message";
   char buf[BUF_SIZE];
   pid_t pid;

   pipe(fds);
   pid = fork();
   if(pid == 0)
   {
      write(fds[1], str1, sizeof(str1));  
      sleep(2);
      read(fds[0], buf, BUF_SIZE);
      printf("Child proc output : %s \n", buf);
   }
   else
   {
      // 부모 프로세스 실행영역 : read로 데이터를 수신하는데, 이는 위의 write 함수를 통해 자식 프로세스가 전송하는 데이터를 수신하기 위함이다.
      // 밑의 write 를 통해 데이터를 전송하는데, 이는 위의 read을 통해 자식 프로세스에게 수신된다.
      read(fds[0], buf, BUF_SIZE);
      printf("Parent proc output : %s \n", buf);
      write(fds[1], str2, sizeof(str2));
      sleep(3);
   }
   return 0;
}

 

이때 if문의 sleep(2)를 주석처리하고 실행해보자.

시간이 지나도 다음 메세지가 뜨지않아서 컨트롤+C로 강제종료했다

단지 자식 프로세스의 실행 시간을 2초 늦추는 코드일뿐인데 실행결과에서 문제가 생겼음을 확인할 수 있다.

주석처리된 코드는 2초 늦추는 코드인데 왜 이런 문제가 발생하는 것일까?

 

그 이유는 파이프에 데이터가 전달되면 먼저 가져가는 프로세스에게 이 데이터가 전달되기 때문이다.

쉽게 말해서 파이프에 데이터가 들어가면, 이 데이터는 임자없는 데이터가 된다. 즉, read 함수 호출을 통해서 먼저 데이터를 읽어 들이는 프로세스에게 데이터가 전달된다. 

 

어렵게 생각할 것없이 파이프를 반드시 하나만 생성해야 하는 것은 아니다.

따라서 다음 그림에서 보이듯이 두 개의 파이프를 생성해서 각각 서로 다른 데이터의 흐름을 담당하게 하면 된다.

 

두 개의 파이프를 이용하면 프로그램의 흐름을 예측하거나 컨트롤할 필요가 없다.

위 모델로 예제 pipe2.c를 변경해보자.

// pipe3.c
#include <stdio.h>
#include <unistd.h>
#define BUF_SIZE 30

int main(int argc, char *argv[])
{
   int fds1[2], fds2[2];
   char str1[] = "Who are you?";
   char str2[] = "Thank you for your message";
   char buf[BUF_SIZE];
   pid_t pid;

   pipe(fds1), pipe(fds2);  // 2개의 파이프 생성
   pid = fork();
   if(pid == 0)
   {
      write(fds1[1], str1, sizeof(str1));  // 자식 프로세스에서 부모 프로세스로의 데이터 전송은 fds1이 참조하는 파이프를 통해 이뤄진다
      read(fds2[0], buf, BUF_SIZE);        // 부모 프로세스에서 자식 프로세스로의 데이터 전송은 fds2가 참조하는 파이프를 통해 이뤄진다.
      printf("Child proc output : %s \n", buf);
   }
   else
   {
      read(fds1[0], buf, BUF_SIZE);
      printf("Parent proc output : %s \n", buf);
      write(fds2[1], str2, sizeof(str2));
      sleep(3);  // 큰 의미는 없지만 부모 프로세스의 종료를 지연시키기위해 삽입
   }
   return 0;
}

 

 

프로세스간 통신의 적용

파이프 기반의 프로세스간 통신 기법을 살펴봤으니 이를 네트워크 코드에 적용해볼 차례이다.

그런데 프로세스간 통신은 서버의 구현에 직접적인 연관은 없다. 그러나 운영체제를 이해한다는 측면에서 의미가 있다.

 

 

메시지를 저장하는 형태의 에코 서버

앞선 포스팅의 제시한 예제 echo_mpserv.c를 확장해서 다음의 기능을 추가해보고자 한다.

 

"서버는 클라이언트가 전송하는 문자열을 전달되는 순서대로 파일에 저장한다."

 

필자는 이를 별도로 프로세스가 담당하게끔 구현하려고 한다. 즉, 별도의 프로세스를 생성해서 클라이언트에게 서비스를 제공하는 프로세스로부터 문자열 정보를 수신하게끔 할 것이다. 물론 이 과정에서 데이터의 수신을 위한 파이프를 생성해야한다.

다음 예제는 어떠한 에코 클라이언트 코드와도 잘 동작하지만, echo_mpclient.c 와 함께 실행해보자.

// echo.storeserv.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <signal.h>
#include <sys/wait.h>

#define BUF_SIZE 100
void error_handling(char* message);
void read_childproc(int sig);

int main(int argc, char* argv[])
{
   int serv_sock, clnt_sock;
   struct sockaddr_in serv_adr, clnt_adr;
   int fds[2];

   pid_t pid;
   struct sigaction act;
   socklen_t adr_sz;
   int str_len, state;
   char buf[BUF_SIZE];
   if(argc != 2)
   {
      printf("Usage : %s <port> \n", argv[0]);
      exit(1);
   }

   act.sa_handler = read_childproc;
   sigemptyset(&act.sa_mask);
   act.sa_flags = 0;
   state = sigaction(SIGCHLD, &act, 0);
   
   serv_sock = socket(PF_INET, SOCK_STREAM, 0);
   memset(&serv_adr, 0, sizeof(serv_adr));
   serv_adr.sin_family = AF_INET;
   serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
   serv_adr.sin_port = htons(atoi(argv[1]));

   if(bind(serv_sock, (struct sockaddr*) &serv_adr, sizeof(serv_adr)) == -1)
      error_handling("bind() error");
   if(listen(serv_sock, 5) == -1)
      error_handling("listen() error");

   pipe(fds);    // 파이프 생성
   pid = fork(); // 파일의 데이터 저장을 담당할 프로세스 생성
   if(pid == 0)  // 자식 프로세스 실행영역 : 파이프의 출구인 fds[0]으로 전달되는 데이터를 읽어서 파일에 저장
                 // 참고로 위의 서버는 종료되지 않고 클라이언트에게 계속 서비스를 제공하기때문에
                 // 파일에 데이터가 어느 정도 채워지면 파일을 닫도록 밑의 반복문을 써줬다.
   {
      FILE * fp = fopen("echomsg.txt", "wt");
      char msgbuf[BUF_SIZE];
      int i, len;

      for(i = 0; i < 10; i++)
      {
         len = read(fds[0], msgbuf, BUF_SIZE);
         fwrite((void*)msgbuf, 1, len, fp);
      }
      fclose(fp);
      return 0;
   }
   
   while(1)
   {
      adr_sz = sizeof(clnt_adr);
      clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
      if(clnt_sock == -1)
         continue;
      else
         puts("new client connected....");
   
      pid = fork();
      if(pid == 0)
      {
         close(serv_sock); 
         while((str_len = read(clnt_sock, buf, BUF_SIZE)) != 0)
         {
            write(clnt_sock, buf, str_len);
            write(fds[1], buf, str_len);  // 바로 위의(if(pid == 0) 위) fork 함수 호출로 생성되는 모든 자식 프로세스는 listen 함수 밑의 파이프의 파일 디스크립터를 복사한다.
                                          // 때문에 파이프입구인 fds[1]을 통해 문자열 정보를 전달할 수 있는 것임
         }

         close(clnt_sock);
         puts("client disconnected...");
         return 0;
      }
      else
         close(clnt_sock); 
   close(serv_sock); 
   return 0;
}

void read_childproc(int sig)
{
   pid_t pid;
   int status;
   pid = waitpid(-1, &status, WNOHANG);
   printf("removed proc id : %d \n", pid);
}

void error_handling(char* message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

위의 실행결과에서 보이듯 둘 이상의 클라이언트를 접속시켜서 서버로 문자열을 전송하기 바란다.

그렇게 해서 어느 정도 문자열이 파일에 저장되고 나면(총 10회의 fwrite 함수 호출이 끝나면) 파일 echomsg.txt를 열어서 문자열의 저장을 확인할 수 있다.

예제 코드 실행을 통해 생성된 echomsg.txt 를 열어보면 입력한 데이터가 저장되어있다

반응형