Linux Kernel 관련 온라인 강의 요약본입니다.
https://olc.kr/course/course_online_view.jsp?id=35&s_keyword=Kernel&x=0&y=0
Process Create Overhead
앞에서 새로운 프로세스를 생성하는 fork()에 대해서 알아봤습니다. 이렇게 새로운 프로세스를 생성하는 과정에서 다음과 같은 큰 오버헤드(Overhead)를 유발합니다.
1.
부모 프로세스의 메모리 이미지를 복사하는 오버헤드
2.
부모 프로세스의 PCB를 복사하는 오버헤드
PCB는 수 킬로바이트 크기이며, 이미지는 더 클 수 있습니다.
이러한 메모리를 할당받고 복사하는 과정에서 발생하는 오버헤드를 줄이는 방법을 알아보겠습니다.
Linux PCB consists of 6 structs
Linux의 경우 모든 실행 단위를 테스크(Task)로 본다고 앞에서 설명했습니다.
이런 테스크는 스레드(Thread)와 프로세스(Process)로 볼 수 있으며, 다시 말해 스레드와 프로세스는 커널에서 테스크로 관리됩니다. → 본 글에서 스레드와 프로세스를 모두 테스크로 칭하겠습니다.
커널에서 테스크를 관리하는 자료 구조는 task_struct이고, 일반적으로 이는 PCB라고 부르고 있습니다.
PCB를 크게보면 6가지로 분류할 수 있습니다.
1.
task basic info
테스크를 실행하기 위한 정보(ex. PID 등등)
2.
files
open한 file의 정보
3.
file system
현재 작업 디렉터리 및 루트 디렉터리 정보
4.
tty
터미널 정보
5.
mm
가상 주소 공간 정보
6.
signals
signal handler, mask, pending signal 정보
struct task_struct
task_struct 구조체 안에는 많은 데이터를 가지고 있습니다.
실제로는 조금 다르지만 일단 강의에 사용된 구조체를 기반으로 설명드리겠습니다.
아래의 구조체에서 중요한 5개의 포인터를 보면 files, fs, tty, mm, signal가 존재합니다.
해당 5개의 포인터에 나머지 변수들을 합쳐서 task_basic_info를 더해 총 6개의 구조로 분리하여 관리하고 있습니다.
실제 task_struct에 여러 데이터가 존재하고 있습니다.
why 6 structs? Reduce Process Creation Overhead
왜 6가지로 분리해서 설명하는지 보겠습니다.
이때까지 fork()를 호출한다는 것은 PCB를 복사한다고 설명했습니다.
만약 Task basic info, files, fs, tty, mm, signals를 모두 copy(read, write) 한다면, 사용하지 않는 메모리 공간이 낭비될 뿐만 아니라 fork() 직후 exec()로 바로 덮어쓰는 경우 불필요한 연산이 발생하게 됩니다.
부모 테스크와 자식 테스크가 동일하게 사용하는 영역이 있다면, 이를 복사하지 않고 공유함으로써 메모리 복사에 따른 오버헤드를 줄일 수 있습니다.
Comparison: Creating child as a Process
예시를 통해서 알아보겠습니다.
동일한 Game XYZ 프로그램을 2개를 실행한다고 가정했을 때, 메모리에 동일한 프로그램을 2개 적재하는 것은 메모리 낭비입니다.
결국 프로그램이 실행되는 코드(text) 영역은 읽기 전용으로 매핑되어 변하지 않고 동일하며, 프로그램이 실행되는 동안 stack, bss, data, heap 영역이 변경될 것이기 때문입니다.
Comparison: Creating child as a Thread
오버헤드를 줄이기 위해, 스레드(Thread)는 테스크 간에 공유 가능한 영역(mm, files, fs 등)은 복사하지 않고 공유하며, 각 스레드마다 실행에 필요한 최소한의 정보만을 가지고 있습니다.
이때 각 스레드는 독립적인 task_struct를 가지지만, 내부의 포인터 데이터를 공유하여 동일한 객체를 가리키도록 설정됩니다.
이렇게 되면 왜 task_struct에 포인터 데이터가 왜 존재하는지 이해할 수 있습니다.
Linux “thread”
Linux에서는 스레드(thread)를 LWP(Light-weight Process)라고도 부르며, fork()가 아닌 clone() 시스템 콜로 생성할 수 있습니다.
clone() system call
clone 시스템 콜이 호출되면 커널은 sys_clone()을 호출합니다.
clone은 5개의 비트를 전달하며, 각 비트는 차례대로 files, fs, tty, mm, signals순으로 1이면 복사, 0이면 공유한다는 뜻입니다.
위의 그림에서 10101의 경우 files, tty, signals는 복사하여 공간을 할당하고 fs, mm은 공유하여 사용하겠다는 뜻 입니다.
실제 지금 clone은 조금 다르지만 일단 큰 그림만 이해하고 넘어간 뒤 공식 문서를 보면서 사용하기면 될 듯 합니다.
제일 Light-Weight인 경우 모든 bit가 0이며, 모든 bit가 1이면 Heavy-Wegiht이라고 부릅니다.
Clone()
커널 입장에서 볼 때 fork(), vfork(), clone() 모두 새로운 테스크를 생성하는 흐름을 가지며, 각 시스템 콜은 어떤 자원을 복사하거나 공유것 인지만 다릅니다.
fork 계열의 시스템 콜과 clone 시스템 콜은 커널 내부에서 do_fork() 함수를 사용하여 새로운 테스크를 생성합니다.
COW(Copy-On-Write)
메모리 이미지란 프로세의 가상 주소 공간 전체를 의미합니다.
지금까지 우리는 PCB의 오버헤드를 줄이는 법에 대해서 알아봤습니다. 하지만 실행 중인 테스크의 메모리 이미지는 PCB보다 훨씬 더 사이즈가 크기 때문에 이를 그대로 복사하는 경우 더 많은 오버헤드가 발생합니다.
아래의 코드에서 부모 테스크가 fork()로 자식 테스크를 생성하면, 자식 테스크는 부모 테스크의 메모리 이미지를 전부 복사하고 복사한 메모리에 다시 /bin/data의 데이터를 덮어쓸 것 입니다.
이를 다시 생각해보면 결국 자식 테스크는 /bin/date로 덮어쓸 것 인데 굳이 부모 테스크의 메모리 이미지를 복사할 필요가 없습니다.
그래서 Linux는 자식 테스크를 생성하는 경우 부모 테스크의 메모리 이미지를 그대로 복사하는 것이 아니라 page mapping table이라는 메모리에 올라와 있는 이미지 영역의 주소만을 복사도록 구현되어 있습니다.
위와 같이 page mapping table만 복사한다면 메모리 전체를 복사하는 것이 아니라 오버헤드를 줄일 수 있습니다.
이렇게 서로 공유하고 있는 페이지에 read하는 것은 문제가 되지 않습니다. write를 하는 경우 문제가 발생하며, 이 때 새로운 페이지를 복사하여 수정합니다.
일단 복사한 뒤 쓰기 작업이 일어나는 경우 페이지를 새롭게 복사하는 것을 Copy-On-Write 줄여서 COW라고 부릅니다.
Parent Process and the Non-Guaranteed wait()
앞선 설명에서는 fork() 이후 CPU는 항상 부모 테스크가 점유하고 있다고 가정했습니다.
그리고 부모 테스크는 바로 wait을 호출한다는 보장을 할 수 없고 이 때 문제가 발생합니다.
만약 부모 테스크가 wait을 호출하지 않고 fork() 이후 바로 메모리에 write를 수행하면, COW(Copy-On-Write)에 의해 부모 테스크의 페이지가 새로 복사됩니다.
여기까지는 문제가 없는 것 처럼 보이지만, 스케줄러가 CPU를 자식 테스크에게 할당하면 자식 테스크는 exec를 통해 /bin/ls를 수행합니다. 결국 복제된 페이지는 의미 없이 복사되었다가 ls로 overlap됩니다.
이러한 불필요한 COW를 줄이기 위해서 fork()가 실행된 이후 커널은 CPU를 부모 테스크가 아닌 자식 테스크에게 전달합니다.
마지막으로 정리해보면 아래와 같은 순서를 따릅니다.
1.
fork()가 호출된 이후 커널은 자식 테스크를 ready queue에 우선 순위를 높여 넣어줍니다.
2.
커널은 시스템 콜 호출 이후 현재 ready queue에서 가장 우선 순위가 높은 테스크를 선택해 CPU를 전달
3.
부모 테스크보다 자식 테스크가 우선 순위가 높음으로, 자식 테스크 실행
4.
자식 테스크에서 exec등 메모리 연산 수행
5.
스케줄러에 의해 부모 테스크와 자식 테스크 중 우선 순위가 높은 테스크에 CPU를 할당
결과적으로 불필요한 COW를 줄이기 위해, 자식 테스크를 먼저 실행한다고 볼 수 있습니다.
후기
커널이 지속적으로 업데이트되면서 현재는 사용되지 않거나 변경된 개념들이 존재해 어떻게 정리해야 하는지 다소 애매한 부분이 있었습니다.
다만 본 글에서는 강의 내용을 기반으로 정리하되, 리눅스 커널의 기본 개념과 설계 이념을 이해하고, 필요에 따라 특정 커널 버전을 직접 분석하는 방식으로 공부하면 될 듯 합니다.













