[pipex] 셸 파이프 개념

JH Bang·2022년 6월 29일
0

42 Seoul

목록 보기
4/9
post-thumbnail

Pipex과제를 보너스까지 진행했으나 아쉽게도 heredoc 부분을 제대로 완료하지 못해 점수가 깎였다. 이 부분은 minishell에서 다듬어보도록 하겠다.

기본 개념

프로세스

우선 파이프엑스 과제를 하기 위해선 프로세스의 개념을 알아야 한다.
프로세스란 프로그램이 실행되는 상태를 말한다. 프로그램이 실행된다는 것은 RAM에 프로그램 코드가 적재돼 동작한다는 것이다.
프로세스'는 운영체제로부터 시스템 자원을 할당 받는 단위 '스레드'는 프로그램 실행 단위다.
프로세스는 운영체제로부터 메모리, 주소공간 등을 할당 받고 스레드는 할당 받은 자원들을 스레드끼리 공유하며 실행된다.

IPC

파이프는 두 프로세스가 통신할 수 있게 하는 정보 전달자다. 정보 생산자와 정보 소비자 프로세스 간 통신을 의미한다.
IPC 방법에는 공유 메모리, 소켓, 파이프 등이 있다.

과제에서 진행하는 파이프에서는 파이프의 양끝단에서 파일 디스크립터를 사용하게 되는데 이는 파이프의 데이터 스트림(프로세스를 출입하는 데이터의 흐름) 번호이다. 프로세스의 출력을 리다이렉션 한다는 것은 데이터를 보내는 곳을 변경한다는 것이다.
프로세스는 fd와 해당 데이터 스트림을 디스크립터 테이블에 저장해 처리한다.

0~2 (표준 입출력)
0 : 표준입력
1 : 표준출력
2 : 표준에러
3 : 추가적인 프로세스의 데이터 스트림

**파일디스크립터가 파일을 의미하는 것은 아님

예를들어 printf()의 경우 표준출력으로 데이터를 내보내는데, 이는 fd = 1인 경우로 디스크립터 테이블에서 fd = 1인 경우의 데이터 스트림을 찾아 데이터를 해당 스트림에 출력한다.

참고) 시그널
프로세스는 OS가 시그널로 관리하는데, 시그널은 보통 프로세스를 종료하기 위해 사용된다.
프로세스가 시그널을 받으면, 프로세스는 시그널 처리기를 실행한다. 시그널 처리기의 기본 동작은 프로세스를 종료하는 것이다. 다만 sigaction()함수를 사용하면 처리기 동작을 변경할 수 있다.
raise() 함수를 사용하면 프로세스 자신에게 시그널을 보내는 것도 가능하다.
또 모든 프로세스는 타이머를 하나씩 갖고 있는데, alram() 또는 sleep()함수중 하나(두개 적용 X)를 적용할 수 있다.

필요 함수 정리

🔎 exec()

 #include <unistd.h>
extern char **environ;
int execl(const char *pathname, const char *arg, ... /*, (char *) NULL */);
int execlp(const char *file, const char *arg, ... /*, (char *) NULL */);
int execle(const char *pathname, const char *arg, ... /*, (char *) NULL, char *const envp[] */);
int execv(const char *pathname, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);

system()함수는 OS가 명령 문자열을 보고 프로그램을 실행하는 함수인데 보안이 허술해 악의적인 사용자가 명령 문자열을 악의적으로 조작이 가능했고, 그리하여 나온 것이 exec()계열 함수다.
exec()는 현재 실행중인 프로세스를 종료하고 해당 프로세스를 대체하여 실행한다. unistd.h에 선언돼 있다. exec() 계열 함수를 호출하면 새 프로세스(외부 프로그램이)가 실행되고 해당 프로세스는 종료된다.

exec 는 대동소이 하지만 사용법은 조금씩 다르다.
l : argv를 list로 나열. 끝 인자는 NULL을 넣어줘야 함.
v : argv를 vector(포인터)로 받는다는 뜻. 마지막 인자값은 NULL
p : 프로그램 디렉토리를 PATH 환경변수에서 참조하여 파일을 실행.
e : 환경변수를 인자로 받아 변경할 수 있음.

주의할점은 execve만 시스템 콜 함수라는 것이다.

#include <unistd.h>
int execve(const char *pathname, char *const argv[], char *const envp[]);

🔎 pipe()

#include <unistd.h>
int pipe(int pipefd[2]);

부모 프로세스가 자식 프로세스를 생성할 때 부모 프로세스는 자식 프로세스에 데이터를 줘야 한다. 따라서 pipe()함수로 데이터를 주게 되는데, pipe() 함수는 데이터 스트림 두 개를 만든다.
fd[0]은 read 부분, fd[1]은 write 부분인데, 파이프가 기준이 아니라 프로세스 기준임.
다만 파이프는 한 방향으로만 작동하므로 파이프를 두개 생성해야 부모 자식 프로세스가 데이터를 주고받는 것이 가능하다.

만약 자식 프로세스의 데이터를 부모 프로세스로 넘긴다고 하면
자식 프로세스에서 close(fd[0]); 를 해준다.
자식 프로세스는 write만 하고 read는 하지 않기 때문이다.
한 쪽을 닫는 이유는 한쪽이 닫힌 파이프는 widowed pipe가 되는데,
이 때 SIGPIPE 시그널이 발생하게 된다.
그러면서 EOF도 부모 프로세스로 전달된다.

🔎 wait() / waitpid()

#include <sys/wait.h>
pid_t wait(int *wstatus);
pid_t waitpid(pid_t pid, int *wstatus, int options);

wait()함수는 OS가 자식프로세스가 완료될 때까지 부모 프로세스를 유지시켜 디먼 프로세스가 되는 것을 막고, 자식 프로세스의 종료를 담보해주는 역할을 한다.
동시에 자식 프로세스가 좀비 프로세스가 되는 것을 막기위해 사용한다.
자식프로세스가 종료돼도 부모프로세스가 진행중이면 자식프로세스는 반환값을 지닌채 좀비 프로세스상태로 대기하고 있다. 이 때 wait()함수로 자식프로세스의 반환값을 받아주면 자식프로세스는 데이터를 넘겨주고 프로세스를 종료하게 된다.

좀비 프로세스?
프로세스는 종료되었지만 메모리 상에서 프로세스 정보가 유지되고 있는 상태다.
자식 프로세스에가 종료시 반환하는 인자값을 부모 프로세스가 받을 때까지
자식 프로세스는 프로세스 자원을 할당받은 상태로 남아있게 된다.

waitpid() 함수도 wait() 함수와 비슷하지만, wait() 함수는 자식 프로세스 중 어느 하나라도 종료되면 프로세스 실행을 재개하는 반면, waitpid()는 특정 자식 프로세스가 종료될 때까지 부모 프로세스가 중단된다. 또 WNOHANG 옵션을 사용하면 부모 프로세스가 자식 프로세스가 종료되지 않아도 다른 작업을 진행하는 것이 가능해진다.
또 wait함수와 달리 원하는 자식프로세스의 종료여부를 확인할 수 있고, 옵션을 사용해 자식프로세스의 exit status도 알 수 있다.

🔎 access()

#include <unistd.h>
int access(const char *pathname, int mode);

파일의 접근권한 상태를 확인한다.
mode에는 R_OK(read), W_OK(write), X_OK(execute), F_OK(file)가 있다.

🔎 fork()

#include <unistd.h>
pid_t fork(void);

자식 프로세스를 생성하는 함수로 부모 프로세스 안 에서 실행되는 모든 프로세스를 복제하여 병렬적으로 실행한다.

🔎 dup() / dup2()

#include <unistd.h>
int dup(int oldfd);
int dup2(int oldfd, int newfd);

dup()함수는 fd table에서 특정 파일을 가리키는 fd를 다른 fd값도 가리키도록 한 뒤 해당 fd를 반환한다.
dup2()함수는 dup()함수와 같으나, fd값을 자동 할당해주는 dup()와 달리 원하는 fd값을 지정해 줄 수 있다.

✔︎ 참고로 fileno()함수를 통해 비어있는 fd를 찾아 fd를 할당해줄 수 있다.

🔎 strerror

#include <string.h>
char *strerror(int errnum);

strerror()함수는 errno에 해당하는 에러 메시지를 가리키는 char * 포인터를 반환한다.

🔎 perror()

#include <stdio.h>
void perror(const char *s);

출력 메시지 외에 추가적인 에러 메시지를 표준 에러 출력(2)한다. errno와 해당 에러 메시지를 같이 출력해주는 printf()함수라고 생각하면 된다.

#include <unistd.h>
int unlink(const char *pathname);

파일을 삭제하는 함수

헤더 요약

<unistd.h>
access()
unlink()
close()
read()
write()
pipe()
dup()
dup2()
execve()
fork()
<stdlib.h>
malloc()
free()
exit()
<sys/wait.h>
waitpid()
wait()
<stdio.h>
perror()
<string.h>
strerror()
<fcntl.h>
open()

간단한 코드로 개념 잡기

env 환경변수 쓰는 이유:
환경변수는 PATH를 사용하기 위해 쓰는데, 커맨드를 받았을 때 해당 커맨드의 경로를 찾기 위해 사용한다. PATH에 저장된 모든 경로에 대해 커맨드가 있는지 확인하고 없으면 에러메시지를 출력해주면 된다.

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

int	main(void)
{
	int	fd[2];
	if (pipe(fd) == -1)
		exit(1);

	int	pid1 = fork();
	if (pid1 < 0)
	{
		perror("error");
		exit(1);
	}
	/*child process*/
	if (pid1 == 0)
	{
		close(fd[0]);
		dup2(fd[1], 1); //STDOUT
		close(fd[1]);
		execlp("ls", "ls", NULL);
	}

	int pid2 = fork();
	if (pid2 < 0)
	{
		perror("error");
		exit(1);
	}

	if (pid2 == 0)
	{
		close(fd[1]);
		dup2(fd[0], 0); //STDIN
		close(fd[0]);
		execlp("sort", "sort", NULL);
	}
	/*main에 있는 것을 닫아줘야 함*/
	close(fd[0]);
	close(fd[1]);
	waitpid(pid1, NULL, 0);
	waitpid(pid2, NULL, 0);
	return (0);
}

여기까지 해봤다면, 이제 execve를 사용해서 pipe를 구현해보면 된다.

그런데 잠깐, 파이프 fd를 사용하려고 하는데 왜 닫아버리는 것일까?
pipe가 사용중인 fd의 경우 close를 하더라도 실제로 close되지는 않는다. (모든 한쪽의 fd가 닫혀야 EOF 발생하지만, 파이프 반대쪽에서 사용중이기 때문) 다만 사용하지 않는 fd를 close하는 것이 좋은 '습관'이라고 한다.
추가적으로 fd는 3가지 이유로 닫히게 되는데,
1) close()를 호출
2) 프로세스가 종료되면 OS가 자동으로 close
3) exec계열 함수가 실행될때
이런 이유로 exec을 사용할 때는 close하지 않아도 close가 되지만, close해놓는 것이 좋은 습관이다.

핵심은 파이프 개념

파이프는 단순하게 말하면 한 프로그램의 아웃풋을 다른 프로그램의 인풋으로 넘겨주는 프로그램을 말한다. 근데 이게 구현을 하다보면 머리가 어질어질 해지는 이유가 바로 fork()함수 때문이다.
fork()함수를 호출하면 현재까지의 프로세스를 다른 메모리에 복제하면서 조건문 if (pid == 0) 이 참이냐에 따라 '분기'가 일어난다.

파이프 개념이 헷갈리는 또 다른 이유는 pipe(int fd[2])의 fd[0]과 fd[1]이 반대로 할당된다는 데 있다. 그러면서도 자식 프로세스에서 close()를 이용해 파이프의 특정 fd값을 닫아도 부모 프로세스에서는 계속 유지돼 있다(...)

쉽게 생각하면 프로그램 관점에서만 생각하면 된다. 프로그램은 STDIN에서 입력을 받고 STDOUT으로 출력한다고 '생각'한다. 그러나 dup계열 시스템콜을 통해 프로그램의 입출력을 '속인다'고 생각하면 이해가 좀더 편해진다. 즉, 프로그램 입장에서는 STDIN에서 입력을 받는줄 알았는데 실제로는 fd[0]에서 입력을 받고 있는 것이었고, STDOUT로 출력을 하는 줄 알았는데 fd[1]으로 출력을 하게 됐다고 이해하면 된다.

과제에서는 다중파이프를 구현할 때는 while문을 써서 무한루프를 돌고, 종료 조건을 주면 된다.

profile
의지와 행동

0개의 댓글