리눅스 쉘은 어떻게 동작하는가 (brief explanation)

2022. 11. 26. 21:53Computer Science

 

 

Overview

개발하면서 자주 사용하게 되는 Shell 프로그램에는 Computer Science의 근간을 이루는 Process, Fork, I/O Redirection, File, Pipe, Signal 등의 중요한 개념들이 모두 포함되며, 이들의 논리적인 상호작용을 통해 사용자의 입력을 수행하고 결과를 출력합니다. 이들 각각의 요소와 Shell 프로그램의 코드를 하나씩 살펴보는 것은 하나의 포스팅에서 다 다루기 어려울 정도로 내용이 많기 때문에 이번 포스팅에서는 Shell 프로그램의 구체적인 구현 사항을 살펴보기보다는 Shell 프로그램이 동작하는 전반적인 그림에 대한 간략한 소개를 하려고 합니다.

 

Shell을 이해하기 위해서는 앞서 언급했던 여러 Computer Science의 핵심 개념들을 이해해야 하는데, 우선 전반적인 동작 과정에 대해 설명하고, 주요 개념들을 하나씩 살펴보면서 전반적인 동작 과정에 대한 설명들을 추가적으로 설명하도록 하겠습니다.

 

Running Examples

아래 예시는 c언어와 kernel 메서드들을 wrapping해서 사용할 수 있는 c library를 사용해서 만든 Custom Shell(YeoulCoding Shell)의 실행 예시입니다. 사진에서 확인할 수 있는 것처럼 한번 프로그램이 실행되고 나면 종료되기 전까지 항상 사용자 입력을 기다리기 위해 ForeGround에서 메인 프로세스가 실행되고 있으며, Pipe / IO Redirection / Program Execution 등 Shell 프로그램이 제공하는 매우 기본적인 요소들이 갖추어져 있는 것을 확인할 수 있습니다. 

 

Custom Shell. pipe / io redirection 등의 기능을 수행할 수 있다

 

Overall Process

Custom Shell이 동작하는 전반적인 프로세스는 아래와 같습니다. 앞서 언급했듯 Job / Fork / Process / Foreground / Signal Handling 등의 기초 개념들이 나올 텐데, 우선은 이러한 개념들에 대한 기본적인 이해를 가정하고 한번 이 프로세스를 설명한 뒤에, 각각의 개념들을 하나씩 짚어가면서 전체 프로세스에 대한 이해를 높여보도록 하겠습니다. 

 

Overall Process

 

  1. 프로그램을 컴파일한 후에 (C언어로 작성된 프로그램이므로 gcc로 컴파일) 바이너리 실행파일을 실행합니다. 실행 직후 main 함수에서는 각종 Signal Handler를 등록한 이후에 while loop을 돌면서 사용자 입력을 대기합니다. 사용자가 입력을 마친 후에 Enter를 누르면 파싱된 사용자 입력을 실행하는 evaluation 함수를 호출합니다. 해당 함수가 실행을 마치고 리턴하면 다시 다음 입력을 대기합니다. 

  2. evaluation 함수 안에서는 사용자의 명령을 실행하기 위해 메인 프로세스를 Fork 해서 자식 프로세스를 만들고, 사용자 명령을 이 자식 프로세스가 수행하도록 합니다. 사용자 입력에 pipe가 사용한 경우 한 줄의 명령을 처리하기 위해 프로그램을 여러 번 수행해야 하는데, 이는 여러개의 자식 프로세스를 만들어서 해결합니다. 예를 들어 "ls | grep hello" 라는 입력이 들어왔다면 부모 프로세스는 "ls"를 처리하는 프로세스 하나, "grep hello"를 처리하는 프로세스를 하나 만들어서 순차적으로 실행합니다. (뒤에서 살펴보겠지만 앞 프로세스의 출력이 뒤 프로세스의 입력으로 들어가야 하므로 Inter Process Communication(이하 IPC) 방법 중의 하나인 pipe를 사용해서 이를 전달합니다.)

  3. 부모 프로세스는 자식 프로세스를 생성하고, 자신과 자식 프로세스(들)를 하나의 프로세스 그룹(Process Group)으로 묶어줍니다.(addJob) 그런 뒤에, 자식 프로세스가 실행을 마칠 때까지 while loop을 돌면서 이를 기다립니다. (하지만 만약 사용자 입력이 Background로 프로그램을 실행하는 것이었다면 기다리지 않고 바로 리턴해서 다음 사용자 입력을 기다립니다)

  4. 자식 프로세스가 실행을 마치면 부모 프로세스에게 SIGCHLD 시그널을 보내는데, 부모는 이 시그널을 받으면 기다리는 것을 멈추고 자식 프로세스의 잔여 리소스들을 모두 제거합니다(이하 Reaping) 만약 더 이상 수행되어야 할 자식 프로세스가 남아있지 않으면 해당 Job(여기서 Job이란 하나의 사용자 입력을 처리하기 위한 프로세스 그룹을 이야기합니다.)을 제거하고 다음 사용자 입력을 기다립니다.
    • 만약 사용자가 실행한 입력이 Foreground Process가 아니라 Background Process일 경우 부모는 자식 프로세스가 끝나기를 기다리지 않고 Job을 등록한 뒤 바로 다음 사용자 입력을 받기 위해 리턴합니다.
  5. 사용자가 프로그램을 종료할 때까지 1~4를 반복하게 됩니다.

 

Background

전반적인 대략의 프로세스를 살펴보았으므로, 이제 각각의 프로세스를 구성하는 개념들을 조금 더 자세히 살펴보면서 프로세스의 각 과정들을 다시 이해해 보도록 하겠습니다.

 

Process & Process Group

프로세스는 "동작하고 있는 프로그램의 인스턴스"를 의미합니다.(A process is an instance of running program.) 앞서 살펴본 예시와 같이 "ls"라는 명령어를 입력했을 때, 우리가 원하는 결과를 출력하기 위해서 프로그램을 메모리에 올리고, CPU를 사용해서 연산하고, 시그널을 처리하는 모든 일련의 과정들을 수행하는 것을 프로세스라고 하는 것입니다. 각각의 프로세스는 운영체제의 스케줄러에 의해 스케줄 되며, 어떤 프로그램이 어떤 순서대로 어떤 인스트럭션까지 수행되고 Context Switch 될지를 결정하는 것은 전적으로 스케줄러의 책임입니다. (따라서 부모 프로세스의 일부가 먼저 실행될 수도 있고, 자식 프로세스의 일부가 먼저 실행될 수도 있습니다.)

 

A process is an instance of a running program.

 

 

프로세스 그룹은 말 그대로 프로세스를 공통된 하나의 그룹으로 묶어주는 역할을 합니다. 프로세스 그룹이 필요한 다양한 경우들이 있을 수 있겠지만 쉘의 관점에서 생각해봤을 때, 프로세스 그룹은 Signal Handling을 서로 다른 프로세스들에게 공통되게 전달하는 데 있어서 매우 중요한 역할을 합니다.

 

앞서 언급했던  "ls | grep hello" 라는 명령어를 쉘에 입력하는 경우를 생각해보겠습니다. 사용자는 한 줄의 명령어를 입력한 것이 되지만,  쉘은 이를 처리하기 위해 실제로는 "ls"를 처리하는 프로세스와 "grep hello"를 처리하는 프로세스, 그리고 두 프로세스 사이를 이어주는 IPC인 pipe 등등의 여러 구성요소들을 생성하게 됩니다. 만약에 쉘이 이 명령을 처리하는 도중에 사용자가 시그널을 보내서 프로세스를 종료하려 한다면, 이 시그널 (SIGINT, SIGSTOP...)은 두 프로세스 중 어느 하나가 아닌 해당 명령과 관련된 모든 프로세스에 전달되어야 할 것입니다. glibc 라이브러리는 프로세스 그룹 id를 가지고 프로세스 그룹에 시그널을 보내는 방법을 제공하며(killpg) 모든 프로세스를 탐색해서 일일이 시그널을 보낼 필요 없이 성공적으로 모든 프로세스들에게 시그널을 보낼 수 있게 됩니다.

 

따라서 사용자가 입력한 명령어는 "프로세스 단위"가 아닌 "프로세스 그룹 단위"로 묶어서 생각하는 것이 좋으며, 편의상 이 프로세스 그룹들을 "Job"이라고 부르도록 하겠습니다. 따라서 "ls | grep hello"는 3개의 프로세스(1개의 부모 + "ls"를 실행하는 자식 프로세스 + "grep hello"를 실행하는 자식 프로세스)가 포함된 하나의 프로세스 그룹이며, 하나의 Job이 됩니다.

 

process & process group

 

 

Signal Handling

시그널은 고수준의 예외처리 메커니즘으로, 프로세스에게 시스템에 어떠한 이벤트가 발생하였음을 알려줍니다. 앞서 살펴본 것처럼 쉘은 하나의 사용자 입력을 처리하기 위해 하나 이상의 프로세스를 실행하기 때문에 이 프로세스에서 발생한 이벤트를 다른 프로세스에 전달할 방법이 필요하며, 또 시스템 수준의 에러나 사용자 액션 (ctrl + z, ctrl + c) 등의 이벤트들을 실행 중인 프로세스에 전달할 방법이 필요합니다. 이를 처리하기 위해 Signal을 사용하며 아래의 표에서 주로 사용하는 시그널들을 확인할 수 있습니다. (SIGINT, SIGKILL과 같은 익숙한 시그널들 외에도 많은 시그널들이 POSIX 표준에 명시되어 있습니다)

 

 

실제 Linux 쉘에서는 수많은 시그널들에 대한 핸들러가 등록되어 동작하겠지만, 간단한 쉘에서는 자식 프로세스가 종료되었을 때 부모에게 보내는 시그널인 SIGCHLD, 사용자가 쉘에 보내는 시그널인 SIGINT, SIGSTOP, SIGCONT 등만 처리해도 충분합니다. 사용자가 ctrl+c를 눌러 쉘에 SIGINT 시그널을 보내는 경우 부모 프로세스는 이를 받아서 killpg를 통해 이 시그널을 해당 프로세스 그룹의 모든 프로세스에게 전달합니다. 해당 시그널을 받은 자식 프로세스들은 실행중인 프로세스를 종료하고, 부모 프로세스는 자식 프로세스를 Reap 한 뒤에 자기 자신도 종료합니다.

 

Fork

쉘의 핵심 매커니즘 중 하나인 Fork는 "한번 실행되고 두 번 리턴한다"는 특징이 있습니다. Fork가 실행된 시점의 프로세스와 모든 것이 동일한 process를 하나 더 만들며, Address Space, File descriptor table, Process Control Block 등의 모든 것을 복사합니다. 쉘은 명령어를 입력받으면 fork를 통해 부모와 완전히 동일한 자식 프로세스를 하나 만들고, execvp 명령어를 통해 기존의 자식 프로세스를 "실행해야 하는 프로그램"으로 덮어씌워 실행합니다. 부모는 자식 프로세스의 process id(pid)를 알고 있기 때문에 자식 프로세스의 실행이 끝나기를 기다렸다가 실행이 종료되면 자식 프로세스의 리소스를 완전히 제거해 줍니다. 

 

I/O Redirection

"ls > output.txt" 라는 명령어를 쉘에 입력하면 리눅스 쉘은 현재 디렉터리에 있는 모든 파일 / 디렉터리들의 이름을 output.txt에 입력하고 쉘에는 아무것도 출력하지 않을 것입니다. 즉 ls가 출력해야 하는 결과를 STD_OUT인 터미널에 출력하지 않고 이를 output.txt에 출력했다는 의미인데, 이를 I/O Redirection이라고 합니다. 

 

open file table, v-node table은 모든 프로세스가 공유, Descriptor Table은 프로세스마다 별도

 

I/O Redirection은 File Descriptor가 가리키는 Open File Table의 Reference를 변경하는 명령어 중 하나인 dup2를 사용해서 수행할 수 있습니다. 기본적으로 하나의 프로세스가 실행되었을 때, 3개의 파일이 항상 열려 있는 상태로 시작하게 되는데, 각각 STD_IN / STD_OUT / STD_ERR를 의미합니다. 이 3개의 파일이 항상 열려있기 때문에 별도로 처리해주지 않아도 터미널에서 입력하고, 출력하고, 에러를 확인할 수 있는 것입니다. 

 

만약 터미널이 아니라 새로운 파일에서 입력을 받고 싶거나, 프로그램의 결과를 새로운 파일로 출력하고 싶다면 이 STD_IN / STD_OUT을 dup2 명령어를 사용해서 새로운 파일로 연결해주면 됩니다. 예를 들어 "ls > output.txt"를 처리하고 싶다면 프로세스 안에서 output.txt 파일을 연 뒤(없다면 생성한 뒤), dup2(output.txt의 fd, STDOUT_FILENO)를 사용해서 표준 출력(터미널)인 STD_OUT을 output.txt로 변경해 주면 됩니다. 뒤에서 살펴보겠지만 서로 다른 프로세스의 Input / Ouput을 이어주는 pipe도 이러한 식으로 동작합니다.

 

Pipes

pipe는 IPC 메커니즘 중 하나로, 서로 다른 프로세스 간에 통신할 수 있는 방법을 제공합니다. "ls | grep hello" 라는 예제를 다시 생각해봤을 때, ls 프로그램의 출력을 grep 프로그램의 입력으로 전달해주어야 하는데,  이때 pipe를 사용합니다. pipe를 생성하면 Read / Write에 해당하는 file descriptor이 할당되는데, 위의 예제에서는 dup2를 사용해 ls의 출력을 터미널(STD_OUT)에 쓰는 것이 아니라 이 pipe의 Write File에 쓰게 되며, grep의 입력을 터미널(STD_IN)에서 받는 것이 아니라 ls의 출력이 쓰여있는 pipe의 Read File에서 받게 되는 것입니다. 결국 pipe도 큰 개념에서는 파일에 읽고 쓰는 행위를 서로 다른 프로세스 간에 지원하는 것이기 때문에 위에서 살펴보았던 I/O Redirection의 개념을 사용해서 한 프로세스의 출력을 다른 프로세스의 입력으로 받을 수 있습니다.

 

 

Conclusion

아주 간단한 Shell 프로그램이 어떻게 동작하는지를 살펴보았는데, Shell의 동작 과정을 이해하기 위해 CS의 기본적이고 중요한 아이디어들을 두루두루 살펴보아야만 했던 것 같습니다. 하나하나의 개념은 각각을 별도의 포스팅 시리즈로 다루어야 할 만큼 역사도 깊도 내용도 방대하지만, 지면상 이 포스팅에서는 정말 간단한 아이디어만 살펴보게 되었습니다.

 

각각의 개념을 아는 것과, 이 개념들을 조합해서 만들어진 무언가를 이해하는 것, 그리고 이러한 이해를 바탕으로 개념들을 조합해서 새로운 것을 만드는 것 사이에는 매우 큰 간격이 있습니다. 이러한 관점에서 쉘 프로그램이 어떻게 동작하는지를 간단하게나마 이해하는 것은 각각의 개념들을 깊이 연관지어 이해하는데 도움이 된다고 생각합니다.

 

Reference

http://csapp.cs.cmu.edu/

https://man7.org/linux/man-pages/man3/killpg.3.html

https://man7.org/linux/man-pages/man2/fork.2.html

 

 

 

반응형