- 프로세스간 통신이 가능하다는 것은 서로 다른 두 프로세스가 데이터를 주고 받을 수 있다는 의미가 되며, 이렇게 되기 위해서는 두 프로세스가 동시에 접근 가능한 메모리 공간이 있어야 한다.
프로세스간 통신
1. 두 프로세스 사이에서의 데이터 전달
2. 두 프로세스 사이에서의 데이터 전달이 가능하려면, 두 프로세스 사이에 메모리가 존재해야 한다.
예를 들어 A 프로세스와 B 프로세스 간의 통신을 위해서 다음과 같은 규칙을 세운다고 가정해보자.
내게 캔디가 하나 생기면 변수 candy의 값을 1로 변경할 거야. 그리고 그 캔디가 없어지면 0으로 다신 변경할 거야. 그러니 너는 변수 candy의 값을 통해서 내 상태를 파악해 !
위 상황에서 A 프로세스는 변수 candy를 통해서 자신의 상태를 B 프로세스에게 말한 셈이다.
B 프로세스는 변수 candy를 통해 A 프로세스가 한 말을 들은 셈이다.
즉, 두 프로세스가 동시에 접근 가능한 메모리 공간만 있다면, 이 공간을 통해서 얼마든지 데이터를 주고 받을 수 있다.
여기서 문제점이 발생한다. [소켓 #11]에서 두 개의 프로세스간의 통신에 성공하였지만, fork()로 생성된 자식 프로세스와 부모 프로세스는 독립된 메모리 공간을 가진다. 즉, 메모리 공간을 공유하지 않는다. 프로세스간 통신은 별도의 방법이 필요하다.
따라서 운영체제가 별도의 메모리 공간을 마련해 줘야 프로세스간 통신이 가능하다.
- 파이프 기법의 구조적 모델
두 프로세스 간 통신을 위해서는 파이프라는 것을 생성해야 한다.
이 파이프는 프로세스에 속하는 자원이 아니며 소켓과 마찬가지로 OS에 속하는 자원이다.
때문에 fork 함수의 호출에 의한 복사 대상이 아니다.
즉, OS가 마련해 주는 메모리 공간을 통해서 두 프로세스는 통신을 하게 된다.
#include <unistd.h>
int pipe(int filedes[2]);
//성공 시 0, 실패 시 -1 반환
/*
filedes[0] : 파이프로부터 데이터를 수신하는데 사용되는 파일 디스크럽터가 저장된다.
즉, filedes[0]는 파이프의 출구가 된다.
filedes[1] : 파이프로 데이터를 전송하는데 사용되는 파일 디스크립터가 저장된다.
즉, filedes[1]은 파이프의 입구가 된다.
*/
#include <stdio.h>
#include <unistd.h>
#define BUF_SIZE 30
int main(int argc, char *argv[]){
int fds[2];
char str[] = "Self pipe test";
char buf[BUF_SIZE];
pid_t pid;
pipe(fds); // 파이프 생성
printf("fds[0]=%d, fds[1]=%d\n", fds[0], fds[1]);
// fds[1]을 통해 쓰기
write(fds[1], str, sizeof(str));
// fds[0]을 통해 읽기
read(fds[0], buf, BUF_SIZE);
puts(buf);
return 0;
}
- 실행 결과
위의 함수를 호출하면서 배열에는 두 개의 파일 디스크립터가 담긴다.
그리고 이들 각각은 파이프의 출구와 입구로 사용이 된다.
결국 부모 프로세스가 위의 함수를 호출하면 파이프가 생성되고, 파이프의 입구 및 출구에 해당하는 파일 디스크립터를 동시에 얻게 되는 것이다.
따라서 부모 프로세스 혼자서 파이프 안으로 데이터를 집어넣고 꺼내는 것도 가능하다.
그런데 부모 프로세스의 목적은 자식 프로세스와의 데이터 송수신이니, 입구 또는 출구에 해당하는 파일 디스크립터 중 하나를 자식 프로세스에게 전달해야 한다.
이것은 fork 함수를 통해 해결이 가능하다.
#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); // 파이프 생성
printf("fds[0]=%d, fds[1]=%d\n", fds[0], fds[1]);
pid = fork(); //파일 디스크립터 복사(공유)
if(pid==0){
write(fds[1], str, sizeof(str));
}
else{
read(fds[0], buf, BUF_SIZE);
puts(buf);
}
return 0;
}
- 실행 결과
부모, 자식 프로세스 모두 파이프의 입출력 경로에 접근이 가능하지만, 자식은 입력 경로에만, 부모는 출력 경로에만 접근해서 통신을 했다.
이제는 하나의 파이프를 통해서 두 프로세스가 양방향으로 데이터를 주고 받는 예제를 작성해보자.
#include <stdio.h>
#include <unistd.h>
#define BUF_SIZE 100
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;
}
- 실행 결과
실행 결과는 위와 같다.
부모 프로세스가 진행하는 코드에 sleep(3)은 부모 프로세스가 먼저 종료되는 것을 방지하기 위함이다. 물론 자식 프로세스는 자신의 일을 진행하므로 크게 문제되지 않는다. 이 문장은 자식 프로세스가 끝나기 전에 명령 프롬포트가 뜨는 상황을 방지하기 위함이다.
여기서 자식 프로세스에 있는 sleep(2)는 무슨 의미일까 ?
다음 코드와 같이 주석처리하고 진행해보자
#include <stdio.h>
#include <unistd.h>
#define BUF_SIZE 100
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;
}
- 실행 결과
위와 같이 문제가 생김을 알 수 있다.
이는 파이프에 데이터가 전달되면, 먼저 가져가는 프로세스에게 이 데이터가 전달되기 때문이다.
즉, 파이프에 데이터가 들어가면, 이는 임자가 없는 데이터가 된다.
결국 자식 프로세스에서 Who are you? 라는 문자열을 송신하고 바로 read로 수신해버리기 때문에
부모 프로세스는 read 함수를 호출하고 나서, 파이프에 데이터가 들어오는 것을 기다리는 상태(Blocking)이 된다.
즉, 하나의 파이프를 이용해서 양방향 통신을 하는 경우, 데이터를 쓰고 읽는 타이밍이 매우 중요해진다. 그런데 이를 컨트롤 하는 것은 사실상 불가능하기 때문에 적절한 방법이 아니다.
이를 해결하기 위해서는 2개의 파이프(쓰기 전용, 읽기 전용)를 생성하면 된다.
양방향 통신 모델을 보자.

두 개의 파이프를 이용하면 프로그램의 흐름을 예측하거나 컨트롤할 필요가 없다.
바로 코드를 보자.
#include <stdio.h>
#include <unistd.h>
#define BUF_SIZE 100
int main(int argc, char *argv[]){
int fds1[2];
int 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", 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;
}
- 실행 결과
자식 프로세스에서 부모 프로세스로의 데이터 전송은 배열 fds1이 참조하는 파이프를 통해서 이뤄진다.
부모 프로세스에서 자식 프로세스로의 데이터 전송은 배열 fds2가 참조하는 파이프를 통해서 이뤄진다.
이제 네트워크 코드에 적용해 볼 차례이다.
프로세스간 통신은 서버의 구현에 직접적인 연관은 없다.
그러나 OS를 이해한다는 측면에서 나름의 의미가 있다.
[소켓 #10]에서 작성한 예제 echo_mpserv를 확장해서 다음의 기능을 추가하고자 한다.
"서버는 클라이언트가 전송하는 문자열을 전달되는 순서대로 파일에 저장한다."
필자는 이를 별도의 프로세스가 담당하게끔 구현하려고 한다.
즉, 별도의 프로세스를 생성해서, 클라이언트에게 서비스를 제공하는 프로세스로부터 문자열 정보를 수신하게끔 할 것이다.
client 코드는 [소켓 #10]에서 작성한 echo_mpclient를 사용하겠다.
#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(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)
{
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);
}
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);
}
- 실행 결과
server 코드에서 초기에 fork() 함수 호출은 서버에서 처음으로 생성하는 자식 프로세스이다.
반복문 안에 fork() 함수 호출은 서버에서 연결 허용 시마다 생성하는 자식 프로세스이다.
즉, 첫 번째 fork() 함수 호출은 파이프를 생성하고 자식 프로세스를 생성 -> 자식 프로세스가 파이프로부터 데이터를 읽어서 저장
두 번째 fork() 함수 호출은 accept() 함수 호출 후 fork 함수 호출을 통해서 파이프의 디스크립터를 복사한다.
그리고 이를 이용해서 이전에 만들어진 자식 프로세스에게 데이터를 전송한다.
둘 이상의 클라이언트를 접속시켜서 서버로 문자열을 전송해보자.
그렇게 해서 어느 정도 문자열이 파일에 저장되고 나면(총 10회의 fwrite 함수 호출이 끝나고)
파일 echomsg.txt를 열면 문자열의 저장을 확인할 수 있다.
참고 : 윤성우의 열혈 TCP/IP 소켓 프로그래밍
Git : https://github.com/im2sh/Socket_Programming/tree/main/lab09