[minishell] 5. 파이프(Pipe) 처리

이대현·2021년 2월 12일
3

42SEOUL

목록 보기
21/27

이 글에서 등장하는 모든 STDIN, STDOUT, STDERR은 파일디스크럽터 0, 1, 2를 조금 더 직관적으로 이해하기 쉽게 사용하려고 제 미니쉘 코드에 define 해 놓은 매크로 입니다. 보통은 STDIN_FILENO 혹은 0으로 사용합니다.

1. dup2() 함수란?

# include <unistd.h>
int dup2( int fd, int fd2 ); 

파일 식별자를 복제해 fd2를 fd1으로 바꾼다.

예를 들어 int dup2(fd, stdout); 와 같이 사용하면, 모든 출력이 fd로 향하게 된다. 즉, dup2 함수를 이용하면 부모프로세스가 자식프로세스에게 표준입력으로 문자열을 주는 프로그램을 만들 수 있게된다.

파이프의 기본 원리이다.

2. 파이프(Pipe) 란?

파이프를 이해하는데 이 글이 매우매우 도움이 되었습니다. 꼭 참고해보세요!

유닉스(Unix)는 단순하지만 매우 가치있는 디자인 철학을 갖고 있는데, 유닉스 파이프(Pipe)의 창시자인 Doug McIlroy는 다음과 같이 말했다.

“한 가지 일만 아주 잘하는 프로그램들을 작성하라. 프로그램들이 다른 프로그램들과 함께 일할 수 있도록 작성하라. 프로그램들이 텍스트 스트림을 처리할 수 있도록 작성하라. 왜냐하면 그것은 보편적인 인터페이스이기 때문이다.”

  • 위에서 dup2 함수를 이해한 것 처럼, 파이프의 창시자 매클로이가 말한 것처럼, 각 명령들이 연결되어 데이터를 처리할 수 있도록 하고싶다. 그리고 그 명령들은 독자적인 프로세스(독립적인 메모리 공간) 안에서 실행된다.
  • 그러므로 각 프로세스들이 서로 통신하기 위한 방법이 필요한데, 그 방법이 바로 pipe(fd[2]) 시스템 호출이다.
  • 파이프는 데이터가 한 프로세스에서 다른 프로세스로 전달되도록 한다.
    • 이렇게 연결된 명령 스트림을 파이프라인(pipeline) 이라고 부른다.
    • 한 파이프라인 안에 있는 명령들이 파이프에 의해 연결되어 있는 것이다.
  • 파이프의 양쪽 끝(입구와 출구)은 2개의 파일 디스크럽터(fd)와 연결되어 있다.
    • 하나는 데이터를 읽기 위한 것이고, 다른 하나는 데이터에 쓰기 위한 것이다.
    • fd 0, 1, 2는 STDIN, STDOUT, STRERR로 이미 정의되어 있으니 주로 fd 3, 4 부터 이용한다.

3. 그림을 통한 파이프 예제

  1. 사용자 입력(STDIN, 0)을 알파벳 순서대로 정렬하는 sort 명령이 실행된다.
  2. 출력될 데이터(STDOUT, 1)를 dup2를 통해 터미널이 아니라 파이프의 왼쪽 끝(fd 4)으로 연결한다.
  3. sort 프로세스로부터 파이프로 전달된 데이터는 다시 파이프의 오른쪽 끝(fd 3)으로부터 gerp 프로세스로 전달된다.
  4. 즉, pipe(fd[2]) 호출은 fd 배열 {3, 4}를 채우게되고, 따라서 fd 4쓰여진(written) 데이터가 fd 3으로부터 읽히도록(read) 만든다.
위 그림의 예제에서 파이프는 왼쪽에서 오른쪽으로 데이터를 전달하는 모델인데, 왜 fd 4가 왼쪽으로 가고 fd 3이 오른쪽으로 갈까?
  • pipe() 호출에 의해 설정되는 읽기(read), 쓰기(write) 액션은 파이프를 사용하는 양쪽의 2개의 프로세스들의 관점에서 정의된다.
  • fd의 숫자가 뭔지는 중요하지 않고, fd[2] 배열에서 각 fd[0, 1] 배열의 용도를 정의해주는 것이 프로세스에게 있어 매우 중요하다.
  • 요약하자면
    1. 파이프는 당방향 통신이기 떄문에 통신 방향을 정해줘야 한다.
    2. 우리 미니쉘에서는 부모에서 쓴다음 자식에서 읽어주면 된다.
    3. 그리고 사용하는 fd를 dup로 연결해주고, 사용하지 않는 fd는 닫아주면 된다.
    4. 즉, 부모에서 쓰고 자식에서 읽으려면, 부모에서는 fd[0], 자식에서는 fd[1] 을 닫아줘야 한다.
  • 그리고 사용할 fd, 즉 파이프를 연결할 때는 이것만 외우면 된다.
    • fd[1] 에 쓰고
    • fd[0] 으로 읽는다

4. 파이프가 여러개 연결되어 있는 경우

A | B | C | D

위와 같은 파이프라인이 있다고 생각했을 때, 처음에 내가 파이프를 이해 느낌은 "A의 표준출력이 파이프를 쭉 타고가서 D가 표준입력으로 받아서 터미널에 출력한다." 였다.

그런데 아니었다.

왜냐면 저러면 B, C 명령어는 실행되지 못하니까...

그냥 파이프는 무조건 앞|뒤 에서만 끝난다고 이해하는게 맞는 것 같다. "A | B 에서 끝나고, B의 명령어를 실행해서 표준출력이 생겼는데 만약 뒤에 또 파이프가 있다면 C의 표준입력으로 넘겨준다." 이게 파이프에 대한 더 정확한 표현이다.

5. 코드를 통한 파이프 예제

아래 그림은 6.3 의 예제를 간략화한 버전이다. pipe()를 호출하고 fork()와 dup2()를 통해 아래 그림의 명령을 실행해보자. 파이프를 사용해 부모 프로세스로부터 자식 프로세스로 데이터가 전달되는 경우를 배울 수 있다.

다시 상기하고 넘어가기.

fd[1] 에 쓰고 fd[0] 으로 읽는다.

#include <unistd.h>
#include <sys/wait.h>
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[]) {
  int fds[2];                      // 2개의 fd를 담을 배열을 정의한다.
  pipe(fds);                       // pipe를 호출해 두 개의 fd로 배열을 채워준다.
  pid_t pid = fork();              // 부모 프로세스의 fd와 메모리를 복제한 자식 프로세스 생성한다.

  if (pid == 0) {                  // if pid == 0, 자식 프로세스
    dup2(fds[0], STDIN_FILENO);    // fds[0]으로 표준입력을 넘겨준다.
    close(fds[0]);                 // fds[0]은 자식 프로세스에서 더이상 필요하지 않기 떄문에 닫아준다. 복사본이기 때문에(?)
    close(fds[1]);                 // 원래부터 필요없었던 fd라 닫아준다.
    char *cmd[] = {(char *)"sort", NULL};   // sort 명령어 인자를 만들어준다.
    if (execvp(cmd[0], cmd) < 0) 
      exit(0);  // sort 명령어 실행하고 문제있으면 exit
  } 

  // 부모 프로세스 코드 시작
  close(fds[0]);                 // 쓰기만 하면되는 부모 프로세스에서는 필요 없는 fd라 닫아준다. 
  const char *words[] = {"pear", "peach", "apple"}; // 자식 프로세스에서 읽을 write input 

  for (int i = 0; i < 3; i++) {
    dprintf(fds[1], "%s\n", words[i]); // fds[1]에 출력을 쓴다.
  }
  close(fds[1]); 

  int status;
  pid_t wpid = waitpid(pid, &status, 0); // 자식 프로세스가 종료될때까지 기다린다.
  return (wpid == pid && WIFEXITED(status) ? WEXITSTATUS(status) : -1);
}

fds[0, 1] 배열의 용도를 정의하는게 핵심이라고 6.3에서 정리했다. fds[0]는 파이프가 자식 프로세스에서 입력을 읽는데 사용하고, fds[1]은 부모 프로세스로부터 출력을 쓰는데 사용된다.

5.1. 자식 프로세스

  • dup2() 함수를 호출하여 자신의 stdin자신의 read( 자식프로세스 왼쪽 끝)와 연결시킨다.
    • 자식 프로세스의 stdin은 더 이상 키보드가 아니라 fds[0]에서 데이터를 입력받게 된다.
  • 이제 자식 프로세스의 stdin은 데이터를 읽어들일 준비가 되었으며, pipe() 호출에 의해 생성된 fd는 이제 더 이상 필요 없으므로 fd를 닫는다.
  • 이제 자식 프로세스는 execvp() 함수를 호출하여 sort 명령을 실행하고, 부모 프로세스의 모든 데이터가 파이프의 read(파이프의 왼쪽 끝)로 쓰여질 때가지 대기한다.
  • 부모 프로세스로부터 모든 데이터가 파이프의 write(파이프의 오른쪽 끝)를 통해 전달된다.
  • sort의 결과를 STDOUT을 통해 터미널로 출력한다.
    • execvp() 호출 후 0, 1, 2 디폴트 프로세스는 자동으로 닫힌다.

5.2. 부모 프로세스

  • fork 호출 후, 부모 프로세스는 데이터를 쓰기만 할 것이기 때문에 fds[0]을 닫는다.
  • 자식 프로세스에게 전달할 데이터를 파이프의 write(파이프의 오른쪽 끝)인 fds[1]에 쓴다.
  • 모든 단어들을 전송한 후에 fds[1]을 닫는다.
  • 자식 프로세스는 sort 명령을 수행한다.
  • 그 후에 waitpid 함수를 호출해 자식 프로세스가 종료될때까지 기다린다.
  • 자식 프로세스가 종료되면 부모 프로세스도 종료된다.

6. minishell 에서의 Pipe 구현

6.1. 프로그램 명령어와 파이프 실행 함수 합치기

코드를 보고 힌트를 얻었다. 프로그램 명령어(bin/ls 등)와 파이프 둘다 fork를 사용해 자식프로세스를 만들어야 하는 함수니까 합쳐도 되지 않을까?

그런데 만들고 보니 함수가 길어지고, 함수는 기능별로 짜는게 좋다는 얘기를 하도 들어서 이 방법이 좋다고 추천할 수는 없을 것 같다. 다만 새로운 시도를 하면서, 남들이 안하는 방식으로 직접 코드를 구현해보면서 정말 많이 배운 것 같다. kycho님이 많이 도와주셨다. 그리고 아예 파이프에 대한 개념, 예제 코드 공부를 저 microshell 코드를 보면서 하는게 좋은 건 확실할 듯!

다만 우리 코드로는 앞 노드의 flag에 접근할 수 없어서, 파이프 플래그가 있으면 fd를 현재 노드에서 열어주는게 아니라 pipe(next_cmd->fds) 와 같은 식으로 다음 노드에서 파이프를 열고, 입력과 출력을 뒷 노드를 기준으로 연결하는 방식으로 구현했다.

좀비 프로세스 조심
  • 파이프를 내가 이해한 방식대로 구현해보고 싶어서 정말 많은 시간을 썼다.
  • 경현형에게 도움도 많이 받고.. 그래도 여전히 에러가 났고,
  • 어느 부분을 손대야 할지 모르겠을 정도로 뇌가 마비된 상태였다.
  • 하나만 문제일 수도 있고, 그 하나때문에 여러개가 문제일 수도 있고, 아예 내 코드의 모든 부분이 시작부터 잘못됐을 수도 있다고 생각하니까 어떻게 고쳐야 할지 너무 힘들었다.
  • 결국 새로 코드를 짜다가, 제일 처음에 불현듯 떠올랐다가 이건 아니겠지 하고 넘어갔던 부분을 마지막으로 수정해보자 했던게 결국 해결책이었다. 그것도 가장 간단한.
  • 코드를 구조적으로, 메모하면서 짜는 습관이 필요할 것 같다는 생각을 했다. 혹은 그림을 그리던가, 어느 프로세스에서 생길 수 있는 문제들을 메모해두고 하나씩 체크리스트 처럼 해결해 나가야겠다.
  • 안 그러면 지금처럼 간단한 문제에 너무 많은 시간을 쓰게된다.

6.2. exit와 export, unset 를 파이프(자식 프로세스)에서 실행할 때

bash-3.2$ ls | exit
bash-3.2$

위 경우 bash 쉘이 꺼지면 안된다. 미니쉘도 마찬가지.

자식 프로세스는 부모 프로세스의 값에 영향을 미칠 수 없기 때문이다. 파이프가 나오면 앞 프로세스의 파이프 플래그를 설정해줬기 때문에 exit도 파이프 플래그 값을 가지던지, 혹은 동작하지 않도록 수정해야 했다. 후자가 더 편할 것 같아서 preflag라는 구조체 멤버를 추가했고 ft_exit 에서 preflag가 파이프면 return ; 을 해서 exit가 동작하지 않도록 했다. export와 unset도 마찬가지.

profile
삽질의 기록들 👨‍💻

0개의 댓글