본문 바로가기

Enginius/Linux

Process diagram in Linux

파일과 마찬가지로 프로세스는 유닉스(UNIX®) 운영체제에서 기초다. 프로세스는 실행 가능한 파일의 명령어를 수행하는 살아있는 존재다. 명령 수행 이외에, 프로세스는 특히 열린 파일, 프로세서 문맥, 주소 공간, 프로그램과 관련한 자료 등을 관리한다. 리눅스 커널은struct task_struct로 정의된 프로세스 기술자에서 완전한 프로세스 관련 정보를 보관한다. 리눅스 커널 원시 코드인 include/linux/sched.h에 들어있는 struct task_struct의 다양한 필드를 확인하자.

프로세스 상태에 대해

생명 주기 동안, 프로세스는 상호 배타적인 상태 집합을 오간다. 커널은 프로세스 상태를 struct task_struct의 state 필드에 보관한다. 그림 1은 프로세스 상태 사이에 일어나는 전이 상태를 보여준다.


그림 1. 프로세스 상태 전이
프로세스 상태 전이 

다양한 프로세스 상태를 검토해보자.

  • TASK_RUNNING: 프로세스는 CPU에서 동작하고 있거나 스케줄을 위해 실행 큐에서 대기하고 있다.
  • TASK_INTERRUPTIBLE: 프로세스는 잠든 상태이며, 사건이 일어나기를 기다리고 있다. 프로세스는 시그널이 인터럽트하도록 열려 있다. 일단 시그널을 받거나 명시적인 깨어나기 호출로 깨어나면, 프로세스 상태가 TASK_RUNNING으로 전이한다.
  • TASK_UNINTERRUPTIBLE: 프로세스 상태는 TASK_INTERRUPTIBLE과 비슷하다. 하지만 이 상태에서 프로세스는 시그널을 처리하지 않는다. 몇몇 중요한 작업을 완료하는 중간에 있기에 이 상태에 있을 경우 프로세스를 인터럽트하는 상황이 그리 바람직하지 않을 가능성도 있다. 기다리고 있던 사건이 발생할 때, 프로세스는 명시적인 깨어나기 호출로 깨어난다.
  • TASK_STOPPED: 프로세스 수행이 멈췄으며 동작하지 않으며 동작할 수도 없다. SIGSTOPSIGTSTP 같은 시그널을 받으면, 프로세스는 이 상태에 도달한다. 프로세스가 SIGCONT 시그널을 받으면 다시 한번 동작 가능한 상태로 바뀐다.
  • TASK_TRACED: 디버거와 같은 다른 프로세스가 감시 중일 때 프로세스가 이 상태에 도달한다.
  • EXIT_ZOMBIE: 프로세스가 종료되었다. 자식 프로세스는 어버이가 몇 가지 통계 정보를 수집하기를 기다리면서 질질 끄는 상태다.
  • EXIT_DEAD: (글자 그대로) 최종 상태다. 어버이가 wait4()나 waitpid() 시스템 호출을 사용해 모든 통계 정보를 수집했기에 프로세스를 시스템에서 제거 중일 때 이 상태에 도달한다.

프로세스 상태 전이에 대한 상세한 정보는 참고자료에 소개한 The Design of the UNIX Operating System을 읽어보자.

이미 언급했듯이 TASK_UNINTERRUPTIBLE과 TASK_INTERRUPTIBLE이 프로세스 잠들기 상태다. 이제 프로세스를 잠들도록 만들기 위해 커널이 제공하는 메커니즘을 살펴볼 차례다.

커널 낮잠자기

리눅스 커널은 프로세스를 잠들기 상태로 만드는 두 가지 방법을 제공한다.

프로세스를 잠들게 만들려면 프로세스 상태를 TASK_INTERRUPTIBLE이나 TASK_UNINTERRUPTIBLE로 설정하고 스케줄러 함수인schedule()을 호출하는 일반적인 방식을 따른다. 이렇게 하면 프로세스가 CPU 실행 큐에서 제거된다. (상태를 TASK_INTERRUPTIBLE로 설정해) 프로세스가 인터럽트 가능한 모드에서 잠들고 있다면, 명시적인 깨우기 호출(wakeup_process())이나 처리가 필요한 시그널로 깨울 수 있다.

하지만 (상태를 TASK_UNINTERRUPTIBLE로 설정하는 방법으로) 프로세스가 인터럽트 불가능 모드에서 잠들고 있다면, 명시적인 깨우기 호출로만 깨울 수 있다. (시그널 처리가 아주 어려울 때 디바이스 I/O 수행 중인 경우처럼) 진짜로 필요하지 않는 이상 인터럽트가 불가능한 프로세스 잠들기 모드보다는 인터럽트가 가능한 프로세스 잠들기 모드를 사용하는 편이 바람직하다.

인터럽트 가능한 상태에서 잠든 태스크가 시그널을 받으면, (이미 마스크되지 않았다면!) 태스크는 시그널을 처리할 것이다. 이 때 진행하던 일을 남겨두고(물론 정리 코드가 필요하다) 사용자 영역으로 -EINTR을 반환한다. 당연한 이야기지만, 반환 코드를 검사해서 적절하게 처리할 책임은 프로그래머에게 있다. 따라서 게으른 프로그래머가 프로세스를 인터럽트 불가능한 잠들기 모드로 두는 편을 선호할지도 모르는 이유는 시그널이 태스크를 깨우지 못하기 때문이다. 하지만 이유 불문하고 인터럽트가 불가능한 잠들기 상태에 있는 프로세스에 깨우기 호출이 일어나지 않는 경우를 주의해야 한다. 이렇게 되면 프로세스에 kill을 보낼 수 없으므로 궁극적으로는 시스템 재시동이 유일한 해결책이 되어버리는 황당한 상황이 벌어진다. 커널과 사용자 영역에 버그를 심을 가능성이 있기 떄문에 몇 가지 세부 사항에 신경을 써야 한다. 또한 (차단된 상태에서 kill이 불가능한 프로세스인) 불사신을 만들어 낼지도 모른다.

이제 커널에서 잠들기와 관련한 새로운 방식을 소개할 차례다!

새로운 잠들기 상태: TASK_KILLABLE

리눅스 커널 2.6.25는 새로운 프로세스 잠들기 상태인 TASK_KILLABLE을 도입했다. 프로세스가 이런 새로운 상태에서 kill이 가능한 잠들기에 빠져 있다면, TASK_UNINTERRUPTIBLE 특성에다 치명적인 시그널에 반응하는 보너스까지 더한 방식으로 동작한다. Listing 1에 커널 2.6.18에서 2.6.26으로 올라가면서 변경된 프로세스 상태(include/linux/sched.h에 정의된 내용)를 비교해놓았다.


Listing 1. 2.6.18과 2.6.26 사이에 달라진 프로세스 상태 비교
리눅스 커널 2.6.18                     리눅스 커널 2.6.26
=================================      ===================================
#define TASK_RUNNING            0      #define TASK_RUNNING            0
#define TASK_INTERRUPTIBLE      1      #define TASK_INTERRUPTIBLE      1
#define TASK_UNINTERRUPTIBLE    2      #define TASK_UNINTERRUPTIBLE    2
#define TASK_STOPPED            4      #define __TASK_STOPPED          4
#define TASK_TRACED             8      #define __TASK_TRACED           8
/* in tsk->exit_state */            /* in tsk->exit_state */
#define EXIT_ZOMBIE             16     #define EXIT_ZOMBIE             16
#define EXIT_DEAD               32     #define EXIT_DEAD               32
/* in tsk->state again */           /* in tsk->state again */
#define TASK_NONINTERACTIVE     64     #define TASK_DEAD               64
                                    #define TASK_WAKEKILL           128

TASK_INTERRUPTIBLE과 TASK_UNINTERRUPTIBLE 상태는 바뀌지 않았음에 주목하자. TASK_WAKEKILL은 치명적인 시그널을 받을 때 프로세스를 깨우도록 설계되어 있다.

Listing 2는 (TASK_KILLABLE 정의와 더불어) TASK_STOPPED와 TASK_TRACED 상태가 바뀐 방식을 보여준다.


Listing 2. 커널 2.6.26에 등장한 새로운 상태 정의
#define TASK_KILLABLE   (TASK_WAKEKILL | TASK_UNINTERRUPTIBLE)
#define TASK_STOPPED    (TASK_WAKEKILL | __TASK_STOPPED)
#define TASK_TRACED     (TASK_WAKEKILL | __TASK_TRACED)

다시 말해, TASK_UNINTERRUPTIBLE + TASK_WAKEKILL = TASK_KILLABLE이다.

TASK_KILLABLE을 사용하는 새로운 커널 API

completion에 대한 몇 가지 정보

completion은 태스크를 잠들기에 빠지게 만들었지만 몇 가지 사건이 완료될 때 깨어나도록 만들고 싶은 경우 사용 가능한 훌륭한 수단이다. completion은 경쟁 조건 없이 동기화가 가능한 간단한 수단을 제공한다.wait_for_completion(struct completion *comp) 루틴은 호출한 태스크를 사건 완료 전까지 인터럽트가 불가능하게 잠들도록 만든다. 잠들기에서 깨어나려면complete(struct completion *comp)complete_all(struct completion *comp) 함수 호출이 필요하다.

wait_for_completion_killable() 이외에도, 사건 완료까지 대기하는 루틴은 다음과 같다.

  • wait_for_completion_timeout()
  • wait_for_completion_interruptible()
  • wait_for_completion_interruptible_timeout()

include/linux/completion.h에 포함된 completion 구조체 정의를 살펴보기 바란다.

새로운 상태를 사용하는 몇 가지 새로운 함수를 살펴보자.

  • int wait_event_killable(wait_queue_t queue, condition);
    이 함수는 include/linux/wait.h에 정의되어 있다. 이 함수는condition이 참이 될 때까지 queue에서 kill 가능한 상태로 호출한 프로세스를 잠들게 만든다.
  • long schedule_timeout_killable(signed long timeout);
    이 함수는 kernel/timer.c에 정의되어 있다. 이 루틴은 기본적으로 현재 태스크 상태를 TASK_KILLABLE로 설정하고schedule_timeout()을 호출한다. 이렇게 되면 호출한 태스크가jiffies timeout 숫자만큼 잠든다(유닉스 시스템에서, jiffy는 연속적인 두 클럭 틱 사이에 벌어진 시간이다).
  • int wait_for_completion_killable(struct completion *comp);
    kernel/sched.c에 정의되어 있다. 이 루틴은 이벤트 완료를 위해 kill이 가능한 상태에서 대기하도록 사용된다. 이 함수는 기다리고 있는 치명적인 시그널이 없을 경우MAX_SCHEDULE_TIMEOUT(LONG_MAX와 똑같은 값으로 정의되어 있다) jiffies가 지나고 schedule_timeout()을 호출한다.
  • int mutex_lock_killable(struct mutex *lock);
    kernel/mutex.c에 정의되어 있다. 이 루틴은 뮤텍스 잠금을 획득하는 데 사용한다. 하지만 잠금을 얻지 못하는 상황에서 태스크가 잠금을 얻으려고 기다리는 도중에 치명적인 시그널을 받으면, 태스크는 시그널 처리를 위해 뮤텍스 잠금을 기다리는 대기 목록에서 제거된다.
  • int down_killable(struct semaphore *sem);
    kernel/semaphore.c에 정의되어 있다. 이 루틴은 세마포어 sem을 획득하는 데 사용한다. 세마포어를 얻지 못하면 잠들기에 빠진다. 치명적인 시그널이 도착하면, 대기자 목록에서 빠지며, 시그널에 반응해야 한다. 세마포어를 획득하는 다른 두 가지 메서드는 down()과 down_interruptible()이다. down()은 이제 한물간 함수이기에 down_killable()이나 down_interruptible()을 사용해야 한다.

NFS 클라이언트 코드에 일어난 변화

이런 새로운 프로세스 상태를 사용하도록 NFS 클라이언트 코드에 몇 가지 변화가 필요하다. Listing 3은 리눅스 커널 2.6.18과 2.6.26 사이에 달라진 nfs_wait_event 매크로의 차이점을 보여준다.


Listing 3. TASK_KILLABLE 때문에 nfs_wait_event에서 달라진 사항
리눅스 커널 2.6.18                           리눅스 커널 2.6.26
==========================================   =============================================
#define nfs_wait_event(clnt, wq, condition)  #define nfs_wait_event(clnt, wq, condition)
 ({                                           ({
  int __retval = 0;                            int __retval = 
                                                   wait_event_killable(wq, condition);
    if (clnt->cl_intr) {                        __retval;
     sigset_t oldmask;                        })
     rpc_clnt_sigmask(clnt, &oldmask);
     __retval = 
     wait_event_interruptible(wq, condition);
       rpc_clnt_sigunmask(clnt, &oldmask);
    } else
        wait_event(wq, condition);
        __retval;
 })

Listing 4는 리눅스 커널 2.6.18과 2.6.26을 비교해 달라진 nfs_direct_wait() 함수 정의를 보여준다.


Listing 4. TASK_KILLABLE 때문에 nfs_direct_wait()에서 달라진 사항
리눅스 커널 2.6.18                                   
=================================           
static ssize_t nfs_direct_wait(struct nfs_direct_req *dreq) 
{                                                           
  ssize_t result = -EIOCBQUEUED;                              

  /* Async requests don't wait here */                         
 if (dreq->iocb)                                              
      goto out;                                                    

 result = wait_for_completion_interruptible(&dreq->completion);

 if (!result)                                                 
   result = dreq->error;                                        
 if (!result)                                                 
   result = dreq->count;                                        

out:                                                            
  kref_put(&dreq->kref, nfs_direct_req_release);
  return (ssize_t) result;
}                                                               



리눅스 커널 2.6.26
=====================
static ssize_t nfs_direct_wait(struct nfs_direct_req *dreq)
{
  ssize_t result = -EIOCBQUEUED;
  /* Async requests don't wait here */
  if (dreq->iocb)
    goto out;

  result = wait_for_completion_killable(&dreq->completion);
  if (!result)
    result = dreq->error;
  if (!result)
    result = dreq->count;
out:
   return (ssize_t) result;
 }

이런 새로운 기능을 활용하기 위해 NFS 클라이언트에 변경된 사항을 좀 더 살펴보려면 참고자료에 나온 리눅스 커널 메일링 리스트를 참조하자.

초기 NFS 마운트 옵션인 intr은 몇 가지 사건을 기다리는 NFS 클라이언트 프로세스를 인터럽트하는 길을 열어 놓았지만 (TASK_KILLABLE처럼) 심각한 시그널뿐만 아니라 모든 인터럽트를 허용한다.

요약

심지어 이 기사에서 소개하는 기능이 현존하는 다른 옵션에 비해 일반적인 개선이 있었더라도, 결국 프로세스가 꼼짝달싹하지 못하는 상황을 피하는 다른 수단에 불과하며, 일반적으로 활용되기까지는 시간이 필요하다. (전통적인 TASK_UNINTERRUPTIBLE을 사용해) 명시적인 깨우기 호출 이외에 다른 인터럽트를 허용하지 않도록 만드는 정책이 진짜로 필요하지 않은 이상 새롭게 도입된TASK_KILLABLE을 활용하는 방식이 바람직하다는 사실만 기억하자.


참고자료

교육