Search
Duplicate
🌠

다사다난했던 pipex 여정

간단소개
pipex를 진행한 과정을 의식의 흐름대로 정리해 보았습니다
팔만코딩경 컨트리뷰터
ContributorNotionAccount
주제 / 분류
42cursus
pipex
회고
Scrap
태그
pipex
9 more properties
이전까지는 과제를 진행하면서 레퍼런스를 참고하는 것을 마다하지 않았다. 과제를 해결하는 방법이 담긴 블로그와 가이드들은 답답한 속을 뻥 뚫어주었지만(고구마 백만 개 먹다가 사이다 원샷하는 기분..) 어느 순간부터는 내가 스스로할 수 있는 생각의 폭을 좁히는 데에도 한 몫한다는 걸 느꼈다.
그래서인지 pipex시간이 오래 걸리더라도 여기저기 박으면서 천 천 히 해보자 는 무언의 다짐과 함께 시작했다. 덕분에 기준 시간(50 hours)의 배가 되는 시간을 할애했고, ‘이거 이렇게 하는 거 아닌데’ 라는 말을 가장 많이 들은 과제로 등극하기도 했지만, 정말정말 의미있는 시간이었다고 생각한다. ^3^

pipex 시작하기

본격적으로 과제를 시작하기 전에 해당 과제에 필요한 개념들부터 공부하기 시작했다. 프로세스부터 시작해서 파이프와 리다이렉션 등등의 개념들을 접했고, 대략적으로 다음과 같은 정보를 습득할 수 있었다.
리눅스의 모든 것은 파일로 이루어져 있다. ls, cat... 이런 명령어들 조차도 모두 파일 형태로 관리된다.
프로세스는 실행 중인 프로그램을 말한다. 운영체제가 관리하는 작업의 단위라고 할 수 있다.
현재 프로세스 안에서 새로운 프로세스를 생성할 수 있다. fork 함수를 사용하면 되는데, 이때 현재 프로세스를 부모 프로세스, 생성된 프로세스를 자식 프로세스라고 부른다.
exec 계열 함수는 인자로 받은 프로그램을 기존 프로세스 대신 실행한다. 단 새로운 프로세스가 현재 위치에 덮어씌워져 현재 프로세스는 종료된다.
부모 프로세스에서 자식 프로세스가 종료될 때까지 기다리거나, 종료 상태를 얻어오고 싶다면 wait 또는 waitpid 함수를 사용할 수 있다.
리다이렉션(redirection)은 표준 입출력 스트림의 방향을 지정할 수 있다. dup2 함수로 구현한다.
파이프(pipe)는 서로 다른 프로세스 간에 데이터를 전달할 수 있게 해준다. pipe 함수로 구현한다.
우선 mandatory 부분에서는 < file1 cmd1 | cmd2 > file2 셸 명령과 똑같이 작동하는 프로그램을 만들어야 했다. 처음 봤을 때는 대체 무슨 명령인가 싶었는데, 파이프와 리다이렉션을 간단히 공부하고 나니 알 수 있었다.
< file1 cmd1 | cmd2 > file2 셸 명령은 file1에서 입력을 받아 cmd1 수행, 그 결과에 cmd2를 수행한 결과를 file2에 입력한다.
여기까지 이해했을 때 나는 임시 파일을 활용해서 mandatory를 해결할 수 있는 방법을 떠올렸다. cmd1의 실행 결과를 임시 파일에 저장하고, cmd2 실행 결과를 file2에 입력하는 방법이었는데, 무작정 구현하고 나서야 pipe 함수를 사용하지 않았다는 사실을 알고 아차! 싶었다. 프로세스 간 데이터를 전달하는 방법으로 정말 어처구니 없는 방법을 생각해내고 만 것이었는데..
왜냐면 일단 pipex 과제의 핵심 중 하나는 리눅스에서 파이프가 동작하는 방식을 배우는 것이기 때문이다. 게다가 이 방법은 두 개 그 이상의 커맨드를 처리하려면 임시 파일을 무한정 생성해야 했다. 훗날 알게 되었는데 임시 파일을 사용하면 < /dev/random cat | head > file2 이런 명령의 구현에서 시그널을 주고 받을 수도 없었다.
이제 임시 파일을 활용하는 방법을 깔끔히 기억에서 지우고 새로운 방법을 생각하기 위해 머리를 쥐어짜기 시작했다. 하지만 파이프는 프로세스 간 통신을 할 수 있게 해주는 것.. 리다이렉션은 표준 스트림의 방향을 바꿔주는 것.. 정도만 이론적으로 이해하고 있었던 터라 pipex 과제에 그 개념들을 어떻게 적용할지 생각해내기까지 굉장히 많은 시간을 썼던 것 같다. 기약없이 시간을 날려버리던 와중, 전체적인 감을 딱 잡게 만들어 준 내용이 있었다.
셸에서 파이프이전 명령의 표준 출력다음 명령의 표준 입력으로 보내주는 역할을 한다.
나의 pipex 여정은 이 포인트을 알기 전과 후로 나뉜다. 파이프의 개념적인 부분을 넘어서, 실제로 어떻게 작동하는지를 이해하려고 하니까 이후의 과정이 착착 진행되기 시작했다.

병렬 처리 그게 뭔데!

나는 명령어들이 병렬적으로 처리되어야 한다는 정보를 입수했다. 그러니까 이전 명령이 끝날 때까지 기다리지 않고 다음 명령들을 동시에 실행한다는 것이다. 이 때문에 sleep 3 | sleep 3 | sleep 3 은 9초가 아니라 3초를 기다리고, cat | ls 는 우선 ls의 결과를 먼저 출력하고 cat의 입력을 기다리게 된다.
안타깝게도 나의 코드는 9초를 기다렸고, cat 명령어의 입력이 끝나끼 전에는 ls의 결과를 출력하지 않았다. wait 함수를 활용해서 각 자식 프로세스들이 종료될 때까지 일일이 기다렸기 때문이었는데, 병렬적으로 처리되어야 한다는 걸 알고서부터 이 방법이 잘못됐다는 걸 인지했다.
사실 아무리 생각해봐도 병렬 처리라는 개념은 어색하게 다가왔다. grep이나 wc 명령 같은 경우 이전 명령의 출력을 받아서 실행되어야 할 텐데 어떻게 병렬 처리를 한다는 걸까? 아무래도 순차적으로 진행되는 것이 맞지, 동시에 진행되는 게 말이 안 된다고 생각했다.
고민에 고민을 거듭하다가 찾은 답은 슬랙에 있었다. 1시간 전 나와 유사한 의문을 가지고 계셨던 분의 따끈따끈한 질문글에서 답을 발견할 수 있었다!!
결국 병렬적으로 처리되어도 문제가 없는 이유는 입력을 필요로 하는 명령들은 입력이 들어올 때까지 기다리기 때문이다. 따라서 병렬적으로 처리한다고 하더라도, 필요한 경우에는 순차적으로 처리되게 되어있다.
나는 이제 코드에서 wait 함수를 모조리 없애버렸고, 이로 인해 또 다른 문제가 생겼다. 자식 프로세스들이 종료되지 않은 채로 부모 프로세스가 종료되어서 고아 프로세스가 발생하기 시작했던 것이다..

고아 프로세스 발생 막기

./pipex file1 “sleep 10” ls file2 같은 경우, 따로 처리를 해주지 않으면 고아 프로세스가 발생한다sleep이 끝나기 전에 pipex 프로그램이 종료되어 ps -al 명령어로 확인하면 고아 프로세스가 된 sleep이 계속 실행되고 있는 것을 볼 수 있었다.
그렇다면 각 자식 프로세스의 종료 상태를 체크해야 한다는 건데.. 나는 각 자식 프로세스들이 끝나는 부분에 죄다 waitpid 함수를 호출하는 무한반복문을 돌리는 방식으로 이 문제를 해결할 고민을 하고 있었는데 이것 때문에 또 많은 시간을 날렸다. 사실 해결 방법은 꽤나 간단했는데,
pipex 프로그램이 종료되기 전에 모든 자식 프로세스가 종료되었는지 확인하기만 하면 되었다.
이를 waitcount 세면서 기다리는 방식으로 구현했다.
void wait_processes() { int count; int wait_pid; int status; count = 0; while (count < 자식 프로세스 개수) { if (wait(&status) == -1) exit(EXIT_FAILUSE); count++; } }
C
복사
자식 프로세스의 id들을 보관해 뒀다가 waitpid 함수로 각 자식 프로세스들이 종료되었는지 하나씩 체크하는 방법도 있었는데, count 세면서 기다리는 방식이 가장 깔끔하고 편한 것 같다.

pipe 함수 제대로 이해하기

여기까지 했을 때 모든 것이 잘 돌아가는 듯 보였으나, /dev/urandom 처럼 아주 긴 파일을 다룰 때 또 다른 문제가 생겼다. 셸에서 < /dev/random cat 을 입력해 보면, 파일이 너무 긴 탓에 cat 명령이 끝나지 않는 걸 볼 수 있다. 하지만 < /dev/random cat | head 이런 식으로 head 명령과 함께 쓰면 열 번째 줄 까지만 출력되면서 종료되는 걸 볼 수 있는데, 이를 나의 pipex 프로그램에 적용해 봤을 때 head 명령이 있음에도 불구하고 프로그램이 끝나지 않았던 것이다!! 이 문제를 해결하기 위해 엄청난 해결책을 생각해 내야 한다고 생각했었는데, 사실 이것은 pipe 함수를 정확히 이해하지 못한 탓에 발생한 불상사였다.
pipex에서 파이프는 하나의 자식 프로세스에서 명령을 실행한 결과를, 다음 명령을 실행하는 자식 프로세스로 전달하기 위해 사용된다. pipe 함수는 2개의 숫자를 담을 수 있는 int 배열을 인자로 받아서 (한 쪽에서 쓴 데이터를 다른 쪽에서 바로 읽을 수 있는) 연결된 두 스트림을 생성한다.
int pipe(int fd[2]);
C
복사
이때 fd[0]은 다른 프로세스에서 전달 받을 데이터를 read하는 파이프 입구가 되고, fd[1]은 다른 프로세스로 전달할 데이터를 write하는 파이프 출구가 된다.
프로그램에 인자로 들어온 명령들은 표준 입력이 필요할 수도 있고, 표준 출력을 할 수도 있다. 둘 중 하나만 할 수도 있고, 하나도 안 할 수도 있다. 따라서 커맨드를 실행하기 전에 파이프의 쓰는 부분과 읽는 부분을 표준 입출력에 알맞게 연결해 줌으로써 프로세스 간 데이터를 전달하도록 구현할 수 있게 된다!
여기까지는 기본적인 내용이라 쉽게 파악할 수 있었지만, pipe 함수를 정확하게 활용하기 위해 꼭 알아야 하는 부분이 더 있었다. 바로 각 프로세스에서 사용하지 않는 파이프의 fd를 적절히 닫아주어야 한다는 점이다.
자식 프로세스에서는 데이터를 전달해야 하기 때문에 파이프의 쓰는 부분을 사용한다. 따라서 사용하지 않는 파이프의 읽는 쪽(fd[0])은 닫아주어야 한다. 그리고 표준 출력을 파이프의 쓰는 쪽인 fd[1]으로 리다이렉션해서 다음 자식 프로세스로 데이터를 전달한다.
close(fd[0]); // 파이프의 읽는 쪽 닫기 dup2(fd[1], STDOUT_FILENO); // 표준 출력을 파이프의 쓰는 쪽으로 연결
C
복사
부모 프로세스에서는 데이터를 전달 받기 때문에 파이프의 읽는 부분을 남겨두고, 사용하지 않는 파이프의 쓰는 쪽(fd[1])은 닫아준다. 그리고 다음에 생성되는 자식 프로세스에서 표준 입력을 파이프의 읽는 쪽인 fd[0]으로 리다이렉션해서 이전 자식 프로세스에서 전달한 데이터를 읽을 수 있게 해준다.
close(fd[1]); // 파이프의 쓰는 쪽 닫기 dup2(fd[0], STDIN_FILENO); // 표준 입력을 파이프의 읽는 쪽으로 연결
C
복사
참고로 다음 자식 프로세스에서 활용하기 위해 부모 프로세스에서 닫아주지 않은 파이프의 읽는 쪽(fd[0])은, 다음 자식 프로세스에서 활용한 후에는 부모 프로세스에서 다시 닫아주어야 했다. 이 내용까지 숙지했을 때부터는 드디어 < /dev/random cat | head 명령을 비롯한 모든 명령들이 pipex 프로그램에서 정상적으로 돌아가도록 구현할 수 있었다.

보너스까지 뿌수기

만다토리에서 정말 많이 헤맸던 지라, 보너스는 비교적 수월하게 마무리한 것 같다.
우선 다중 파이프를 처리하기 위해서 들어온 command들을 가지고 while문 돌면서 자식 프로세스 생성하고, 표준 입출력 정확하게 연결하고, 파이프를 정확하게 닫아주니까 정상적으로 작동했다.
표준 입출력 연결은 첫, 중간, 마지막 프로세스에 따라 다르게 설정해 주어야 했다.
첫 프로세스는 입력을 infile, 출력을 현재 파이프의 읽는 부분으로 연결
중간 프로세스는 입력을 이전 파이프의 쓰는 부분, 출력을 현재 파이프의 읽는 부분으로 연결
마지막 프로세스는 입력을 이전 파이프의 쓰는 부분, 출력을 outfile로 연결
파이프를 닫아주는 작업에서는 3가지만 신경썼다.
자식 프로세스현재 파이프의 읽는 부분을 닫기 (마지막 프로세스는 해당 X)
부모 프로세스현재 파이프의 쓰는 부분을 닫기 (마지막 프로세스는 해당 X)
부모 프로세스이전 파이프의 읽는 부분을 닫기 (첫 프로세스는 해당 X)
사실 이 내용은 조금만 생각하면 충분히 어렵지 않게 해결할 수 있는 부분인 것 같다. 나에게는 heredoc 처리가 더 어려웠는데, 입력 처리를 어떻게 해주어야 할지 감을 잡기까지가 오래 걸렸기 때문이다.
히어독은 << 기호 뒤에 구분 식별자가 오는 것이 기본적인 문법이다. 예를 들어 << END 라면 END 라는 텍스트가 입력될 때까지 표준 입력을 받는다. 나는 get_next_line을 활용해 표준 입력을 받고, 그 내용을 임시 파일에 저장하는 방법으로 진행했다.
임시 파일을 활용할 때 중요하게 생각하는 점은 읽어온 내용을 임시 파일에 모두 write해줬으면, 해당 임시 파일을 닫아준 후에 다시 열어서 사용해 주어야 한다는 것이다. 나는 이걸 모르고 임시 파일에 내용이 제대로 입력됐는데 왜 안 되는거야!!! 하며 한동안 또 시간을 날리고 말았지만, 좋은 경험이었다. 해결했을 때 행복이 배가 되는 정말 좋은 경험^^!
참고한 자료
잘못된 부분이 있다면 알려주세요