https://www.yes24.com/Product/Goods/122109062
그림으로 배우는 리눅스 구조 - 예스24
선배가 옆에서 하나하나 알려주듯 친절히 설명해주는실습과 그림으로 배우는 리눅스 지식의 모든 것 * Go 언어와 Python, Bash 스크립트 실습 코드 제공* 이 도서는 『실습과 그림으로 배우는 리눅
www.yes24.com
1. 빠른 프로세스 작성 처리
이전 포스트까지는 page table entry(PTE)에 물리 주소만 존재한다고 가정하였다. 그러나 지금부터는 다양한 비트를 추가할 것이다. 먼저, PTE에 쓰기 권한 비트를 추가하면, fork()를 했을 때 다음과 같이 같은 물리 주소를 두 프로세스가 공유하다록 구성한다. 이때 초기 상태에서는 두 프로세스 모두 쓰기 권한은 없다.
이때 둘 중 한 프로세스가 데이터를 갱신하고자 하면, 즉 "쓰기"를 하고자 하면, PTE에서 쓰기 권한이 없으므로 Page Fault가 발생하고, Page Fault 핸들러가 해당하는 공유 페이지를 해제하고, 프로세스마다 전용 페이지를 만들어서 사용하도록 한다. 이때부터는 두 가지의 물리 공간이 생성되었으므로 둘 다 쓰기 권한이 생긴다.
이것이 Copy-on-Write(COW)이다. write 하려는 '순간'에 copy를 진행한다는 것이다. 이러한 COW 방식을 채용하면 fork()를 빠른 시간 안에 동작시킬 수 있으며, 시스템 전체의 메모리 사용량도 최대한 줄일 수 있다.
cow.py를 통해 이 과정을 실습해보자. 이 프로그램은 100MiB 메모리 확보하고, 모든 페이지에 데이터를 쓰고, 이후 fork()를 사용해서 같은 과정을 반복한다.
#!/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()
부모 프로세스가 100MiB를 사용하고, 이어서 자식 프로세스도 100MiB를 사용함을 확인한다. 중요한 사실은, 두 개의 프로세스가 가동되었지만 증가한 메모리양은 메모리를 "쓴" 부분인 100MiB만큼만 이라는 것이다! 이를 통해 메모리 용량을 절약할 수 있다. 또 중요한 건 RSS 필드가 자식 생성 직후와 자식 프로세스 메모리 접근 후가 큰 차이 없다는 것이다. 왜냐하면, RSS 필드는 페이지 테이블 내에서 물리 메모리 할당 메모리 합계를 나타낼 뿐이기 때문이다.
예를 들어, 자식 프로세스가 처음에는 300~700을 점유하고 있었다면 그 합계인 400을 나타내고, 이후 copy on write 후 할당 메모리가 300~600, 700~800으로 변경되었다고 해도 그 합계는 400으로 같다.
2. execve() 함수의 고속화 : Demand paging
Demand paging 기능은 execve() 함수 호출에도 어울리는 기능이다. 앞서 Demand paging은 메모리에 접근할 때 그 부분만 새롭게 할당을 해주는 것이다. 이것을 paging에도 응용해보자. execve() 호출 직후에 가상 메모리와 페이지 테이블만 만들고 물리 메모리는 할당을 해주지 않는다(당연히 disk에서 메모리에 load를 하지 않으므로 즉시 이루어진다.)
이때 프로그램이 엔트리 포인트에서 시작한다면 Page Fault가 발생하므로, Page Fault handler가 disk에서 메모리에 로드하고 물리 주소를 page table entry에 추가해준다. exception에서 돌아온 프로세스는 다음 명령을 이어가고, 프로세스가 진행되다가 새로운 page에 접근하면 동일한 과정이 반복된다.
3. 프로세스 통신
여러 프로그램이 협조해서 동작하기 위해서는, 프로세스끼리 타이밍을 맞춰서(동기화해서) 처리해야 한다. 이를 위한 OS의 기능이 프로세스 통신이다. 하지만 프로세스를 원하는 대로 동작시키기란 쉬운 일이 아니다. 다음 코드를 보자.
#!/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:
data *= 2
sys.exit(0)
os.wait()
print("자식 프로세스 종료후 데이터 값: {}".format(data))
이 코드에서 원하는 동작은 다음과 같다.
- 자식 프로세스가 data를 1000에서 2000으로 2배 늘려주고 종료한다.
- 부모 프로세스는 자식 프로세스가 끝날 때까지 기다리고, data값(2000 기대)를 출력하고 종료한다.
그러나 실행하면 다음과 같이 원하는 결과를 얻을 수 없다.
방금 배웠던 것을 생각해보면 이유는 단순하다. 자식 프로세스가 data를 변경하려는 순간, 부모 프로세스와 독립된 메모리 공간을 할당받기 때문이다. 따라서 자식 프로세스가 종료된 후에는 메모리를 반납하고, 부모프로세스의 data는 그대로 1000임이 자명하다. 그렇다면 이를 어떻게 해결해야 할까? 답은 shared memory를 갖도록 하는 것이다.
위 그림처럼 공유 메모리를 만들고 기록한다면 원하는 결과를 도출할 수 있다. shared memory를 사용한 코드는 다음과 같다.
#!/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))
이 밖에도 시그널, 파이프, 그리고 TCP/IP를 배울 때 등장하는 소켓이 프로세스의 통신 방법에 해당한다.
4. 배타적 제어
시스템에 존재하는 자원에는 동시에 접근하면 안되는 것이 있다. 이를 위해서 어떤 자원에 한 번에 하나의 처리만 접근 가능하도록 관리하는 구조가 있는데, 이것이 바로 배타적 제어(exclusive control) 구조이다. 이를 실습하기 위해 비교적 쉬운 방법인 File lock 구조를 사용해보자. 우선 실습 전에 에디터를 이용해 count file을 만들고 거기에 0을 넣는다.
이때 cat count 명령을 사용하면 당연히 0이 나올 것이다. 그러면 다음과 같이 count를 1 늘리고 종료하는 inc.sh 프로그램을 실행하고 다시 count를 확인해 보자.
#!/bin/bash
TMP=$(cat count)
echo $((TMP + 1)) >count
예상대로 1이 나온다. 그러면 count을 다시 0으로 되돌리고 inc.sh 파일을 1000번 실행해 보자.
예상대로 1000이 나온다. 이제부터는 inc.sh를 2개의 프로세스씩 병렬로 실시해보자. 동일한 작업을 병렬로 처리할 뿐이므로 예상값은 똑같이 1000이다.
놀랍게도 2가 나왔다.. 왜 이렇게 나왔을지를 다음 시나리오로 설명할 수 있다.
- inc.sh 프로그램에서 A가 0을 읽음
- inc.sh 프로그램에서 A가 1을 쓰기 전 B가 0을 읽음.
- inc.sh 프로그램에서 A가 1을 씀
- inc.sh 프로그램에서 B가 1을 씀
시나리오는 언제든지 바뀔 수 있다. 핵심은 어떤 프로세스가 쓰기 전에 다른 프로세스가 읽는 행위를 막아야 한다는 것이다. 이를 실제로 구현하는 방법이 상호 배제(mutual exclusion) 이다. 이를 구현하기 위해 이쯤에서 두 가지 용어를 정의하자.
- 크리티컬 섹션(critical section) : 동시에 실행되면 안되는 처리 흐름
- 아토믹(atomic) 처리 : 외부에서 봤을 때 하나의 처리로 다루어야 하는(즉, 쪼갤 수 없는) 처리 흐름. 예를 들어 x++; 라는 코드는 실행되거나, 실행되지 않을 뿐 중간에 다른 프로세스가 끼어들 수 없다.
그러면 배타적 제어를 구현한 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
이 코드의 알고리즘을 보면
- lock이 걸려있으면 무한루프를 돈다.
- lock이 없어져서 무한루프에서 풀려나면 lock을 설정하고, count++ 하고 lock을 푼다.
잘 동작할 것 같은데 결과를 확인해보자.
하지만 9가 나온다.. 왜 제대로 동작하지 않았을까? concurrency problem의 원인을 찾기 위해서는, 항상 "worst case scenario"를 찾기 위해 노력해야 한다. 이 코드의 최악의 시나리오는 무엇일까?
- A가 lock이 없음을 확인하고 무한루프를 깨고 lock을 걸기 바로 직전!!
- B가 lock이 없음을 확인하고 무한루프를 깸
- A가 lock을 걸고 count++함.(0->1)
- B가 lock을 걸고 count++함.(0->1)
이러한 최악의 경우가 생기는 것이다. 즉, "lock이 없음을 확인했다면 lock을 설정" 하는 부분이 "Atomic" 해야 한다는 것이다!!! 이를 위해서는 시스템 콜을 사용해 커널의 도움을 받아 atomic 처리를 해주자. 이를 위해서 flock(), fcntl() 시스템 콜을 사용한다. 그러면 최종적으로 flock 시스템 콜을 사용한 inc-look.sh 프로그램을 사용해서 실습을 해보자.
#!/bin/bash
flock lock ./inc.sh
이렇게 배타적 제어를 완료(?) 했다고 생각하지만 배워야 할 건 사실 더 많다! 이 책에는 나와있지 않지만 mutex semaphore에 대해서도 공부해야 한다.. 그밖에도 atomic 처리를 조금더 심화시켜 배우기 위해 compare and exchange, compare and swap에 대해서도 공부하면 좋다.
5. 멀티 프로세스와 멀티 스레드
병렬컴퓨팅의 핵심은 멀티 프로세스와 멀티 스레드를 얼마나 효율적으로 사용하는가에 달려 있다. 멀티 프로세스는 앞서 fork()와 execve()함수를 이용해 처리한다고 설명했다. 그러면 멀티 스레드는 어떻게 구현하는가? 애초에 스레드는 뭘까? 스레드의 등장 배경은, 한 프로세스를 병렬 처리할 수 없을까? 라는 논의에서 시작되었다. 구체적으로 POSIX 스레드 API를 보면 다음과 같이 스레드를 스케줄링할 수 있다.
#include <pthread.h> // pthread_t, pthread_create(), pthread_join() ...
void* func0000(void* args0);
void* func0001(void* args1);
int main(void){
pthread_t tid[2];
int args[2];
int status;
pthread_create(&tid[0], NULL, func0000, (void*)&args[0]);
pthread_create(&tid[1], NULL, func0001, (void*)&args[1]);
pthread_join(tid[0], (void**)&status);
pthread_join(tid[1], (void**)&status);
return 0;
}
스레드는 이런 식으로 한 프로세스에서 여러 개의 처리를 쪼개서 진행할 수 있다. 스레드의 장점과 단점을 살펴보면 다음과 같다.
-장점
- 페이지 테이블 복사가 필요 없어서 생성 시간이 짧다.
- 다양한 자원을 동일한 프로세스의 모든 스레드가 공유하므로 메모리를 비롯한 자원 소모량이 적다.
- 모든 스레드가 메모리를 공유하므로 협조해서 동작하기 쉽다.
-단점
- 하나의 스레드에서 발생한 장애가 모든 스레드에 영향을 준다.
- 스레드가 자원을 공유하므로 여기에서도 자원 선점과 동기화 문제를 가진다.
장점이 좋지만, 단점이 치명적이기에 멀티 스레드 프로그램을 만드는 것은 쉽지 않지만, 최근에는 간단하고 안전하게 멀티 스레드 프로그램을 작성할 수 있는 다양한 지원 기능이 존재한다.
'Linux > 그림으로 배우는 리눅스 구조' 카테고리의 다른 글
[그림으로 배우는 리눅스 구조] 6. 장치 접근(2) (0) | 2024.08.30 |
---|---|
[그림으로 배우는 리눅스 구조] 6. 장치 접근(1) (0) | 2024.08.30 |
[그림으로 배우는 리눅스 구조] 4. 메모리 관리 시스템(2) (0) | 2024.08.29 |
[그림으로 배우는 리눅스 구조] 4. 메모리 관리 시스템(1) (0) | 2024.08.29 |
[그림으로 배우는 리눅스 구조] 2. 프로세스 관리(기초편) (0) | 2024.08.27 |