티스토리 뷰

https://www.yes24.com/Product/Goods/122109062

 

그림으로 배우는 리눅스 구조 - 예스24

선배가 옆에서 하나하나 알려주듯 친절히 설명해주는실습과 그림으로 배우는 리눅스 지식의 모든 것 * Go 언어와 Python, Bash 스크립트 실습 코드 제공* 이 도서는 『실습과 그림으로 배우는 리눅

www.yes24.com

 

 1. 프로세스 생성

 

 현대 운영체제의 핵심은 다수의 프로세스와 다수의 스레드를 안정적으로 운영하는 것이다. 리눅스에서 새로운 프로세스를 생성하는 방법은 fork()와 execve() 함수이다. 그러면 각각 함수에 대해서 알아보자.

 먼저 fork()함수의 동작 과정은 다음과 같다. 

  1. 부모 프로세스가 fork()를 호출한다.
  2. 자식 프로세스용 메모리 영역을 확보한 후, 그곳에 부모 프로세스의 메모리를 "그대로" 복사한다. 
  3. 부모 프로세스와 자식 프로세스는 둘 다 fork()함수에서 복귀한다. 이때 부모 프로세스의 fork() 리턴값은 자식 프로세스의 pid 값이고, 자식 프로세스의 fork() 함수 리턴값을 항상 0이다. 이를 이용해 독자적인 처리가 가능하다.

 이 과정을 파이썬으로 실습해보자.

import os, sys

ret = os.fork()
if ret == 0:
    print("자식 프로세스: pid={}, 부모 프로세스의 pid={}".format(os.getpid(), os.getppid()))
    exit()
elif ret > 0:
    print("부모 프로세스: pid={}, 자식 프로세스의 pid={}".format(os.getpid(), ret))
    exit()

sys.exit(1)

 

 따라서 ret 값이 0일 경우는 자식 프로세스에서의 리턴값이므로 exit을 했고, 부모 프로세스의 경우 자식 프로세스의 pid값을 ret으로 받아서, 이 값을 출력한다. 따라서 fork()함수 값에 따라 어떻게 분기가 진행되는지 파악할 수 있다.

 

 그 다음으로, execve()함수에 대해 설명하겠다. execve()의 경우 자식 프로세스가 새로운 프로그램으로 바뀐다. 따라서 execve() 함수는 fork()를 통해 생성된 자식 프로세스에서 이루어진다고 보면 된다. 그림으로 보면 다음과 같다.

 

  1. 자식 프로세스에서 execve()함수를 호출한다.
  2. execve()함수 인수로 지정한 실행 파일에서 프로그램을 읽어서, 메모리에 배치할 정보를 가져온다.
  3. 현재 프로세스의 메모리(즉, 자식 프로세스의 메모리)를 새로운 프로세스 데이터로 덮어 씌운다.
  4. 프로세스를 새로운 프로세스의 최초로 실행할 명령(Entry point)부터 실행한다.

  이를 다음 파이썬 코드로 실습해보자.

#!/usr/bin/python3

import os, sys

ret = os.fork()
if ret == 0:
    print("자식 프로세스: pid={}, 부모 프로세스 pid={}".format(os.getpid(), os.getppid()))
    os.execve("/bin/echo", ["echo", "pid={}에서 안녕".format(os.getpid())], {})
    exit()
elif ret > 0:
    print("부모 프로세스: pid={}, 자식 프로세스 pid={}".format(os.getpid(), ret))
    exit()

sys.exit(1)

 

 

  fork.py와 거의 같은데, 자식 프로세스일 경우 execve()함수를 사용해서 /bin/echo 프로세스로 자기 메모리 영역을 대체한다. 따라서 자식 fork.py()가 echo 로 전환되어서 "pid ~" 문자열을 출력하고 종료된다.

 이렇게 부모 프로세스가 자식 프로세스를 생성하는 것은 컴퓨터 전원을 킬 때부터 발생한다. 컴퓨터 전원을 킨 순간부터 시스템이 초기화되는 과정을 살펴보자.

  1. 컴퓨터 전원을 키면, BIOS가 기동해서 하드웨어를 초기화시킨다.
  2. 펌웨어가 GRUB같은 부트 로더를 기동한다.
  3. 부트 로더가 리눅스 커널을 기동한다.
  4. 리눅스 커널이 systemd 프로세스(또는 init() 프로세스)를 생성한다.
  5. systemd 프로세스가 root가 되어 자식에 자식을 생성하면서 Process Tree 구조가 만들어진다.

 이 Tree 구조를 터미널에서 pstree 명령으로 확인할 수 있다. pstree -p 명령으로 pid까지 표시해보면 다음과 같다.

 init 역할의 systemd()프로세스에서 모든 자식 프로세스가 차례로 생성되는 것을 확인할 수 있고, 방금 명령한 pstree 프로세스는 zsh 셸 프로세스에서 생성됨을 확인한다.

 

2. 프로세스 생성

 

 앞서 pstree로 본 것 처럼 수많은 프로세스가 한꺼번에 돌아가는데, 이 모든 프로세스를 CPU가 스케쥴링하기에는 벅차 보인다. 하지만 대부분의 프로세스는 인간 사용자에게 "보여질" 필요가 없다. 사용자가 프로세스를 화면에 띄워 놓지 않거나, 연산 명령이 장기간 들어오지 않는 경우는 굳이 프로세스가 켜져 있을 이유가 없다. 이 경우에 프로세스는 sleep 상태가 된다. 이를 확인해 보자. 터미널에 ps aux를 입력해보면 현재 실행중인 process들에 대한 정보를 얻을 수 있다.

 

 출력 결과 중 TIME 열을 보자. 여기에서 현재 계속 실행해놓았던 zsh shell의 실행시간이 0.00임을 알 수 있다. 이를 통해 대부분의 시간 동안 zsh 프로세스가 sleep 상태였음을 알 수 있다. 

 

 따라서 process는 항상 running하는 것이 아니라 여러 State로 구성됨을 알 수 있다. 이 state들을 finite state diagram으로 나타내면 다음 그림과 같다.그림에서는 sleep을 waiting으로 표현했다.

 그렇다면 모든 프로세스가 sleep일 때는 무슨 일이 일어날까? 그때는 CPU에서 idel process 상태가 된다. idle은 "아무 일도 하지 않는" 특수한 프로세스로, 정말 모든 프로세스가 sleep이어야 가능하므로 터미널 상에서는 확인할 수 없다. idel 프로세스에서는 device의 소비 전력을 최대한 억제하면서 대기한다.

 

3. 프로세스 종료

 python에서 exit() 함수로 함수를 종료할 경우, exit() 함수는 exit_group() 함수를 실행한다. 이 함수는 시스템 콜을 호출한다. exit_group() 함수 내부에서 커널이 프로세스 자원을 회수하는 역할을 한다. 또한 프로세스가 종료하면 부모 프로세스는 wait()나 waitpid()같은 시스템 콜을 호출해서 다음과 같은 정보를 얻을 수 있다.

  • 프로세스 반환값 : exit() 함수의 인수에 mod 256 한 값
  • 시그널에 따라 종료하였는지 여부
  • CPU 시간 정보

이를 통해 프로세스 종료에 대한 에러 로그를 수집할 수 있다. 

 

4. 좀비 프로세스와 고아 프로세스

 좀비 프로세스(Zombie process)란 말 그대로 exit()으로 종료시켜도 살아있는 자식 프로세스를 말한다. 왜 좀비 프로세스가 필요할 지 생각해보면, 바로 wait() 계열 system call을 위해서이다. 이러한 system call이 호출될 때까지 자식 프로세스는 살아있어야한다. wait()이 끝난 후에는 커널이 좀비 프로세스를 완전히 죽이고(Terminated), 자원을 커널로 되돌린다. 따라서 좀비 프로세스가 시스템에 다수 존재한다면, 이는 부모 프로세스에 문제가 생겼음을 의심해야 한다.

 만약 wait() 실행 전에 부모 프로세스가 종료된다면 해당하는 자식 프로세스는 고아 프로세스(Orphan process)가 된다. 이때 커널은 고아 프로세스의 부모로 init을 설정해준다. 따라서 부모 프로세스가 계속 죽어버린다면 init을 부모로 삼는 좀비 프로세스가 많아질 텐데, 리눅스에서는 init이 주기적으로 wait() 계열 시스템 콜을 호출해서 좀비 프로세스를 주기적으로 청소한다.

 

5. 시그널

 시그널은 어떤 프로세스가 다른 프로세스에 어떤 신호를 보내서 외부에서 실행 순서를  바꾸는 방법이다. 설명을 들으면 뭔지 헷갈리긴 하지만, 사실 우리는 일상적으로 시그널을 사용하고 있다.

 

 바로 위와 같이 shell에서 ctrl + c를 누르면 강제로 프로세스를 종료할 때 발생한다. ctrl + c를 눌렀을 경우, SIGINT 시그널을 프로세스에 전달하고, SIGINT를 전달받은 프로세스는 곧바로 종료된다. 그 밖의 시그널은 다음과 같은 종류가 있다.

 

  • SIGCHILD : 자식 프로세스 종료 시 부모 프로세스에 보내는 시그널. 보통은 시그널 핸들러 내부에서 wait() 계열 시스템 콜을 호출한다.
  • SIGSTOP : 프로세스 실행을 일시적으로 정지하는 시그널. 사용자가 ctrl + z을 누르면 프로그램 동작을 '정지'할 수 있는데, 이때 shell에서 프로세스에 SIGSTOP 시그널을 보낸다.
  • SIGCONT : 정지된 프로세스 실행을 재개하는 시그널.

 앞서 '시그널 핸들러' 라는 언급을 했는데, 각 프로세스는 각 시그널에 대해 시그널 핸들러를 미리 등록해 둔다. 프로세스를 실행하다가 해당하는 시그널을 수신하면, 시그널 핸들러에 해당하는 주소로 넘어가서 동작시킨 다음, 이전에 하던 동작을 재개한다.

 

6. 세션

 세션(Session)은 사용자가 gterm같은 단말 에뮬레이터 또는 ssh 등을 사용해서 시스템에 로그인했을 때의 로그인 세션에 대응하는 개념으로, 모든 세션에는 해당 세션을 제어하는 '단말'(terminal)이 존재한다. 세션과 뒤에 설명하는 프로세스 그룹(Process group)을 이용해서 셸이 백그라운드로 실행한 프로세스를 제어할 수 있다.

 세션 내부 프로세스를 조작하기 위해서는 단말을 이용해서 셸을 비롯한 프로세스에 지시하거나, 프로세스 출력을 받으면 된다. 세션에는 세션 ID 또는 SID라고 불리는 값이 할당된다. bash나 zsh같은 셸 프로세스는 세션 리더(Session leader)가 된다. 예를 들어 ps ajx 명령어를 zsh shell 에서 가동하면 다음과 같이 zsh을 세션 리더로 zsh의 자식 프로세스 ps ajx가 나타난다.

 

 pts/0 이 단말에 해당하고, pts/0의 세션리더 zsh에서 SID 464를 할당받고(당연히 PID 역시 464), zsh에서 실행한 ps ajx 프로세스가 PID 14355로 실행되고, SID는 zsh의 PID인 464임을 확인할 수 있다.

 세션에 할당된 단말이 연결이 끊긴다면, 세션 리더에는 SIGHUP 시그널이 생긴다. 이때 shell은 자신이 관리하던 작업을 종료시키고, 자신도 종료한다. 이때 shell이 종료되는걸 원하지 않는다면, 다음 명령어를 사용하면 편리하다.

  • nohup 명령어 : SIGHUP 시그널을 무시하는 명령어. 따라서 shell에서 SIGHUP을 받아도 프로세스는 종료되지 않는다.
  • bash의 disown 내장 명령어 : 실행 중인 작업을 bash 관리 대상에서 제외한다. 따라서 bash가 종료되어다 자식 process에 SIGHUP을 보내지 않는다.

7. 프로세스 그룹

 프로세스 그룹은 여러 프로세스를 하나로 묶어서 한꺼번에 관리한다. 여기까지 배운다면 이제 리눅스 명령어 중 & 와 | 의 차이를 이해할 수 있다. 예를 들어 다음 두 명령을 &를 이용해 동시에 실행한다고 생각해보자.

  • zsh에서 go build hello.go & 실행
  • zsh에서 ps aux | less 실행

이때 zsh는 두 가지의 프로세스 그룹이 생긴다. 편의상 다음과 같이 생각하자.

  • group 1 : go build hello go
  • group 2 : ps aux | less

 따라서 &을 명령어 사이에 둔다는 것은 두 가지의 프로세스 그룹이 생성된다는 의미이다. 그러면 | 은 무슨 의미일까? | 는 ps aux 프로세스와 less 프로세스를 연결하는 파이프(pipe) 이다. 이렇게 프로세스 그룹으루 묶어준다면, 프로세스 그룹에 시그널을 보내면 모든 하위 프로세스에 시그널을 보낼 수 있다. 예를 들어 kill {PGID}를 한다면, 해당 Process Group ID를 가지는 모든 프로세스를 종료시킨다.

 어떤 세션 내부에 있는 프로세스 그룹은 두 종류로 나뉜다.

  • foreground 프로세스 그룹 : 셸의 포그라운드 작업에 대응하며, 세션 당 하나만 존재하고 세션 단말에 직접 접근할 수 있다.
  • background 프로세스 그룹 : 셸의 백그라운드 작업에 대응하고, 백그라운드 프로세스가 단말을 조작하려고 하면 SIGSTOP을 받았을 때처럼 실행이 일시 중단되고, fg 내장 명령어 등으로 프로세스가 포그라운드 프로세스 그룹이 될 때까지 이 상태를 유지한다.

 즉 단말에 접근하려면 포그라운드 프로세스 그룹이 되어야 한다. 이를 다음 명령어로 알아보자. ps aux가 아니라 ps ajx에 주의한다.

$ go build hello.go & ps ajx | less

 

 pts/0에 해당하는 zsh 셸의 PID는 474이다. 이를 부모로 하는 두 가지의 PGID(18998, 18999)가 생성되고, 앞서 설명했듯 18998 PGID에 해당하는 go build hello.go 프로세스와, 18999 PGID에 해당하는 18999 pid의 ps ajx와, 이것과 pipe로 연결된 동일한 PGID를 가진 19000 pid의 less 프로세스가 생성됨을 확인할 수 있다. 

 여기에서 foreground 프로세스 그룹은 ps ajx 프로세스임을 알수 있고, 확인하는 방법은 STAT 필드에서 +가 붙었는지를 확인하면 된다. 

 

 8. 데몬

 데몬(daemon)이란, "상주하는 프로세스"를 의미한다. 데몬의 특징은 시스템이 시작할 때부터 종료할 때까지 계속해서 실행된다는 것이다. 이를 통해 데몬의 부모 프로세스는 누구일지를 짐작할 수 있다. 바로 init 프로세스이다. 그럼 zsh 셸도 부모가 init(pid 1)인데 이것도 데몬일까? 그건 아니다. 데몬의 특징을 생각한다면, 데몬은 입출력이 필요없기 때문에 단말이 필요없다. 따라서 ps ajx 커맨드를 입력했을 때 단말에 해당하는 TTY필드가 ? 로 되어있음을 확인할 수 있다.

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/10   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30 31
글 보관함