그림으로 배우는 Linux 구조 - 5 / 프로세스 관리


프로세스 관리

빠른 프로세스 작성 처리

리눅스는 가상 메모리 기능을 응용해서 프로세스 작성을 빠르게 처리합니다. 각각 fork() 함수와 execve() 함수를 대상으로 설명하겠습니다.

fork() 함수 고속화: Copy on Write

fork() 함수를 호출할 때 부모 프로세스의 메모리를 자식 프로세서에 모두 복사하는 것이 아니라 페이지 테이블만 복사합니다. 페이지 테이블 엔트리 내부에는 페이지에 쓰기 권한을 관리하는 필드가 있는데, 이때 부모와 자식 양쪽을 대상으로 모든 페이지에 쓰기 권한을 무효화합니다.

linux5_01

이후에 메모리를 읽을 때 부모와 자식 사이에 공유된 물리 페이지에 접근 가능합니다. 한편, 부모와 자식 중 어느 쪽이 데이터를 갱신하려고 하면 페이지 공유를 해제하고, 프로세스마다 전용 페이지를 만듭니다. 자식 프로세스가 페이지 데이터를 갱신하면 다음과 같은 일이 일어납니다.

  1. 쓰기 권한이 없으므로 CPU에서 페이지 폴트가 발생합니다.
  2. CPU가 커널 모드로 바뀌고 커널의 페이지 폴트 핸들러가 동작합니다.
  3. 페이지 폴트 핸들러는 접속한 페이지를 별도의 물리 메모리에 복사합니다.
  4. 자식 프로세스가 변경하려고 했던 페이지에 해당하는 페이지 테이블 엔트리를 부모와 자식 프로세스를 대상으로 모두 변경합니다. 자식 프로세스의 엔트리는 3에서 복사한 영역을 참조합니다.

linux5_02

fork() 함수를 호출할 때가 아니라 이후에 각 페이지에 처음으로 쓰기를 할 때 데이터를 복사하므로 이런 방식을 카피 온 라이트(Copy on Write)라고 부릅니다. CoW라고도 합니다.

CoW를 이용하면 프로세스가 fork() 함수를 호출하는 순간에는 메모리를 전부 복사하지 않아도 되므로 fork() 함수 처리가 빨라지고 메모리 사용량도 줄어듭니다. 게다가 프로세스를 생성해도 모든 메모리에 쓰기 작업이 발생하는 건 극히 드문 일이므로 시스템 전체 메모리 사용량도 줄어듭니다.

그런 다음 페이지 폴트에서 복귀한 자식 프로세스는 데이터를 변경합니다. 앞으로 동일한 페이지에 접근하면 부모와 자식 각자의 전용 메모리가 할당되어 있으므로 페이지 폴트가 발생하는 일 없이 데이터를 변경할 수 있습니다.

cow.py

#!/usr/bin/python3

import os
import subprocess
import sys
import mmap

ALLOC_SIZE = 100 * 1024 * 1024
PAGE_SIZE  = 4096

def access(data):
    for i in range(0, ALLOC_SIZE, PAGE_SIZE):
        data[i] = 0

def show_meminfo(msg, process):
    print(msg)
    print("free 명령어 실행 결과:")
    subprocess.run("free")
    print("{}의 메모리 관련 정보".format(process))
    subprocess.run(["ps", "-orss,maj_flt,min_flt", str(os.getpid())])
    print()

data = mmap.mmap(-1, ALLOC_SIZE, flags=mmap.MAP_PRIVATE)
access(data)
show_meminfo("*** 자식 프로세스 생성 전 ***", "부모 프로세스")

pid = os.fork()
if pid < 0:
    print("fork()에 실패했습니다", file=os.stderr)
elif pid == 0:
    show_meminfo("*** 자식 프로세스 생성 직후 ***", "자식 프로세스")
    access(data)
    show_meminfo("*** 자식 프로세스의 메모리 접근 후 ***", "자식 프로세스")
    sys.exit(0)

os.wait()
./cow.py 
*** 자식 프로세스 생성 전 ***
free 명령어 실행 결과:
               total        used        free      shared  buff/cache   available
Mem:        32475208     1433948    28808820        3468     2232440    30574480
Swap:         499996           0      499996
부모 프로세스의 메모리 관련 정보
  RSS  MAJFL  MINFL
112000     0  26718

*** 자식 프로세스 생성 직후 ***
free 명령어 실행 결과:
               total        used        free      shared  buff/cache   available
Mem:        32475208     1433948    28808820        3468     2232440    30574480
Swap:         499996           0      499996
자식 프로세스의 메모리 관련 정보
  RSS  MAJFL  MINFL
109252     0    365

*** 자식 프로세스의 메모리 접근 후 ***
free 명령어 실행 결과:
               total        used        free      shared  buff/cache   available
Mem:        32475208     1534748    28708020        3468     2232440    30473680
Swap:         499996           0      499996
자식 프로세스의 메모리 관련 정보
  RSS  MAJFL  MINFL
109380     0  25971

이 결과에서 다음과 같은 사실을 알 수 있습니다.

  • 자식 프로세스 생성 전부터 생성 직후 사이에는 시스템 전체 메모리 사용량은 약 1MiB 밖에 늘어나지 않습니다.
  • 자식 프로세스의 메모리 접근 후에는 시스템 메모리 사용량이 약 100MiB 늘어납니다.

부모와 자식 프로세스가 각자 독립적인 데이터를 가진 것처럼 보이지만, 내부 구조를 살펴보면 사실은 메모리를 공유하고 있어서 메모리 용량을 절약할 수 있습니다. 또 다른 중요한 점은 자식 프로세스의 RSS 필드값이 생성 직후와 메모리 접근 후에도 그다지 변하지 않는다는 부분입니다.
실제로 RSS 값은 프로세스가 물리 메모리를 다른 프로세스와 공유하는지 여부를 따지지 않습니다. 단순히 각 프로세스의 페이지 테이블 내부에서 물리 메모리가 할당된 메모리 영역 합계를 RSS로 보고합니다. 부모 프로세스와 공유하는 페이지에 쓰기를 해서 카피 온 라이트가 발생하더라도 페이지에 할당된 물리 메모리가 변경될 뿐입니다. 따라서 물리 메모리가 미할당 상태에서 할당 상태로 바뀌는 건 아니므로 RSS 값은 변하지 않습니다.
이러한 이유로 ps 명령어로 확인한 모든 프로세스의 RSS 값을 합치면 전체 물리 메모리 용량을 넘는 경우도 있습니다.


execve() 함수의 고속화: Demand paging

linux5_03

4장에서 설명한 실제로 사용 시 물리 메모리를 할당하는 Demand paging은 프로세스에 새로운 메모리 영역을 할당할 때뿐만 아니라 execve() 함수 호출에도 잘 어울리는 기능입니다. execve() 함수 호출 직후라면 프로세스용 물리 메모리는 아직 할당되지 않습니다.


linux5_04

이후에 프로그램이 엔트리 포인트에서 실행을 시작하면 엔트리 포인트에 대응하는 페이지가 존재하지 않으므로 페이지 폴트가 발생합니다.


linux5_05

페이지 폴트 처리 결과로 프로세스에 물리 메모리가 할당됩니다.


linux5_06

앞으로 다른 페이지에 접근할 때마다 각각 위와 같은 흐름으로 물리 메모리가 할당됩니다.


프로세스 통신

여러 프로그램이 협조해서 동작해야 한다면 프로세스끼리 데이터를 공유하거나 서로 타이밍을 맞춰서(동기화해서) 처리해야 합니다. 이런 협조를 손쉽게 처리하기 위해 OS가 제공하는 기능이 프로세스 통신입니다.
리눅스는 목적별로 수많은 프로세스 통신 수단을 제공합니다. 전부를 소개할 수 없으니 알기 쉬운 몇 종류만 소개해 봅니다.

공유 메모리
  1. 정수 데이터 1000을 생성하고 데이터 값을 출력합니다.
  2. 자식 프로세스를 작성합니다.
  3. 부모 프로세스는 자식 프로세스 종료를 기다립니다. 자식 프로세스는 1에서 만든 데이터 값을 2배로 만들고 종료합니다.
  4. 부모 프로세스는 데이터 값을 출력합니다.
#!/usr/bin/python3

import os
import sys

data = 1000

print("자식 프로세스 생성전 데이터 값: {}".format(data))
pid = os.fork()
if pid < 0:
    print("fork()에 실패했습니다", file=os.stderr)
elif pid > 0:
    print("부모 data Address: ", hex(id(data)))
elif pid == 0:
    print("자식 data Address: ", hex(id(data)))
    data *= 2
    print("자식 data Address: ", hex(id(data)))
    sys.exit(0)

os.wait()
print("자식 프로세스 종료후 데이터 값: {}".format(data))
$ ./non-shared-memory.py 
자식 프로세스 생성전 데이터 값: 1000
부모 data Address:  0x7f535d3fd050
자식 data Address:  0x7f535d3fd050 # (data *= 2 이전)
자식 data Address:  0x7f535d3fe890 # (data *= 2 이후)
자식 프로세스 종료후 데이터 값: 1000

fork() 함수를 호출한 이후의 부모와 자식 프로세스는 데이터를 공유하지 않기 때문에 어떤 한쪽의 데이터를 갱신하더라도 다른 쪽 프로세스에 있는 데이터에는 영향을 주지 않습니다. 카피 온 라이트 기능으로 fork() 함수 호출 직후에는 물리 메모리를 공유하고 있지만 쓰기 작업을 하면 별도의 물리 메모리가 할당됩니다. (data *=2 하기 전의 메모리 주소가 서로 다름을 알 수 있다.)

공유 메모리(Shared memory) 방식을 사용하면 여러 프로세스에 동일한 메모리 영역을 매핑할 수 있습니다. 이번에는 mmap() 시스템 콜을 사용한 공유 메모리가 어떻게 되는지 살펴봅시다.

#!/usr/bin/python3

import os
import sys
import mmap
from sys import byteorder

PAGE_SIZE = 4096

data = 1000
print("자식 프로세스 생성 전 데이터 값: {}".format(data))
shared_memory = mmap.mmap(-1, PAGE_SIZE, flags=mmap.MAP_SHARED)

shared_memory[0:8] = data.to_bytes(8, byteorder)

pid = os.fork()
if pid < 0:
    print("fork()에 실패했습니다", file=os.stderr)
elif pid == 0:
    data = int.from_bytes(shared_memory[0:8], byteorder)
    data *= 2
    shared_memory[0:8] = data.to_bytes(8, byteorder)
    sys.exit(0)

os.wait()
data = int.from_bytes(shared_memory[0:8], byteorder)
print("자식 프로세스 종료 후 데이터 값: {}".format(data))
$ ./shared-memory.py 
자식 프로세스 생성 전 데이터 값: 1000
자식 프로세스 종료 후 데이터 값: 2000


시그널

POSIX(Portable Operating System Interface for Unix)에는 SIGUSR1, SIGUSR2 처럼 프로그래머가 자유롭게 용도를 정하면 되는 시그널이 있습니다. 이런 시그널을 사용해서 두 프로세스가 서로 시그널을 주고받으며 진행 정도를 확인하면서 처리를 진행할 수 있습니다.

파이프

프로세스는 파이프(|)를 통해 통신할 수 있습니다. bash 같은 셸에서 | 문자로 프로그램끼리 처리 결과를 연계할 수 있습니다.

소켓

리눅스는 프로세스끼리 소켓(Socket)으로 연결해서 통신할 수 있습니다. 소켓은 무척 널리 사용되는 중요한 기능입니다. 소켓은 크게 나눠서 두 종류가 있습니다. 하나는 유닉스 도메인 소켓(UNIX domain socket)입니다. 이 소켓은 같은 기기에 있는 프로세스 사이에서만 통신하는 방법입니다. 또 다른 하나는 TCP 소켓, UDP 소켓 입니다. 이쪽은 인터넷 프로토콜 스위트(Internet protocol suite) 또는 TCP/IP 프로토콜(규약)에 따라서 여러 프로세스와 통신합니다. 유닉스 도메인 소켓에 비해서 속도가 느린 편이지만 다른 기기에 있는 프로세스 사이에도 통신이 가능하다는 큰 장점이 있습니다. 이런 TCP, UDP 소켓은 인터넷에서 널리 사용하고 있습니다.


배타적 제어

시스템에 존재하는 자원에는 동시에 접근하면 안되는 것이 많습니다. 이런 문제를 방지하기 위해 어떤 자원에 한 번에 하나의 처리만 접근 가능하게 관리하는 배타적 제어(exclusive control) 구조가 존재합니다.
배타적 제어는 직관적이지 않아 이해하기 어려우므로 비교적 이해하기 쉬운 편인 File lock 구조를 사용해서 설명합니다.

#!/bin/bash
TMP=$(cat count)
echo $((TMP + 1)) >count
$ echo 0 > count
$ for ((i=0;i<1000;i++)) ; do ./inc.sh & done; for ((i=0;i<1000;i++)); do wait; 
done
...
$ cat count
18

inc.sh 스크립트를 & 백그라운드 병렬로 1000개 실행 시 기대 값 1000이 다른 결과가 나왔습니다. 이런 문제를 피하려면 count 갑승ㄹ 읽어서 1을 더하고 그 값을 count 파일에 다시 쓰는 처리가 한 번에 하나의 inc.sh 프로그램에서만 실행되도록 해야 합니다. 이걸 실제로 구현하는 방법이 상호 배제(mutual exclusion) 입니다.

  • Critical section(임계구역): 동시에 실행되면 안되는 처리 흐름을 뜻합니다. inc.sh 프로그램이라면 count 값을 읽어서 1을 더하고 count 파일에 다시 쓰는 처리에 해당합니다.
  • Atomic 처리: 시스템 외부에 봤을 때 하나의 처리로 다루어야 하는 처리 흐름을 말합니다. 예를 들어 inc.sh 프로그램의 크리티컬 섹션이 아토믹하다면 A프로그램이 count 파일에서 0을 읽고 count 파일에 1을 쓰는 동안, 프로그램 B는 중간에 끼어들 수 없습니다.

inc.sh 프로그램에서 배타적 제어를 구현하는 방법으로, lock 파일을 사용해서 파일이 존재하면 다른 처리가 크리티컬 섹션에 들어가 있음을 뜻하는 방법은 어떨까요? inc-wrong-lock.sh

#!/bin/bash

while : ; do
  if [ ! -e lock ] ; then
    break
  fi
done
touch lock
TMP=$(cat count)
echo $((TMP + 1)) >count
rm -f lock

코드를 보면 기존의 inc.sh 프로그램에서 처리 시작 전에 lock 파일 유무를 확인합니다. 파일이 존재하지 않을 때만 lock 파일을 만들고 크리티컬 섹션에 들어가고 처리가 끝나면 lock 파일을 지우고 종료합니다.

$ echo 0 > count
$ rm lock
$ for ((i=0;i<1000;i++)) ; do ./inc-wrong-lock.sh & done; for 
((i=0;i<1000;i++)); do wait; done
...
$ cat count
14

기대와 전혀 다른 값이 출력되었습니다. 왜 그런 걸까요?

  1. inc-wrong-lock.sh 프로그램 A가 lock 파일이 없는 걸 확인하고 진행
  2. inc-wrong-lock.sh 프로그램 B가 lock 파일이 없는 걸 확인하고 진행
  3. inc-wrong-lock.sh 프로그램 A가 count 파일에서 0을 읽음
  4. inc-wrong-lock.sh 프로그램 B가 count 파일에서 0을 읽음
  5. 이후 inc.sh 프로그램과 동일한 현상이 발생

이런 문제를 피하려면 lock 파일 존재를 확인하고 파일을 작성하는 처리 흐름이 중간에 끼읃ㄹ수 없도록 모두 아토믹 처리가 되어야 합니다. 어쩐지 똑같은 말을 반복하는 것 같지만 실제로 이렇게 하는 방법이 바로 File lock 입니다. File lock은 flock()이나 fcntl() 시스템 콜을 사용해서 어떤 파일의 lock/unlock 상태를 변경합니다. 구체적으로는 다음 처리를 중간에 다른 처리가 끼어드는 일 없이 아토믹하게 실행합니다.

  1. 파일이 lock 상태인지 확인합니다.
  2. lock 상태라면 시스템 콜이 실패합니다.
  3. unlock 상태라면 lock 상태로 바꾸고 시스템 콜이 성공합니다.

시스템 콜 사용법은 설명하지 않겠지만 좀 더 알아보고 싶다면 man 2 flock 또는 man 2 fcntl해서 F_SETLK, F_GETLK 설명을 확인해 보기 바랍니다.

File lock을 거는 방법은 flock 명령어로 쉘스크립트에도 사용 가능합니다. inc-lock.sh 프로그램 처럼 첫 번째 인수에 파일을 지정하면 해당 파일을 lock 상태로 만들고, 두 번째 인수로 지정한 프로그램을 실행해 줍니다.

inc-lock.sh

#!/bin/bash

flock lockfile ./inc.sh
$ echo 0 > count
$ touch lockfile
$ for ((i=0;i<1000;i++)) ; do ./inc-lock.sh & done; for ((i=0;i<1000;i++)); do wait; done
...
$ cat count
1000


멀티 프로세스와 멀티 스레드

프로그램을 병렬 동작시키는 방법은 두 가지가 있습니다. 하나는 전혀 다른 일을 하는 여러 프로그램을 동시에 동작시키는 것이고, 또 다른 방법은 어떤 목적을 지닌 하나의 프로그램을 여러개의 흐름으로 분할해서 실행하는 것입니다. 이번에는 어떤 목적을 지닌 하나의 프로그램을 여러 개의 흐름으로 분할해서 실행하는 방법을 설명합니다. 분할 실행 방법은 크게 두 종류로 나눠서 멀티 프로세스와 멀티 스레드가 있습니다.
멀티 프로세스는 앞서 설명한 fork()함수나 execve()함수를 사용해서 필요한 만큼 프로세스를 생성하고 이후에 각자 프로세스 통신 기능을 사용해서 처리합니다. 한편, 멀티 스레드는 프로세스 내부에 여러 개의 흐름을 작성합니다.





Reference

  • 그림으로 배우는 리눅스 구조 (다케우치 사토루)
  • E-Book

Tag: [ book  linux  memory  ]