지난 포스팅에선 fork와 exec을 분리한 이유에 대해 알아보았다. fork와 exec을 분리함으로써 I/O Redirection, File Descriptor 조정 등의 Setting 작업을 할 수 있게 하기 위함이라 했다. 이번 시간도 비슷한 이야기를 할 것이다. 지난 시간에 다루지 못한 Pipe이다. Pipe는 과거 SP Shell Project에서 중점적으로 구현했던 개념으로, IPC(Inter-Process Communication)의 대표적 예시라고 소개한 바 있다. Process 간의 통신을 가능케 하는 대표적 방법론 중 하나이다.
Pipe는 하나의 Process를 다른 Process와 연결시키는 단방향(Unidirectional) Byte Stream이다.
Channel이라 보면 되고, Channel의 한 쪽 끝 Process에서 Write한 Data를 반대 쪽 끝 Process에서 Read하는 흐름이다.
Character에 대한 FIFO(First-In, First-Out) Queue라고 볼 수 있다. ★
Pipe의 특징
No-Structured Communication : Pipe에선 Pipe를 통해 흐르는 Data의 Size, 송/수신자 등의 정보를 전혀 알 수 없다.
Special type of file in a kernel
Pipe는 크게 두 종류로 나뉜다. 하나는 무명(Anonymous) Pipe이고, 다른 하나는 유명(有名, Named) Pipe이다. Named Pipe는 말 그대로 특정 이름을 통해 Access할 수 있는 Pipe로, 말그대로 그 이름만 안다면 어떤 Process이든 접근 가능하다. 반면, Anonymous Pipe는 말그대로 이름이 없는 Pipe로, 해당 Pipe에 연결된 Process들만이 접근 가능하다.
Anonymous Pipe : Process에 의해 생성되고, File Descriptor를 통한 통신은 오로지 Process 상속 관계에서만 가능한 Pipe
Parent Process에서 fork하면 Child Process에게 File Descriptor Table이 그대로 상속된다고 했다. 따라서, Parent와 Child는 서로 같은 fd 정보를 공유하고, 같은 원리로 Anonymous Pipe를 둘 다 접근할 수 있는 것이다. ★
오로지 조상(Ancestor) Process와 후손(Descendant) Process 관계에서만 이 Anonymous Pipe를 통한 통신이 가능하다. (Restrictive한 속성)
C에서는 이러한 Anonymous Pipe를 pipe라는 함수를 통해 생성할 수 있다.
pipe 함수는 마치 open 함수처럼 File Descriptor를 반환한다. ★★
fd[0]을 Read File Descriptor로, fd[1]을 Write File Descriptor로 지정한다.
int pipe(int fd[2]);
// f[0] for reading!
// f[1] for writing!
// This is an Unidirectional Communication!
아래와 같은 예시 코드를 통해 Anonymous Pipe의 원리를 이해할 수 있다.
#include <stdio.h>
#include <unistd.h>
int main(void) {
inr n, fd[2], pid;
char line[100];
if (pipe(fd) < 0) // Anonymous Pipe 생성 실패 시 음수 반환 ★
exit(-1);
if ((pid = fork()) < 0) // fork 실패 시 음수 반환!
exit(-1);
else if (pid > 0) { // Parent Process Routine
close(fd[0]); // Read fd를 닫음. "난 Read 안할거야!"
write(fd[1], “Hello World!\n”, 13); // Write fd에 print하고,
wait(NULL); // Child를 기다린다.
}
else { // Child Process Routine
close(fd[1]); // Write fd를 닫음. "난 Write 안할거야!"
n = read(fd[0], line, MAXLINE); // Read fd에서 Read하고,
write(STDOUT_FILENO, line, n); // stdout에 읽은 Data 출력!
}
}
위 코드는 아주 간단하게 이해할 수 있을 것이다. 아래의 그림과 함께 보자. Parent에서는 자신의 fd[0]을 닫아놓고, fd[1]에 Data를 쏜다. fd[0]이 닫혀 있으니, 그 Data는 Child로의 fd[0]으로 흐를 것이다.
한편, Child는 자신의 fd[1]을 닫아놓고, fd[0]으로부터 Data를 읽는다. Parent에서 쏜 데이터가 여기로 흐를 것이다. 이어서 Child는 자신이 읽은 Data를 STDOUT으로 출력한다.
※ 이때, Anonymous Pipe 매커니즘에서 왜 항상 close가 있을까?
=> Read할 Process는 Write fd를 Close하고, Write할 Process는 Read fd를 Close한다. 왜일까? 그것은 바로, I/O 중 하나의 연산을 수행하고 있는 Process가 다른 연산의 fd를 닫아 놓지 않으면, Parent나 Child 중 누가 먼저 수행될지를 알 수 없고, 그 과정에서 Pipe가 닫히지 않았기 때문에 데이터가 의도보다 먼저 흘러버릴 수 있다. 따라서 닫아놓는 것이다. ★★★
※ 한편, Anonymous Pipe 통신 시 특정 사이즈 이하의 Message 통신은 Atomic함이 보장된다.
=> 너무 큰 Data의 통신에서만 Concurrency를 고려하면 된다. 물론, 애초에 Concurrency를 늘 고려하는 것이 좋은 습관일 것!
Parent-Child 관계를 넘어, 임의의 두 Process가 서로 데이터를 주고 받고자 할 때 사용하는 Non-Anonymous한 Pipe를 Named Pipe라고 한다. Process는 알다시피 Private Address Space를 가진다. 따라서, Process끼리 통신을 하기 위해선 IPC가 필요하고, 그 중 하나의 방법이 Pipe라 했다. 또한, Pipe는 Anonymous Pipe와 Named Pipe로 나뉘는데, Anonymous Pipe는 오로지 같은 Family 안의 Process끼리만 소통을 가능케 하므로, 진정한 의미의 IPC는 이 Named Pipe라 할 수 있다.
Named Pipe : 임의의 두 Process가 하나의 Kernel Buffer File을 두고 양방향(Bidirectional) 소통을 하는 Pipe 방식이다.
'No Parent-Child Relationship'
간단히 'FIFO'라고도 부른다.
Pipe들과 연결된 Entry들은 File System에서 관리한다. ★
Named Pipe는 복수의 Writer를 가질 수 있다. ★
Named Pipe는 해당 File로 소통한 Process들이 통신을 마치더라도 그 Pipe 자체는 계속 유지된다. ★
Named Pipe와 Anonymous Pipe는 Kernel 내의 Buffer 형태의 File이라는 점은 같지만, Named Pipe는 그러한 File에 이름이 있는 것이다. ★
Named Pipe는 mkfifo(Make FIFO), mknod라는 명령으로 생성할 수 있다. ★★
Named Pipe에 대한 read/write 연산은 단순히 read(), write() System Call로 처리할 수 있다. with File Descriptor!
int mkfifo(const char *path, mode_t mode); // make FIFO !!
int unlink(const char *path); // unlink FIFO !!
아래는 Named Pipe로 소통하는 두 Process의 모습을 보여주는 간단한 코드이다.
/* writer.c */
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
int main(void) {
int fd;
char * myfifo = "/tmp/myfifo"; // /tmp에 myfifo라는 이름의 Named Pipe를
// 생성할 것이다. ★★★
mkfifo(myfifo, 0666); // Named Pipe FIFO 생성
fd = open(myfifo, O_WRONLY); // 해당 Name을 이용해 open부터 하자! ★
write(fd, "Hello", 6); // 이어서, 해당 FIFO에 대한 fd로 연산 수행!
close(fd);
unlink(myfifo); // Named Pipe 제거 (File 제거) ★
return 0;
}
/* reader.c */
#include <fcntl.h>
#include <stdio.h>
#include <sys/stat.h>
#include <unistd.h>
int main(void) {
int fd;
char * myfifo = "/tmp/myfifo", buf[1024];
fd = open(myfifo, O_RDONLY); // Named Pipe Open!
read(fd, buf, 1024); // read from FIFO
printf("%s\n", buf); // STDOUT에 읽은 Data 출력
close(fd);
return 0;
}
~> 위와 같이 간단하게 mkfifo()와 unlink()를 Writer Process에서 호출함으로써 Named Pipe를 사용할 수 있다.
~> Reader Process의 경우 mkfifo, unlink를 사용할 필요가 없다. 정확히는, mkfifo를 통한 Named Pipe 생성은 한 프로세스에서만 수행하면 된다. 해제도 마찬가지!
=> 그냥 간단히 File 명을 통해 일반적인 File 접근하듯이 Named Pipe를 사용할 수 있음을 기억하자. ★★
----> 주로 이러한 FIFO File은 임시 파일이므로 /tmp에 만들곤 한다. (관습)
어떤 Program이 아래와 같은 코드로 이루어졌다고 해보자.
fd1 = open( ~ );
fd2 = open( ~ );
while(1) {
k = read(fd1, buf ..);
g = read(fd2, buf ..);
...
}
~> 이 코드의 문제는 무엇인가?
=> 특별한 Option 없이 그냥 Open한 File들은 기본적으로 모두 Read/Write에 대한 Blocking이 이뤄진다.
=> 만약, 위 코드에서 fd1에 데이터가 오지 않고 fd2에만 데이터가 온다면, fd2에선 데이터를 받을 준비가 되어 있음에도 코드 순서상 fd1을 먼저 기다려야하므로, 블로킹 관련 Option없이 호출한 fd1, fd2로 인해 fd2는 Blocking되어 Hanging Program 상황이 되버린다. ★★★
----> 이를 처리하기 위해선 SP Concurrent Server Project에서처럼 Thread를 여럿 띄워서 각각 처리하는 방식을 도입해야한다.
~> 그러나, 위와 같은 상황을 그냥 open 함수로 간단하게 처리하는 방법도 존재하는데, 그것이 바로 'O_NONBLOCK' Option이다. open 함수 호출 시 O_NONBLOCK Option을 설정해주면, 해당 File Descriptor로 Data가 오지 않을 때, 그냥 오지 않는대로 함수를 Return하는 기능이 추가된다. 즉, Read할 데이터가 없으면 Delay 없이 바로 반환할 수 있는 것이다. ★
만약, Named Pipe FIFO를 O_RDONLY 또는 O_WRONLY로 Open했을 때
O_NONBLOCK도 함께 Option으로 추가할 경우,
O_RDONLY | O_NONBLOCK : 읽을 Data가 없으면 read 함수가 Delay없이 바로 Return한다.
O_WRONLY | O_NONBLOCK : Read하려고 File을 Open한 Process가 하나도 없는 경우 Error를 표시한다.
O_NONBLOCK을 함께 Option으로 추가하지 않는 경우,
"그런데, 갑자기 이 O_NONBLOCK 이야기가 왜 나오는거죠?"
Named Pipe에선 Reader Process와 Writer Process 간의 Synchronization이 상당히 중요하다.
만약, Reader가 준비되어 있지 않은 상태에서 Writer가 일을 해버리면 데이터가 정상적으로 전송되지 않을 수 있기 때문이다. ★
If a Writer Process opens with O_WRONLY and without O_NONBLOCK, it will wait for another (reader) process to open the FIFO.
- 즉, O_NONBLOCK을 사용하지 않으면 Reader나 Writer를 기다려 동기화되지만, Hanging이 가능해진다는 Problem이 있고,
- O_NONBLOCK을 사용해서 Open하면 Writer가 Hanging할 일은 없지만 그 타이밍을 제대로 맞추기가 어려워 정상적인 통신이 어려운 것이다.
이 두 가지 상황을 고려해서 Named Pipe를 사용해야하는 것!!! ★
/* Writer Process with O_NONBLOCK */
// ...
int main(void) {
int fd;
char * myfifo = "/tmp/myfifo";
mkfifo(myfifo, 0666);
fd = open(myfifo, O_WRONLY | O_NONBLOCK); // Reader가 없으면 Write 불가!! ★
write(fd, "Hello", 6);
close(fd);
unlink(myfifo);
return 0;
}
지금까지 IPC(Inter-Process Communication)의 일환인 Pipe에 대해 알아보았다. 이번엔 이러한 IPC에는 Pipe 외에 또 어떤 방법론이 있는지 알아보자.
IPC(Inter-Process Communication) : A mechanism for various processes to communicate among them, although they are running on different address space.
~> 좌측의 Message Passing 방법에선 두 Process가 Kernel을 이용해 통신한다.
=> Message Passing 방법의 일례가 Pipe이다.
~> 우측의 Shared Memory 방법에선 두 Process가 Shared Memory를 이용해 통신한다.
=> Shared Memory 방법에 대해선 아래에서 설명한다.
Shared Memory의 이론적 원리를 설명한다. 위 그림을 보자.
Virtual Memory와 Physical Memory 간의 Mapping은 OS가 수행한다.
이때, 두 Process가 있다고 해보자. 두 Process의 Virtual & Private Address Space는 각각 OS를 통해 Physical Memory의 어딘가로 맵핑된다.
한편, Physical Memory 어딘가엔 Shared Memory를 위해 준비된 공간이 있다.
이때, POSIX의 mmap 따위의 OS 함수가 이러한 Shared Physical Memory 공간의 주소를 각각의 Process의 Virtual Memory에 적절하게 맵핑시키고, 그 공유 공간의 주소를 반환한다.
이러한 Shared Memory 기법은 POSIX API 기준으로 다음과 같이 사용할 수 있다. 자세한 코드 디테일보다는, 사용하는 Interface가 무엇인지에 더 집중하자.
/* writer.c */
#include <stdio.h>
#include <stlib.h>
#include <string.h>
#include <fcntl.h>
#include <sys/shm.h>
#include <sys/stat.h>
int main(void) {
const int SIZE 4096; // Shared Memory Object의 Size
const char *name = "EXP"; // Shared Memory Object의 이름
const char *message_0 = "Hello ";
const char *message_1 = "World!\n";
int shm_fd; // Shared Memory File Descriptor
void *ptr; // Pointer to Shared Memory Object
shm_fd = shm_open(name, O_CREAT | O_RDRW, 0666); // Create SHM
ftruncate(shm_fd, SIZE); // Set the size of SHM
ptr = mmap(0, SIZE, PROT_WRITE, MAP_SHARED, shm_fd, 0); // Mapping
sprintf(ptr, "%s", message_0);
ptr += strlen(message_0);
sprintf(ptr, "%s", message_1);
ptr += strlen(message_1);
return 0;
}
/* reader.c */
#include <stdio.h>
#include <stlib.h>
#include <fcntl.h>
#include <sys/shm.h>
#include <sys/stat.h>
int main(void) {
const int SIZE 4096;
const char *name = "OS";
int shm_fd;
void *ptr;
shm_fd = shm_open(name, O_RDONLY, 0666);
ptr = mmap(0, SIZE, PROT_READ, MAP_SHARED, shm_fd, 0);
printf("%s", (char *)ptr);
shm_unlink(name); // Shared Memory Object 제거!!
return 0;
}
~> shm_open으로 Physical Memory Address에 Shared Memory를 생성하거나, 또는 기존의 Shared Memory 공간을 Open한다.
~> ftruncate를 통해 처음 Shared Memory를 생성하는 Process의 경우 Shared Memory의 Size 지정이 가능하다.
~> mmap을 통해 Physical Memory에 할당된 Shared Memory의 Address를 Virtual Memory와 맵핑하고, 반환받을 수 있다.
~> shm_unlink를 이용해 Shared Memory를 없앨 수 있다.
이밖에도 다양한 IPC 기법이 있으며, 이들에 대해선 차차 알아볼 것이다.
금일 포스팅은 여기까지이다.