#os #computerarchitecture
> [!introduction]
> 오늘날 우리는 컴퓨터로 여러가지 일을 동시에 합니다. 저도 지금 웹브라우저로 필요한 정보를 찾는 동시에 화면을 반으로 나누어 한쪽에 초고를 띄워놓고 나머지 반쪽 화면을 통해 이 글을 적고 있습니다. 어떻게 이런 일이 가능한 것일까요? 그 비밀은 프로세스*Process*라는 개념에 있습니다. 이 글에서는 프로세스에 대한 이론적 내용부터 실제 운영체제에서 어떻게 프로세스를 구현하고 있는지 알아보며 프로세스의 탄생과 죽음을 추적합니다.
# 프로세스란 무엇인가?
프로세스가 무엇인지를 깊이 이해하기 위해서는 주변 개념들도 같이 섭렵하는 것이 좋은 방법 중 하나인데, 이를 위해서는 프로세스와 관련 개념들이 컴퓨터와 함께 발전해왔다는 것을 알 필요가 있다. 그런 측면에서, 우선은 과거의 프로세스가 어떤 개념이었는지를 간략하게나마 알아보자.
## 과거의 프로세스
컴퓨터라는 물건이 탄생한지 얼마 되지 않았던 시절, 아직 운영체제라는 개념이 존재하지 않았던 컴퓨터는 연구실에서나 볼 수 있는 귀한 물건이었고 한 대를 여러 사람이 같이 써야 할 만큼 비쌌다. 프로그램은 주로 천공 카드*punched card*라는 물리적인 형태를 갖추고 있었고, 컴퓨터는 주변기기를 통해 프로그램에 기록된 명령을 순서대로 읽고 그에 맞는 명령을 실행했다.

프로그램 하나를 시작하고 나면 그 프로그램이 끝나야 다른 프로그램을 투입할 수 있었기 떄문에, 컴퓨터를 사용하고자 하는 사람은 자신의 프로그램과 데이터를 작업 대기열*job queue*에 걸어놓고 먼저 신청한 사람의 작업이 끝날 때까지 기다려야 했는데, 이때 작업*job*은 컴퓨터를 차지한 바로 그 작업을 가리키는 말이었고, 컴퓨터가 이 작업을 실행하는 최소 단위를 태스크*task*라 불렀다.[^1][^2][^3]
![[OldJobQueue.png|실제 작업 대기열에 추가되고 있는 프로그램(물리)]]
위 그림은 EDSAC 소개 영상 중 프로그램이 기록된 천공 카드를 작업 대기열에 추가하고 있는 모습이다. EDSAC 옆에는 실제 사람이 운영자*operator*를 담당하며 천공 카드를 EDSAC의 테이프 리더기에 놓고 컴퓨터가 천공 카드에 기록된 프로그램을 읽고 실행하도록 했다. 어느 정도 시간이 지나면 프로그램이 종료되고, 운영자는 결과값이 적힌 종이를 보관함에 꽂아 해당 작업을 의뢰한 사람이 찾아가도록 했다.
오늘날도 컴퓨터는 프로그램을 실행한다. 하지만 컴퓨터가 발전하면서 이제 프로그램은 먼 옛날의 그 천공 카드가 아닌, 하드디스크와 같은 저장 장치에 실행할 수 있는 형태로 저장되어 있는 명령어의 집합이다. 이제는 운영자 대신 운영체제*operating system*가 컴퓨터를 관리하고, 천공 카드에 기록된 프로그램들이 실제로 줄을 서 있던 작업 대기열은 이제 큐*queue*라는 자료구조로 구현되는 추상적인 개념이 되었으며, 종이 대신 디스플레이에 결과값이 출력된다.
![[Loading.png]]
컴퓨터가 프로그램을 실행하려면 저장 장치에서 프로그램을 읽어 RAM으로 불러오는 방식으로 프로그램을 실행하는데, 이 과정에서 앞서 소개한 개념이 오늘날 사용되는 프로그램과 프로세스의 개념으로 변화하게 된다. 프로그램은 이제 구멍이 뚫린 종이 카드가 아니라 특정 매체에 저장된 목적 코드*Object Code*이며, **프로세스는 실행 중인 프로그램의 상태, 컴퓨터에서 연속적으로 실행되고 있는 프로그램의 '동적인 상태'다.**[^4]
[^1]: [케임브리지 대학 수학 연구소의 컴퓨터 EDSAC에 대한 1951년 영상](https://www.youtube.com/watch?v=6v4Juzn10gM)을 통해 초창기 컴퓨터가 어떻게 사용되었는지 엿볼 수 있다.
[^2]: 자세한 내용은 [이 링크](https://stackoverflow.com/questions/3073948/job-task-and-process-whats-the-difference)를 통해 확인할 수 있다. 오늘날 Job과 Task는 구분없이 사용되기도 하며 그 정의가 모호하다.
[^3]: 커널 수준에서 프로세스나 스레드를 다룰 때 작업*job*이라는 용어를 프로세스나 스레드 대신 사용하기도 한다: “Tasks or jobs are simply the pieces of work that should be performed in separate flows of execution.” (Amini, 2019, p. 395)
[^4]: 이제 작업은 한줄의 커맨드를 실행한 결과로 생성된 프로세스를 가리키는 개념이 되었다: “Unix shells use the abstraction of a job to represent the processes that are created as a result of evaluating a single command line.” (Bryant and O'Hallaron, 2016, p. 796)
# 프로그램에서 프로세스로
요약하자면 프로세스는 작동 중인 프로그램 및 그와 관련된 자원을 뜻한다. 그렇다면 프로그램은 어떻게 프로세스가 되는 것일까? 어떤 프로그램의 프로세스가 만들어지는 과정을 자세히 들여다보자. 개념과 더불어 리눅스 커널*Kernel*[^5]에서 프로세스 및 관련 개념을 어떻게 구현하였는지를 파헤쳐볼 것이다.
## User Mode & Kernel Mode
프로세스를 만들기 위해서는 우선 디스크 어딘가에 저장되어 있을 프로그램을 주 메모리로 불러와야 하는데, 이 작업이 이루어지는 시간 동안 운영체제가 하드웨어를 제어할 권한이 필요하다. 그런데 CPU, 메모리, 입출력 장치 등의 하드웨어는 여러가지 작업에서 공통적으로 사용하는 핵심 자원이기 때문에, 프로그램을 디스크에서 읽어오는 와중에 다른 프로세스가 하드웨어에 멋대로 개입해 작업을 망치게 둘 수는 없다. 오늘날 컴퓨터가 하는 일은 한두개가 아니다. 운영체제의 관점에서 보면 생각보다 컴퓨터가 하는 일이 엄청 많지만 그렇다고 해서 이 모든 일을 한번에 해낼 수는 없는 노릇이다. 컴퓨터가 사용 가능한 자원은 한정되어 있고 컴퓨터의 자원이 필요한 프로그램도 한두개가 아니다.
[^5]: 운영체제의 핵심이 되는 프로그램으로, 시스템의 모든 것을 완전히 제어하고 시스템을 관리한다.
그래서 일반적으로 운영체제에서는 임의의 프로세스가 하드웨어를 제멋대로 다룰 수 없도록 설계되어 있으며, 대신 커널을 통해서만 하드웨어를 다룰 수 있다. 당연히 커널과 응용프로그램(사용자)의 활동 영역은 철저하게 분리되어 있으며, 프로세스에서 코드를 실행하는 상황은 커널의 권한이 필요없는 사용자 모드*User Mode*와 커널의 권한이 있어야 실행할 수 있는 커널 모드*Kernel Mode*로 구분할 수 있다.
## Exception & Trap
그렇다면 사용자 모드에서 커널 모드로 넘어가는 그 과정은 어떻게 이루어지는 것일까? 바로 여기서 예외*exception*와 트랩*trap*이라는 개념이 등장한다.
![[AnatomyOfAnException.png|Bryant and O'Hallaron, 2016, p. 760]]
예외란 프로세스가 자신의 코드를 실행하다가 중단해야 하는 상황을 의미한다. 프로세스가 실행을 중단해야 하는 이유는 다양하다. 디스크에서 어떤 파일을 읽어오기 위해 사용자 모드에서 커널 모드로 넘어가야 하는 상황일 수도 있고, 프로세스가 강제 종료되는 상황일 수도 있다. 이렇듯 일단 프로세스를 중단해야 하는 일이 발생했을 때, 운영체제에서는 예외 처리를 위해 예외 처리를 전담하는 핸들러*Handler*에게 프로그램의 제어 권한을 잠시 넘기고 핸들러가 성공적으로 예외를 처리하면 다시 핸들러로부터 제어 권한을 넘겨받아 프로그램을 계속 실행한다. 예외 덕분에 응용프로그램과 운영체제는 평소에 분리되어 있다가 중요한 일이 생기면 서로 협동할 수 있다.
![[TrapHandling.png|Bryant and O'Hallaron, 2016, p. 763]]
트랩은 이 예외의 하위 개념 중 하나로, 응용프로그램이 고급 권한을 요구하는 명령을 수행하기 위해 **의도적으로** 발생시킨 예외다. 트랩을 통해 응용프로그램은 커널의 권한이 필요한 명령을 수행할 수 있으며, 이러한 명령을 시스템 호출*system call*이라 부른다.
## Exception Table
그렇다면 핸들러는 어떻게 각 시스템 호출에 맞는 명령을 수행할 수 있는 걸까? 그 비밀은 예외 테이블*Exception Table*이라는 자료구조에서 엿볼 수 있다. 프로세스를 생성시키기 위한 시스템 호출은 핸들러에게 제어 권한을 넘기는데, 이때 핸들러는 모든 시스템 호출을 처리하는 하나의 함수가 아니며 오직 하나의 예외만을 처리한다. 그래서 운영체제에는 보통 예외 처리를 담당하는 핸들러들을 가리키는 주소가 담긴 테이블이 구현되어 있다.
![[ExceptionTable.png|Bryant and O'Hallaron, 2016, p. 760]]
예외 테이블이라 불리는 이 테이블에는 여러가지 예외 상황을 규정하고 여기에 0부터 $n-1$까지의 번호를 겹치지 않게 하나씩 연결한다. 예를 들어, x86-64 시스템에서 실행되고 있는 리눅스 환경에서는 0으로 나누려 할 때 생기는 오류가 0번에 대응한다. 이 번호가 예외 테이블의 인덱스이며, 해당 인덱스가 가리키는 곳에는 예외 상황을 처리하기 위한 코드로 향하는 주소가 담겨 있어 예외 상황이 발생하면 커널은 상황에 맞는 코드를 실행할 수 있다. 여기에는 당연히 프로세스를 생성하기 위한 시스템 호출도 포함되어 있다. 현재 실행 중인 프로세스가 새 프로세스를 생성하는 시스템 호출을 실행하면 트랩이 발생하고, 이에 따라 프로그램의 제어 권한을 넘겨받은 커널에서는 트랩 테이블에서 프로세스를 생성하는 핸들러를 호출한다.
운영체제의 양대산맥이라 할 수 있는 리눅스와 윈도우 모두 프로세스를 생성하기 위한 시스템 콜이 존재하는데, 리눅스의 `fork()`, 윈도우의 `NtCreateProcess()`가 바로 새로운 프로세스를 생성하는 시스템 호출이다.
## 프로세스 목록
지금까지 설명한 것은 프로세스의 생성을 떠받치고 있는 이론적 기반이다. 이제 생성된 프로세스를 관리하기 위한 여러가지 장치를 살펴볼 차례다. 운영체제는 자원을 효과적으로 사용하고 사용자가 여러 프로그램을 동시에 사용할 수 있도록 실행 가능한 프로세스를 한곳에 모아 관리하는데, 이때 사용하는 대표적인 자료구조가 큐*Queue*다. 새로 만들어진 프로세스는 우선 당장 실행할 수 있는 프로세스는 레디 큐*Ready Queue*에 투입되며, 이곳에 있는 프로세스는 스케줄러에 의해 선택되기만 하면 바로 실행될 수 있도록 만반의 준비가 되어 있는 상태다. 그 외에도 만약 프로세스가 어떤 작업을 오랫동안 기다려야 하는 경우(ex. 입출력) 그 시간에 다른 프로세스를 돌리는 것이 합리적인데, 이를 위해 어떤 작업을 오랫동안 기다려야 하는 프로세스는 모두 대기열*Wait Queue*애 투입된다.
![[ReadyQueueAndWaitQueue.png]]
프로세스를 저장하는 큐는 보통 연결 리스트의 형태로 구현되고, 스케줄러는 일정한 주기로 큐 내부의 프로세스 중 실행할 프로세스를 선택*Dispatch*한다.
## Process Control Block
운영체제는 프로세스를 주기적으로 바꿔주면서 CPU가 최대한 쉬지 않고 일하게 만들어야 하기 때문에, 프로세스의 내부 구조와는 별개로 프로세스를 하나의 물체로 다룰 필요가 있다. 그래서 대부분의 운영체제에는 프로세스에 대한 정보를 담은 자료구조가 존재하는데, 이를 PCB*Process Control Block*라 부른다. PCB는 프로세스를 관리하기 위한 큐의 원소에 해당하며, 대략적인 구조는 아래와 같다.
![[PCBLayout.png|PCB의 대략적인 구조]]
PCB는 프로세스를 통제하기 위한 모든 정보들의 집합으로, Process Descriptor 혹은 Task Control Block라 부르기도 하는데, 여기에는 다음의 정보가 포함되어 있다.
- 프로세스의 상태
- 프로세스의 번호
- 프로세스의 PC 레지스터에 저장된 주소
- CPU 레지스터에 저장된 값들
- CPU 스케줄링에 대한 정보
- 메모리 관리에 대한 정보
- I/O 상태에 대한 정보
이정도면 실제 프로세스가 어떻게 구현되어 있고 어떻게 작동하는지 이해할 준비가 됐으니 이제 실제 운영체제의 사례와 함께 살펴보자. 리눅스에서는 PCB를 프로세스 서술자*Process Descriptor*라는 이름을 가진 `task_struct`라는 구조체로 구현되어 있으며, 여기에는 프로세스에 대한 모든 정보가 들어가 있다. 현재 기준 이 구조체는 리눅스 소스 코드의 `include/linux/sched.h`에 정의되어 있으나, 너무나 내용이 방대한 관계로 여기서는 일부 코드만 소개한다.
```C
struct task_struct {
#ifdef CONFIG_THREAD_INFO_IN_TASK
/*
* For reasons of header soup (see current_thread_info()), this
* must be the first element of task_struct.
*/
struct thread_info thread_info;
#endif
/* ... */
/*
* Pointers to the (original) parent process, youngest child, younger sibling,
* older sibling, respectively. (p->father can be replaced with
* p->real_parent->pid)
*/
/* Real parent process: */
struct task_struct __rcu *real_parent;
/* Recipient of SIGCHLD, wait4() reports: */
struct task_struct __rcu *parent;
/*
* Children/sibling form the list of natural children:
*/
struct list_head children;
struct list_head sibling;
struct task_struct *group_leader;
/* ... */
/* CPU-specific state of this task: */
struct thread_struct thread;
/*
* New fields for task_struct should be added above here, so that
* they are included in the randomized portion of task_struct.
*/
randomized_struct_fields_end
} __attribute__ ((aligned (64)));
```
이 구조체는 프로세스마다 하나씩 존재하며, 리눅스 커널에서는 태스크 리스트*Task List*라 불리는 원형 이중 연결 리스트를 통하여 프로세스를 관리하고 있다. 태스크 리스트에 각 프로세스에 해당하는 `task_struct` 구조체가 들어가 있으며, 스케줄러는 태스크 리스트에서 프로세스와 연결된 이 구조체를 선택하는 방식으로 실행할 프로세스를 선택한다. 그런데 `task_struct`는 현재의 리눅스 커널에서는 프로세스와 직접적으로 연결되어 있지 않다. 2.6 버전 이전에는 각 프로세스의 커널 스택 맨끝에 저장되어 있었지만, 그 이후부터는 커널에서 사용하는 자료구조를 전담하는 슬랩 할당자*Slab Allocator*라는 할당자를 통하여 `task_struct`구조체를 할당하고 해제하기 때문에 이제는 프로세스 할당자의 위치를 별도로 처리하고 있다.
### `thread_info`
그 방법이 바로 `arch/CPU_ARCH/include/asm/thread_info.h`에 정의된 `thread_info` 구조체다. 여기서 `CPU_ARCH`는 ARM64, x86 등 리눅스 커널이 지원하는 CPU 아키텍처를 의미한다.
> [!Warning]
> 커널 중에서도 하드웨어에 가까운 로우 레벨 코드에서는 동일한 구조체여도 아키텍처에 따라 내부 구현이 다르기 때문에 아키텍처에 따라 개념이 아닌 실제 구현의 경우, 이 글에 적힌 내용과 다른 방식으로 구현되어 있을 수도 있다. `thread_info`구조체의 경우, CPU 아키텍처마다 서로 다르게 구현되어 있다.
이 구조체는 프로세스에 대한 정보를 담고 있는데, 메모리의 상황에 따라 동적으로 만들어지는 `task_struct`와 달리 프로세스 커널 스택의 맨끝에 저장되어 있다. 이름에서 눈치챘을 수도 있겠지만, 리눅스에서는 프로세스와 스레드의 구현이 구분되지 않는다. 둘 다 동일한 자료구조를 사용하며, 차이는 단지 그 안의 값에서 비롯될 뿐이다.
```C
/*
* low level task data that entry.S needs immediate access to.
*/
struct thread_info {
unsigned long flags; /* low level flags */
#ifdef CONFIG_ARM64_SW_TTBR0_PAN
u64 ttbr0; /* saved TTBR0_EL1 */
#endif
union {
u64 preempt_count; /* 0 => preemptible, <0 => bug */
struct {
#ifdef CONFIG_CPU_BIG_ENDIAN
u32 need_resched;
u32 count;
#else
u32 count;
u32 need_resched;
#endif
} preempt;
};
#ifdef CONFIG_SHADOW_CALL_STACK
void *scs_base;
void *scs_sp;
#endif
u32 cpu;
};
```
위 코드는 ARM64 아키텍처에서 정의된 `thread_info`구조체다. arch/arm64/kernel/entry.S에서는 이 구조체에 직접 접근하여 태스크에 대한 정보를 확인한다. 리눅스에서 프로세스에 대한 모든 정보는 `task_struct` 구조체가 가지고 있는데, `thread_info`가 가장 먼저 등장하는 멤버이기 때문에 커널은 모든 프로세스의 `task_struct`에 효율적으로 접근할 수 있다. 리눅스에서는 `thread_info`에 효율적으로 접근하기 위해 `current_thread_info()`라는 매크로를 정의하고 있는데, `thread_info` 에서 가져온 예시와 동일한 아키텍처(ARM64) 기준으로 `include/linux/thread_info.h`에 정의되어 있으며 그 정의는 아래와 같다.
```C
#define current_thread_info() ((struct thread_info *)current)
```
### `current`
여기서 갑자기 등장한 `current`의 정체는 바로 `get_current` 함수로 이어지는 매크로다. `arch/arm64/include/asm/current.h`에는 `current` 매크로가 `get_current` 함수가 정의되어 있다.
```C
/*
* We don't use read_sysreg() as we want the compiler to cache the value where
* possible.
*/
static __always_inline struct task_struct *get_current(void)
{
unsigned long sp_el0;
asm ("mrs %0, sp_el0" : "=r" (sp_el0));
return (struct task_struct *)sp_el0;
}
#define current get_current()
```
이 함수는 현재 실행 중인 프로세스를 얻어오는 함수인데, `sp_el0` 레지스터에 `task_struct`를 가리키는 포인터를 직접 저장하고 있는 것을 알 수 있다. 이는 곧 ARM64 아키텍처 기반의 하드웨어 환경에서는 레지스터 하나를 지정해 그곳에 현재 실행 중인 프로세스를 가리키는 포인터를 저장하는 방식으로 커널이 현재 프로세스에 언제든 접근할 수 있도록 하고 있다.
## 공간 마련하기
프로세스를 생성하는 핸들러에서는 프로세스가 사용할 주소 공간을 만들어주는데, 이 공간을 가상 메모리*Virtual Memory* 혹은 가상 주소 공간*Virtual Address Space*라 부른다. 커널은 새로운 프로세스를 위한 여러 자료구조를 생성하고 고유한 번호를 부여한다.
![[LinuxProcessVirtualMemory.png]]
프로세스가 사용하는 가상 메모리에는 크게 네 개의 구역이 존재하는데, 각각 다음과 같다:
- Code(Text) Section
- 프로세스의 코드가 실행 가능한 상태로 저장되어 있다.
- 크기가 고정되어 있으며, 실행 도중에 그 크기가 변하지 않는다.
- Data Section
- 전역 변수들이 저장되어 있다.
- 크기가 고정되어 있으며, 실행 도중에 그 크기가 변하지 않는다.
- Stack Section
- 지역 변수들, 반환 주소, 함수 인자 등 일시적인 수명을 가진 변수들이 저장되어 있다.
- 실행 중에 그 크기가 변한다.
- Heap Section
- 실행 중에 동적으로 할당된 메모리가 저장되어 있다.
- 실행 중에 그 크기가 변한다.
### `mmap` & `munmap`
리눅스에서 그 역할을 담당하고 있는 것은 바로 `mmap()` 함수와 `munmap()` 함수다.
```C
#include <unistd.h>
#include <sys/mman.h>
void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *start, size_t length);
```
`mmap()` 함수로 가상 메모리에 새로운 공간을 할당한다. `mmap()`함수는 `start`로부터 `offset`만큼 떨어진 곳부터 크기가 `length` 바이트인 곳에 파일 서술자 `fd`가 가리키는 새로운 메모리 공간을 할당한다. `prot`은 새로 할당된 메모리 공간의 접근 권한에 대한 매개변수이며, `flags`는 연결된 객체의 유형을 결정한다. `start`부터 시작하고 길이가 `length`인 가상 메모리 공간을 해제할 때는 `munmap()` 함수를 사용한다.
리눅스에서 실제로 가상 메모리를 구현할 때는 `task_struct`외에도 메모리 서술자*memory descriptor*`mm_struct`, `vm_area_struct`를 사용한다. 이들의 관계는 아래 그림과 같다.
![[LinuxVirtualMemory.png]]
## `fork()`
이제 리눅스 커널에서 프로세스 생성을 담당하는 시스템 콜인 `fork()`를 살펴보자. 정확히 말하자면 `fork()`는 하나의 프로세스가 다른 프로세스를 생성할 수 있도록 하는 시스템 호출이다. `fork()`를 호출하면, 현재 프로세스에서 파생된 새로운 프로세스가 생긴다. 함수는 한번 호출하면 한번 반환되는 것이 일반적이지만, `fork()`는 새로운 프로세스를 만들고, 이후의 코드는 각 프로세스에서 독립적으로 실행되기 때문에 이를 두고 `fork()` 함수는 **한번 호출하고 두번 반환된다**고 말한다.
```C
#include <unistd.h>
pid_t fork(void);
```
이 호출의 결과로 반환되는 값은 `pid_t`라는 자료형을 가지고 있는데, 이게 무엇인지를 이해하기 위해서는 리눅스를 비롯한 유닉스 기반 운영체제에서 여러 프로세스를 관리하는 방식을 알아야 한다.
### 프로세스 ID & 프로세스 그룹 ID
유닉스 운영체제는 생성된 프로세스에 고유의 번호*Process Identification Number(PID)*를 붙여 관리한다. 프로세스를 만들 때는 현재 실행 중인 프로세스에서 만들게 되는데, 이때 현재 실행 중인 프로세스를 부모 프로세스*Parent Process*, 새로 생성되는 프로세스를 자식 프로세스*Child Process*라고 부른다. 부모 프로세스로부터 자식 프로세스가 생겨나기 때문에 프로세스의 의존을 나타내는 그래프는 트리의 형태를 띤다. 이렇듯 유닉스 기반 시스템에는 프로세스 사이에 독특한 계층 구조가 존재하며, 모든 프로세스는 PID가 1인 `init` 프로세스의 자손이다. 시스템의 모든 프로세스는 정확히 하나의 부모 프로세스를 가지며, 모든 프로세스는 하나 이상의 자식 프로세스를 가질 수 있는데, 이때 같은 부모 프로세스를 가지는 자식 프로세스를 형제 프로세스*Sibling Process*라 부른다.
![[ProcessGroup.png|Bryant and O'Hallaron, 2016, p. 796]]
또한 프로세스의 수가 많아지면 이들을 묶어서 분류하기도 하는데, 이럴 때 사용하는 것이 프로세스 그룹 ID*Process Group ID*, 일명 PGID다. 그룹 ID가 같은 프로세스들은 한 그룹으로 묶이며, 이들은 PID와 PGID가 같은 프로세스를 뿌리 노드로 삼는 서브트리를 구성한다. 새로운 프로세스를 만들고 나면 부모 프로세스와 자식 프로세스 간의 관계를 다음과 같은 관점에서 규정할 수 있게 된다.
- Resource Sharing Option: 프로세스 간의 자원 공유*resource sharing*에 대한 옵션이다. 부모와 자식 프로세스가 모든 자원을 공유하거나, 자식 프로세스가 부모 프로세스의 자원 중 일부만 공유하거나, 부모와 자식 프로세스가 서로 자원을 공유하지 않는다.
- Execution Option: 프로세스의 동시 실행*execution*에 대한 옵션이다. 부모와 자식 프로세스가 번갈아 가며 동시에 실행될 수 있거나, 자식 프로세스가 종료되어야 부모 프로세스가 실행된다.
- Address Space Option: 프로세스의 주소 공간*Address Space* 에 대한 옵션이다. 자식이 부모와 동일한 데이터를 가지거나, 자식 프로세스가 새로운 데이터를 가진다.
리눅스에서 PID를 담당하는 자료형이 바로 `pid_t`이다. 즉 `fork()`가 반환하는 것은 PID인데 앞서 살펴봤듯 `fork()`는 두번, 그러니까 기존의 프로세스(부모 프로세스)와 새로운 프로세스(자식 프로세스)에서 모두 반환되고 그 값은 부모 프로세스에서는 자식 프로세스의 PID, 자식 프로세스에서는 0이다. 만약 오류가 생기면 발생한 오류를 의미하는 어떤 음수를 반환한다.
```C
struct task_struct {
// ...
/*
* Pointers to the (original) parent process, youngest child, younger sibling,
* older sibling, respectively. (p->father can be replaced with
* p->real_parent->pid)
*/
/* Real parent process: */
struct task_struct __rcu *real_parent;
/* Recipient of SIGCHLD, wait4() reports: */
struct task_struct __rcu *parent;
/*
* Children/sibling form the list of natural children:
*/
struct list_head children;
struct list_head sibling;
struct task_struct *group_leader;
// ...
```
또한 리눅스에서 부모 프로세스와 자식 프로세스에 접근할 수 있는데, 이는 각 프로세스와 연결된 `task_struct` 구조체에는 부모 프로세스와 자식 프로세스, 그리고 형제 프로세스를 가리키는 멤버들이 존재하기 때문이다. `parent`멤버와 `real_parent`멤버가 부모 프로세스를 가리키며, `children`은 자식 프로세스를, `sibling`은 형제 프로세스를 가리킨다. 심지어 프로세스 그룹의 최상위에 존재하는 프로세스도 `group_leader`멤버로 참조할 수 있다. 프로세스 계층 구조 덕분에 리눅스에서는 시스템의 어떤 프로세스에서도 다른 특정 프로세스를 찾아갈 수 있다.
### `fork()`의 구현 살펴보기
`fork()`의 호출 인터페이스는 앞서 봤듯 간단하지만, 그 구현을 파고 들어가면 결코 간단하지 않다. `fork()`의 구현은 GNU C 라이브러리(`glibc`) 등의 C 라이브러리에서 이루어지며, 내부적으로 `clone3()`를 호출한다. `clone3()`는 기존의 `clone()`시스템 호출을 대체하는 새로운 구현이며, 리눅스 커널의 `sys_clone3()` 함수와 연결되어 있다.
```C
/**
* sys_clone3 - create a new process with specific properties
* @uargs: argument structure
* @size: size of @uargs
*
* clone3() is the extensible successor to clone()/clone2().
* It takes a struct as argument that is versioned by its size.
*
* Return: On success, a positive PID for the child process.
* On error, a negative errno number.
*/
SYSCALL_DEFINE2(clone3, struct clone_args __user *, uargs, size_t, size)
{
int err;
struct kernel_clone_args kargs;
pid_t set_tid[MAX_PID_NS_LEVEL];
#ifdef __ARCH_BROKEN_SYS_CLONE3
#warning clone3() entry point is missing, please fix
return -ENOSYS;
#endif
kargs.set_tid = set_tid;
err = copy_clone_args_from_user(&kargs, uargs, size);
if (err)
return err;
if (!clone3_args_valid(&kargs))
return -EINVAL;
return kernel_clone(&kargs);
}
```
`sys_clone3()`를 호출하면, 복사해야 할 데이터를 `kargs`에 담은 뒤 `kernel_clone()`이라는 시스템 호출을 실행한다. 이어지는 소스 코드는 `kernel_clone()`의 코드다.
```C
/*
* Ok, this is the main fork-routine.
*
* It copies the process, and if successful kick-starts
* it and waits for it to finish using the VM if required.
*
* args->exit_signal is expected to be checked for sanity by the caller.
*/
pid_t kernel_clone(struct kernel_clone_args *args)
{
u64 clone_flags = args->flags;
struct completion vfork;
struct pid *pid;
struct task_struct *p;
int trace = 0;
pid_t nr;
/*
* For legacy clone() calls, CLONE_PIDFD uses the parent_tid argument
* to return the pidfd. Hence, CLONE_PIDFD and CLONE_PARENT_SETTID are
* mutually exclusive. With clone3() CLONE_PIDFD has grown a separate
* field in struct clone_args and it still doesn't make sense to have
* them both point at the same memory location. Performing this check
* here has the advantage that we don't need to have a separate helper
* to check for legacy clone().
*/
if ((clone_flags & CLONE_PIDFD) &&
(clone_flags & CLONE_PARENT_SETTID) &&
(args->pidfd == args->parent_tid))
return -EINVAL;
/*
* Determine whether and which event to report to ptracer. When
* called from kernel_thread or CLONE_UNTRACED is explicitly
* requested, no event is reported; otherwise, report if the event
* for the type of forking is enabled.
*/
if (!(clone_flags & CLONE_UNTRACED)) {
if (clone_flags & CLONE_VFORK)
trace = PTRACE_EVENT_VFORK;
else if (args->exit_signal != SIGCHLD)
trace = PTRACE_EVENT_CLONE;
else
trace = PTRACE_EVENT_FORK;
if (likely(!ptrace_event_enabled(current, trace)))
trace = 0;
}
p = copy_process(NULL, trace, NUMA_NO_NODE, args);
add_latent_entropy();
if (IS_ERR(p))
return PTR_ERR(p);
/*
* Do this prior waking up the new thread - the thread pointer
* might get invalid after that point, if the thread exits quickly.
*/
trace_sched_process_fork(current, p);
pid = get_task_pid(p, PIDTYPE_PID);
nr = pid_vnr(pid);
if (clone_flags & CLONE_PARENT_SETTID)
put_user(nr, args->parent_tid);
if (clone_flags & CLONE_VFORK) {
p->vfork_done = &vfork;
init_completion(&vfork);
get_task_struct(p);
}
if (IS_ENABLED(CONFIG_LRU_GEN_WALKS_MMU) && !(clone_flags & CLONE_VM)) {
/* lock the task to synchronize with memcg migration */
task_lock(p);
lru_gen_add_mm(p->mm);
task_unlock(p);
}
wake_up_new_task(p);
/* forking complete and child started to run, tell ptracer */
if (unlikely(trace))
ptrace_event_pid(trace, pid);
if (clone_flags & CLONE_VFORK) {
if (!wait_for_vfork_done(p, &vfork))
ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);
}
put_pid(pid);
return nr;
}
```
이 함수는 `copy_process()`함수를 호출하는데, `copy_process()`함수의 전체 내용을 요약하면 다음과 같다. 소스 코드의 내용이 방대한 관계로 핵심만 요약해 일부만 소개한다.
1. `dup_task_struct()`함수를 호출해 커널 스택을 새로 만들고, 새 프로세스를 위한 `thread_info`구조체와 `task_struct`구조체를 만든다. 새로 만들어진 데이터의 값은 현재의 태스크와 동일하며, 이 시점에서는 부모 프로세스와 자식 프로세스가 동일한 프로세스 서술자를 공유한다.
2. 새로 만든 자식 프로세스로 인해 현재 사용자의 프로세스 개수 제한을 넘어가지 않는지 확인한다.
3. 이제 자식 프로세스를 부모와 구별해야 한다. 프로세스 서술자의 다양한 항목을 초기화한다. 프로세스 서술자에서 부모 프로세스의 값을 물려받지 않는 항목은 주로 통계와 관련된 정보이며, 대부분의 `task_struct`멤버 값은 바뀌지 않는다.
4. 자식 프로세스의 상태를 `TASK_UNINTERRUPTIBLE`로 설정해 아직 실행되지 않게 한다.
5. `copy_flags()`함수를 호출해 `task_struct` 구조체의 `flags` 내용을 정리한다. 작업이 관리자 권한을 가지고 있음을 뜻하는 `PF_SUPERPRIV` 플래그를 초기화한다. 프로세스가 `exec()`함수를 호출하지 않았음을 뜻하는 `PF_FORKNOEXEC`플래그를 설정한다.
6. `alloc_pid()`함수를 호출해 새로 만든 태스크에 새로운 PID 값을 할당한다.
7. `clone()`함수에 전달된 플래그 값에 따라 열린 파일 및 파일시스템 정보, 시그널 핸들러, 프로세스 주소 공간, 네임스페이스*namespace* 등을 복제하거나 공유한다. 보통 스레드 사이에서는 이런 자원을 공유하며, 그 외의 경우에는 개별적으로 사용하므로 이 단계에서 복제한다.
```C
struct task_struct *copy_process(struct pid *pid, int trace,
int node, struct kernel_clone_args *args)
{
struct task_struct *p;
const u64 clone_flags = args->flags;
int retval;
/* 1. Validate clone flags combinations */
if ((clone_flags & CLONE_THREAD) && !(clone_flags & CLONE_SIGHAND))
return ERR_PTR(-EINVAL); // Threads must share signals
if ((clone_flags & CLONE_SIGHAND) && !(clone_flags & CLONE_VM))
return ERR_PTR(-EINVAL); // Shared signals require shared VM
/* 2. Duplicate task_struct from parent */
p = dup_task_struct(current, node);
if (!p)
goto fork_out;
/* 3. Initialize basic task properties */
p->flags |= PF_FORKNOEXEC; // Haven't called exec yet
p->pid = pid_nr(pid);
p->start_time = ktime_get_ns();
/* 4. Copy credentials and check resource limits */
retval = copy_creds(p, clone_flags);
if (is_rlimit_overlimit(RLIMIT_NPROC)) // Process count limit
goto bad_fork_cleanup_count;
/* 5. Initialize task subsystems */
sched_fork(clone_flags, p); // Scheduler setup, assign CPU
audit_alloc(p); // Audit subsystem
security_task_alloc(p); // LSM hooks
/* 6. Copy or share parent resources based on clone flags */
retval = copy_files(clone_flags, p); // File descriptors
retval = copy_fs(clone_flags, p); // Filesystem info
retval = copy_sighand(clone_flags, p); // Signal handlers
retval = copy_signal(clone_flags, p); // Signal state
retval = copy_mm(clone_flags, p); // Memory mappings
retval = copy_namespaces(clone_flags, p); // Namespaces
retval = copy_thread(p, args); // Architecture-specific setup
/* 7. Allocate PID for the new process */
if (pid != &init_struct_pid) {
pid = alloc_pid(p->nsproxy->pid_ns_for_children);
if (IS_ERR(pid))
goto bad_fork_cleanup_thread;
}
/* 8. Handle CLONE_PIDFD if requested */
if (clone_flags & CLONE_PIDFD) {
retval = pidfd_prepare(pid, flags, &pidfile);
pidfd = retval;
put_user(pidfd, args->pidfd); // Return fd to userspace
}
/* 9. Set up thread group relationships */
if (clone_flags & CLONE_THREAD) {
p->group_leader = current->group_leader;
p->tgid = current->tgid; // Same thread group ID
} else {
p->group_leader = p;
p->tgid = p->pid; // New process group
}
/* 10. Make process visible to the system */
write_lock_irq(&tasklist_lock);
/* Set parent relationships */
if (clone_flags & CLONE_PARENT)
p->real_parent = current->real_parent; // Share parent
else
p->real_parent = current; // Current is parent
/* Add to process lists */
if (thread_group_leader(p)) {
list_add_tail(&p->sibling, &p->real_parent->children);
list_add_tail_rcu(&p->tasks, &init_task.tasks);
attach_pid(p, PIDTYPE_TGID);
__this_cpu_inc(process_counts);
} else {
current->signal->nr_threads++;
list_add_tail_rcu(&p->thread_node, &p->signal->thread_head);
}
attach_pid(p, PIDTYPE_PID);
nr_threads++;
total_forks++;
write_unlock_irq(&tasklist_lock);
/* 11. Post-fork notifications */
proc_fork_connector(p); // Process events connector
cgroup_post_fork(p, args); // Cgroup setup
perf_event_fork(p); // Performance monitoring
return p;
/* Error cleanup paths - unwind everything in reverse order */
bad_fork_cleanup_mm:
if (p->mm) mmput(p->mm);
bad_fork_cleanup_signal:
if (!(clone_flags & CLONE_THREAD))
free_signal_struct(p->signal);
bad_fork_cleanup_thread:
exit_thread(p);
bad_fork_free:
put_task_stack(p);
delayed_free_task(p);
fork_out:
return ERR_PTR(retval);
}
```
`copy_process()`함수가 성공한 경우 `kernel_clone()`함수는 새로 만든 자식 프로세스를 깨워서 실행한다.
### Copy-on-Write
한가지 더 알아두면 좋은 것은 오늘날 `fork()`가 부모 프로세스의 모든 자원을 자식 프로세스로 복사해 가지 않는다는 것이다. 이는 공유가 가능한 데이터를 가능한 복사하지 않고 그대로 사용하기 위한 것인데, 여기서 copy-on-write은 **기록사항 발생 시 복사**를 의미하며, 프로세스의 주소 공간에 실제 쓰기 작업이 일어날 때까지 데이터 복사를 지연 또는 방지하는 기능이다. 아래 그림의 (a)처럼 프로세스는 각자의 가상 메모리*Virtual Memory*를 가지면서 물리 메모리*Physical Memory*를 공유하는데, 만약 Copy-on-write로 지정된 데이터에 기록사항이 발생한 경우, 물리 메모리 상에서는 해당 부분만 복사 후 기록하게 된다. 그러면 같은 그림의 (b)처럼 물리 메모리 전체를 복사하지 않아도 수정할 부분만 복사되고 이 부분에 쓰기 작업을 수행할 프로세스의 가상 메모리가 가리키는 위치만 바뀌기 때문에 쓰기 작업에 소요되는 메모리의 복사가 현저히 줄어든다.
![[CopyOnWrite.png]]
리눅스 커널은 의도적으로 자식 프로세스를 먼저 실행하는데, 덕분에 부모 프로세스가 먼저 실행되면서 주소 공간에 쓰기 작업이 생기는 바람에 발생하는 Copy-on-write 작업을 막을 수 있다. 만약 `fork()`직후에 `exec()`함수가 실행되는 것처럼 프로세스가 절대 기록을 하지 않는다면 메모리 복사가 필요 없어진다. 그래서 `fork()`함수가 하는 일은 부모 프로세스의 페이지 테이블을 복사하는 것과 자식 프로세스를 위한 프로세스 서술자를 만들어주는 것 뿐이다.
## `exec()`
리눅스를 비롯한 유닉스 기반 시스템의 프로세스 생성 방식은 독특하다. 대부분의 운영체제는 새로운 주소 공간에 새 프로세스를 만들고, 실행 파일을 읽은 다음 그 코드를 실행한다, 유닉스는 이 과정을 `fork()`와 `exec()`라는 두 함수로 분리했다. `fork()`는 방금 전까지 살펴봤으니 알겠는데, 그럼 `exec()`는 무슨 함수일까? 사실 이 질문은 무의미한데, 왜냐하면 `exec()`이라는 이름을 가진 함수는 존재하지 않기 때문이다. 여기서 말하는 `exec()` 함수란 사실 `execve()`라는 시스템 호출을 중심으로 하는 함수 군을 일컫는 말이다.
```C
#include <unistd.h>
int execl(const char *pathname, const char *arg0, ... /* (char *)0 */ );
int execv(const char *pathname, char *const argv[]);
int execle(const char *pathname, const char *arg0, ... /* (char *)0, char *const envp[] */ );
int execve(const char *pathname, char *const argv[], char *const envp[]);
int execlp(const char *filename, const char *arg0, ... /* (char *)0 */ );
int execvp(const char *filename, char *const argv[]);
int fexecve(int fd, char *const argv[], char *const envp[]);
```
`exec()` 함수 군은 위와 같이 총 7개의 함수로 구성되어 있으며 이들의 관계는 아래와 같다.
![[ RelationshipOfTheSevenExecFunction.png]]
### 로딩
`exec()` 함수 군에 속한 함수들이 무슨 역할을 수행하는지 이해하기 위해서는 로딩*Loading*이라는 개념을 알아야 한다. 로딩이란, 프로세스에 프로그램을 불러오는 과정을 의미한다. 이제 막 생긴 새로운 프로세스는 프로그램이 없어서 아무것도 실행하고 있지 않는데, 여기에 프로그램을 실행하기 위한 코드를 불러와야 한다. 커널에서는 어떤 프로그램을 불러올 때 다른 시스템 호출처럼 핸들러를 호출하고, 디스크에 저장되어 있는 프로그램을 주 기억 장치(보통 RAM)로 불러오기 위해 로더 프로그램*Loader Program*을 사용한다.
![[LoaderMap.png]]
로더 프로그램은 다음과 같은 작업을 수행한다.
+ 실행 맥락과 실행을 요청한 사용자의 권한을 확인한다.
+ 메인 메모리로부터 새 프로세스에 대한 메모리를 할당한다.
+ 실행 파일의 이진 파일 내용을 할당된 메모리로 복사한다.
+ 이는 대부분 데이터 및 텍스트 세그먼트를 포함한다.
+ 스택 세그먼트에 메모리 영역을 할당하고 초기 메모리 매핑을 준비한다.
+ 메인 스레드[^6] 및 스택 메모리 영역이 생성된다.
+ 커맨드 라인 인수를 메인 스레드의 스택 영역 최상단에 스택 프레임으로 복제한다.
+ 실행에 필요한 필수 레지스터를 초기화한다.
+ 프로그램의 진입점에 대한 첫번째 명령어를 실행한다.
[^6]: 멀티스레드 프로그램에서 가장 중요한 스레드다. 실행 흐름의 측면에서 가장 처음 실행되고 가장 마지막으로 종료되는 스레드이며, 프로세스의 다른 모든 스레드가 시작되는 기원이다.
리눅스에서 바로 이 로딩을 담당하는 함수가 `exec()` 함수 군이다. 이제 이들의 구현을 살펴보자.
### `exec()`의 구현 살펴보기
앞서 살펴봤던 것처럼 `exec()` 함수들은 `execve()` 함수를 중심으로 이루어져 있으며, `execve()`를 제외한 나머지 모든 함수는 `execve()`를 포장한 래퍼 함수*Wrapper Function*이다. 이들의 역할은 새로운 실행파일을 주소 공간에 불러오고 이를 실행하는 것이다.
![[ RelationshipOfTheSevenExecFunction.png]]
우선 이들이 어떻게 `execve()` 함수를 호출하도록 연결되어 있는지 살펴보자. `exec()` 함수 군의 함수들은 GNU C 라이브러리(glibc)에 의해 제공되며, 이들은 모두 `__execve()`함수를 호출한다. 이들이 호출한 함수는 리눅스 커널에 존재하는 `do_execve()` 함수와 연결되어 있으며, 이 함수는 아래와 같이 `fs/exec.c`에 정의되어 있다.
```C
static int do_execve(struct filename *filename,
const char __user *const __user *__argv,
const char __user *const __user *__envp)
{
struct user_arg_ptr argv = { .ptr.native = __argv };
struct user_arg_ptr envp = { .ptr.native = __envp };
return do_execveat_common(AT_FDCWD, filename, argv, envp, 0);
}
```
`do_execve()` 함수가 호출하는 `do_execveat_common()` 함수는 같은 파일 안에 정의되어 있으며, 다음과 같다. 이 함수가 바로 `exec()` 계열 함수들이 해야 할 일을 처리하는 핵심 함수다.
```C
static int do_execveat_common(int fd, struct filename *filename,
struct user_arg_ptr argv,
struct user_arg_ptr envp,
int flags)
{
struct linux_binprm *bprm;
int retval;
if (IS_ERR(filename))
return PTR_ERR(filename);
/*
* We move the actual failure in case of RLIMIT_NPROC excess from
* set*uid() to execve() because too many poorly written programs
* don't check setuid() return code. Here we additionally recheck
* whether NPROC limit is still exceeded.
*/
if ((current->flags & PF_NPROC_EXCEEDED) &&
is_rlimit_overlimit(current_ucounts(), UCOUNT_RLIMIT_NPROC, rlimit(RLIMIT_NPROC))) {
retval = -EAGAIN;
goto out_ret;
}
/* We're below the limit (still or again), so we don't want to make
* further execve() calls fail. */
current->flags &= ~PF_NPROC_EXCEEDED;
bprm = alloc_bprm(fd, filename, flags);
if (IS_ERR(bprm)) {
retval = PTR_ERR(bprm);
goto out_ret;
}
retval = count(argv, MAX_ARG_STRINGS);
if (retval < 0)
goto out_free;
bprm->argc = retval;
retval = count(envp, MAX_ARG_STRINGS);
if (retval < 0)
goto out_free;
bprm->envc = retval;
retval = bprm_stack_limits(bprm);
if (retval < 0)
goto out_free;
retval = copy_string_kernel(bprm->filename, bprm);
if (retval < 0)
goto out_free;
bprm->exec = bprm->p;
retval = copy_strings(bprm->envc, envp, bprm);
if (retval < 0)
goto out_free;
retval = copy_strings(bprm->argc, argv, bprm);
if (retval < 0)
goto out_free;
/*
* When argv is empty, add an empty string ("") as argv[0] to
* ensure confused userspace programs that start processing
* from argv[1] won't end up walking envp. See also
* bprm_stack_limits().
*/
if (bprm->argc == 0) {
retval = copy_string_kernel("", bprm);
if (retval < 0)
goto out_free;
bprm->argc = 1;
pr_warn_once("process '%s' launched '%s' with NULL argv: empty string added\n",
current->comm, bprm->filename);
}
retval = bprm_execve(bprm);
out_free:
free_bprm(bprm);
out_ret:
putname(filename);
return retval;
}
```
이 함수는 실행할 파일의 이름, 인자 배열(`argv`), 환경 변수 배열(`envp`), 그리고 각종 플래그(`flags`)를 받아 새로운 프로세스 이미지를 준비한다. 먼저 전달받은 파일 이름이 유효한지 검사하고, 에러라면 즉시 반환한다. 그 다음, 프로세스 수의 제한(`RLIMIT_NPROC`)을 초과했는지 확인하고 만약 제한을 초과했다면 더 이상 `execve()` 호출이 실패하지 않도록 플래그를 초기화하고, 에러를 반환한다. 이후 프로그램 실행에 필요한 정보를 담을 `linux_binprm` 구조체를 할당한 다음, 인자와 환경 변수의 개수를 각각 세고, 정해진 한도을 넘거나 에러가 발생하면 할당된 자원을 해제하고 반환합니다. 이때, `bprm_stack_limits()`함수를 통해 스택 크기 제한도 확인한다.
그렇다면 인자 배열은 무엇일까? 인자 배열에는 프로세스가 실행할 프로그램의 이름과 함께 프로그램에 전달되어야 하는 변수들이 들어간다. 아래 그림은 인자 배열에 들어가는 값들과 그 순서를 나타내고 있다. 가장 먼저 프로그램의 이름(`ls` : 파일 탐색 프로그램)이 들어가고, 그 다음으로 추가되어야 할 인자(`-lt` : 탐색 결과를 수정한 시간 순서대로 나열, `/user/include` : 탐색할 경로)들이 들어간다. 환경 변수 배열에는 사용자가 직접 전달하는 변수들 외에도 실행 환경을 알맞게 설정하기 위한 변수들이 들어간다. 예를 들어, `PWD=/usr/droh`는 현재 위치한 경로를 의미하며, `USER=droh`는 현재 사용자를 의미한다.
![[ArgumentListOrganization.png]]
이제 실행 파일 이름을 커널 공간으로 복사하고, 환경 변수와 인자 문자열도 커널로 복사한다. 만약 인자 배열이 비어 있다면, `argv[0]`에 빈 문자열을 추가해 일부 사용자 프로그램이 오동작하지 않도록 보완한다. 준비된 정보를 바탕으로 실제 실행(`bprm_execve`)을 실행하게 되는데, 이 함수는 프로그램을 실행하기에 앞서 `check_unsafe_exec()` 등의 함수를 호출하여 안전한 실행 환경을 만드는 데 집중한다. 그리고 프로그램 실행에 성공했을 때 자원 정리나 프로세스 추적 등 여러가지 뒤처리 과정을 수행한다.
```C
static int bprm_execve(struct linux_binprm *bprm)
{
int retval;
retval = prepare_bprm_creds(bprm);
if (retval)
return retval;
/*
* Check for unsafe execution states before exec_binprm(), which
* will call back into begin_new_exec(), into bprm_creds_from_file(),
* where setuid-ness is evaluated.
*/
check_unsafe_exec(bprm);
current->in_execve = 1;
sched_mm_cid_before_execve(current);
sched_exec();
/* Set the unchanging part of bprm->cred */
retval = security_bprm_creds_for_exec(bprm);
if (retval || bprm->is_check)
goto out;
retval = exec_binprm(bprm);
if (retval < 0)
goto out;
sched_mm_cid_after_execve(current);
rseq_execve(current);
/* execve succeeded */
current->in_execve = 0;
user_events_execve(current);
acct_update_integrals(current);
task_numa_free(current, false);
return retval;
out:
/*
* If past the point of no return ensure the code never
* returns to the userspace process. Use an existing fatal
* signal if present otherwise terminate the process with
* SIGSEGV.
*/
if (bprm->point_of_no_return && !fatal_signal_pending(current))
force_fatal_sig(SIGSEGV);
sched_mm_cid_after_execve(current);
rseq_set_notify_resume(current);
current->in_execve = 0;
return retval;
}```
여기서 실제로 프로그램을 실행시키는 함수는 `exec_binprm()` 함수다. ARM64 아키텍처에서는 여기서 함수를 타고 계속 흘러가다보면 `load_elf_binary()` 함수에 이르게 되는데, 이 함수에서는 새로운 프로그램을 불러오는, `exec()` 계열 함수에서 가장 핵심이 되는 작업을 수행한다. 코드가 너무나도 방대한 관계로 여기서는 일부만 소개한다.
```C
static int load_elf_binary(struct linux_binprm *bprm)
{
struct file *interpreter = NULL;
unsigned long load_bias = 0, elf_entry, e_entry;
struct elf_phdr *elf_phdata;
struct elfhdr *elf_ex = (struct elfhdr *)bprm->buf;
int retval, i;
/* 1. Validate ELF header */
if (memcmp(elf_ex->e_ident, ELFMAG, SELFMAG) != 0)
goto out; /* Not an ELF file */
if (elf_ex->e_type != ET_EXEC && elf_ex->e_type != ET_DYN)
goto out; /* Not executable or dynamic */
if (!elf_check_arch(elf_ex))
goto out; /* Wrong architecture */
/* 2. Load program headers */
elf_phdata = load_elf_phdrs(elf_ex, bprm->file);
if (!elf_phdata)
goto out;
/* 3. Find and load interpreter (dynamic linker) */
for (i = 0; i < elf_ex->e_phnum; i++) {
if (elf_phdata[i].p_type == PT_INTERP) {
/* Read interpreter path (e.g., /lib/ld-linux.so) */
char *elf_interpreter = read_interp_path(elf_phdata[i]);
interpreter = open_exec(elf_interpreter);
break;
}
}
/* 4. Check for stack execute permission */
int executable_stack = EXSTACK_DEFAULT;
for (i = 0; i < elf_ex->e_phnum; i++) {
if (elf_phdata[i].p_type == PT_GNU_STACK) {
if (elf_phdata[i].p_flags & PF_X)
executable_stack = EXSTACK_ENABLE_X;
else
executable_stack = EXSTACK_DISABLE_X;
}
}
/* ============ POINT OF NO RETURN ============ */
/* After this, the old process memory is destroyed */
retval = begin_new_exec(bprm); /* Flush old process */
if (retval)
goto out_free_dentry;
setup_new_exec(bprm); /* Setup new process environment */
/* 5. Setup stack for argv, envp, auxv */
retval = setup_arg_pages(bprm, randomize_stack_top(STACK_TOP),
executable_stack);
if (retval < 0)
goto out_free_dentry;
/* 6. Map ELF segments into memory */
unsigned long start_code = ~0UL, end_code = 0;
unsigned long start_data = 0, end_data = 0;
unsigned long elf_brk = 0;
for (i = 0; i < elf_ex->e_phnum; i++) {
if (elf_phdata[i].p_type != PT_LOAD)
continue;
unsigned long vaddr = elf_phdata[i].p_vaddr;
/* First LOAD segment - determine load_bias for PIE/ASLR */
if (i == 0 && elf_ex->e_type == ET_DYN) {
if (interpreter) {
/* PIE binary with interpreter */
load_bias = ELF_ET_DYN_BASE;
if (current->flags & PF_RANDOMIZE)
load_bias += arch_mmap_rnd(); /* ASLR */
} else {
/* Static PIE or shared library */
load_bias = 0; /* Let mmap choose address */
}
load_bias = ELF_PAGESTART(load_bias - vaddr);
}
/* Map the segment */
error = elf_load(bprm->file, load_bias + vaddr, &elf_phdata[i]);
if (BAD_ADDR(error))
goto out_free_dentry;
/* Track code and data boundaries */
unsigned long k = elf_phdata[i].p_vaddr;
if (elf_phdata[i].p_flags & PF_X) { /* Executable */
if (k < start_code) start_code = k;
if (k + elf_phdata[i].p_filesz > end_code)
end_code = k + elf_phdata[i].p_filesz;
} else { /* Data */
if (k < start_data) start_data = k;
if (k + elf_phdata[i].p_filesz > end_data)
end_data = k + elf_phdata[i].p_filesz;
}
/* Track heap start (brk) */
k = elf_phdata[i].p_vaddr + elf_phdata[i].p_memsz;
if (k > elf_brk)
elf_brk = k;
}
/* Apply load_bias to all addresses */
e_entry = elf_ex->e_entry + load_bias;
start_code += load_bias;
end_code += load_bias;
start_data += load_bias;
end_data += load_bias;
elf_brk += load_bias;
/* 7. Load interpreter if present */
if (interpreter) {
elf_entry = load_elf_interp(interpreter);
/* Entry point is now the interpreter (ld.so) */
} else {
elf_entry = e_entry; /* Direct entry to program */
}
/* 8. Create ELF auxiliary vectors on stack */
retval = create_elf_tables(bprm, elf_ex);
/* Stack now contains: argc, argv[], envp[], auxv[] */
/* 9. Update process memory map info */
struct mm_struct *mm = current->mm;
mm->start_code = start_code;
mm->end_code = end_code;
mm->start_data = start_data;
mm->end_data = end_data;
mm->start_brk = mm->brk = ELF_PAGEALIGN(elf_brk);
mm->start_stack = bprm->p;
/* 10. Start the new program */
struct pt_regs *regs = current_pt_regs();
START_THREAD(elf_ex, regs, elf_entry, bprm->p);
/* For ARM64:
* - PC = elf_entry (interpreter or program entry)
* - SP = bprm->p (stack pointer)
* - Return to user mode
*/
retval = 0;
out:
return retval;
out_free_dentry:
/* Cleanup on error */
if (interpreter)
fput(interpreter);
kfree(elf_phdata);
goto out;
}
```
우선 앞으로 실행할 프로그램을 불러올 준비를 마친 뒤, 기존의 프로세스가 가지고 있던 메모리를 해제한다. 그 다음 새 프로세스에 맞게 환경을 설정하고, 메모리를 매핑한다. 스택 공간을 설정한 뒤, `argv`, `envp` 등 스택 공간에 들어가야 하는 데이터를 투입한다. 마지막으로 프로세스의 메모리 관련 정보를 업데이트한 뒤, 새 프로그램을 시작한다.
> [!Warning]
> 그림은 x86 아키텍처를 기준으로 설명되어 있으니 유의해 주세요.
이 함수가 수행하는 작업이 곧 로딩이며, 로딩의 결과는 아래 그림과 같다. 프로세스의 메모리 레이아웃이라는 이름으로 배우게 되는 프로세스의 메모리 구조는 바로 이러한 과정을 통해 만들어진다.
![[LoaderMap.png]]
# 프로세스 상태
지금까지 프로세스가 만들어지는 과정을 살펴보았으니, 이제 프로세스를 관리하기 위해 마련된 프로세스의 여러가지 특성을 살펴볼 차례다. 운영체제는 합리적인 자원 분배를 위해 프로세스의 상태*state*를 확인한다.
![[ProcessState.png]]
운영체제에 존재하는 프로세스의 상태는 크게 다섯가지로 분류할 수 있다.
- new : 새로운 프로세서가 생성된 상태.
- ready : 프로세스가 실행 가능한 상태. 대기 상태에 있는 프로세스는 실행되기 위해 대기열에 있는 상태다.
- waiting : 프로세스가 특정 상황이 일어나기를 기다리고 있는 상태다. 프로세스가 기다리는 상황이 발생하면 커널은 프로세스의 상태를 '실행 중'으로 바꾼다.
- running : 프로세스가 현재 실행 중인 상태다.
- terminated : 프로세스가 종료된 상태다. 프로세스가 종료 상태가 되면 운영체제는 프로세스에 할당된 자원을 회수한다.
리눅스에서는 `TASK_RUNNING`, `TASK_INTERRUPTIBLE` `TASK_UNINTERRUPTIBLE`, `__TASK_TRACED`, `__TASK_STOPPED`라는 총 5가지 프로세스 상태를 지원하는데, 다만 이론적으로 나눈 프로세스 상태와 똑같은 구분은 아니고 역할이 조금 다르다.
![[LinuxProcessState.png]]
- `TASK_RUNNING`은 프로세스가 실행 가능한 상태를 의미한다. 이 상테에 있는 프로세스는 현재 실행 중이거나 실행되기 위해 실행 대기열*run queue*에 들어가 있는 상태다. 사용자 공간에서 실행된 프로세스는 이 상태만 가질 수 있으며, 커널 공간에서 실행 중인 프로세스도 이 상태에 속한다.
- `TASK_INTERRUPTIBLE`은 프로세스가 특정 조건이 발생하기를 기다리며 쉬는 중인 상태를 의미한다. 기다리는 조건이 발생하면 커널은 프로세스의 상태를 `TASK_RUNNING`으로 바꾼다. 프로세스가 시그널을 받은 경우에는 조건에 상관없이 실행 가능한 상태로 바뀐다.
- `TASK_UNINTERRUPTIBLE`은 시그널을 받아도 실행 가능한 상태로 바뀌지 않는다는 점만 제외하면 `TASK_INTERRUPTIBLE`과 같은 상태다. 이 상태는 프로세스가 방해받지 않고 특정 조건을 기다려야 하는 경우, 기다리는 조건이 금방 발생하는 경우에 사용한다.
- `__TASK_TRACED`는 디버거 등의 다른 프로세스가 `ptrace`를 통해 해당 프로세스를 추적하고 있는 상태를 의미한다.
- `__TASK_STOPPED`는 프로세스의 실행이 정지된 상태를 의미하는데, 실행 중이지도 않고 실행 가능한 상태도 아니다. `SIGSTOP`, `SIGTSTP`, `SIGTTIN`, `SIGTTOU`같은 시그널을 받거나 디버그 중에 아무 시그널이나 받으면 이 상태가 된다.
# 프로세스 종료하기
프로세스가 모든 일을 마치고 종료될 때, 부모 프로세스는 상태 값을 받을 수 있고 자식 프로세스에게 할당됐던 자원들이 운영체제에 의해 해제된다. 시스템 호출*system call* 이나 코드에 의해 자식 프로세스가 강제로 종료될 수 있는데, 이때 운영체제에 따라 어떤 프로세스가 종료되면 해당 프로세스의 자식 프로세스가 모두 멈추거나*cascading termination*, 모든 프로세스의 부모인 최초의 프로세스를 부모로 다시 정하기도 한다. 프로세스가 종료되면, 커널은 프로세스가 가지고 있던 자원을 반납하고 부모 프로세스에 자식 프로세스가 종료된다는 소식을 알려준다. 일반적으로 이 과정은 자발적으로 일어나지만, 처리할 수도 없고 무시할 수도 없는 시그널이나 예외를 만나는 상황에서는 프로세스가 비자발적으로 종료되기도 한다.
리눅스에서 프로세스 종료는 `kernel/exit.c`에 정의된 `do_exit()` 함수를 통해 진행되는데, 거기서 일어나는 일을 요약하자면 다음과 같다.
1. `task_struct` 구조체의 `flags` 항목에 `PF_EXITING` 플래그를 설정한다.
2. `del_timer_sync()` 함수를 호출해 커널 타이머를 제거한다. 이 함수가 반환되면, 대기 중인 타이머와 실행 중인 타이머가 없다는 것이 보장된다.
3. BSD 방식의 프로세스 정보 기록 기능을 사용한다면 `acct_update_integrals()` 함수를 호출해 관련 정보를 기록한다.
4. `exit_mm()` 함수를 호출해 해당 프로세스가 가지고 있는 `mm_struct`를 반환한다. 다른 프로세스에서 이 주소 공간을 사용하지 않는다면 커널은 해당 자원을 해제한다.
5. `exit_sem()` 함수를 호출한다. 프로세스가 IPC*Inter-Process Communication* 세마포어를 얻기 위해 대기하고 있었다면, 이 시점에서 대기 상태가 해제된다.
6. `exit_files()` 및 `exit_fs()` 함수를 통해 관련 파일 서술자 및 파일 시스템의 참조 횟수를 줄인다. 만약 참조 횟수가 0이 된다면 해당 객체를 사용하는 프로세스가 없다는 뜻이므로 해당 자원을 반환한다.
7. 태스크의 종료 코드를 `task_struct`의 `exit_code` 멤버에 저장한다. 종료 코드는 `exit()` 함수에서 지정한 값, 또는 커널의 종료 방식에 의해 결정되며, 그 값은 부모 프로세스가 사용할 수 있다.
8. `exit_notify()` 함수를 호출해 부모 프로세스에 시그널을 보내고 해당 프로세스가 속한 스레드군의 다른 스레드 또는 `init`프로세스를 자식 프로세스의 새로운 부모로 설정한다. `task_struct` 구조체의 `exit_state` 멤버에 태스크 종료 상태를 `EXIT_ZOMBIE`로 설정한다.
9. `schedule()`함수를 호출해 새로운 프로세스로 전환한다. 이제 이 프로세스는 스케줄링의 대상이 아니므로 이 코드가 종료되는 태스크가 실행하는 마지막 코드가 된다.
```C
void __noreturn do_exit(long code)
{
struct task_struct *tsk = current;
int group_dead;
WARN_ON(irqs_disabled());
WARN_ON(tsk->plug);
kcov_task_exit(tsk);
kmsan_task_exit(tsk);
synchronize_group_exit(tsk, code);
ptrace_event(PTRACE_EVENT_EXIT, code);
user_events_exit(tsk);
io_uring_files_cancel();
exit_signals(tsk); /* sets PF_EXITING */
seccomp_filter_release(tsk);
acct_update_integrals(tsk);
group_dead = atomic_dec_and_test(&tsk->signal->live);
if (group_dead) {
/*
* If the last thread of global init has exited, panic
* immediately to get a useable coredump.
*/
if (unlikely(is_global_init(tsk)))
panic("Attempted to kill init! exitcode=0x%08x\n",
tsk->signal->group_exit_code ?: (int)code);
#ifdef CONFIG_POSIX_TIMERS
hrtimer_cancel(&tsk->signal->real_timer);
exit_itimers(tsk);
#endif
if (tsk->mm)
setmax_mm_hiwater_rss(&tsk->signal->maxrss, tsk->mm);
}
acct_collect(code, group_dead);
if (group_dead)
tty_audit_exit();
audit_free(tsk);
tsk->exit_code = code;
taskstats_exit(tsk, group_dead);
unwind_deferred_task_exit(tsk);
trace_sched_process_exit(tsk, group_dead);
/*
* Since sampling can touch ->mm, make sure to stop everything before we
* tear it down.
*
* Also flushes inherited counters to the parent - before the parent
* gets woken up by child-exit notifications.
*/
perf_event_exit_task(tsk);
exit_mm();
if (group_dead)
acct_process();
exit_sem(tsk);
exit_shm(tsk);
exit_files(tsk);
exit_fs(tsk);
if (group_dead)
disassociate_ctty(1);
exit_task_namespaces(tsk);
exit_task_work(tsk);
exit_thread(tsk);
sched_autogroup_exit_task(tsk);
cgroup_exit(tsk);
/*
* FIXME: do that only when needed, using sched_exit tracepoint
*/
flush_ptrace_hw_breakpoint(tsk);
exit_tasks_rcu_start();
exit_notify(tsk, group_dead);
proc_exit_connector(tsk);
mpol_put_task_policy(tsk);
#ifdef CONFIG_FUTEX
if (unlikely(current->pi_state_cache))
kfree(current->pi_state_cache);
#endif
/*
* Make sure we are holding no locks:
*/
debug_check_no_locks_held();
if (tsk->io_context)
exit_io_context(tsk);
if (tsk->splice_pipe)
free_pipe_info(tsk->splice_pipe);
if (tsk->task_frag.page)
put_page(tsk->task_frag.page);
exit_task_stack_account(tsk);
check_stack_usage();
preempt_disable();
if (tsk->nr_dirtied)
__this_cpu_add(dirty_throttle_leaks, tsk->nr_dirtied);
exit_rcu();
exit_tasks_rcu_finish();
lockdep_free_task(tsk);
do_task_dead();
}
```
이 시점에서 이 태스크는 더 이상 실행 가능하지 않으며 가지고 있는 메모리라고는 커널 스택, `thread_info` 구조체, 그리고 `task_struct` 구조체가 전부다. 이제 부모 프로세스가 그 안에 담긴 정보를 처리하거나 커널이 더이상 그 정보가 필요 없다고 알려주면 프로세스가 차지하고 있던 나머지 메모리도 반환돼 시스템의 가용 메모리로 되돌아간다.
## Zombie Process
실제 프로세스는 종료했지만 부모 프로세스가 그 상태를 확인하지 못했을 때(ex. POSIX API를 사용하는 환경에서 `wait()` 함수를 호출하기 전에 자식 프로세스가 먼저 종료됨) 이 프로세스를 좀비 프로세스*zombie process*라고 부른다. 실제로는 종료된 프로세스지만, 이들을 관찰하는 부모 프로세스의 입장에서는 아직 종료된 프로세스가 아니다. `do_exit()` 함수가 완료되고 나면 프로세스는 좀비 프로세스가 되는데, 이러한 좀비 프로세스는 뭔가 조치를 취하지 않으면 영원히 시스템 자원을 잡아먹으며 성능을 저하시킨다. 이때는 프로세스 서술자를 따로 제거해주면 되는데, `release_task()` 함수가 이 작업을 진행해준다. 여기서 일어나는 일은 대략 다음과 같다.
1. `__exit_signal()` 함수를 호출하고, 이 함수는 `__unhash_process()` 함수를 호출하며, 이어서 `detach_pid()` 함수에서 해당 프로세스를 pidhash와 태스크 리스트에서 제거한다.
2. `__exit_signal()` 함수는 종료된 프로세스가 사용하던 남은 자원을 반환하고, 통계값과 기타 정보를 기록한다.
3. 해당 태스크가 스레드군의 마지막 스레드였다면 대표 스레드가 좀비가 된 것이므로, `release_task()` 함수는 대표 스레드의 부모 프로세스에 이 사실을 알린다.
4. `release_task()` 함수는 `put_task_struct()` 함수를 호출해 프로세스의 커널 스택 및 `thread_info` 구조체가 들어있던 페이지를 반환하고, `task_struct` 구조체가 들어있던 슬랩 캐시를 반환한다.
```C
void release_task(struct task_struct *p)
{
struct release_task_post post;
struct task_struct *leader;
struct pid *thread_pid;
int zap_leader;
repeat:
memset(&post, 0, sizeof(post));
/* don't need to get the RCU readlock here - the process is dead and
* can't be modifying its own credentials. But shut RCU-lockdep up */
rcu_read_lock();
dec_rlimit_ucounts(task_ucounts(p), UCOUNT_RLIMIT_NPROC, 1);
rcu_read_unlock();
pidfs_exit(p);
cgroup_release(p);
/* Retrieve @thread_pid before __unhash_process() may set it to NULL. */
thread_pid = task_pid(p);
write_lock_irq(&tasklist_lock);
ptrace_release_task(p);
__exit_signal(&post, p);
/*
* If we are the last non-leader member of the thread
* group, and the leader is zombie, then notify the
* group leader's parent process. (if it wants notification.)
*/
zap_leader = 0;
leader = p->group_leader;
if (leader != p && thread_group_empty(leader)
&& leader->exit_state == EXIT_ZOMBIE) {
/* for pidfs_exit() and do_notify_parent() */
if (leader->signal->flags & SIGNAL_GROUP_EXIT)
leader->exit_code = leader->signal->group_exit_code;
/*
* If we were the last child thread and the leader has
* exited already, and the leader's parent ignores SIGCHLD,
* then we are the one who should release the leader.
*/
zap_leader = do_notify_parent(leader, leader->exit_signal);
if (zap_leader)
leader->exit_state = EXIT_DEAD;
}
write_unlock_irq(&tasklist_lock);
/* @thread_pid can't go away until free_pids() below */
proc_flush_pid(thread_pid);
add_device_randomness(&p->se.sum_exec_runtime,
sizeof(p->se.sum_exec_runtime));
free_pids(post.pids);
release_thread(p);
/*
* This task was already removed from the process/thread/pid lists
* and lock_task_sighand(p) can't succeed. Nobody else can touch
* ->pending or, if group dead, signal->shared_pending. We can call
* flush_sigqueue() lockless.
*/
flush_sigqueue(&p->pending);
if (thread_group_leader(p))
flush_sigqueue(&p->signal->shared_pending);
put_task_struct_rcu_user(p);
p = leader;
if (unlikely(zap_leader))
goto repeat;
}
```
이제 프로세스 서술자와 해당 프로세스와 연관된 모든 자원이 해제된다.
## Orphan Process
반대로 부모 프로세스가 종료를 위해 시스템 호출을 사용하지 못하고 먼저 종료된 경우, 자식 프로세스는 고아 프로세스*orphan process*가 되어 홀로 남겨진다. 이때 부모를 새로 지정하지 않으면 그 프로세스는 시스템 메모리를 계속 낭비하게 된다. 이에 대한 해결책으로 종료 후 상태에 대한 정보를 넘길 부모 프로세스가 없는 상황을 해결하기 위해 리눅스에서는 해당 프로세스가 속한 스레드군의 다른 프로세스를 부모 프로세스로 지정하거나, 그것이 불가능할 때에는 `init` 프로세스를 이들의 새로운 부모 프로세스로 지정한다. `do_exit()` 함수는 `exit_notify()` 함수를 호출하고, 이 함수에서 `forget_original_parent()` 함수를 호출하고, 여기서 `find_new_reaper()` 함수를 호출하는데, 여기서 부모 프로세스를 재지정한다.
```C
/*
* When we die, we re-parent all our children, and try to:
* 1. give them to another thread in our thread group, if such a member exists
* 2. give it to the first ancestor process which prctl'd itself as a
* child_subreaper for its children (like a service manager)
* 3. give it to the init process (PID 1) in our pid namespace
*/
static struct task_struct *find_new_reaper(struct task_struct *father,
struct task_struct *child_reaper)
{
struct task_struct *thread, *reaper;
thread = find_alive_thread(father);
if (thread)
return thread;
if (father->signal->has_child_subreaper) {
unsigned int ns_level = task_pid(father)->level;
/*
* Find the first ->is_child_subreaper ancestor in our pid_ns.
* We can't check reaper != child_reaper to ensure we do not
* cross the namespaces, the exiting parent could be injected
* by setns() + fork().
* We check pid->level, this is slightly more efficient than
* task_active_pid_ns(reaper) != task_active_pid_ns(father).
*/
for (reaper = father->real_parent;
task_pid(reaper)->level == ns_level;
reaper = reaper->real_parent) {
if (reaper == &init_task)
break;
if (!reaper->signal->is_child_subreaper)
continue;
thread = find_alive_thread(reaper);
if (thread)
return thread;
}
}
return child_reaper;
}
```
`find_new_reaper()` 함수가 정상적으로 끝나면 적당한 프로세스가 새 부모 프로세스로 지정된 것이다. 다음으로 해야 할 일은 같은 부모를 가졌던 모든 프로세스의 부모 프로세스를 재지정하는 일이다. 이 작업은 `forget_original_parent()` 함수에서 이루어진다.
```C
/*
* Make init inherit all the child processes
*/
static void forget_original_parent(struct task_struct *father,
struct list_head *dead)
{
struct task_struct *p, *t, *reaper;
if (unlikely(!list_empty(&father->ptraced)))
exit_ptrace(father, dead);
/* Can drop and reacquire tasklist_lock */
reaper = find_child_reaper(father, dead);
if (list_empty(&father->children))
return;
reaper = find_new_reaper(father, reaper);
list_for_each_entry(p, &father->children, sibling) {
for_each_thread(p, t) {
RCU_INIT_POINTER(t->real_parent, reaper);
BUG_ON((!t->ptrace) != (rcu_access_pointer(t->parent) == father));
if (likely(!t->ptrace))
t->parent = t->real_parent;
if (t->pdeath_signal)
group_send_sig_info(t->pdeath_signal,
SEND_SIG_NOINFO, t,
PIDTYPE_TGID);
}
/*
* If this is a threaded reparent there is no need to
* notify anyone anything has happened.
*/
if (!same_thread_group(reaper, father))
reparent_leader(father, p, dead);
}
list_splice_tail_init(&father->children, &reaper->children);
}
```
이 안에서는 또 `exit_ptrace()` 함수를 호출하는데, 이 함수를 통해 추적 기능을 사용하는 자식 프로세스에 대해서도 마찬가지로 부모 프로세스를 다시 지정해준다.
```C
/*
* Detach all tasks we were using ptrace on. Called with tasklist held
* for writing.
*/
void exit_ptrace(struct task_struct *tracer, struct list_head *dead)
{
struct task_struct *p, *n;
list_for_each_entry_safe(p, n, &tracer->ptraced, ptrace_entry) {
if (unlikely(p->ptrace & PT_EXITKILL))
send_sig_info(SIGKILL, SEND_SIG_PRIV, p);
if (__ptrace_detach(tracer, p))
list_add(&p->ptrace_entry, dead);
}
}
```
`forget_original_parent()` 함수가 끝나면, `exit_notify()` 함수에서는 프로세스 그룹 전체가 종료되는 상황일 경우 `kill_orphaned_pgrp()` 를 통해 고아가 된 프로세스 그룹에 시그널을 보낸다. 그 이후 만약 자동으로 프로세스를 정리해야 한다면(`autoreap == true`), 프로세스 상태를 `EXIT_DEAD`로 바꾸고, `dead` 리스트에 추가한다. 만약 멀티스레드 환경에서 실행된 `exec()` 등으로 그룹 리더를 기다리는 상황이면, 대기 중인 태스크를 깨운다. 마지막으로 락을 해제하고, `dead` 리스트에 있는 모든 태스크를 순회하며 정리(`release_task()`)한다.
```C
/*
* Send signals to all our closest relatives so that they know
* to properly mourn us..
*/
static void exit_notify(struct task_struct *tsk, int group_dead)
{
bool autoreap;
struct task_struct *p, *n;
LIST_HEAD(dead);
write_lock_irq(&tasklist_lock);
forget_original_parent(tsk, &dead);
if (group_dead)
kill_orphaned_pgrp(tsk->group_leader, NULL);
tsk->exit_state = EXIT_ZOMBIE;
if (unlikely(tsk->ptrace)) {
int sig = thread_group_leader(tsk) &&
thread_group_empty(tsk) &&
!ptrace_reparented(tsk) ?
tsk->exit_signal : SIGCHLD;
autoreap = do_notify_parent(tsk, sig);
} else if (thread_group_leader(tsk)) {
autoreap = thread_group_empty(tsk) &&
do_notify_parent(tsk, tsk->exit_signal);
} else {
autoreap = true;
/* untraced sub-thread */
do_notify_pidfd(tsk);
}
if (autoreap) {
tsk->exit_state = EXIT_DEAD;
list_add(&tsk->ptrace_entry, &dead);
}
/* mt-exec, de_thread() is waiting for group leader */
if (unlikely(tsk->signal->notify_count < 0))
wake_up_process(tsk->signal->group_exec_task);
write_unlock_irq(&tasklist_lock);
list_for_each_entry_safe(p, n, &dead, ptrace_entry) {
list_del_init(&p->ptrace_entry);
release_task(p);
}
}
```
---
# 참고 자료
+ Amini, Kamran. 2019. _Extreme C: Taking You to the Limit in Concurrency, OOP, and the Most Advanced Capabilities of C_. 1st ed. Birmingham: Packt Publishing Limited. (한국어판 : Amini, K. (2022). _전문가를 위한 C_ (박지윤, Trans.; 1st ed.). 한빛미디어.)
+ Silberschatz, A., Galvin, P. B., & Gagne, G. (2018). _Operating system concepts_ (10th ed.). Wiley.
+ Arpaci-Dusseau, R. H., & Arpaci-Dusseau, A. C. (2014). _Operating Systems: Three Easy Pieces_ (Version 1.10). Arpaci-Dusseau Books, Inc.
+ R. E. Bryant and D. R. O’Hallaron, _Computer systems: a programmer’s perspective_, Third edition. Boston Amsterdam London: Pearson, 2016.
+ Love, R., _Linux Kernel Development_, Third Edition. Addison-Wesley Professional, 2010.