reference:
- "리눅스 커널 내부구조" / 백승재, 최종무
- "Operating Systems: Three Easy Pieces" / Remzi H. Arpaci-Dusseau and Andrea C. Arpaci-Dusseau
태스크는 생성된 뒤 자신에게 주어진 일을 수행하며, 이를 위해 디스크 I/O나 Lock 등 CPU 이외의 자원을 요청하기도 함. 당장 태스크에 제공할 수 없다면 커널은 해당 태스크를 잠시 '대기'상태로 두고, 요청한 자원이 사용 가능해지면 다시 '수행'상태로 전이시켜준다. 따라서 태스크는 상태 전이(state transition)라는 특징을 갖는다.
위 그림에서 EXIT_ZOMBIE와 EXIT_DEAD는 task_struct 구조체의 exit_state 필드에 저장되는 값이고, 그 외의 상태들은 state 필드에 저장되는 값이다. 위 그림에서 나온 값들 외에도 ~/include/linux/sched.h 커널 소스에는 보다 많은 상태 값들이 정의되어 있다.
태스크가 생성된 후 부터 일련의 상태 변화는 다음과 같다.
태스크는 준비 상태(TASK_RUNNING, ready)가 된다. 스케줄러는 여러 태스크 중에 실행시킬 태스크를 선택적으로 수행시킨다. 따라서 TASK_RUNNING 상태는 TASK_RUNNING(ready) 상태와 CPU를 배정받아 명령어들을 처리하고 있는 TASK_RUNNING(running) 상태로 구분된다.
n개의 CPU를 갖는 시스템에서는 임의의 시점에 최대 n개의 태스크가 실행 상태(TASK_RUNNING(running))에 있을 수 있다.
태스크는 발생하는 사건에 따라 다음과 같은 상태로 전이할 수 있다.
태스크가 자신이 해야 할 일을 다 끝내고 exit()를 호출하면(또는 kill 된다면) TASK_DEAD 상태(terminated)로 전이. 구체적으로는 task_struct 구조체 내 exit_state 값과 조합하여 TASK_DEAD(EXIT_ZOMBIE) 상태로 전이된다. ZOMBIE 상태는 말 그래도 죽어있는 상태로 태스크에 할당되었던 자원을 대부분 커널에 반납한 상태이다. 그러나 에러 번호와 같이 자신이 종료된 이유, 자신이 사용한 자원의 통계 정보 등을 부모 태스크에 알려주기 위해 유지되고 있는 상태이다.
부모 태스크가 wait() 함수를 호출하면 자식 태스크의 상태는 TASK_DEAD(EXIT_DEAD) 상태로 바뀌고, 부모는 자식의 종료 정보를 넘겨받는다. 이 후 TASK_DEAD(EXIT_DEAD) 상태의 자식 태스크는 자신이 유지하고 있던 자원을 모두 반환하고 종료된다.
TASK_RUNNING(running) 상태에서 수행되던 태스크가 자신에게 할당된 CPU 시간을 모두 사용하였거나, 보다 높은 우선순위를 가진 태스크에게 밀려 TASK_RUNNING(ready) 상태로 전환된다.
SIGSTOP, SIGTSTP, SIGTTIN, SIGTOUT 등의 시그널을 받은 태스크는 TASK_STOPPED 상태로 전이. 추후 SIGCONT 시그널을 받아 다시 TASK_RUNNING(ready) 상태로 전환. 한편, 디버거의 ptrace() 호출에 의해 디버깅되고 있는 태스크는 시그널을 받는 경우 TASK_TRACED 상태로 전이.
실행 상태(TASK_RUNNING(runnig))에 있던 태스크가 특정한 사건을 기다려야 할 필요가 있으면 대기 상태(TASK_INTERRUPTIBLE, TASK_UNINTERRUPTIBLE, TASK_KILLABLE)로 전이한다. 예를 들어 디스크와 같은 주변 장치에 요청을 보내고 그 요청이 완료되기까지 기다리거나, 사용 중인 시스템 자원 대기 등이 대표적이다.
대기 상태(wait)로 전이한 태스크는 기다리는 사건(event)에 따라 특정 큐(queue)에 매달려 대기하게 됨. 실행중인 태스크 하나가 대기 상태가 되면, 스케줄러가 다시 호출되고, 준비 상태에 있는 태스크 중 하나를 선택하여 실행 상태로 전이시킨다.
사건을 기다려야 하는 태스크는 대기 상태로 만들고, 준비된 다른 태스크를 실행시킴으로써 CPU 효율을 높임.
대기 중인 태스크가 기다렸던 사건(event)이 발생하면 대기 상태의 태스크를 다시 준비 상태로 전이시킨다.
멀티 코어 시스템에서 발생 가능한 데드락(deadlock)을 방지하기 위해 내부적으로 TASK_WAKING 상태가 사용될 수도 있다.
실행 중인 TASK_RUNNING(running) 상태의 태스크를 실행 권한에 따라 사용자 수준 실행(user level running) 상태와 커널 수준 실행(kernel level running) 상태로 구분할 수 있다.
커널 수준의 권한이 사용자 수준의 권한보다 더 강력하다는 의미는 사용자 수준 권한에서는 접근이 금지된 커널 내부 자료구조를 접근하거나 수행이 금지된 특권 명령어를 커널 수준 권한에서 수행할 수 있다는 의미.
사용자 수준 실행상태에서 커널 수준 실행 상태로 전이할 수 있는 방법은 크게 다음과 같다.
시스템 콜 사용: 태스크가 시스템 콜을 요청하면 리눅스의 커널에 트랩에 걸리게 되고, 그 결과 태스크의 상태가 커널 수준 실행 상태로 전이되며 커널의 시스템 콜 처리 루틴으로 제어가 넘어감.
인터럽트 발생: 시스템 콜과 마찬가지로 인터럽트가 발생되면 리눅스 커널에 인터럽트가 걸리게 됨. 이때 실행중인 태스크가 사용자 수준에서 동작하고 있다면 커널 수준 실행 상태로 전이되고, 커널의 인터럽트 처리 루틴으로 제어가 넘어감.
사용자 수준에서 프로그램이 수행될 때에는 32bit CPU를 기준으로 0~4GB까지의 주소 공간 중 3GB 아래 부분에 스택을 배치하고 수행.
커널 수준에서 수행되는 코드는 바로 리눅스 그 자체. 리눅스도 C와 어셈블리로 작성된 S/W이기에 수행되기 위해서 스택이 필요.
이러한 용도로 사용하기 위해 리눅스 커널은 태스크가 생성될 때마다 태스크 별로 8KB 혹은 16KB의 스택을 할당해 줌.
하나의 태스크가 시스템 콜을 요청 했다면 이를 처리하기 위해 커널은 이 태스크에 할당하였던 커널 스택을 사용하여 요청된 작업을 수행.
결론적으로 태스크가 생성되면 리눅스는 task_struct 구조체와 커널 스택을 할당하게 된다.
태스크 당 할당되는 커널 스택은 thread_union 이라고 불리며, thread_info 구조체를 포함하고 있다. thread_info 구조체를 리눅스에선 프로세스 디스크립터라 부르기도 한다.
이 구조체 안에는 해당 태스크의 task_struct를 가리키는 포인터와 스케줄링의 필요성 여부를 나타내는 플래그, 태스크의 포멧을 나타내는 exec_domain 등의 필드 존재.
태스크가 시스템 콜 등을 통해 커널 수준 실행 상태로 진입한 뒤, 수행 작업을 모두 마쳤다면, 다시 사용자 수준 실행 상태로 복귀하여 수행하던 곳부터 다시 작업을 시작해야한다. 그러기 위해서는 커널과 사용자 수준간의 변화 시에 현재까지의 작업 상황을 어딘가 저장해 놓아야 한다. 이는 커널로 진입되는 시점에 커널 스택 안에 현재 레지스터의 값들을 구조체인 pt_regs를 이용하여 저장한다.
open() 시스템 콜을 사용자 태스크가 호출하는 상황 가정
open() 시스템 콜의 번호는 unistd.h라는 헤더 파일에 정해져 있고, 인텔 CPU 기준으로 eax 레지스터에 저장.
open() 시스템 콜의 실제 핸들러 함수는 세 가지 인자를 받는데 이 인자들은 각각 ebx, ecx, edx에 저장. 결국 시스템 콜 도중에 이 레지스터들의 내용이 바뀌게 되며 따라서 원래의 레지스터 내용을 pt_regs 구조체에 담아서 커널 스택 내 저장.
sys_open() 함수가 수행을 끝내고 사용자 수준으로 다시 리턴될 때 이 구조체의 내용을 사용하여 이전 실행 상태 복원.