프로세스와 쓰레드의 생성과 수행
태스크 문맥
-
태스크가 필요한 정보는 엄청나게 많다. 예를 들어서 태스크가 실행되면서 여러 파일을 오픈 할 수가 있고, 그 결과 파일 디스크립터를 리턴 받는데, 이 또한 커널이 태스크마다 관리해 주어야 할 정보이다.
-
또한 태스크를 스케줄링 하기 위해 필요한 정보인 우선순위, CPU 사용량 등의 정보 등도 필요하며, 태스크의 가족 관계, 태스크에게 전달된 시그널, 태스크가 사용하고 있는 자원 등의 정보도 관리해야 한다.
-
운영체제 연구자들은 태스크와 관련된 이러한 모든 정보를 문맥 (CONTEXT) 라고 부른다. 태스크의 문맥은 크게 세 부분으로 구분할 수 있는데, 첫 번째 부분은 시스템 문맥으로 태스크의 정보를 유지하기 위해서 커널이 할당한 자료구조들이다. 대표적인 자료구조로는
task_strcut
, 파일 디스크립터, 파일 테이블, 세그먼트 테이블, 페이지 테이블 등이 있다. 두 번째 부분은 메모리 문맥으로 텍스트, 데이터, 스택, 힙, 스왑 공간등이 여기에 포함된다. 세번째 부분은 하드웨어 문맥으로 컨텍스트 스위치를 할 대 태스크의 현재 실행 위치에 대한 정보를 유지하며 쓰레드 구조 또는 하드웨어 레지스터 문맥이라고 불린다. -
~/include/linux/sched.h
라는 파일에서task_struct
자료구조의 각 변수의 이름들을 확인할 수 있다.
상태 전이와 실행 수준 변화
-
태스크는 생성된 뒤, 자신에게 주어진 일을 수행하며, 이를 위해서 디스크 I/O나 락등 CPU 이외의 자원을 요청하기도 한다. 만약 태스크가 당장 제공해줄 수 없는 자원을 요청한다면, 커널은 이 태스크를 잠시 ‘대기’ 하도록 만든 뒤에 다른 태스크를 먼저 수행시키며, 태스크가 요청했떤 자원은 사용 가능해지면 다시 ‘수행’ 시켜 줌으로써 보다 높은 시스템 자원 활용률을 제공하려 한다. 따라서 태스크는 상태 전이라는 특징을 가지게 된다.
-
일단 태스크가 생성되면 그 태스크는 준비 상태 (
TASK_RUNNING
) 가 된다. 스케줄러는 여러 태스크 중에서 실행시킬 태스크를 선택하여 수행시킨다. 따라서TASK_RUNNING
상태는 구체적으로 준비 상태와 실제 CPU를 배정받아 명령어들을 처리하고 있는 실행 상태 두 가지로 나뉘게 된다. -
즉
n
개의 CPU를 가지고 있는 시스템에서는 임의의 시점에 최대 n 개의 태스크가 실제 실행 상태에 있을 수 있다. -
실행 상태에 있는 태스크들은 발생하는 사건에 따라서 다음과 같은 상태로 전이할 수 있다. 첫째, 태스크가 자신이 해야할 일을 다 끝내고
exit()
를 호출하면TASK_DEAD
상태로 전이된다. 보다 구체적으로는task_struct
구조체 내에 존재하는exit_state
값과 조합하여TASK_DEAD(EXIT_ZOMBIE)
상태로 전이된다. -
ZOMBIE
상태는 말 그대로 죽어있는 상태로써, 태스크에게 할당되어 있던 자원을 대부분 커널에게 반납한 상태이다. 그러나 자신이 종료된 이유 (예: error 번호), 자신이 사용한 자원의 통계정보를 부모 태스크에게 알려주기 위해서 유지되고 있는 상태이다. -
추후에 부모 태스크가
wait()
등의 함수를 호출하면 자식 태스크의 상태는TASK_DEAD(EXIT_DEAD)
상태로 바뀌게 되며, 부모는 자식의 종료 정보를 넘겨 받게 된다. 그런 뒤에TASK_DEAD(EXIT_DEAD)
상태의 자식 태스크는 자신이 유지하고 있던 자원을 모두 반환하고 최종 종료된다.
런큐와 스케줄링
-
여러 개의 태스크들 중에서 다음번 수행시킬 태스크를 선택하여
CPU
라는 자원을 할당하는 과정을 스케줄링이라 부른다. -
리눅스의 태스크는 실시간 태스크와 일반 태스크로 나뉘며 각각을 위해 별도의 스케줄링 알고리즘이 구현되어 있다. 리눅스가 제공하는 140 단계의 우선순위 중 실시간 태스크는 0 ~ 99 단계를 사용하며 일반 태스크는 100 ~ 139 까지 총 40 단계의 우선순위를 사용한다.
-
따라서 실시간 태스크는 항상 일반 태스크보다 우선하여 실행됨을 의미한다.
런 큐와 태스크
-
일반적으로 운영체제는 스케줄링 작업 수행을 위해 수행 가능한 상태의 태스크를 자료구조를 통해서 관리한다. 리눅스에서는 이 자료구조를 런 큐라고 한다. 운영체제의 구현에 따라서 런큐는 한 개 혹은 여러개 존재할 수 있으며, 자료구조의 모양이나 관리 방법 역시 달라진다.
-
리눅스의 런큐는
~/kernel/sched/sched.h
파일 내에struct rq
라는 이름으로 정의 되어 있으며 부팅이 완료된 이후에 각 CPU 별로 하나씩의 런큐가 유지된다. -
태스크가 처음 생성되면
init_task
를 헤드로 하는 이중 연결 리스트에 삽입된다. 결국 리숙스 시스템에 존재하는 모든 태스크들은 해당 연결 리스트에 연결되어 있다. -
다중 CPU 환경에서 런큐가 여러 개라면 새롭게 생성된 태스크는 어느 런 큐에 삽입될까? 새롭게 생성되는 태스크는 부모 태스크가 존재하던 런 큐로 삽입이 된다. 이는 자식 태스크가 부모 태스크와 같은 CPU에서 실행될 때 더 높은 캐시 친화력을 얻을 수 있기 때문이다.
-
대기상태에서 깨어난 태스크는 대기전에 수행되던 런큐로 삽입된다. 이 또한 캐시 친화력을 위해서이다.
-
스케줄러가 수행되면 해당 CPU의 런큐에서 다음에 수행시킬 태스크를 골라내는데, 이때 두 가지의 고려사항이 있다. 첫 번째는 어떤 태스크를 선택할 것인가이다. 이를 위해서 리눅스는 일반 태스크를 위해서
CFS(Completely Fair Scheduler)
를 사용하며 실시간 태스크를 위해서는FIFO, RR, DEADLINE
정책을 사용한다.
문맥 교환
-
수행 중이던 A 라는 태스크에 할당되어 있던 타임 슬라이스가 모두 소진되거나, 혹은 수행중이던 A라는 태스크가 특정 사건을 기다리기 위해 잠들어야 하는 경우, 리눅스 커널은 새로이 수행할 태스크 B를 선택하여 CPU 라는 자원을 배정해준다.
-
이렇게 수행 중이던 태스크의 동작을 멈추고 다른 태스크로 전환하는 과정을 문맥 교환이라고 부른다.
-
즉 스케줄링이 일어나면 문맥 교환이 발생하고 문맥 교환 시엔 현재 수행중이던 태스크의 문맥을 저장해 두어야 한다. 이때 문맥은 CPU 레지스터 즉, H/W 컨텍스트를 뜻한다. 이를 위해서
task_struct
에 H/W CONTEXT를 담아 두기 위한 필드를 만들어 두었다. -
task_struct
구조는 태스크가 실행하다가 중단되어야 할 때 태스크가 현재 어디까지 실행했는지 기억하는 공간이다. 태스크는 실행 중에 다양한 상태 전이를 겪는다. 실행 중에 사건을 기다릴 필요가 있으면 대기 상태로 전이하고, 시간 할당량이 지나 타임 아웃되면 준비 상태로 전이한다. -
실행 중에 인터럽트가 발생할 대에도 자신이 어디까지 실행했는지 기억해야 한다. 그래야만 인터럽트 처리가 끝난 후 중지한 이후부터 다시 실행할 수 있기 때문이다.
태스크와 시그널
- 시그널은 태스크에게 비동기적인 사건의 발생을 알리는 메커니즘이다. 태스크가 시그널을 원활히 처리하려면 다음과 같은 3가지 기능을 지원해야 한다.
1. 다른 태스크에게 시그널을 보낼 수 있어야 한다.
2. 자신에게 시그널이 오면 그 시그널을 수신할 수 있어야 한다. -> 이를 위해서 signal, pending 이라는 변수가 존재함
3. 자신에게 시그널이 오면 그 시그널을 처리할 수 있는 함수를 지정할 수 있어야 한다. -> 이를 위해 sys_signal() 이라는 시스템 호출이 존재하며, task_struct 내에 sighead 라는 변수가 존재한다.
-
사용자가 쉘에서
kill PID
와 같은 명령어를 사용하여 특정 PID를 가지고 있는 태스크를 종료하면 이때 사용자는 PID를 공유하고 있는 쓰레드들 (즉 쓰레드 그룹)이 모두 종료되는 것을 기대할 것이다. 리눅스에서 PID는 실제로는tgid
를 의미한다는 것을 살펴본 바 있다. -
따라서
PID
가 같은 태스크들은 의미상 같은 쓰레드 그룹임을 의미한다. 그러므로PID
를 공유하고 있는 모든 쓰레드들 (쓰레드 그룹)간에 시그널을 공유하는 메커니즘이 필요하다. 이렇게 여러 태스크들 간에 공유해야 하는 시그널이 도착하면 이를task_struct
구조체의signal
필드에 저장해 둔다. -
반대로 특정 태스크에게만 시그널을 보내야하는 경우 시그널은
task_struct
구조체의pending
필드에 저장해둔다. 시그널을signal
필드나pending
필드에 저장할 때는 시그널 번호 등을 구조체로 정의하여 큐에 등록시키는 구조를 택하고 있으며, 이를 위해서sys_tkill()
과 같은 시스템 호출을 도입하였다. -
한편 각 태스크는 특정 시그널이 발생했을 때 수행될 함수, 즉 시그널 핸들러를 지정할 수 있다. 이때 사용자 지정 시그널 핸들러를 설정하게 해주는 함수가
sys_signal()
이다. 태스크가 지정한 시그널 핸들러는task_struct
구조체의sighand
필드에 저장된다. -
또한 태스크는 특정 시그널을 받지 않도록 설정할 수 있는데, 이는
task_struct
의blocked
필드를 통해서 이뤄진다. 그런데 시그널 중에SIGKILL
과SIGSTOP
이라는 시그널은 받지 않도록 설정하거나 무시할 수 없다. 그 외의 시그널은 별도의 핸들러를 등록시키거나, 받지 않거나 혹은 무시하는 것이 가능하다. -
수신한 시그널의 처리는 태스크가 커널 수준에서 사용자 수준 실행 상태로 전이할 때 이루어진다. 즉 커널은
pending
필드의 비트맵이 켜져있는지, 혹은signal
필드의count
가 0이 아닌지를 검사를 통해서 처리를 대기중인 시그널이 있는지 검사하고, 이 시그널이 블록되어 있지 않다면 시그널 번호에 해당하는 시그널 핸들러를sighand
필드의action
배열에서 찾아서 수행시켜주게 된다. -
만약 태스크가 명시적으로 핸들러를 등록하지 않은 경우 커널은 시그널 무시, 태스크 종료, 태스트 중지 등과 같은 디폴트 액션을 취하게 된다.
-
인터럽트와 트랩 그리고 시그널 간의 차이점은 인터럽트와 트랩이 사건의 발생을 커널에게 알리는 방법이라면, 시그널은 사건의 발생을 태스크에게 알리는 방법이다.
참고 문헌
>> Home