본문 바로가기
Programming Skills/Shell

[Unix] 파이프(pipe) 시스템 콜 - pipe() 없이 pipe 구현하기

by jungcow 2021. 5. 29.

1. man 2 pipe

  • Prototype: int pipe(int fildes[2]);
  • Description:
    • 단방향 데이터 흐름을 만들어주는 파이프를 생성한다.
      • 처음 fd는 read end에, 두번째 fd는 write end에 연결된다.
      • 즉, filedes[1]에 데이터를 쓰면 filedes[0]에 나타난다.
      • 이는 프로그램간의 데이터 통신을 구성할 수 있게 해준다.
      • 이와 같이 STDOUT과 STDIN은 하나의 pipe로 연결되어 있다는 것을 알 수 있다.
      One of the most significant consequences of pipes in Unix is that Unix programs, whenever possible, are designed to read from standard input (stdin) and print to standard output (stdout).
  • Return values:
    • 성공: 0을 반환한다.
    • 실패: -1을 반환한다. (errno 전역변수에 어떤 에러인지 알려주도록 설정해준다.)

2. pipe() 사용해보기

2-1. 부모와 자식 프로세스간의 통신 구성하기

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

#define BUFFER_SIZE 256

int        main(int argc, char *argv[])
{
  pid_t        pid;
  char        buf[BUFFER_SIZE];
  int            fd[2];
  int            len;

  if (pipe(fd) < 0)
    perror("pipe error");
  pid = fork();
  if (pid < 0)
    perror("fork error");
  if (pid == 0)
  {
      close(fd[0]);
    write(fd[1], "Hello, I'm child\n", strlen("Hello, I'm child\n"));
  }
  else if (pid > 0)
  {
    close(fd[1]);
    len = read(fd[0], buf, BUFFER_SIZE);
    write(1, buf, len);
  }
  return (0);
}

순서를 살펴보자

  1. pipe()를 통해 fd[0]과 fd[1]에 pipe를 연결시켰다.
    • 여기서 fd[0]과 fd[1]은 사용하지 않는 fd중 가장 작은 값을 차례로 할당 받게 된다.
  2. fork()를 통해 부모프로세스를 복사해 자식프로세스를 구성하였다.
    • 여기서 주목해야 할 점은 fd[0]과 fd[1]이 pipe에 연결된 것도 모두 복사가 된 것이다.
    • 즉, 자식 프로세스의 fd[0]와 fd[1]도 각각 pipe의 read end, write end에 연결되어 있게 된다.
  3. 자식 프로세스의 fd[0]은 필요없으니 닫고 fd[1]에 쓴다.
  4. 부모 프로세스의 fd[1]은 필요 없으니 닫고 fd[0]으로 읽는다.
    • 주의 : 여기서 닫는 이유는 두 프로세스가 동시에 같은 end(read end 나 write end)에 접근하려 하면 데이터가 손상될 수 있기 때문이다.

2-2. 자식과 자식 프로세스간의 통신 구성하기

  • 여러 명령어들이 있고 이를 파이프로 연결하기 위해선 자식 프로세스간의 통신이 필요하다.
  • 이를 위해, 병렬처리를 위한 wait 또는 waitpid와, 같은 stream에 접근해서 데이터를 읽고 쓸 수 있게 하기 위한 dup 또는 dup2를 사용해야 한다.
  • 간단히 코드를 살펴보기 전에 주의해서 볼 점은 다음과 같다.
    1. 파이프로 연결된 각 명령어들은 동시에 실행된다.(bash 기준)
      • 이전 명령을 기다리지 않는다.
      • waitpid로 구현해보자.
    2. execve와 같이 STDOUT으로 실행 결과를 출력하는 시스템 콜을 사용할 경우, n 번째 명령어가 n-1번째 명령어의 출력에 접근할 수 있는 방법은 아래와 같다.
      • STDOUT에 해당하는 fd를 n-1번째 자식 프로세스와 연결된 pipe의 write end에 연결시킨다.(dup2 이용)
      • 그리고 n-1번째 자식 프로세스와 연결된 pipe의 read end, 와 write end를 모두 닫는다.
        • n-1번째의 부모 프로세스는 아직 pipe와 연결된 상태이다.
        • n-1번째 자식에서의 실행 결과는 n-1번째 pipe의 write end에 들어갔고 이를 가져오기 위해선 n-1번째 pipe의 read end에 접근해야 한다.
      • 이 상태에서 n번째 자식 프로세스를 만든다.
        • 이 때 n-1번째 pipe는 다시 n번째 자식 프로세스 기준 연결되어 있게 된다.(부모 프로세스의 fd와 연결된 pipe모두 복제되기 때문)
        • 이제 STDIN 스트림을 n-1번째 read end에 연결시킨다.
        • 그 다음 부모 프로세스에서도 n-1번째 pipe에 연결된 fd를 모두 닫고, n번째 자식 프로세스에서도 n-1번째 pipe에 연결된 fd를 모두 닫는다.
      • execve는 실행할 때 해당 명령어가 STDIN에서 입력을 받는다면, STDIN의 입력을 받아올 것이다.
      • 이전 단계에서 n번째 자식 프로세스에서 STDIN을 이전 자식 프로세스의 read end와 연결시켰기 때문에 STDIN을 읽어오는 행위는 이전 pipe의 read end를 읽어오는 동작과 동일하게 되었기 때문에 이전 자식 프로세스와 현재 자식 프로세스간의 통신이 완료될 수 있게 된다.
    3. 예제를 봐보자.
void    close_fds(int *fds)
{
    close(fds[0]);
    close(fds[1]);
}

int        treat_pipeline(t_execute *execute, int *new_fd, int *old_fd, int idx)
{
    if (idx + 1 < execute->num)
    {
        if (dup2(new_fd[WRITE], STDOUT_FILENO) < 0)
            return (-1);
        close_fds(new_fd);
    }
    if (idx > 0)
    {
        if (dup2(old_fd[READ], STDIN_FILENO) < 0)
            return (-1);
        close_fds(old_fd);
    }
    return (1);
}

int        execute_child(t_execute *execute, char *envp[], int (*fd)[2], int idx)
{
    char    ***command;

    command = execute->command;
    if (treat_pipeline(execute, fd[NEW], fd[OLD], idx) < 0)
        exit(1);
    if (execve(command_path, command[idx], NULL) < 0)
        exit(1);
    exit(0);
}

int        ft_execute(t_execute *execute, char *envp[])
{
    pid_t    pid;
    int        i;
    int        fd[2][2];
    int        status;

    i = -1;
    while (++i < execute->num)
    {
        if (pipe(fd[NEW]) < 0)
            exit(EXIT_FAILURE);
        pid = fork();
        if (pid < 0)
            exit(EXIT_FAILURE);
        else if (pid == 0)
            execute_child(execute, envp, fd, i);
        else if (pid > 0 && i > 0)
        {
            waitpid(pid, &status, 0);
            close_fds(fd[OLD]);
        }
        fd[OLD][0] = fd[NEW][0];
        fd[OLD][1] = fd[NEW][1];
    }
    close_fds(fd[OLD]);
    return (1);
}

순서대로 살펴보자.

1.  명령어마다 루프를 돌리면서 pipe를 생성한 후 fork로 자식프로세스를 만들고 있다.
2.  자식 프로세스에선 우선 pipe의 fd를 다루고 있다.
    -   현재 pipe의 write end를 STDOUT과 연결시킨다.
    -   이전 pipe의 read end를 STDIN과 연결시킨다.
    -   이전 pipe와 연결된 fd는 자식과 부모 프로세스 모두에서 닫는다.
    -   현재 pipe와 연결된 fd는 자식 프로세스에서만 닫는다.
    -   이의 결과로 execve는 입력을 이전 pipe의 read end에서 받아오고 결과는 현재 pipe의 write end에 출력하게 된다.
3.  부모 프로세스에선 waitpid로 자식 프로세스가 정상 종료 될 때까지 기다린다.
4.  또한 위에서 언급했듯이 이전 pipe와 연결된 fd들을 모두 닫는다.
5.  그리고 현재 pipe에 연결된 fd들을 모두 이전(old) 로 옮겨넣는다.
6.  그 다음 명령어에서 new에 pipe()로 fd들을 할당한다.
7.  위의 내용을 반복한다.

헷갈렸던 점

  • 부모 프로세스와는 다른 fd조작이 필요했다.
  • fork를 하면 pipe에 연결된 fd까지 모두 복사된다는 점을 잊어선 안되었다.
    • 이전 명령어에서 pipe에 연결된 fd들을 모두 닫는다 해도 부모 프로세스에선 계속 연결되어있기 때문에 다음 명령어에서 fork를 할 때 그 자식 프로세스는 이전 pipe에 연결되어 있다.
  • 이외에도 몇가지 더 있었던 것으로 기억하는데 정확히 기억은 잘 나지 않는다..

그럼 pipe없이 pipe를 구현할 수 있나?

결론을 말하자면 pipe를 흉내낼 수 있을 것 같다.

pipe는 시스템 콜로써, 사용 가능한 read end전용 fd와 write end 전용 fd를 할당해주는데 이를 하나의 file로 구현해보자.

int        ft_pipe(int *fd, int i)
{
    char    *index;
    char    *path;

    index = ft_itoa(i);
    path = ft_strjoin("./pipe/", index);
    free(index);
    if (path == NULL)
          exit(1);
    fd[0] = open(path, O_CREAT | O_RDONLY, 0444);
    fd[1] = open(path, O_CREAT | O_WRONLY, 0222);
    if (fd[0] < 0 || fd[1] < 0)
    {
        free(path);
        return (-1);
    }
    free(path);
    return (1);
}
  • pipe의 내부 기능을 알았으니, 이를 open을 이용해서 구현해보면 위와 같다.
  • 한 파일을 read 전용과 write 전용으로 열어서 그 fd를 할당해 주었다.

결론

파일을 이용해서 pipe()를 구현할 수 있었다.

※ 수정

  • 파일을 이용해 pipe()를 구현할 수 없다!
  • 이유를 살펴보자.
  • pipe()는 시스템 콜로써 사용하지 않는 fd 두개를 작은 순서로 각각 read endwrite end에 할당한다. 즉, 하나의 pipe에 두개에 읽기전용과 쓰기 전용 fd가 생기는 것이다.
  • 여기서 file로 구현한 것과의 차이점이 별반 없다고 느낀다면 아주 정상이다. 그렇다면 뭐가 다른 것일까?
  • 파일에서 read를 open하자마자 close한 후 write에 써보자.
  • 또한 pipe()에서도 똑같이 read end를 close한 후 write end에 써보자.
  • 여기서 어떤 것에 차이가 있는지 알 수 있다. 아래를 계속해서 봐보자

SIGPIPE

  • file 이용
  • #include <unistd.h> #include <fcntl.h> int main(void) { int fd1 = 0; int fd2 = 1; char buf[10]; fd1 = open("test.txt", O_CREAT | O_RDONLY, 0644); fd2 = open("test.txt", O_CREAT | O_WRONLY, 0644); close(fd1); write(fd2, "helloworld", 10); close(fd2); return (0); }
  • pipe() 사용
#include <unistd.h>
int main(void)
{
    int fd[2];

    pipe(fd);
    close(fd[0]);
    write(fd[1], "helloworld", 10);
    close(fd[1]);
    return (0);
}

결과

  • file 이용
    ~ ······························································· 11:11:59  
❯ gcc test.c -o file

    ~ ······························································· 11:12:10  
❯ ./file

    ~ ······························································· 11:12:12  
❯ echo $?
0

    ~ ······························································· 11:12:14  
❯ cat test.txt
helloworld%
  • pipe() 사용
    ~ ······························································· 11:12:18  
❯ gcc test.c -o pipe

    ~ ······························································· 11:12:40  
❯ ./pipe

    ~ ······················································ PIPE ✘  11:12:42  
❯ echo $?
141
  • 위 결과를 보면 차이점은 다음과 같다.
    1. 파이프 시스템콜은 read end가 닫혔을 때, broken pipe가 되면서 SIGPIPE라는 시그널이 발생하여 127 + 14 = 141의 exit status가 설정된다. 하지만 파일로 구현한 것은 깨지는 것에 대한 개념이 없다. 즉, 파일의 readonly와 writeonly로 연 fd들은 연결되어있지 않은 독립적인 fd이다.
    2. cat | ls 와 같은 명령어를 입력했을 때, cat에 연결된 pipe는 ls가 종료되자마자 broken pipe가 되어 SIGPIPE 시그널이 발생해야 bash와 같이 동작을 하게 되는데, 파일 같은 경우엔 위와 같은 이유로 SIGPIPE같은 시그널을 받을 수 있는 조건(fd가 연결되어 있어야 함)이 안되기 때문에 bash와는 다르게 무한정 입력을 받게 된다.
  • 정리하자면 다음과 같다.
    • 파일로 구현한 것은 fd가 각각 서로에게 영향을 안주는 fd이기 때문에 pipe와는 구조적으로 완전히 다르게 된다.
    • 이에 따라 broken pipe로 동작해야할 경우에 파일로 구현한 것은 이를 인지하지도 못한다.
    • 또 이 이유로 SIGPIPE와 같은 시그널도 발생할 수 없게 된다.
    • 즉, 파일로 구현한 파이프는 pipe() 시스템콜과 전혀 다르다.

헷갈렸던 점

처음엔 한 fd가지고 read한 직후에 write를 해보았었다.

char    buf[256] = "hello world";

write(fd, buf, 256);
read(fd, buf, 256);
write(1, buf, 256)

하지만 이런식의 동작은 가능하지 않는데, 이유는 다음과 같다.

  • offset이 달라진다.
    • write한 직후에 fd의 offset은 write한 지점까지로 된다.
    • 이후 read로 fd를 읽어오려 하니 offset은 이미 읽어오려는 지점을 지난 상태라 읽어올 수 없다.
    file descriptor 구조
  • 파일 디스크립터와 열려 있는 파일의 관계open file description은 현재 파일의 offset, flag, 접근 모드, i/o 관련 설정, 파일의 i-node 객체를 가리키는 레퍼런스를 갖고 있다.만약 같은 open file description을 가리키는 2개의 fd는 offset값을 공유 한다.
  • 출처: https://dev-ahn.tistory.com/96 [Developer Ahn]
  • i-node는 파일 종류 (일반파일, 소켓, fifo)와 권한, lock 목록 포인터, 여러 파일 오퍼레이션과 다양한 파일 속성(크기, 타임스탬프등)을 갖고 있다.
  • 각 프로세스별로 커널은 open file descriptor table 을 갖고 있다. 테이블의 각 엔트리는 하나의 파일 디스크립터에 대한 동작 제어 플래그, 열린 파일을 가리키는 참조를 담고 있다.
  • file descriptor와 fd table에 관해 더 자세히 알아보려면 여기 에 들어가보자.
  • 따라서 offset을 공유하지 않게끔 fd를 read전용과 write전용 두개의 fd를 생성해주어야 pipe의 기능을 할 수 있게 된다.

느낀 점

pipe() 시스템 콜을 사용하여 구현할 땐 자식 프로세스간의 통신에 이해를 하는데에 집중이 되었다면, pipe 없이 구현하는 것은 fd들의 관리 시스템을 더 잘 이해할 수 있었던 것 같다. table과 오프셋 개념도 알게 되었고 위 사진에서처럼 세 단계로 관리되고 있음을 알게 되었다. 다음엔 fd를 이용한 redirection까지 구현해보도록 하자.

'Programming Skills > Shell' 카테고리의 다른 글

[Shell] 리다이렉션(Redirection)  (0) 2021.05.29
[Shell] test([) 명령어  (0) 2021.05.29