[운영체제] Thread (2) - Multithreading Model, Implicit Threading, and Other Issues
지난번 글에 이어서 오늘도 thread에 관하여 공부해보고자 합니다. 오늘은 thread의 종류와 다중 thread의 몇 가지 모델에 대해서 먼저 알아본 뒤, thread에 관한 여러 이슈들을 다뤄볼까 합니다.
1. User thread vs. Kernel thread
지난 글에서는 thread를 그냥 일반적인 의미에서의 thread로 간주하고 공부를 했습니다. 하지만 사실 thread에도 두 가지 종류가 존재합니다. 바로 user thread (사용자 쓰레드)와 kernel thread (커널 쓰레드)입니다. 당연히 사용자 레벨에서 사용되는 thread가 user thread이고, kernel 영역에서 사용되는 thread가 kernel thread입니다.
User thread는 kernel과 관계없이 user library에 의해 지원이 됩니다. 즉 thread의 생성과 소멸뿐만 아니라 실행을 위한 스케쥴링, context의 저장 등이 모두 사용자 레벨에서 이루어집니다. 당연히 모든 code와 data들은 user level에 존재하고, 운영체제의 kernel은 몇 개의 user thread가 존재하는지 조차 알지 못합니다.
앞에서도 여러번 언급했듯이, 어떤 작업이든 kernel이 개입하는 순간 system call이 필수적으로 필요하기 때문에 성능적으로 큰 overhead가 발생하게 되고, 이는 속도 저하로 이어집니다. 하지만 user thread는 kernel과는 아예 무관하게 동작하기 때문에 kernel thread에 비해 빠르다는 장점이 있습니다. 그리고 kernel을 거치지 않기 때문에 thread들의 switching이나 스케쥴링도 비교적 간편하게 이루어질 수 있습니다. 단점으로는 kernel이 user thread들의 존재조차 모르고 있기 때문에 kernel과의 소통이 힘들다는 단점이 있습니다. 즉 kernel이 스케쥴링과 같은 상황에서 user 입장에서는 좋지 않은 결정을 내릴 수도 있다는 것이죠.
Kernel thread는 운영체제에 의해 직접 지원되고 관리됩니다. 모든 code와 data들이 kernel 영역에 존재하며, thread를 만들거나 관리할 때 system call을 이용해야 합니다. 장점으로는 kernel이 thread에 대한 모든 것을 알고 있기 때문에 스케쥴링과 같은 어떤 결정을 내려야 하는 상황에서 user thread보다 훨씬 유용합니다. 즉 CPU가 스케쥴링을 할 때 thread가 많은 process에 우선순위를 주는 등의 고려가 가능한 것이죠. 단점으로는 모든 thread가 kernel에 의해 관리되기 때문에 상당한 overhead가 발생하고, kernel의 복잡도가 굉장히 증가합니다. 당연하게도, user thread들에 비해 연산 속도가 느리다는 점도 단점입니다.
지금까지 살펴본 user thread와 kernel thread 간의 관계에 따라서 다중 thread 모델의 종류가 결정됩니다.
2. 다중 Thread 모델 (Multithreading Models)
1. 다대일 모델 (Many-to-one Model)
다대일 모델은 몇 개의 user thread가 한 개의 kernel thread와 연결되어 있는 형태를 말합니다. 다대일 모델의 장점은 user thread가 가지는 장점과 거의 유사합니다. 다중 thread를 사용하지만, kernel 입장에서는 user level에 thread가 1개가 있는지 20개가 있는지 알지 못하기 때문에 모든 스케쥴링을 비롯한 관리들이 user level에서 이루어집니다. 당연히 속도가 빠르고 thread를 생성하고 관리하기 쉽습니다.
단점으로는 한 개의 thread라도 system call을 수행하다가 block이 되면 kernel과 연결되어 있는 길이 하나뿐이기 때문에 모든 thread들이 block 되게 됩니다. 그리고 결국 여러 user thread들 중 하나의 thread만이 kernel과 작업을 할 수 있기 때문에 multicore 시스템에서 한 번에 하나의 thread만이 작업에 참여할 수 있어 병렬적인 작업이 불가능합니다. 이러한 이유 때문에 현재는 거의 사용하지 않는 방법입니다.
2. 일대일 모델 (One-to-one Model)
다음으로는 일대일 모델입니다. 각각의 user thread들이 하나의 kernel thread들에 연결되어 있는 형태입니다. 다대일 모델과는 정반대의 특성들을 가지는데, user thread를 생성하면 kernel thread도 생성되는 형태로 구현되며, user thread 하나 당 kernel thread가 하나씩 연결되어 있기 때문에 thread 하나가 block 되더라도 다른 thread들은 정상적으로 작동이 가능하여, 동시성 (Concurrency)을 보장할 수 있습니다. 물론 여러 개의 multicore 시스템에서 여러 개의 thread들이 병렬적으로 실행이 가능하다는 장점도 있습니다.
하지만 우선 thread 생성이 힘들고, 관리도 어렵기 때문에 thread가 많아지게 되면 시스템의 성능에 큰 부담을 줄 수 있습니다. 그래서 시스템들은 thread의 최대 개수를 정해두어 관리하는 방법으로 overhead를 방지하곤 합니다. 우리가 사용하는 Windows나 Linux 모두 일대일 모델을 사용하고 있습니다.
3. 다대다 모델 (Many-to-many Model)
다대다 모델은 여러 개의 user thread들을 그와 같은 수, 또는 조금 더 적은 수의 kernel thread와 연결하는 형태입니다. Kernel thread의 수는 시스템이나 하드웨어에 따라 결정되며, user thread들은 user level의 스케쥴러에 의해 kernel thread들에게 복합적으로 연결되게 되는데 아래 쪽의 그림을 참고하는 것이 이해하는데 도움이 될 것 같습니다. Kernel thread가 여러 개 존재하기 때문에 multicore 시스템에서는 당연히 병렬적인 수행이 가능합니다. 다대다 모델은 다대일 모델과 일대일 모델의 장점을 결합한 형태이기 때문에 많은 수의 thread들이 좀 더 손쉽게 지원되는 장점을 가지지만, 구현이 어렵다는 단점이 있습니다.
다대다 모델에 일대일 모델을 결합한 Two-level 모델 이라는 것도 존재합니다. 이는 다대다 모델을 기본으로 하지만, 일대일 모델처럼 하나의 user thread가 하나의 kernel thread에만 연결되는 것을 허용하는 모델입니다.
3. Thread Library
Thread 라이브러리는 프로그래머에게 thread를 생성하고 관리하기 위한 API를 제공합니다. Thread 라이브러리를 제공하는 방법에는 두 가지가 있습니다.
첫 번째는 kernel의 지원 없이 완전히 user level에서만 라이브러리를 제공하는 방법입니다. 즉 라이브러리의 함수를 호출하면 system call이 호출되는 것이 아닌, user level에 구현되어 있는 함수를 호출하게 됩니다.
두 번째는 kernel level의 라이브러리를 제공하는 방법입니다. 라이브러리를 위한 모든 code와 자료구조는 kernel 내부에 존재하여 라이브러리를 호출하는 것은 system call이 호출되는 것과 같은 결과를 만들어 냅니다. 현재는 POSIX Pthread와 Windows thread API, 그리고 JAVA thread API가 주로 사용됩니다. POSIX의 Pthread는 user level과 kernel level 모두에서 (주로 kernel level에서) 제공되며, Linux에서 보통 Pthread를 많이 사용하고 있습니다. Windows의 thread API는 kernel level의 API를 제공하며, JAVA thread는 조금 더 높은 레벨의 라이브러리로, JVM을 제공하는 모든 시스템에서 사용할 수 있기 때문에 Windows와 Linux를 포함한 대부분의 시스템에서 사용이 가능합니다.
4. 암묵적 Threading (Implicit Threading)
Thread의 사용이 점점 늘어나고, multicore system도 발전을 거듭하면서, 수백 개, 수천 개의 thread가 활용되는 응용 프로그램들이 나타나게 되자, thread를 일일이 생성시키고 관리하는 것이 어려워지고 이에 따라 프로그램의 정확도가 떨어지는 결과를 보이는 일들이 생기게 되었습니다. 이에 따라 Implict Threading이라는 것이 등장하게 되는데 implicit threading이란 프로그래머가 직접 thread들을 생성하고 관리하는 것이 아닌, 컴파일러나 런타임 라이브러리들이 대신 thread들의 생성과 관리를 수행하는 형태를 뜻합니다. 여기서는 가장 대표적인 두 가지 방법만 알아보고자 합니다.
1. Thread Pool
현재 까지는 thread가 필요하면 thread를 생성하고, 그리고 사용이 완료되면 thread를 소멸시키는 방법을 사용하였습니다. 하지만, thread를 생성하고 소멸시키는 것 자체가 큰 overhead가 될 수 있고, thread의 생성이 무제한적이기 때문에 너무 많은 thread가 생성되고 제대로 관리가 되고 있지 않을 시에는 시스템의 resource들을 고갈시키는 원인이 될 수 있습니다.
그래서 Thread Pool이라는 개념이 등장하게 되는데, 이는 시작할 때 특정 개수의 thread들을 thread pool에 미리 만들어두고 thread가 필요한 일이 생기면 사용 가능한 thread들을 할당시켜주는 형태입니다. 그리고 사용이 완료되면 thread를 소멸시키지 않고 다시 thread pool에 반납시켜 다음 요청을 기다리는 시스템을 가집니다. 필요할 때 마다 thread를 생성할 필요가 없고 반납된 thread를 재사용하기 때문에 생성과 소멸로 인해 발생하는 overhead를 줄일 수 있는 방법입니다.
2. OpenMP
OpenMP는 공유 메모리 영역에서 병렬 프로그래밍을 가능하게 해주는 API와 컴파일러 디렉티브의 집합입니다. 쉽게 말하자면 프로그램의 code 중에 병렬적으로 실행될 수 있는 영역에 #pragma omp parallel 이라는 구문을 삽입하면 시스템의 코어 개수에 맞게 thread를 생성하며 #pragma omp for 구문을 통해 해당 for문 안의 부분을 병렬적으로 실행하게 된다. #include <omp.h>의 선언을 통해 C와 C++에서 사용이 가능합니다.
5. 기타 Thread와 관련된 이슈들
다중 Thread를 활용하기 위해서는 다른 여러가지 고려사항들이 존재합니다.
1. fork(), exec()
과연 fork() system call이 호출되어 새로운 process가 생성되면, 새로운 process는 부모 process의 모든 thread를 복제해야 하는가, 아니면 하나의 thread만 복제해야 하는가?라는 질문이 있을 수 있습니다. 물론 두 가지 버전의 fork() system call이 모두 존재하며, 상황에 맞게 사용해야 합니다. 예를 들어 fork() system call이 호출된 직후 exec() system call을 사용한다면 바로 다른 code들로 process가 대체되기 때문에 thread들을 모두 복제할 필요가 없어집니다.
2. Signal Handling
Unix나 Linux에서 사용하는 signal에 관한 이슈도 존재합니다. 바로 signal이 발생했을 때 어떤 thread에 signal을 전해주어야 하는가에 대한 문제인데요, signal이 발생한 thread에 전달해주어야 하는지, 모든 thread들에 전달해 주어야 하는지, process 내의 특정 thread에게 전해주어야 하는지, 혹은 발생하는 signal을 모두 받는 전담 thread를 만들어서 signal을 받게 할지 등을 고민해보아야 합니다.
3. Thread Cancellation
마지막으로 Thread Cancellation에 관한 이슈입니다. Thread가 정상적으로 종료되기 이전에 thread가 취소된다면 어떻게 해야하는가에 대한 문제로, Asynchronous cancellation (비동기식 취소)와 Deferred cancellation (지연 취소)가 존재합니다. 비동기식 취소는 즉시 한 thread가 target thread를 종료시키는 방법이고, 지연 취소는 target thread가 과연 지금 종료되어도 다른 문제가 발생하지 않는지 확인하고 종료시키는 방법입니다. 물론 기본 값은 지연 취소로 되어있습니다.
Thread에 대해 신나게 설명하다보니 글이 꽤 길어지게 되었습니다. Thread에 관한 내용은 여기까지 공부하기로 하고 다음 글에서는 CPU 스케쥴링에 대한 내용으로 돌아오겠습니다. 읽어주셔서 감사합니다.