본문 바로가기
2CHAECHAE 학교생활/OSNW실습

[ OS/NW 실습 ] 8주차 - 멀티 프로세스 소켓 프로그래밍 ②

by 2CHAE._.EUN 2022. 10. 31.

[ init 프로세스 ]

 

init = PID 1 프로세스

 

프로세스는 부모에서 자식으로 계층적 구조를 가진다. 프로세스는 1에서 부터 시작한다. 

즉, PID가 1인 프로세스는 모든 프로세스의 부모 프로세스이다.  모든 프로세스는 init 프로세스에서 fork&exec로
생성이 된다.

 

프로세스는 자식 프로세가 먼저 종료되는 것이 정상으로 간주된다. 일반적으로 어떤 프로세스가 종료가 되면,

종료되기 이전에 자신이 생성시킨 모든 자식 프로세스를 종료시킨다.

 

고아 프로세스 : 부모 프로세스가 먼저 종료된 프로세스 ex) 데몬 프로세스

고아 프로세스는 init 프로세스에 의해서 관리가 된다. 

 


[ 데몬 프로세스 ]

 

데몬( daemon ) 프로세스 : 사용자가 인식하지 못하게 백그라운드로 동작하는 프로세스 

 

데몬 프로세스는 사용자 및 다른 프로세스들로부터의 영향을 받지 않아야 하는 프로세스이다. 

웹서버, FTP서버, DNS 서버 같은 서버 프로그램들이 데몬 프로세스로 동작한다. 

 

데몬 프로세스 조건

1. 고아 프로세스

2. 표준 입력, 표준 출력, 표준 에러를 닫는다.

→ file descriptor 0,1,2는 데몬 프로세스에서 사용하지 않는다. 사용을 하게 되면 화면에 출력되기 때문에 사용하지 않는다.

3. 제어 터미널을 가지지 않는다. 

→ 원격 터미널 연결이 끊어지는 경우 원치 않게 프로세스가 종료될 수 있으므로 종료되지 않게끔 제어 터미널을 

가지지 않는다. 

 

< 데몬 프로세스 작성 예제 >

int main() 
{ 
	pid_t pid; 
    
	if (( pid = fork()) < 0) exit(0); 
    
	// 부모프로세스를 종료한다. -> 고아 프로세스
   	 else if(pid != 0) exit(0); // PID > 0 일 경우
    
    // 이 아래에서 부터 실행할 수 있는 프로세스는 자식 프로세스 밖에 남지 않았다.
    
	// 데몬 프로그램은 상호대화할 일이 없으므로 표준입/출/에러를 닫는다. 
   	 close(0); close(1); close(2); 
    
	// 세션을 생성한다. 
  	 setsid(); 
    
	/* 사용자환경에서 독립된 자신의 환경을 만든다.
    기존의 환경이 리셋되면서 터미널이 사라진다. 
    또한 새로운 터미널을 지정하지 않았기 때문에, 
    이 프로세스는 결과적으로 터미널을 가지지 않게 된다. */
    
	// 데몬 프로그램이 실행할 코드를 작성한다.
  	 while(1) {
	
   	} 
}

 

< daemonOSNW.c >

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

int main()
{
  int pid;
  int i = 0;

  printf("process start  %d\n", getpid());
  pid = fork();

  if (pid > 0) // 부모 프로세스일 경우
  {
    printf("parent process pid(%d)\n", getpid());
    sleep(5);
    exit(0); // 부모 프로세스 종료
  }
  else if (pid == 0) // 자식 프로세스
  {
    printf("child process pid(%d) of parent pid(%d)\n", getpid(), getppid());
    close(0); close(1); close(2); // 표준 입출력, 에러 닫아버림
    setsid(); // 세션 생성
    sleep(10);
    printf("I'm daemon\n");
    i = 1000;
    while(1)
    { 
      printf("child : %d\n", i);
      i++; 
      sleep(2);
    }
  }
  return 1;
}

 

 


[ 프로세스 기다리기 ]

 

pid_t wait( int *status );

 

wait()은 부모 프로세스가 자식 프로세스를 기다리는 것이다. 자식 프로세스의 종료 시점과 부모 프로세스를 동기화한다.

 

case 1 : 부모 프로세스가 wait()을 하는 동안 자식 프로세스가 수행이 된다. 자식 프로세스가 종료되면 다시 

부모 프로세스로 돌아와서 수행을 한다. 자식 프로세스와 부모 프로세스의 동기화가 잘 맞은 경우이다.

 case 2 : 자식 프로세스가 먼저 종료가 되어서 exit(1)을 통해 1을 전달할지라도 부모 프로세스가 받을 준비가 되어있지 

않고 늦게 wait()을 하는 경우이다. 자식 프로세는 종료가 되었지만 exit(1) 때문에 1을 부모 프로세스로 전달을 해야되는

역할이 남아있다. 해당 프로세스는 종료는 되었지만 종료가 제대로 되지 않아 부모 프로세스가 제대로 값을 받을 때까지
좀비 프로세스가 된다.

 

1. 자식 프로세스가 종료되면, 종료 상태 값을 가지고 대기를 한다. 

→ exit을 호출해도 프로세스가 메모리 상에서 삭제되지 않는다,

2. 부모 프로세스가 wait 함수를 호출해야 모든 자원이 해제가 된다. 

→ wait로 정리되기 전까지 프로세스는 좀비 상태로 남는다.

 

좀비 프로세스 : exit() 실행으로 종료는 되었으나, 운영체제에 의해 관리되고 있는 상태의 프로세스

종료된 자식 프로세스의 상태 정보는 wait() 함수를 통해서 얻어온다.

 

pid = fork();

if (pid > 0) {
	printf("부모 프로세스 pid(%d)\n", getpid());
	printf("자식 프로세스 종료를 기다림\n");
	pid = wait(&pstatus);
	printf("=========================\n");
	printf("종료된 자식 프로세스 : %d\n", pid);
	printf("종료 값 : %d\n", pstatus/256);
} else if (pid == 0) {
	sleep(2);
	printf("I'm Zombie %d\n", getpid());
	exit(100);
}

 

wait(int *status)의 status는 4byte로 총 32bit이다. 하위 8bit는 모두 0으로 채워져 있다. 하위 8bit가 0으로 채워져
있다는 의미는 2^8을 곱한 셈으로 shift left된 것이다. 그래서 pstatus 값을 256으로 나눠야지 정상적인 종료 값을
구할 수 있다.

 

< child_wait.c >

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

int main()
{
        int pid;
        int pstatus;

        printf("process start %d\n", getpid());
        pid = fork();

        if (pid > 0) {
                printf("parent process pid(%d)\n", getpid());
                printf("wait for child(%d) to die\n", pid);
                printf("hit a key"); getchar(); printf("\n");
                pid = wait(&pstatus); // 여기서 부터 wait 중
                printf("=========================\n");
                printf("terminated child process : %d\n", pid);
                printf("return value from child  : %d\n", pstatus/256);
                
         // wait 하기 전에 자식 프로세스가 돌아가기를 위함
        } else if (pid == 0) {
                sleep(2);
                printf("I'm Zombie %d\n", getpid());
                exit(100); 
        }

        return 1;
}

 

실행 이미지에 <defunct> 표시가 있다면 종료되어 실행하지 않고 있는 좀비 프로세스이다.

 


[ 멀티 프로세스와 소켓 프로그래밍 ]

 

서버 프로그램은 다수의 클라이언트를 동시에 처리할 수 있어야 한다.

 

대표적인 다중 클라이언트 처리 기술( 다중 접속 처리 서버 기술 )

① 멀티 프로세스( Multi-process )

② 멀티 쓰레드( Multi-thread )

③ 입출력 다중화( I/O Multiplexing )

 

1. fork()를 사용해서 클라이언트와 통신하는 코드를 분리한다. → accept() 호출 후 fork() 호출

 

socket, bind, listen 시스템 콜 함수들은 한번만 실행한다. 반면에 accept()은 계속해서 반복한다.

왜냐하면 accept()는 클라이언트가 connect을 할 경우 계속해서 응답을 해줘야 하기 때문이다.

하지만 만약 하나의 클라이언트의 read/write 과정이 오래 걸릴 경우 다른 클라이언트의 요청을 위해서 fork()를

사용한다. fork()를 하면 부모 프로세스와 똑같이 생긴 자식 프로세스가 생기는데 생성된 자식 프로세스는

connected된 클라이언트하고 통신( read/write )을 하고 종료한다. 부모 프로세스는 오직 다른 클라이언트로부터

요청이 들어오기를 기다린다.

 

 

n개의 클라이언트가 존재할 경우 총 프로세스의 개수는 n+1개이다. ( n개의 자식 프로세스 + 1개의 부모 프로세스 )

 


[ fork() 수행 후 소켓 지정 번호 복사 ]

 

fork()를 수행하여 자식 프로세스를 만들 때, 부모 프로세스의 자원이 복사가 된다.

자원 : 코드, 데이터, 소켓을 포함한 모든 열린 파일들( 파일 지정 번호 ), 시그널

 

* 파일 지정 번호 및 소켓도 복사가 되기 때문에 부모 프로세스는 클라이언트와 통신을 직접적으로 하지 않고

클라이언트의 요청을 받아들이는 역할을 한다. 자식 프로세스가 클라이언트와의 통신을 직접적으로 수행한다.

 

클라이언트 소켓 = accept( 서버소켓, , )

accept() : 클라이언트로 부터 접속이 오는 것을 기다리고, 기다리다가 누가 접속하면 받아들이는 것까지 수행을 한다. 

 

부모 프로세스는 서버 소켓으로 accept()를 호출하고 클라이언트 소켓은 사용하지 않는다. 반면에 자식 프로세스는

클라이언트 소켓으로 클라이언트와 통신하고 서버 소켓은 사용하지 않는다.

 

< ./echo_server_fork.c >

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

#define MAXLINE 1024
#define PORTNUM 3600

int main(int argc, char **argv)
{
	int listen_fd, client_fd;
	pid_t pid;
	socklen_t addrlen;
	int readn;
	char buf[MAXLINE];
	struct sockaddr_in client_addr, server_addr;

	if( (listen_fd = socket(AF_INET, SOCK_STREAM, 0)) < 0)
	{
		return 1;
	}
	memset((void *)&server_addr, 0x00, sizeof(server_addr));
	server_addr.sin_family = AF_INET;
	server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
	server_addr.sin_port = htons(PORTNUM);

	if(bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) ==-1)
	{
		perror("bind error");
		return 1;
	}
	if(listen(listen_fd, 5) == -1)
	{
		perror("listen error");
		return 1;
	}

	signal(SIGCHLD, SIG_IGN);
	while(1)
	{
		addrlen = sizeof(client_addr);
		client_fd = accept(listen_fd,
			(struct sockaddr *)&client_addr, &addrlen);
		if(client_fd == -1)
		{
			printf("accept error\n");
			break;
		}
		pid = fork();
		if(pid == 0)
		{
			close( listen_fd );
			memset(buf, 0x00, MAXLINE);
			while((readn = read(client_fd, buf, MAXLINE)) > 0)
			{
				printf("Read Data %s(%d) : %s",
						inet_ntoa(client_addr.sin_addr),
						client_addr.sin_port,
						 buf);
				write(client_fd, buf, strlen(buf));
				memset(buf, 0x00, MAXLINE);
			}
			close(client_fd);
			return 0;
		}
		else if( pid > 0)
			close(client_fd);
	}
	return 0;
}

 


[ 멀티 프로세스 기술의 장점 ]

 

1. 단순한 프로그램의 흐름 → accept 후 fork 함수 호출

2. 오랜 시간 검증된 기술

→ 유닉스는 멀티 프로세스 기반으로 시작했다. 멀티 스레드 기술은 비교적 최근에 도입

3. 안정적인 동작

→ 독립된 프로세스로 동작하기 때문에 프로세스의 잘못된 작동이 다른 프로세스에 영향을 미치지 않는다. 

 


[ 멀티 프로세스 기술의 단점 ]

 

1. 프로세스 복사에 따른 성능 문재

프로세스가 새로 생성이 되기 때문에 많은 CPU와 메모리 비용이 소모될 수 있고, 코드가 중복된다.

    또한 연결과 종료가 빈번한 서비스에서는 연결 지연이 생길 수 있다.

2. 프로세스간 정보 교환이 어렵ㄴ다.

독립된 프로세스로 작동하기 때문에 프로세스간 통신에 IPC를 이용해야 한다.