Skip to content

프로세스 관리

1. 프로세스와 구조체

  • 프로세스는 프로그램 코드를 실행하면서 생기는 모든 결과물이다.

    • 일반적인 의미: 실행 중인 프로그램
    • 포괄적인 의미: 사용 중인 파일, 대기 중인 시그널, 커널 내부 데이터, 프로세서 상태, 메모리 주소 공간, 실행 중인 하나 이상의 스레드 정보 등
  • 프로세스는 fork() 호출 시 생성되고, 기능을 수행한 뒤, exit()를 호출해 종료된다. 부모 프로세스는 wait() 호출로 자식 프로세스 종료 상태를 확인할 수 있다.

  • 스레드는 프로세스 내부에서 동작하는 객체이고, 개별적인 PC, Stack, Register(context)를 가지고 있다.

  • 리눅스 커널은 프로세스와 스레드를 구분하지 않는다.

  • 리눅스 커널에 대한 접근은 오직 시스템 콜과 ISR로만 가능하다.

  • 커널은 프로세스를 ‘task list’라는 **circular bidirctional linked list **자료구조로 저장한다.

  • ‘Task list’는 <linux/sched.h>task_struct 구조체로 정의되어있다.

    • task_struct는 프로세스를 관리하는 데 필요한 모든 정보를 가지고 있어서 정의만 약 300줄 가량이며 32-bit 시스템 기준 약 1.7KB에 달하는 상당히 큰 구조체다.
    • task_struct는 ‘slab allocator’(객체 재사용 및 캐시 기능 지원)를 이용해 동적할당 한다.
      • 커널 2.6 버전 이전에는 각 프로세스 커널의 최하단(또는 최상단)에 task_struct를 뒀다. 그래야 x86처럼 레지스터 개수가 적은 아키텍처에서 레지스터에 따로 task_struct의 주솟값을 저장하지 않고 바로 접근할 수 있었기 때문이다.
      struct task_struct {
      volatile long state;
      void *stack;
      atomic_t usage;
      unsigned int flags;
      unsigned int ptrace;
      int lock_depth;
      #ifdef CONFIG_SMP
      #ifdef __ARCH_WANT_UNLOCKED_CTXSW
      ...
      }

2. 프로세스 상태

  • 프로세스는 다음과 같은 5가지 상태를 가진다.

    • TASK_RUNNING: Ready queue에서 대기중이거나, 현재 동작 중인 프로세스다.
    • TASK_INTERRUPTIBLE/TASK_UNINTERRUPTIBLE: 특정 조건이 발생하기를 기다리며 중단된 상태에 있는 프로세스다. 조건 발생 시 TASK_RUNNING으로 바뀐다. Signal 수신 여부로 두 상태를 구분한다.
    • TASK_TRACED: 디버거 같은 장비를 사용하는 외부 프로세스가 ptrace를 사용해 해당 프로세스를 추적하고 있는 상태다.
    • TASK_STOPPED: 프로세스가 SIGSTOP 같은 signal을 받아 실행이 정지된 상태다.
  • 프로세스의 상태는 <linux/sched.h>set_task_state() 함수로 설정 가능하다.

3. 프로세스 계층 트리

  • 모든 프로세스는 PID 1인 init 프로세스의 자식 프로세스다.
  • task_struct는 부모-형제-자식 프로세스의 관계를 표현하고 있다.
  • 또한, task_struct는 (Bidirectional circular linked list의 요소를 가리키는) *next, *prev 포인터를 갖고 있다.

4. 프로세스 생성

  • UNIX에서는 fork()로 프로세스를 생성할 때 부모 프로세스의 모든 리소스를 그대로 자식 프로세스에 복사하는 식으로 구현했다. 일반적으로 자식 프로세스는 생성된 후 exec()을 이용해 다른 프로그램을 실행하는 경우가 많으므로 이러한 방법은 굉장히 비효율적이었다.

  • 리눅스는 ‘Copy-and-write’를 이용해서 이 문제를 해결했다.

    • 자식 프로세스가 공유자원에 write을 시도할 때 부모 프로세스 → 자식 프로세스 리소스 복사한다.
    • 자식 프로세스가 공유자원에 write을 하지 않는 경우 (대부분 생성 후 바로 exec()하는 경우), 큰 최적화 효과를 얻을 수 있다.
  • 프로세스가 생성되는 세부적인 과정은 다음과 같다.

  1. 리눅스의 glibc 속 fork()clone() 이라는 시스템콜을 다양한 플래그를 적용해 부모-자식 프로세스간 공유자원을 지정한 뒤 호출한다.

    • linux - Which file in kernel specifies fork(), vfork()… to use sys_clone() system call - Unix & Linux Stack Exchange
  2. clone()do_fork()함수를 호출하고 do_fork()copy_process()를 호출해 내부적으로 아래 과정을 수행한다.

    1. dup_task_struct() 함수 호출
      • 새로운 프로세스 스택 공간 할당, 새로운 thread_info, task_struct 구조체를 생성한다.
      • 생성할 때 부모의 process descriptor를 그대로 가져와서 생성한다.
    2. 프로세스 개수 제한을 넘었는지 검사한다. 자식 프로세스 구조체의 일부 멤버변수를 초기화. (상태= TASK_UNINTERRUPTED)
    3. copy_flag() 함수 호출
      • task_structflags 내용을 정리한다.
      • PF_SUPERPRIV 플래그 초기화: 현재 수행하는 작업이 관리자 권한임을 의미.
      • PF_FORKNOEXEC 플래그 초기화: 프로세스가 exec() 함수를 호출하지 않았음을 의미.
    4. alloc_pid() 함수 호출
      • 자식 프로세스에게 새로운 PID값을 할당한다.
    5. clone()의 매개변수로 전달된 플래그에 따라 파일시스템 정보, signal handler, 주소공간, namespace 등을 share하거나 copy한다. (보통 스레드는 share를, 프로세스는 copy 한다.) 생성한 자식 프로세스의 포인터를 반환한다.
  • vfork() 시스템콜은 부모 프로세스의 page table을 copy하지 않는다는 점을 제외하면 fork()와 동일. 그러나 copy-and-write을 사용하는 리눅스 특성상 fork() 대비 이득이 적어서 거의 사용하지 않는다.

5. 스레드 구현 및 취급

  • 대표적인 modern-programming 기법인 스레드는 공유 메모리를 가진 여러 프로그램을 ‘동시에’(concurrent) 수행해 multi-processor 환경에서는 진정한 병렬처리를 구현할 수 있다.
  • 스레드는 개별 task_struct를 갖고 메모리를 부모 프로세스와 공유하고 있는 정상 프로세스다. (리눅스는 프로세스와 스레드를 구분하지 않는다.)
  • 따라서 스레드도 내부적으로는 프로세스 생성 때와 똑같이 clone() 시스템콜을 이용한다. 여러 플래그를 parameter로 넘겨서 스레드의 특성을 부여할 뿐이다. (i.e. clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0);)
  • <linux/sched.h>의 최상단에 스레드 생성 관련 clone flags가 정의되어있다.

커널 스레드

  • 커널도 일부 동작은 백그라운드에서 실행하는 것이 좋은데, 이때 커널 공간에서만 존재하는 특별한 스레드인 ‘커널 스레드’를 이용한다.

  • 가장 큰 차이점은 주소 공간이 없다는 점이다. (프로세스의 주소 공간을 가리키는 mm 포인터가 NULL이다.)

  • 커널 스레드는 <linux/kthread.h>에 정의돼있고, kthreadd라는 최상위 부모 스레드가 모든 하위 커널 스레드를 만드는 방식으로 동작한다.

  • 커널 스레드는 kthread_run 매크로로 kthread_create()를 호출해 clone() 시스템콜을 호출해 만든다.

6. 프로세스 종료

  • 프로세스는 main()이 끝날 때 묵시적으로 또는 명시적으로 exit()를 호출하여 종료된다.
  • exit() 함수는 내부적으로 <kernel/exit.c>에 정의된 do_exit() 함수를 호출한다.
  • 프로세스 종료 과정은 아래와 같다.
void __noreturn do_exit(long code)
{
struct task_struct *tsk = current;
int group_dead;
...
exit_signals(tsk); /* 1 - sets PF_EXITING */
acct_update_integrals(tsk); /* 2 */
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);
...
tsk->exit_code = code; /* 3 */
taskstats_exit(tsk, group_dead);
exit_mm(); /* 4 */
if (group_dead)
acct_process();
trace_sched_process_exit(tsk);
exit_sem(tsk); /* 5 */
exit_shm(tsk);
exit_files(tsk); /* 6 */
exit_fs(tsk);
...
exit_notify(tsk, group_dead); /* 7 */
proc_exit_connector(tsk);
mpol_put_task_policy(tsk);
...
lockdep_free_task(tsk);
do_task_dead(); /* 8 */
}
  1. current의 flags의 PF_EXITING 플래그를 설정한다.
  2. acct_update_integrals() 함수를 호출해 종료될 프로세스 정보를 기록한다. current의 exit_code에 exit() 함수에서 지정한 값에 따른 종료코드가 저장된다.
  3. exit_mm() 함수를 호출해 프로세스의 mm_struct를 반환해 자원 해제한다.
  4. exit_sem() 함수를 호출해 프로세스의 세마포어를 반환해 대기 상태를 해제한다.
  5. exit_files(), exit_fs() 함수를 호출해 file descriptor 및 file system의 참조 횟수를 하나 감소한다. 참조 횟수가 0이면 해당 객체를 사용하는 프로세스가 없다는 의미이므로 자원 해제한다.
  6. exit_notify() 함수를 호출해 부모 프로세스에 signal을 보낸다. 이때 해당 프로세스가 자식 프로세스를 가지고 있었다면, 자신의 부모 프로세스 or 자신이 속한 스레드 group의 다른 스레드 or init 프로세스 중 하나를 부모로 설정한다.
  7. current의 state을 TASK_DEAD로 설정해 좀비 프로세스로 만든다.
  • 부모 프로세스의 동작은 다음과 같다.
  1. release_task() 함수를 호출해 더는 자식 좀비 프로세스가 필요없다고 커널에게 signal을 보낸다. release_task() -> __exit_signal() -> __unhashed_process() -> detach_pid()
  2. __exit_signal()에서 좀비 프로세스의 남은 정보도 완전히 메모리 반환한다. release_task()put_task_struct() 함수를 호출해 좀비 프로세스의 stack, thread_info 구조체, task_struct 구조체가 들어있던 페이지 및 slab cache를 반환한다. 이제 프로세스와 연관된 모든 자원이 해제돼 완전히 종료됐다.
  • 부모 프로세스가 좀비가 된 자식 프로세스를 책임지고 종료하지 못할 때 리눅스의 유명한 문제인 ‘좀비 프로세스 문제’가 발생한다.

    • 시스템 메모리를 낭비하는 문제가 발생하는 것이다.

    • 따라서, 위 과정 중 8번에서 다뤘듯, 부모 프로세스가 없을 때 다른 부모 프로세스 후보들 중 하나를 선택해 부모로 설정해주는 과정이 반드시 필요하다.

      1. do_exit() 함수에서 exit_notify() 함수를 호출한다.

      2. exit_notify() 함수에서 forget_original_parent() 함수를 호출한다. forget_original_parent() 함수는 종료할 프로세스의 부모 프로세스를 반환하는 함수다.

        • 이때, 부모 프로세스가 먼저 종료된 ‘문제 좀비 프로세스’인 경우, 적당한 부모 프로세스를 선택해주는 역할도 함께 한다.종료 프로세스가 속한 스레드 group 내에서 다른 스레드를 찾는다. 찾았다면, 해당 스레드를 부모로 만들고 반환한다.
        • 만일 다른 스레드가 없다면, init 프로세스를 찾고 init 프로세스를 부모로 만들어서 반환한다.
      3. 부모 프로세스를 찾았으니 종료할 프로세스의 모든 자식 프로세스의 부모로 설정한다.

  • 이로써, 좀비 프로세스를 적절히 종료하지 못해 발생하는 문제를 미연에 방지할 수 있다.


참고