Process Management

Merry Berry·2023년 6월 30일
0

Linux Kernel

목록 보기
1/4
post-thumbnail

1. Process

실행 중인 프로그램이며, Task라고도 한다. 스레드는 프로세스 내에서 동작하는 객체이다.
리눅스 커널에서는 프로세스와 스레드를 구분하지 않는다.

fork()로 자식 생성, exec()로 새 프로그램 로드, exit()로 종료 및 자원 반납, wait4()로 자식 프로세스의 종료 상태 회수가 진행된다.


2. Process Descriptor & Task Structure

커널은 프로세스의 실행 정보를 task_struct에 저장하며, 이를 task descriptor이라 한다. 그리고 시스템에서 동작 중인 모든 프로세스의 task descriptor을 doubly linked list로 연결하여 관리한다. 4.6 kernel 기준 task_struct는 include/linux/sched.h에 정의되어 있다.

2.1. Process Descriptor Allocation

slab allocator을 이용하여 task descriptor을 동적으로 할당한다. 그리고 thread_info구조체를 스택의 상단(스택이 위로 자랄 경우) 또는 하단(스택이 아래로 자랄 경우)에 두는데, 해당 구조체 내에 있는 task 포인터가 task_struct를 가리킨다. thread_info는 x86 4.6v kernel 기준 arch/x86/include/asm/thread_info.h에 정의되어 있다.

2.2. Process Descriptor Storation

프로세스는 PID로 식별하고, pid_t의 opaque한 타입이며, 실제로는 int형을 사용한다. PID의 최대값은 곧 동시에 존재할 수 있는 프로세스의 최대 수를 의미하는데, 이 값은 /proc/sys/kernel/pid_max를 수정하여 변경할 수 있다.

커널 내부에서는 현재 수행중인 task의 task_struct에 접근할 때 current 매크로를 사용한다. 이 매크로의 구현은 아키텍처별로 다른데, register에 task_struct의 포인터를 저장하거나, x86에서는 커널 스택에서 thread_info의 위치를 찾아 task_struct에 접근한다.

x86에서의 current 매크로는 현재 stack pointer에서 하위 13bit를 mask하여 thread_info 구조체의 위치를 계산하는데, current_thread_info()가 이 역할을 담당한다.

위 코드는 arm 4.1.9v arch/arm/include/asm/thread_info.h에 있는 current_thread_info()를 보인다. 여기서 current_top_of_stack()은 stack pointer의 값을, THREAD_SIZE는 stack size를 의미한다.

2.3. Process State

  • TASK_RUNNING: 프로세스가 실행 중이거나 run queue에서 대기하는 상태
  • TASK_INTERRUPTIBLE: 특정 조건이 발생하는 것을 기다리는 상태로, 조건을 만족하는 event가 발생하거나 시그널 수신 시 TASK_RUNNING으로 전이
  • TASK_UNINTERRUPTIBLE: 시그널을 수신해도 상태가 전이하지 않는 점을 빼면 TASK_INTERRUPTIBLE 상태와 동일
  • __TASK_TRACED: 디버거 등의 프로세스가 ptrace 등으로 해당 프로세스를 추적하는 상태
  • __TASK_STOPPED: 실행 정지 상태로, SIGSTOP, SGTSTP, SIGTTIN, SIGTTOU 등의 시그널을 수신하거나, 디버깅 중 시그널을 수신하였을 경우의 상태

2.4. Process State Manipulation

task의 state를 변경하기 위해서는 set_task_state() 함수를 이용하면 된다. 해당 함수는 SMP 에서 다른 프로세서와의 순서를 제어하여 메모리를 보호한다. 만약 SMP가 아니면 task->state = state를 수행하는 것과 동일하다. 해당 함수는 'include/linux/sched.h'에서 매크로로 정의되어 있다.

2.5. Process Context

일반적인 프로세스는 사용자 공간(user space)에서 실행하지만, system call을 호출하거나 exception이 발생하면 해당 프로세스를 커널 공간(kernel space)에서 실행한다. 이를 "커널이 프로세스 컨텍스트에 있다"고 한다. 그리고 커널 공간에서 모든 처리가 끝나면 사용자 공간으로 복귀하여 실행된다. 이때 커널 공간으로 접근하기 위해서는 system call이나 exception과 같은 interface를 통해 접근해야 한다.

2.6. Process Family Tree

모든 프로세스의 조상은 init(1)이고, init/init_task.c에 선언된init_task 변수를 통해 전역적으로 접근 가능하다.

task_struct에는 parent의 task_struct을 가리키는 포인터가 있고, sibling와 child는 linked list로 관리된다.


3. Process Creation

대부분의 운영체제에서는 spawn 방식으로 프로세스를 생성하는데, 주소 공간을 할당한 후 해당 프로그램을 로딩하여 실행한다. 그러나 유닉스는 이 과정을 두 가지의 과정으로 나누었다. fork() 함수는 pid, ppid, 통계 정보 등을 제외한 나머지를 복사한다. 그리고 exec()를 사용하여 새로운 프로그램을 로딩하여 실행한다.

3.1. Copy-on-Write

fork() 호출 시 parent를 복사하여 child를 생성하는데, 대부분의 경우 fork() 이후 exec()를 수행하므로 fork()에서 수행하는 복사 과정은 불필요하다. 따라서 task descriptor과 page table 등 필수적인 것만 복사하고 나머지는 공유하되, 만약 공유 데이터를 write하는 상황이 발생할 경우 copy 이후 write를 수행한다.

3.2. Forking

리눅스의 clone() system call 호출 시 다양한 플래그를 인자로 설정하여 부모와 공유할 자원을 지정할 수 있다.

/* Prototype for the glibc wrapper function */

#define _GNU_SOURCE
#include <sched.h>

int clone(int (*fn)(void *_Nullable), void *stack, int flags,
          void *_Nullable arg, ...  /* pid_t *_Nullable parent_tid, 
          void *_Nullable tls, pid_t *_Nullable child_tid */ );

fork(), vfork(), __clone() 라이브러리 함수는 플래그를 적절히 설정하여 clone()을 호출하고, 최종적으로 이 내부에서는 do_fork()를 호출하여 본격적으로 프로세스를 생성한다. 또한 이 함수에서 copy_process()를 호출한다.

copy_process의 동작 과정은 아래와 같다.
1. dup_task_struct()를 호출하여 kernel stack 생성 및 thread_info, task_struct 복제

2. 프로세스 최대 수를 초과하는지 확인

3. child의 task_struct field 중 parent로부터 물려받지 않는 항목들을 초기화
4. child의 state가 TASK_UNINTERRUPTIBLE로 설정
5. copy_flags() 호출하여 플래그 초기화
6. alloc_pid() 호출하여 PID 할당
7. clone() 플래그에 따라 자원 공유 및 복제
8. caller에 생성한 프로세스의 포인터 반환

대부분 fork() 이후 exec()를 사용하므로, 커널은 copy-on-write를 방지하기 위해 자식 프로세스를 먼저 수행한다.

3.3. vfork()

vfork()를 통해 생성된 child는 parent의 주소 공간을 공유하며 thread 형태로 실행되고, parent는 child가 exec()을 호출하거나 종료할 때까지 대기한다.
vfork()는 parent의 page table을 복제하지 않는다는 점을 제외하면 fork()와 동일하게 동작한다. 과거 fork()에 copy-on-write가 구현되지 않은 3BSD Unix에서 최적화 용도로 사용되었으나, 현재는 fork()를 더 많이 사용한다. vfork()는 적절한 flag를 설정하여 내부적으로 clone()을 호출한다.

  1. copy_process()에서 task_struct의 vfork_done 필드를 NULL로 초기화
  2. do_fork()에서 특별한 flag가 set되어 있는 경우 vfork_done이 특정 주소를 가리키도록 설정
  3. child process를 우선 실행 후, 함수를 반환하지 않고 parent가 child로부터 vfork_done field를 통해 signal을 받을 때까지 대기
  4. task가 종료되어 메모리 공간을 반환할 때 호출되는 mm_release()함수에서, vfork_done이 NULL인지 확인하고, 아니면 parent에게 signal 전송
  5. do_fork()로 복귀 후 parent를 깨우고 반환

4. Linux Thread Implementation

리눅스에서는 스레드와 프로세스의 차이를 두지 않는다. 커널에서 명시적으로 스레드를 지원하는 여타 운영체제와 달리, 리눅스에서는 스레드를 자원을 공유하는 task로 보고, 프로세스와 동일하게 task_struct로 관리한다.

4.1. Thread Creation

스레드는 프로세스와 마찬가지로 clone() 시스템 호출을 사용하는데, 스레드가 공유하는 자원에 따라 flag를 설정하여 호출한다.

4.2. Kernel Threads

커널 스레드는 커널 공간에서 실행되는 task로 사용자 프로세스와 마찬가지로 선점 가능하다. 그러나 커널 스레드는 사용자 공간으로 context가 전환되지 않고, 주소 공간을 갖고 있지 않다. 즉 task_struct의 mm field가 NULL이다. 커널 스레드의 예시로 ksoftirqd, flush가 있으며, kthreadd가 커널 스레드를 생성한다.

4.6 kernel 기준으로 kthread_create() 매크로 함수에서 kthread_create_on_node()를 호출한다. 해당 매크로 함수는 /include/linux/kthread.h에 정의되어 있다.

kthread_create_on_node() 함수는 /kernel/kthread.c에 정의되어 있으며, 이 함수는 형식 문자열인 namefmt를 이름으로 하고 threadfn(data)를 실행하는 task를 생성한다.

그리고 /include/linux/kthread.h에서 정의된 매크로 함수 kthread_run()은 내부적으로 kthread_create()을 호출하고, 생성된 task를 wake_up_process()로 실행한다.

커널 스레드는 한 번 실행되면 do_exit() 함수를 호출하거나 다른 task에서 대상 task의 task_struct를 인자로 kthread_stop() 함수를 호출할 때까지 실행된다.

5. Process Termination

프로세스는 exit()함수의 호출이나 signal, exception에 의해 종료되는데, 어떤 이유로 종료되든 간에 do_exit()가 호출되어 종료 작업을 진행한다. 이 함수는 /kernel/exit.c에 정의되어 있다.

  1. flags field를 PF_EXITING으로 설정

  1. del_timer_sync() 함수를 호출하여 kernel timer 제거
  2. BSD 방식 프로세스 정보 기록을 사용한다면 acct_update_integrals()함수 호출하여 정보 기록

  1. exit_mm() 함수를 호출하여 프로세스의 mm_struct 반환
  2. exit_sem() 함수를 호출하여, 프로세스가 semaphore을 대기하고 있었을 경우 대기 상태 해제
  3. exit_files(), exit_fs() 함수를 호출하여, file descriptor와 file system 참조 횟수 감소

  1. exit code를 task_struct의 exit_code에 저장

  1. exit_notify() 함수 호출하여 parent에 signal 전송, 종료하고자 하는 프로세스의 child의 parent를 동일 스레드군에 속한 다른 스레드 혹은 init으로 설정, exit_stateEXIT_ZOMBIE로 설정

  1. schedule() 함수를 호출하여 다른 프로세스 실행, do_exit()은 반환 과정이 없다.

do_exit() 이후 해당 프로세스는 아직 task_struct, thread_info, kernel stack을 갖고 있는 EXIT_ZOMBIE상태가 되고, parent 프로세스에게 전달해야 하는 정보만을 유지한다. parent에 정보가 전달되어 처리되거나 커널이 해당 정보를 유지할 필요가 없다고 알려주면 프로세스의 나머지 메모리 영역을 반환받는다.

5.1. Process Descriptor Removal

do_exit()을 통해 정리 작업을 진행해도 프로세스 서술자는 여전히 남아 있는데, 여기에는 parent에게 전달해야 하는 정보가 담겨 있다. 따라서 parent가 정보를 받아 처리하거나 커널에서 해당 정보를 유지할 필요가 없다고 알리면 프로세스 서술자를 제거하는 작업을 해야 한다. 즉, 프로세스 종료 정리작업과 프로세스 서술자 제거작업은 분리되어 있다.

wait()함수는 child가 종료될 때까지 parent를 기다리도록 하는 함수로, child의 pid를 반환하며 child의 exit code 포인터를 인자로 전달한다. 이 함수는 wait4() 시스템 호출로 구현된다.

release_task() 함수를 통해 프로세스 서술자를 위해 할당된 메모리를 제거하는데, 내부에서는 다음 과정을 거친다.

  1. __exit_signal() -> __unhash_process() -> detach_pid()에서, task를 pidhashtask list에서 제거
  2. __exit_signal()에서 프로세스에 의해 남아 있던 자원을 반환하고 통계 정보를 기록
  3. 종료하는 task가 프로세스 내에서 유일한 스레드라면, 해당 task는 좀비 프로세스이므로 parent에게 이 사실을 알림
  4. put_task_struct() 함수 호출로 커널 스택과 task_struct, thread_info를 저장하는 페이지를 할당 해제하고, task_struct를 보관하는 슬랩 캐시 또한 할당 해제

이 시점에서 프로세스 서술자를 포함한, 프로세스 관련 모든 자원이 할당 해제된다.

5.2. Dilemma of the Parentless Task

만약 child가 먼저 종료하기 전에 parent가 종료한다면, 해당 child의 새 parent를 연결해주어야 한다. 리눅스에서는 parent가 속한 스레드 군에서 다른 스레드를 부모로 설정하거나, 이것이 어렵다면 init을 부모로 설정한다. init는 주기적으로 wait()를 호출하여 좀비 프로세스를 정리한다.

do_exit() -> exit_notify() -> forget_original_parent() -> find_new_reaper()에서 새로운 parent 프로세스가 지정된다. 아래는 4.6 kernel 기준 /kernel/exit.c에 정의된 find_new_reaper() 함수의 코드이다.

491line에서는 father가 속한 스레드 그룹 중 종료되지 않은 스레드를 찾아 thread에 대입한다. 그리고 thread가 NULL이 아니면 해당 포인터를 반환하고, 그렇지 않으면 father부터 시작하여 parent로 계속 올라가 새로운 parent가 될 스레드를 탐색한다. 탐색하다 init을 만나면 child를 반환하는데 이 부분은 이해하지 못했다

위 함수에서 새로운 parent(reaper)를 선택했다면 forget_original_parent()에서 father을 자식으로 했던 모든 프로세스의 parent를 reaper로 설정한다.

만약 추적 기능을 사용한다면, 해당 기능을 수행하는 child 프로세스의 새로운 parent 또한 reaper로 설정한다. 이는 forget_original_parent()/kernel/ptrace.cexit_ptrace()를 호출하며 발생한다.

0개의 댓글