책정리/열혈 TCP,IP

10장 멀티프로세스 기반의 서버 구현

GONII 2015. 8. 5. 16:15
  • 프로세스의 이해와 활용

    다중 접속 서버의 구현 방법들

    네트워크 프로그램은 CPU의 연산을 필요치 않는 데이터의 송수신 시간이 큰 비중을 차지하므로, 둘 이상의 클라이언트에게 동시에 서비스를 제공하는 것이 CPU를 보다 효율적으로 사용하는 방법이 된다. 때문에 우리는 둘 이상의 클라이언트에게 동시에 서비스를 제공하는 다중접속 서버에 대해 논의하고자 한다. 다음은 대표적인 다중접속 서버의 구현 모델 및 구현 방법이다.

멀티 프로세스 기반 서버

다수의 프로세스를 생성하는 방식으로 서비스 제공

멀티플렉싱 기반 서버

입출력 대상을 묶어서 관리하는 방식으로 서비스 제공

멀티쓰레딩 기반 서버

클라이언트의 수만큼 쓰레드를 생성하는 방식으로 서비스 제공

프로세스(Process)의 이해

프로세스는 간단히 다음과 같이 정의할 수 있다.

"메모리 공간을 차지한 상태에서 실행중인 프로그램"

하드디스크에 저장되어 있는 상태는 프로그램이라고 한다. 이 프로그램이 메인 메모리(Main Memory)라는 곳으로 이동해서 실행을 위한 준비를 마치게 되는데 바로 이 시점부터 프로세스라고 부를 수 있게 된다.

프로세스 ID

모든 프로세스는 생성되는 형태에 상관없이 운영체제로부터 ID를 부여 받는다. 그리고 이를 가리켜 '프로세스 ID'라 하는데 이는 2이상의 정수 형태를 띤다. 숫자 1은 운영체제가 시작되자마자 실행되는 프로세스에게 할당되기 때문에 우리가 만들어 내는 프로세스는 1이라는 값의 ID를 받을 수 없다.

fork 함수호출을 통한 프로세스의 생성

프로세스의 생성에는 몇 가지 방법이 있다. 여기서는 멀티프로세스 기반 서버의 구현에 사용되는 fork 함수에 대해 설명한다

#include <unistd.h>

pid_t fork(void);
// 성공 : 프로세스 ID
// 실패 : -1 반환

fork 함수는 호출한 프로세스의 복사본을 생성한다. 즉 전혀 새로운 다른 프로그램을 바탕으로 프로세스를 생성하는 것이 아니라 이미 실행중인, fork 함수를 호출한 프로세스를 복사하는 것이다. 그리고는 두 프로세스 모두 fork 함수의 호출 이후 문장을 실행하게 된다. 완전히 동일한 프로세스로 메모리 영역까지 동일하게 복사하기 때문에 이후의 프로그램 흐름은 fork 함수의 반환값을 기준으로 나뉘도록 프로그래밍 해야 한다. 즉 fork함수의 다음 특징을 이용해서 프로그램의 흐름을 구분해야 한다.

부모 프로세스

fork 함수의 반환 값은 프로세스의 ID

자식 프로세스

fork 함수의 반환 값은 0

부모 프로세스(Parent Process)란 원본 프로세스, fork함수를 호출한 주체가 된다. 자식 프로세스(Child Process)는 부모 프로세스의 fork 함수 호출을 통해 복사된 프로세스를 의미한다.

부모 프로세스가 fork 함수를 호출하는 순간 자식 프로세스가 복사되어 각각이 fork 함수호출의 반환 값을 받게 된다.

  • fork.c

#include <stdio.h>

#include <unistd.h>

   

int gval = 10;

int main(int argc, char* argv[])

{

pid_t pid;

int lval = 20;

gval++, lval+=5;

   

pid = fork();

if( pid == 0 ) // child Process

gval+=2, lval+=2;

else

gval-=2, lval-=2;

   

if( pid == 0 )

printf("child proc:[%d, %d]\n", gval, lval);

else

printf("Parent Proc: [%d, %d]\n", gval, lval);

   

return 0;

}

  • 프로세스 & 좀비(Zombie)프로세스

    좀비(Zomebie) 프로세스

    프로세스가 생성되고 나서 할 일을 다 하면(main 함수의 실행을 완료하면) 사라져야 하는데 사라지지 않고 좀비가 되어 시스템의 중요한 리소스를 차지하기도 한다. 이 상태에 있는 프로세스를 '좀비 프로세스'라고 하는데 이는 시스템에 부담을 주는 원인이 되기도 한다. 때문에 좀비 프로세스를 소멸시켜야 하는데 이를 위해서 좀비의 소멸방법을 정확히 알아야 한다.

    좀비 프로세스의 생성이유

    fork 함수의 호출로 생성된 자식 프로세스가 종료되는 상황을 두 가지 예로 들면 다음과 같다.

    • 인자를 전달하면서 exit를 호출하는 경우
    • main함수에서 return 문을 실행하면서 값을 반환하는 경우

    exit 함수로 전달되는 인자 값과 main 함수의 return 문에 의해 반환되는 값 모두 운영체제로 전달된다. 그리고 운영체제는 이 값이 자식 프로세스를 생성한 부모 프로세스에게 전달될 때까지 자식 프로세스를 소멸시키지 않는데, 바로 이 상황에 놓여 있는 프로세스를 가리켜 좀비 프로세스라 한다. 즉, 자식 프로세스를 좀비 프로세스로 만드는 주체는 운영체제이다.

    해당 자식 프로세스를 생성한 부모 프로세스에게 exit함수의 인자값이나 return 문의 반환 값이 전달되어야 한다.

    부모 프로세스가 가만히 있는데 운영체제가 알아서 값을 전달해주지는 않는다. 부모 프로세스의 적극적인 요청이 있어야 운영체제는 값을 전달해 준다.

    • zombie.c

#include <stdio.h>

#include <unistd.h>

   

int main(int argc, char* argv[])

{

pid_t pid = fork();

   

// child process

if( pid == 0 )

{

puts("hi, i am child process");

}

else

{

printf("child process ID: %d\n", pid);

sleep(30);// sleep 30 sec

}

   

if( pid == 0 )

puts("end child process");

else

puts("end parent process");

   

if( pid == 0 )

puts("end child process");

else

puts("end parent process");

   

return 0;

}

좀비 프로세스의 소멸1: wait 함수의 사용

자식 프로세스의 소멸을 위해 전달 값을 요청하는 방법엔느 두 가지가 있다.

#include <sys/wait.h>

pid_t wait(int* statloc);
// 성공 : 종료된 자식 프로세스의 ID
// 실패 : -1 반환

위 함수가 호출되었을 때, 이미 종료된 자식 프로세스가 있다면, 자식 프로세스가 종료되며니서 전달한 값이 매개변수로 전달된 주소의 변수에 저장된다. 그런데 이 변수에 저장되는 값에는 자식 프로세스가 종료되면서 전달한 값 이외에도 다른 정보가 함께 포함되어 있으니, 다음 매크로 함수를 통해서 값의 분리 과정을 거쳐야 한다.

WIFEXITED

자식 프로세스가 정상 종료한 경우 '참(true)'를 반환

WEXITSTATUS

자식 프로세스의 전달값을 반환

wait함수의 인자로 변수 status의 주소 값이 전달되었다면, wait 함수의 호출 이후에는 다음과 같은 유형의 코드를 구성해야 한다.

if( WIFEXITED(status) )

{

puts("Normal termination");

printf("Child pass num: %d", WEXITSTATUS(status)); // 반환값은?

}

  • wait.c

#include <stdio.h>

#include <stdlib.h>

#include <unistd.h>

#include <sys/wait.h>

   

int main(int argc, char* argv[])

{

int status;

pid_t pid = fork();

   

if( pid == 0 )

{

return 3;

}

else

{

printf("child PID:%d\n", pid);

pid = fork();

if( pid == 0 )

{

exit(7);

}

else

{

printf("child PID:%d\n", pid);

wait(&status);

if( WIFEXITED(status) )

printf("child send one:%d\n", WEXITSTATUS(status));

   

wait(&status);

if(WIFEXITED(status))

printf("child sned two:%d\n", WEXITSTATUS(status));

sleep(30);

}

}

return 0;

}

wait 함수는 호출된 시점에서 종료된 자식 프로세스가 없다면, 임의의 자식 프로세스가 종료될 때까지 블로킹(Blocking) 상태에 놓인다는 특징이 있다. 때문에 함수의 호출에 주의해야 한다.

좀비 프로세스의 소멸2: waitpid 함수의 사용

wait 함수의 블로킹이 문제가 된다면 waitpid 함수의 호출을 고려하면 된다. 이는 좀비 프로세스의 생성을 막는 두번째 방법이자 블로킹 문제의 해결책이기도 하다.

#include <sys/wait.h>

pid_t waitpid(pid_t pid, int* statloc, int options);
// 성공 : 종료된 자식 프로세스의 ID(또는 0)
// 실패 : -1반환

  • 시그널 핸들링

    운영체제야 네가 좀 알려줘

    자식 프로세스 종료의 인식주체는 운영체제이다. 따라서 운영체제가 열심히 일하고 있는 부모프로세스에게 다음과 같이 이야기해줄 수 있다면 효율적인 프로그램의 구현이 가능하다.

    "부모프로세스! 네가 생성한 자식 프로세스가 종료되었어"

    그러면 부모프로세스는 하던 일을 잠시 멈추고, 자식 프로세스의 종료와 관련된 일을 처리하면 된다. 이러한 시나리오의 프로그램 구현을 위해서 '시그널 핸들링(Signal Handling)'이라는 것이 존재한다. '시그널'은 특정상황이 발생했음을 알리기 위해 운영체제가 프로세스에게 전달하는 메시지를 의미한다. 그리고 그 메시지에 반응해서 메시지와 연관된 미리 정의된 작업이 진행된느 것을 가리켜 '핸들링' 또는 '시그널 핸들링'이라고 한다.

    시그널과 signal 함수

    프로세스는 자식 프로세스의 종료라는 상황 발생시, 특정 함수의 호출을 운영체제에게 요구해야 한다. 이 요구는 다음 함수의 호출을 통해서 이뤄진다.

#include <signal.h>

void (*signal(int signo, void(*func)(int)))(int);
// 시그널 발생시 호출되도록 이전에 등록된 함수의 포인터 반환

함수 이름 : signal

매개변수 선언 : int signo, void(*func)(int)

반환형 : 매개변수형이 int이고 반환형이 void인 함수 포인터

첫번째 인자로 특정 상황에 대한 정보를, 두 번째 인자로 특정 상황에서 호출될 함수의 주소 값(포인터)을 전달한다. 그러면 첫 번째 인자를 통해 명시된 상황 발생시, 두번째 인자로 전달된 주소 값의 함수가 호출된다. signal 함수를 통해서 등록 가능한 특정 상황과 그 상황에 할당된 상수 몇몇을 정리해보면 다음과 같다

SIGALRM

alarm 함수호출을 통해서 등록된 시간이 된 상황

SIGINT

CTRL+C 가 입력된 상황

SIGCHLD

자식 프로세스가 종료된 상황

시그널이 등록되면 등록된 시그널 발생시 운영체제는 해당 시그널이 등록된 함수를 호출해준다.

#include <unistd.h>

unsigned int alarm(unsigned int seconds);
// 0 또는 SIGALRM 시그널이 발생하기까지 남아있는 시간을 초 단위로 반환

위 함수를 호출하면서 양의 정수를 인자로 전달하면, 전달된 수에 해당하는 시간이 지나서 SIGALRM 시그널이 발생한다. 그리고 0을 인자로 전달하면 이전에 설정된 SIGALRM 시그널 발생의 예약이 취소된다. 그런데 위의 함수호출을 통해서 시그널의 발생을 예약만 해놓고, 이 시그널이 발생했을 때 호출되어야 할 함수를 지정하지 않으면 프로세스가 그냥 종료되어 버리니 이를 주의해야 한다.

  • signal.c

#include <stdio.h>

#include <unistd.h>

#include <signal.h>

   

void timeout(int sig)

{

if( sig == SIGALRM )

puts("time out!");

alarm(2);

}

void keycontrol(int sig)

{

if( sig == SIGINT )

puts("CTRL+C pressed");

}

int main(int argc, char* argv[])

{

int i;

signal(SIGALRM, timeout);

signal(SIGINT, keycontrol);

alarm(2);

   

for ( i = 0 ; i < 3 ; i++ )

{

puts("wait...");

sleep(100);

}

return 0;

}

sigaction 함수를 이용한 시그널 핸들링

sigaction함수는 signal 함수와 유사하다. signal 함수를 대체할 수 있고, signal 함수보다 훨씬 안정적으로 동작한다. 안정적으로 동작하는 이유는 다음과 같다.

"signal 함수는 유닉스 계열의 운영체제 별로 동작방식에 있어서 약간의 차이를 보일 수 있지만 sigaction 함수는 차이를 보이지 않는다"

#include <signal.h>
int sigaction(int signo, const struct sigaction* act, struct sigaction* oldact);

// 성공 : 0 반환
// 실패 : -1 반환

  • signo

    signall 함수와 마찬가지로 시그널의 정보를 인자로 전달

  • act

    첫 번째 인자로 전달된 상수에 해당하는 시그널 발생시 호출될 함수의 정보 전달

  • oldact

    이전에 등록되었던 시그널 핸들러의 함수 포인터를 얻는데 사용되는 인자, 필요 없다면 0 전달

sigaction이라는 이름의 구조체 변수를 선언 및 초기화해야 하는데, 이 구조체는 다음과 같이 정의되어 있다.

struct sigaction

{

void (*sa_handler)(int);

sigset_t sa_mask;

int sa_flags;

}

  • sigaction.c

#include <stdio.h>

#include <unistd.h>

#include <signal.h>

   

void timeout(int sg)

{

if( sig == SIGALRM )

puts("time out!");

alarm(2);

}

   

int main(int argc, char* argv[])

{

int i;

struct sigaction act;

act.sa_handler = timeout;

sigemptyset(&act.sa_mask);

act.sa_flags = 0;

sigaction(SIGALRM, &act, 0);

   

alarm(2);

   

for( i = 0 ; i < 3 ; i++ )

{

puts("wait...");

sleep(100);

}

return 0;

}

시그널 핸들링을 통한 좀비 프로세스의 소멸

  • remove_zombie.c

#include <stdio.h>

#include <stdlib.h>

#include <unistd.h>

#include <signal.h>

#include <sys/wait.h>

   

void read_childproc(int sig)

{

int status;

pid_t id = waitpid(-1, &status, WNOHANG);

if( WIFEXITED(status) )

{

printf("Removed proc id:%d\n", id);

printf("child send: %d\n", WEXITSTATUS(status));

}

}

   

int main(int argc, char* argv[])

{

pid_t pid;

struct sigaction act;

act.sa_handler = read_childproc;

sigemptyset(&act.sa_mask);

act.sa_flags = 0;

sigaction(SIGCHLD, &act, 0);

   

pid = fork();

// 자식 프로세스 실행 영역

if( pid == 0 )

{

puts("hi i'm child");

sleep(10);

return 12;

}

// 부모 프로세스 실행 영역

else

{

printf("child proc id: %d\n", pid);

pid = fork();

// 또 다른 자식 프로세스 실행 영역

if( pid == 0 )

{

puts("hi i'm child process");

sleep(10);

exit(24);

}

else

{

int i;

printf("child proc id:%d\n", pid);

for( i = 0 ; i < 5 ; i++ )

{

puts("wait...");

sleep(5);

}

}

}

return 0;

}

  • 멀티태스킹 기반의 다중접속 서버

    프로세스 기반의 다중접속 서버의 구현 모델

    이번에는 동시에 둘 이상의 클라이언트에게 서비스를 제공하는 형태로 에코 서버를 확장해보겠다. 다음 그림은 이어서 구현할 멀티프로세스 기반의 다중접속 에코 서버의 구현 모델을 보이고 있다.

    위 그림에서 보이듯이 클라이언트의 서비스 요청(연결요청)이 있을 때마다 에코 서버는 자식 프로세스를 생성해서 서비스를 제공한다. 즉, 서비스를 요청하는 클라이언트의 수가 다섯이라면 에코 서버는 추가로 다섯 개의 자식 프로세스를 생성해서 서비스를 제공한다. 이를 위해서 에코 서버는 다음 과정을 거쳐야 한다

    • 에코 서버(부모 프로세스)는 accept 함수호출을 통해 연결요청을 수락한다.
    • 이때 얻게 되는 소켓의 파일 디스크립터를 자식 프로세스를 생성해서 넘겨준다.
    • 자식 프로세스는 전달받은 파일 디스크립터를 바탕으로 서비스를 제공한다.

    다중접속 에코 서버의 구현

    • echo_mpserv.c

#include <stdio.h>

#include <stdlib.h>

#include <string.h>

#include <unistd.h>

#include <signal.h>

#include <sys/wait.h>

#include <arpa/inet.h>

#include <sys/socket.h>

   

#define BUFSIZE 30

void errorHandling(char* msg);

void readChildProc(int sig);

   

int main(int argc, char* argv[])

{

int servSock, clntSock;

struct sockaddr_in servAddr, clntAddr;

   

pid_t pid;

struct sigaction act;

socklen_t addrSz;

int strLen, state;

char buf[BUFSIZE];

if( argc != 2 )

{

printf("usage : %s <port>\n", argv[0]);

exit(1);

}

   

act.sa_handler = readChildProc;

sigemptyset(&act.sa_mask);

act.sa_flags = 0;

state = sigaction(SIGCHLD, &act, 0);

servSock = socket(PF_INET, SOCK_STREAM, 0);

memset(&servAddr, 0, sizeof(servAddr));

servAddr.sin_family = AF_INET;

servAddr.sin_addr.s_addr = htonl(INADDR_ANY);

servAddr.sin_port = htons(atoi(argv[1]));

   

if( bind(servSock, (struct sockaddr*)&servAddr, sizeof(servAddr)) == -1 )

errorHandling("bind() error");

if( listen(servSock, 5) == -1 )

errorHandling("listen() error");

   

while(1)

{

addrSz = sizeof(clntAddr);

clntSock = accept(servSock, (struct sockaddr*)&clntAddr, &addrSz);

if( clntSock == -1 )

continue;

else

puts("new client connected...");

pid = fork();

if( pid == -1 )

{

close(clntSock);

continue;

}

if( pid == 0 )

{

close(servSock);

while( (strLen = read(clntSock, buf, BUFSIZE)) != 0 )

write(clntSock, buf, strLen);

   

close(clntSock);

puts("client disconnected...");

return 0;

}

else

close(clntSock);

}

   

close(servSock);

return 0;

}

   

void errorHandling(char* msg)

{

puts(msg, stderr);

putc('\n', stderr);

exit(1);

}

void readChildProc(int sig)

{

pid_t pid;

int status;

pid = waitpid(-1, &status, WNOHANG);

printf("removed proc id : %d\n", pid);

}

fork 함수 호출을 통한 파일 디스크립터의 복사

부모 프로세스가 지니고 있던 두 소켓(하나는 서버 소켓, 하나는 클라이언트와 연결된 소켓)의 파일 디스크립터가 자식 프로세스에게 복사되었다.

fork 함수가 호출되면 부모 프로세스의 모든 것이 복사되니 소켓도 함께 복사되었을 거라 생각할 수 있다. 그러나 소켓은 프로세스의 소유가 아니다. 엄밀히 말해서 소켓은 운영체제의 소유다. 다만 해당 소켓을 의미하는 파일 디스크립터만이 프로세스의 소유인 것이다. 그런데 굳이 이렇게 이해하지 않아도 소켓이 복사된다는 것은 다음의 이유로도 이치에 맞지 않는다

"소켓이 복사되면 동일한 PORT에 할당된 소켓이 둘 이상이 됩니다."

즉, 예제 echo_mpserv.c에서 for함수의 호출결과는 다음과 같다. fork 함수 호출 이후에 하나의 소켓에 두 개의 파일 디스크립터가 할당된 모습을 보인다.

위와 같은 형태를 유지하면 이후에 자식 프로세스가 클라이언트와 연결되어 있는 소켓을 소멸하려 해도 소멸되지 않고 계속 남아있게 된다. 그래서 fork 함수호출 후에는 다음 그림에서 보이듯이 서로에게 상관이 없는 소켓의 파일 디스크립터를 닫아줘야 한다.

위 그림의 형태로 파일 디스크립터를 정리하기 위해 close함수를 호출한 것이다.

  • TCP의 입출력 루틴(Routine) 분할

    입출력 루틴 분할의 의미와 이점

    지금까지 구현한 에코 클라이언트의 데이터 에코방식은 다음과 같았다.

    "서버로 데이터를 전송한다. 그리고 데이터가 에코되어 돌아올때까지 무조건 기다린다. 그리고 에코되어 돌아온 데이터를 수신하고 나서야 비로소 데이터를 추가로 전송할 수 있다."

    한 번 데이터를 전송하면 에코 되어 돌아오는 데이터를 수신할 때까지 마냥 기다려야 했다. 프로그램 코드의 흐름이 read와 write를 반복하는 구조였기 때문이다. 데이터의 송신과 수신을 분리해보자. 분리를 위한 기본 모델은 다음과 같다.

    위 그림에서 보이듯이 클라이언트의 부모 프로세스는 데이터의 수신을 담당하고, 별도로 생성된 자식 프로세스는 데이터의 송신을 담당한다. 그리고 이렇게 구현해 놓으면 입력과 출력을 담당하는 프로세스가 각각 다르기 때문에 서버로부터의 데이터 수신여부에 상관없이 데이터를 전송할 수 있다.

    이러한 구현방식을 택하는 이유에는 여러 가지가 있지만, 가장 중요한 이유는 프로그램의 구현이 한결 수월해진다는데 있다.

    입출력 루틴 분할의 또 다른 장점을 들라고 한다면 데이터 송수신이 잦은 프로그램의 성능향상을 들 수 있다.

    위 그림의 왼쪽은 이전 에코 클라이언트의 데이터 송수신 방식을, 그리고 오른쪽은 입출력 루틴을 분리시킨 에코 클라이언트의 데이터 송수신 방식을 보여준다. 서버에서의 차이는 없다. 차이가 나는 부분은 클라이언트 영역이다. 입출력 루틴이 분리된 클라이언트는 데이터의 수신여부에 상관없이 데이터 전송이 가능하기 때문에 연속해서 데이터의 전송이 가능하다. 따라서 동일한 시간 내에서의 데이터 송수신 분량이 상대적으로 많을 수밖에 없다.

    에코 클라이언트의 입출력 루틴 분할

    • echo_mpclient.c

#include <stdio.h>

#include <stdlib.h>

#include <string.h>

#include <unistd.h>

#include <arpa/inet.h>

#include <sys/socket.h>

   

#define BUFSIZE 30

void errorHandling(char* msg);

void readRoutine(int sock, char* buf);

void wrtieRoutine(int sock, char* buf);

   

int main(int argc, char* argv[])

{

int sock;

pid_t pid;

char buf[BUFSIZE];

struct sockaddr_in servAddr;

if( argc != 3 )

{

printf("Usage:%s <ip><port>\n", argv[0]);

exit(1);

}

sock = socket(PF_INET, SOCK_STREAM, 0);

memset(&servAddr, 0, sizeof(servAddr));

servAddr.sin_family = AF_INET;

servAddr.sin_addr.s_addr = inet_addr(argv[1]);

servAddr.sin_port = htons(atoi(argv[2]));

   

if( connect(sock, (struct sockaddr*)&servAddr, sizeof(servAddr)) == -1 )

errorHandling("connect() error");

   

pid = fork();

if( pid == 0 )

writeRoutine(sock, buf);

else

readRoutine(sock, buf);

   

close(sock);

return 0;

   

return 0;

}

   

void errorHandling(char* msg)

{

fputs(msg, stderr);

fpuc('\n', stderr);

exit(1);

}

void readRoutine(int sock, char* buf)

{

while(1)

{

int strLen = read(sock, buf, BUFSIZE);

if( strLen == 0 )

return ;

   

buf[strLen] = 0;

printf("message from server: %s", buf);

}

}

void wrtieRoutine(int sock, char* buf)

{

while(1)

{

fgets(buf, BUFSIZE, stdin);

if( !strcmp(buf, "q\n") || !strcmp(buf, "Q\n") )

{

shutdown(sock, SHUT_WR);

return;

}

write(sock, buf, strlen(buf));

}

}

   

반응형