나의 브을로오그으

#11. 프로세스간 통신의 기본 개념 본문

네트워크/열혈 TCP_IP 소켓프로그래밍

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

__jhp_+ 2022. 8. 19. 11:32

프로세스간 통신이 가능하다라는 말은? 쉽게 말하면 두 프로세스가 데이터를 주고 받을 수 있다는 말이다. 이게 되려면 두 프로세스가 동시 접근이 가능한 메모리 공간이 있어야 한다.

 

 

프로세스간 통신의 기본 이해

"내게 빵이 하나 생기면 변수 bread에 1로 변경할게. 그리고 그 빵을 먹어버리면 변수 bread의 값을 0으로 변경할게. 그리고 너는 bread값을 보고 내 상태를 파악해"

위의 말은 가장 간단한 프로세스간 규칙이라고 볼 수 있다.

즉, 프로세스 A는 말하고, 프로세스 B는 들은 셈이다. 두 프로세스가 동시에 접근 가능한 메모리 공간만 있다면, 이 공간을 통해서 얼마든지 데이터를 주고 받을 수 있다. 그런데 챕터 10의 프로세스 생성에 대한 내용 중 프로세스 생성 시 생성한 프로세스와 별개의 독립적인 메모리 공간을 갖는다.

fork함수를 호출하여 자식 프로세스를 생성하게 되면 부모 프로세스와 자식 프로세스는 메모리 공간을 조금도 공유하지 않는다. 그래서 프로세스간 통신은 별도의 공간을 통해서만 이뤄진다.

 

파이프(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]

/* linux */
#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));
	}
	else
	{
		read(fds[0], buf, BUF_SIZE);
		puts(buf);
	}

	return 0;
}

1. pipe 함수호출을 통해 파이프를 생성하고 있다. 이로 인해 배열 fds에는 입출력을 위한 파일 디스크립터가 각각 저장된다.

2. fork 함수 호출을 통해 자식 프로세스에서도 파이프의 파일 디스크립터를 복사하여 소유하게 된다. 여기서 주의할 점은 파이프가 복사된게 아니라! 파이프 입출력에 사용 될 파일 디스크립터가 복사된 것이라는 것을 명확하게 알아야 한다. 이로써 부모, 자식 프로세스가 모두 입출력 파일 디스크립터를 소유하게 되었다.

3. 자식 프로세스에서 문자열을 보내면 부모 프로세스에서 이를 읽는다.

 

 

파이프(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(fds[0], buf, BUF_SIZE);
		printf("Parent proc output: %s \n", buf);
		write(fds[1], str2, sizeof(str2));
		sleep(3);
	}
	return 0;
}

1. 자식 프로세스 영역을 보면 데이터 송신과 수신 모두 하고 있다. 특히 write 후에 sleep 함수를 호출하고 있다. 이는 자식 프로세스가 파이프를 통해 데이터를 송신하고 곧바로 수신하는 것을 방지하고자 추가하였다.

2. 부모 프로세스 영역에서는 데이터 수신을 먼저 하는데, 수신 후 콘솔에 출력하고, 파이프로 데이터 전송 한다. 부모 프로세스에서 sleep 함수를 호출하는 이유는 부모 프로세스 종료 시 명령 프롬프트가 떠버리기 때문에 호출하였다.

 

"sleep 함수를 호출한 이유는 파이프에 데이터가 전달되면, 먼저 가져가는 프로세스에게 이 데이터가 전달되기 때문이다."

 

 

[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);
	pid = fork();
	if (pid == 0)
	{
		write(fds1[1], str1, sizeof(str1));
		read(fds2[0], buf, BUF_SIZE);
		printf("Child proc output: %s \n", str1);
	}
	else
	{
		read(fds1[0], buf, BUF_SIZE);
		printf("Parent proc output: %s \n", buf);
		write(fds2[1], str2, sizeof(str2));
		sleep(3);
	}
	return 0;
}

1. 두 개의 파이프를 생성한다.

2. 자식 프로세스에서 부모 프로세스로의 데이터 전송은 배열 fds1이 참조하는 파이프를 통해서 이루어진다.

3. 부모 프로세스에서 자식 프로세스로의 데이터 전송은 배열 fds2가 참조하는 파이프를 통해서 이루어진다.

4. 부모 프로세스에서 실행되는 sleep 함수는 종료 지연을 위해 추가한 것일 뿐이다.

 

 

#2. 프로세스간 통신의 적용

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

 

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

10챕터의 echo_mpserv.c를 확장해서 기능을 추가해보자.

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

코드 작성 시 별도의 프로세스를 생성해서, 클라이언트에게 서비스를 제공하는 프로세스로부터 문자열 정보를 수집한다. 이러기 위해서는 수신을 위한 파이프를 생성해야 한다.

 

 

[echo_storeserv.c]

/* linux */
#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 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(SIGCHILD, &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, (sockaddr*)&serv_adr, 0) == -1)
		error_handling("bind() error");
	if (listen(serv_sock, 5) == -1)
		error_handling("listen() error");

	pipe(fds);
	pid = fork();
	if (pid == 0) /// proc id 7177
	{
		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;
	}
	else
	{
		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) /// proc id : 7185, 7191
		{
			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);
			}

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

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

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

1.  파이프 생성 후 파일의 데이터 저장을 담당할  프로세스를 생성하고 있다.

2. 자식 프로세스 실행 영역을 보면 파이프의 출구인 fds[0]으로 전달되는 데이터를 읽어서 파일에 저장하고 있다. 참고로 위의 서버는 종료되지 않고 클라이언트에게 계속해서 서비스를 제공하는 형태이기 때문에, 파일에 데이터가 어느 정도 채워지면 파일을 닫도록 반복문을 구성한다.

3. fork 함수호출로 생성되는 모든 자식 프로세스는 생성한 파이프의 파일 디스크립터를 복사한다. 때문에 파이프의  입구인 fds[1]을 통해서 문자열 정보를 전달할 수 있는 것이다.

 

[실행결과: echo_storeserv.c]

root@my_linux:/tcpip# gcc echo_storeserv.c -o serv
root@my_linux:/tcpip# ./serv 9190
new client connected...
new client connected...
removed proc id: 7177 <- 파이프 출구를 통해 데이터를 10회 수신바디으면 프로세스가 종료됨.
client disconnected...
removed proc id: 7185
client disconnected...
removed proc id: 7191

 

[실행결과 : echo_mpclient.c one]

root@my_linux:/tcpip# gcc echo_mpclient.c -o client
root@my_linux:/tcpip# ./client 127.0.0.1 9190
One
Message from server: One
Three
Message from server: Three
Five
Message from server: Five
Seven
Message from server: Seven
Nine
Message from server: Nine
Q
root@com:/home/swyoon/tcpip#

 

[실행결과 : echo_mpclient.c two]

root@my_linux:/tcpip# gcc echo_mpclient.c -o client
root@my_linux:/tcpip# ./client 127.0.0.1 9190
Two
Message from server: Two
Four
Message from server: Four
Six
Message from server: Six
Eight
Message from server: Eight
Ten
Message from server: Ten
Q
root@com:/my_linux/tcpip#

 

위의 결과처럼 둘 이상의 클라이언트를 접속시켜서 서버로 문자열 전송을 해보면 총 10회 이후 fwrite 함수호출이 끝나고

echomsg.txt를 열어서 문자열의 저장을 확인할 수 있다.

 

 

지금까지 다중접속 서버의 첫 번째 모델에 대한 내용이 끝났다.

"프로세스와 파이프의 개념으로 다수의 대화방을 개설해서 둘 이상의 클라이언트가 대화할 수 있는 서버와 클라이언트를 구현하고 싶은데, 어디서부터 시작을 해야 할까?"

라는 생각이 든다면!!!

 

프로세스와 파이프의 개념만으로 복잡한 기능의 서버를 구현하려면 프로그래밍에 대한 높은 숙련도와 경험으 요구된다. 그래서 초보 개발자들은 이 모델을 바탕으로 구현의 폭을 넓히기가 쉽지 않다. 그러나 앞으로 나공부 할 두 가지 서버 모델이 기능적으로도 더 강력하고, 우리가 생각하는 기능을 표현하기에도 훨씬 수월하다.

 

[내용 확인문제]

01. 프로세스간 통신이 의미하는 바는 무엇인가? 이를 개념적으로, 그리고 메모리의 관점에서 각각 설명해 보자.

답)

프로세스간 통신이 의미하는 바는 프로세스 간에 데이터를 공유하는 것을 의미하며, 메모리 관점에서는 프로세스가 할당받은 메모리는 서로 독립적인데, 여러개의 프로세스가 서로 통신을 하려면 운영체제로부터 공유하여 사용 가능한 메모리를 할당받아야한다. 즉, 공유가능한 메모리를 통해 프로세스간 통신을 할 수 있다.

 

02. 프로세스간 통신에는 IPC라는 별도의 매커니즘이 요구된다. 그리고 이는 운영체제에 의해서 지원되는 별도의 기능이다. 그렇다면 프로세스간 통신에 있어서 이렇듯 운영체제의 도움이 필요한 이유는 무엇인가?

답)

프로세스는 완전히 별개의 메모리 구조를 지니므로, 동시에 접근 가능한 메모리 공간을 통해 통신을 해야한다. 따라서 메모리 할당은 운영체제가 관리하는 영역이므로 운영체제의 도움이 필요하다.

 

03. 프로세스간 통신에는 IPC 기법으로 "파이프(Pipe)"라는 것이 있다. 파이프의 IPC 기법과 관련해서 다음 질문에 답해보자.

a. 파이프는 프로세스간에 데이터를 송수힌하는 경로를 의미한다. 그렇다면 이 경로는 어떻게 해서 생성되며, 누구에 의해서 만들어지는가?

답) 생성방법은 pipe()라는 함수를 호출하면 운영체제에 의해서 pipe 자원을 사용 할 수 있게 된다.

b. 프로세스간 통신을 위해서는 통신의 주체가 되는 두 프로세스 모두 파이프에 접근이 가능해야 한다. 그렇다면 하나의 파이프에 두 프로세스는 어떻게 해서 모두 접근이 가능한가?

답) fork 함수를 호출하여 자식 프로세스를 생성하면 파이프의 입/출력 파일 디스크립터가 복사되어 자식 프로세스도 해당 파이프 리소스를 사용 가능하며, 이 파일 디스크립터를 통해 접근 가능하다.

c. 파이프는 두 프로세스간에 양방향 통신이 가능하게 한다. 그렇다면 양방향 통신을 진행하는데 있어서 특히 주의해야 할 사항은 무엇인가?

답) 파이프로 데이터가 전달되면 먼저 가져가는 프로세스에게 이 데이터가 전달되므로, 실행 흐름을 잘 예측 해야한다.

 

04. IPC 기법을 확인하는 차원에서, 두 프로세스 사이에서 총 3회에 걸쳐서 문자열을 한번씩 주고받는 예제를 작성해 보자. 물론 두 프로세스는 부모, 자식의 관계로 형성이 되며, 주고 받을 문자열의 종류는 프로그램상에서 여러분이 임의로 결정하기 바란다.

답)

/* linux */
#include <stdio.h>
#include <unistd.h>
#include <signal.h>

#define BUF_SIZE 30

int main(int argc, char* argv[])
{
	int fds1[2], fds2[2];

	char str1[] = "Welcome! my world~";
	char str2[] = "wow! awesome!!!";
	char str3[] = "Thank you:)";
	char str[][3] = { str1, str2, str3 };

	int str_len;
	char buf[BUF_SIZE];
	
	pid_t pid;

	pipe(fds1), pipe(fds2);
	pid = fork();
	if (pid == 0)
	{
		for (int i = 0; i < 3; ++i)
		{
			write(fds1[1], str[i], sizeof(str[i]));
			str_len = read(fds2[0], buf, BUF_SIZE);
			buf[str_len] = 0;
			printf("child proc output: %s \n", buf);
		}
	}
	else
	{
		for (int i = 0; i < 3; ++i)
		{
			str_len = read(fds1[0], buf, BUF_SIZE);
			buf[str_len] = 0;
			printf("parent proc output: %s \n", buf);
			write(fds2[1], str[i], str_len);
		}
		sleep(3); // 부모 프로세스가 먼저 종료되면 명령 프롬프트가 닫혀 
		// 자식 프로세스의 마지막 문자열을 확인 불가능 확인을 위한 sleep 추가
	}

	return 0;
}