수업/System Programming

[System Programming] pipe 공부

hw-ani 2022. 11. 9. 21:30

pipe는 UNIX system IPC(InterProcessCommunication) 중 하나이다.

즉, Process간 통신을 할때 사용된다.


> File Descriptor

file descriptor란 process가 file을 다루기위해 사용하는 개념으로 0 이상의 정수값을 갖는다.

processor가 생길때마다 각 processes는 각자 file descriptors (목록)을 가진다. process는 file에 직접 접근하는게아니라 이 file descriptor을 통해서 지정된 file에 접근한다.

그럼 이제 processor가 file을 open할때마다 사용하지 않는 file descriptor 숫자 중 "가장 작은 값"과 해당 file을 연결(?)해서 접근할 수 있게 해주는 것이다.

참고로 0(stdin),1(stdout),2(stderr)는 processor 생성시 내 terminal에 자동으로 연결된다. 그래서 보통 우리가 파일을 열면 3부터 할당된다.

(stdin, stdout, stderr은 보통 monitor나 keyboard 같이 default로 지정된게 있다.)

 

fork()를 해서 processor 복사가 일어나면 이 file descriptor도 같이 통째로 복사된다.

(execvp도 마찬가지)

 


하나 짚고 가자면 아래의 redirection이나 pipe 관련해선 shell program이 처리하도록 shell program 내부에 구현된다.
다른 programs처럼 따로 실행시켜야하는 따로 존재하는 외부 program이 아니다.

> Redirection

process 생성할때 file descriptors가 자동으로 생성→standard stream과 연결된다고 했는데, 이때 연결되는 stream을 redirection을 통해 변경할 수 있다.

`./a.out >a1.txt`

위처럼 `>`나 `<`를 이용해 stdin이나 stdout이 아닌 우리가 지정한 file과 연결되도록 할 수 있다.

위처럼 redirect해두면 printf 같은 기본적으로 stdin에 작성하는 함수를 사용했을때 a1.txt에 적히는 효과를 볼 수 있다.

(`2>` 라고하면 stderr을 redirect 한다.)

 

우리가 명령어 줄에 입력하면 Shell이 process의 file descriptor 0,1,2의 값을 바꿈으로써 redirection이 가능하다.

일단 기본으로 terminal driver에 연결돼있는 file descriptor 0,1,2를 어떻게 바꾸는지 먼저 보자.

open system call을 사용하면 kernel은 사용하지 않는 file descriptors 중 가장 작은 번호에 연결해준다.

위 원리를 이용하면 간단하다. 두가지 방법이 있다.

 

Ⅰ. close() → open()

1) close(0);  //close라는게 별다른게아니라 file descriptor랑 file 연결을 끊는다 정도인듯

2) open("IwantToConnectThisFileToSTDIN", O_RDONLY);

이렇게 해주면, 일단 1번 file descriptor의 연결이 끊기고, 다음으로 여는 파일은 자동으로 0번(stdin)에 할당된다.

 

Ⅱ. open() → close() → dup() → close()

1) fd = open("IwantToConnectThisFileToSTDIN", O_RDONLY)

2) close(0);

3) dup(fd);

4) close(fd);

dup()은 인자로 넘어온 file descriptor을 복제한다. 즉 fd가 A라는 file을 가리킨다면, dup(fd)를 하면 새로운 file descriptor가 A와 연결된다. A와 연결되는 file descriptor가 두개 생기는 셈.

여기선 0을 닫고 dup을 하므로 0에 연결된다. 이후 기존 fd를 닫아주면 된다.

(말고 dup2 함수를 이용하면 더 간단하게 위 과정을 진행할 수 있다.)

 

 

이제, Shell에서 redirect하는 원리를 알아보자.

참고로 fork와 exec는 기존 process의 attributes는 변경하지 않는다. file descriptors도 마찬가지로 fork나 exec가 호출돼도 변경되지 않는다.

지난 글에서 알아봤듯이 Shell에서 특정 프로그램을 실행시키는 기능은, fork() 이후 parent process에선 wait(), child process에선 exec~() 의 순서로 작동한다.

그럼 fork나 exec...를 호출해도 file descriptor는 바뀌지 않는다는걸 알았으니 답은 간단하다.

fork()와 exec~() 사이에 위에서 한 것처럼 file descriptors 0, 1, 2을 Shell에 입력된 명령어대로 변경해주면 된다.

 

아래는 Shell에서 특정 program을 redirect해서 실행시키는 흐름을 간단하게 나타낸 것이다. 기존엔 fork만 하고 바로 실행시켰지만 여기선 위에서 배운대로 redirect 시킨 후 실행시켜주면 된다.

 

아래는 위 흐름대로 redirect를 하는 정해진 명령어를 처리하는 것을 간단하게 구현한 코드이다.

//간단하게 Shell 동작과 redirection을 구현해서 `who > userlist`를 수행해보자.

#include <stdio.h>

int main()
{
    int pid, fd;
	
    if ((pid = fork()) == -1) {
    	perror("fork");
        exit(1);
    }
    //child process
    if (pid == 0) {
    	close(1);  //여기랑 바로 아래줄이 redirect하는 과정이다.
        fd = creat("userlist", 0644);
        execlp("who", "who", NULL);
        perror("execlp");
        exit(1);
    }
    //parent process
    if (pid != 0) {
    	wait(NULL);
    }
    return 0;
}

 

 

 

> pipe

> pipe 명령어

pipe 명령어로 프로그램들을 연결해서 실행하면, 앞쪽 process의 output이 뒷쪽 process의 input으로 들어간다.

두 program은 동시에 실행되며 I/O가 앞뒤로 연결된다.

`ls -al | more`

 

원리 자체는 redirection과 크게 다르지 않다. 결국 앞 뒤 processor가 연결되도록 file descriptor을 바꿔주는 것이다.

그럼 앞쪽 output이 뒷쪽 input으로 들어가려면, 중간에 매개해주는 file이 있어야한다.

pipe 명령어를 사용하면 아마 내부적으로 그런 매개 file을 만들어서 연결해줄 것이다. 여기서 그냥 일반적인 file을 사용하진 않는다. 그냥 file에서 서로 읽고 쓰고 lseek까지 해버리면 뒤죽박죽이 될 가능성이 꽤 크기 때문이다. 또 일반 file처럼 disk에 저장하고 읽어오고 하는 것은 매우 비효율적이다.

그래서 pipe라고 하는, 진짜 file은 아니고 kernel에서 제공하는 file처럼 쓰이는 memory 상의 interface를 사용한다.

 

pipe는 아래와 같은 특징을 가진다. (아래 pipe() system call이 만드는 pipe 용도의 file도 마찬가지이다.)

1. 다른 file처럼 disk에 저장하지 않고 virtual file이라 하여 memory에 올려서 쓴다.

2. kernel 내의 one-way data channel이다.

3. First-In-First-Out

4. lseek가 작동하지 않는다.

5.

6. I/O가 동시에 작동하면 안좋기때문에, 보통 한방향으로만 소통하기위해 주로 input/output을 분리하여 다룬다.

 

뭐어쨌든 그런 특수한 매개 file인 pipe를 앞쪽 processor의 1번 file descriptor에 꼽고, 뒷쪽 processor의 0번 file descriptor에 꼽으면 그게 pipe이다.

"UNIX의 탄생" 책에서 pipe는 이미 redirection이 있었기때문에 구현이 오래걸리지 않았다고 적혀있는데 그게 이말이네

바로 위 그림은 실제로 저렇게 된다는게 아니라, 이해를 위해 좀 부정확하게 표현한 것이다. 저기서 temp 역할을 하는 것이 pipe이다.
실제 pipe 기법은 두 프로세스 모두 실행중에 진행되고, file을 통하지도 않으므로, 위 그림은 참고로만 보자.

 

 

> pipe() system call

pipe system call은 pipe를 생성해준다. 이를 통해 fork()로 분리된 process와 통신할 수 있다. 차근차근 알아보자.

int pipe(int fd[2]);

이렇게 pipe()를 호출하면 하나의 pipe가 생성된다.

 

 

pipe() system call은 인자로 넘겨준 배열에 임시로 만든 pipe 용도 file 하나의 read end와 write end의 file descriptors를 각각 fd[0]과 fd[1]에 담아서 넘겨준다.

풀어서 말하자면, 위에서 봤듯이 pipe 명령어처럼 process간 통신을 하려면 매개 file이 필요하다.

pipe() system call은 그런 매개 file 역할을 하는 pipe를 만들어주는 것이다. 하지만 pipe에서 read/write가 동시에 되면 안 좋으니 fd[0]에는 해당 file에 read만 할 수 있는 file descriptor을 저장해주는 것이고, fd[1]에는 해당 file에 write만 할 수 있는 file descriptor을 저장해서 반환해주는 것이다.

(아마 pipe 함수에서 같은 pipe file을 fd[0]에는 O_RDONLY로 연 것을 저장하고, fd[1]에는 O_WRONLY로 연 것을 저장하는 것 같다. 말이 그렇단거지 mode야 다르게  수도 있겠지.)

 

 

pipe() System call은 fork()와 같이 쓰인다. Parent와 Child process의 통신 수단으로 쓰인다. fork()를 하면 child process에 file descriptor도 같이 복사된다는 점을 상기한 후 아래 코드를 보자.

#include <stdio.h>
#include <fcntl.h>

int main()
{
	int fd[2];
	int pid;
	char buff[100];
	pipe(fd);
    
	printf("fd[0] = %d\n", fd[0]);
	printf("fd[1] = %d\n", fd[1]);

	pid = fork();
	if (pid == 0) {  //Child process
		close(fd[1]);
		
		read(fd[0], buff, 100);
		printf("CHILD : %s\n", buff);
	}
	else {           //Parent process
		close(fd[0]);
		write(fd[1], "hello", 6);
	}
	return 0;
}

fd[0]은 read descriptor이고, fd[1]은 write descriptor이다.

위 code에선 child process에서 read를 하기위해 fd[1]을 닫고, parent process에서 write를 하기위해 fd[0]을 닫는다.

보통 한 방향으로만 소통해야 혼선이 없기 때문에 위처럼 사용하지 않는 것은 닫고 시작한다.

아래 그림에서 fork가 일어나면 모두 복사가돼서 둘 다 pipe의 양쪽끝 fd를 가지게되니 하나씩 닫고 시작한다는 말이다.

양방향으로 소통하고싶다면 pipe()를 한번 더 호출해서 pipe를 하나 더 만들면 된다.

 

둘 중에 누가먼저 실행되는지는 OS 스케쥴하는거에 따라 다르기때문에 정확하게는 알 수 없지만,

만약 pipe가 비어있는데 read가 호출된다면 입력을 기다린다.(block된다.)

 

 

 

> mkfifo 명령어 & mkfifo() system call

`$ mkfifo pathname`

`mkfifo(const char * pathname, mode_t mode)`

 

위의 pipe() 명령어를 사용하면 pipe가 한 process 내에서 잠깐 만들어지고 없어지므로 parent-child 관계가 아니라면 사용할 수 없다.

mkfifo를 이용하면 유지되는 pipe file을 만들 수 있다. 이를 named pipe라고 한다. `ls -l`로 출력해보면 file type으로 `p`가 나온다.

 

지금은 network로 소통하는게아니라 한 machine 안에서 processor들이 소통하므로 endian이나 size 문제는 고민할 필요가 없다.

 

 

> pipe 구현

pipe 또한 별도의 프로그램이 아닌 shell에서 지원하는 기능이다. 이를 구현해보자.

로직은 간단하다. redirection 구현과 비슷한데, 다른 점은

1. `$who | more` 같이 입력되면 두 process를 실행시켜야하므로 Shell의 child process에서 한번 더 fork를 해줘야한다는 것

2. 두번째 fork전에 pipe()로 pipe를 생성해야한다는 것

3. 각 process로 들어가서 redirect를 해주는건 같은데, 안쓰는 놈은 닫아야한다는 것

적고 보니까 꽤 다른 것 같긴한데 ㅋㅋ 쨌든 중요한건 redirect는 한 process에 fd만 바꿔주면 돼서 그렇게 복잡하진 않지만, pipe는 두 process로 다시 분기돼고 거기서 redirect 후에 안쓰는 애들은 닫아주기도 해야 한다는 것
각각 사용하는 함수라던가 특성을 잘 알면 어렵진 않을듯.

 

아래는 Shell에서 pipe를 사용한 명령어가 들어왔을때 발생하는 간단한 흐름이다.

Shell에서부터 보자면, fork가 두번 발생한다.

 

 

 

아래는 Shell에서부터가 아니라 pipe 기능만 하는 별도 프로그램의 소스코드이다.

(위 그림에서 내가 추가한 부분 말고 `pipe(p)`부터를 구현한 것)

#include <stdio.h>
#include <unistd.h>

#define oops(m,x) {perror(m); exit(x); }

int main(int ac, char **av)
{
	int thepipe[2];
    int newfd, pid;
    
    if (ac!=3) {
    	fprintf(stderr, "usage: ./pipe cmd1 cmd2\n");
        exit(1);
    }
    if (pipe(thepipe) == -1)  //pipe 생성
    	oops("Cannot get a pipe", 1);
    if ((pid = fork()) == -1)  //fork
    	oops("Cannot fork", 2);
    
    if (pid > 0) {  //parent process
    	close(thepipe[1]);  //안쓰는 write end 닫기
        if (dup2(thepipe[0], 0) == -1) //pipe의 read end로 redirect
        	oops("could not redirect stdin", 3);
        close(thepipe[0]);  //위에서 dup했으니 기존 fd는 닫기
        execlp(av[2], av[2], NULL);
        oops(av[2], 4);
    }
    
    //여기 아래론 child process
    close(thepipe[0]);
    if (dup2(thepipe[1], 1) == -1)
    	oops("could not redirect stdout", 4);
    close(thepipe[1]);
    execlp(av[1], av[1], NULL);
    oops(av[1], 5);
    
    return 0;
}

 


위에서도 말했지만

"pipe는 file이 아니다."

하지만 몇몇 특성은 비슷하기도한데, 다른 점도 있다. 추가로 자세하게 알아보자.

 

1. pipe에서 `read()`를 했는데 pipe가 비었다면 pipe에 내용이 적할때까지 read의 호출은 block된다.

2. 모든 writing end가 `close()`됐을때 `read()`를 한다면 0이 return된다.(==EOF가 나온다.)

3. process가 pipe로부터 bytes를 읽으면 그 data는 사라진다. 그러므로 reader가 하나가 아니라면 문제가 생길 수 있다.

4. pipe에 write를 할때 pipe에 남는 공간이 없다면 공간이 생길때까지 write의 호출은 block된다.

5. pipe에 write할때 minimun chunk size가 보장된단걸 이용하자. POSIX 표준은 kernel이 데이터 블럭을 512bytes 이하로 쪼개지 않도록 보장한다. Linux는 4096만큼 끊기지 않는 pipe buffer size를 보장한다. 따라서 두 processes가 같은 pipe에 최대 512 bytes로 write한다면 message들은 split되지 않을거라는게 보장된다.(Linux 4096은 먼상관인지 모르겠음)

6. 모든 reading end가 `close()`됐을때 `write()`를 한다면 -1 return하기도하고 signal 발생하기도하고 뭐 어쨌든 문제가 생긴다.(자세한건 p.344)