- 우리가 일반적인 프로그래밍 언어로 여러 스레드를 생성하여 멀티 스레드 프로그래밍을 구현하려고 할 때, 언어 자체의 스레드 관련 라이브러리를 사용하여 스레드를 생성하곤 한다.
- 이렇게 우리가 언어로 생성한 스레드를 user level thread(user thread)라 하는데, 이 스레드는 운영체제가 직접 관리하지 않고, 언어 라이브러리에 의해서 관리된다.
- 그런데 이 라이브러리는 해당 프로세스 내부에서의 스레드만 관리하기 때문에, 하나의 운영체제 내에서 돌아가는 전체 프로세스(스레드)에 대한 정보를 알 수 없다.
- 결국 라이브러리 내부에서는 내부 스레드들 간의 스케줄링을 통해 최대한 자원을 효율적으로 사용하려 하지만, 결국 각 유저 스레드는 언어 라이브러리에 의존하게 되는 것이고 모든 프로세스의 정보를 전부 가지고 있는 커널의 스케줄링만큼 효율적이지 않다. 그러니까 어떤 애플리케이션이 다른 프로세스의 스레드를 제어할 수 없다는 것이다.
- 한편 커널 스레드는 운영체제가 직접 관리하는 스레드를 말하는데, 정말 CPU 스케줄링의 대상이 되는 스레드를 의미한다.
- 그런데 가만 생각해 보면 유저 스레드가 정말 명령어를 직접 실행할 수 있을까?
- 사용자 스레드는 이 스레드를 생성한 프로세스의 주소 공간에만 머물고, 커널의 개입 없이 관리된다. 즉, 커널은 이러한 스레드의 생성에 대해 전혀 모른다.
- 그런데 명령어는 CPU가 실행시켜야 하고, CPU의 자원을 할당받는 주체는 커널 스레드가 될 것이다(CPU 스케줄링의 대상이 커널 스레드 이기 때문).
- 결론적으로, 유저 스레드는 직접 명령어를 실행할 수 없고 커널 스레드와의 매핑을 통해 API를 사용하는 것 마냥 필요한 명령어를 실행시켜야 한다.
- 커널 입장에서는 유저 스레드라는 것은 정말 어떤 프로세스의 메모리 한 부분에 불과하다
- 근데 커널 스레드와 유저 스레드를 매핑하는 방식에도 여러 가지가 존재한다.

- 위 그림처럼 여러 유저 스레드가 하나의 커널 스레드에 매핑되는 N:1 매핑에서는 유저 스레드가 변경돼도 kernel 레벨에서 context switching이 발생하지 않는다.
- 하지만, 커널 스레드가 시스템 콜 함수를 호출해서 blocking 되면 cpu를 사용하지 않는데도 다른 모든 유저 스레드가 blocking 된다.
- 시스템이 싱글 코어라면 써도 상관없을 것 같다.

- 어떤 유저 스레드가 시스템 콜을 호출해도 다른 유저 스레드에 영향을 받지 않는다. 그래서 현대 같은 멀티 프로세서 시스템에서 쓰기 적합하다.
- 근데 유저 스레드 생성할 때마다 커널 스레드도 생성해야 한다 -> 유저 스레드와 커널 스레드 생성 비용이 같다.
- 그래서 애플리케이션 레벨에서 스레드 풀 같은 것을 사용하지 않고 제한 없이 스레드를 생성하면 애플리케이션뿐만 아니라 시스템 전체 성능 저하가 될 수 있다. 물론 이것도 운영체제 선에서 어느 정도 스케줄링을 할 것 같긴 하다.

- 이 방식도 유저 스레드를 많이 생성할 수 있고, 시스템 콜로 어떤 커널 스레드가 블락되어도 다른 커널 스레드에 의해 유저 스레드가 매핑돼 작업할 수 있다.
- 근데 시스템 콜 작업은 어쨌든 blocking 되어야 하므로, 위 그림에서 3번 시스템 콜이 일어나면 결국 전체 스레드가 blocking 된다.
이제 다양한 운영체제의 스레드 관리 전략에 대해 알아보자.
POSIX Thread
- Unix계열 운영체제인 POSIX는 posix thread를 지원하는데, 이것은 스레드 라이브러리가 아니고, 인터페이스이다.
- 그래서 Linux, bsd, Solaris에 posix thread 구현체가 각각 있다.
- 그렇기 때문에 모든 posix thread가 구현 방식이 서로 다르다.
- 특이한 점은, posix thread는 user level에서 생성되지만, 커널이 이 user thread를 관리하고 스케줄링하기 때문에, kernel thread와 큰 차이가 발생하지 않는다.
- 그래서 posix thread는 하이브리드 스레드라고도 불리며 kernel context, user context를 모두 가지고 커널이 직접 스케줄링한다. 위에서 본 내용과는 사뭇 다른 개념이다.
- 즉, user thread인 pthread를 통해 kernel thread를 사용하는 효과를 낼 수 있다.
#include <pthread.h>
int pthread_create(pthread_t *restrict thread,
const pthread_attr_t *restrict attr,
void *(*start_routine)(void *),
void *restrict arg);
Linux Thread, LWP
- 리눅스에서 pthread_create의 구현은 스레드를 emulate(대리로 실행) 하기 위해 Light Weight Process를 사용한다. 위에서 posix thread는 하이브리드 스레드라고 불린다 했는데, kernel context와 user context를 모두 가지고 있다고 했다.
- 이게 어떻게 가능한 것일까? 바로 LWP라는 개념 덕분이다.
- 리눅스는 사용자 영역에서 스레드를 생성하게 되면 커널 수준에서는 해당 스레드의 부모 프로세스와 메모리, 리소스 정보를 공유하는 포인터를 갖고 있는 자식 프로세스를 생성한다. 이렇게 생성된 프로세스는 포인터를 가지고 있고, fork()하는 것보다 가볍기 때문에 LWP라 부른다.
- 커널 내부적으로 clone()을 통해 생성함.
- 부모 프로세스의 정보를 포인터로만 가지고 있어 같은 정보에 접근할 수 있고, 굉장히 가볍고 필요한 작업만 처리할 수 있다.
- 이렇게 생성된 lwp를 task_struct로 구현하고 task_list에 저장돼 커널에서 스케줄링한다.
- task_struct는 thread의 자료구조인데 그래서 lwp를 스레드라고도 하는 것 같다.
- 그래서 리눅스의 PCB는 task_struct이고, 프로세스와 스레드 모두 같은 프로세스로 취급한다.
- Linux는 초기 버전에 커널 스레딩을 지원하지 않았고, LinuxThreads Product라는 프로젝트에서 posix thread를 구현한 스레드를 지원하게 되었다. 여기서 위에서 말한 lwp 개념이 적용되고, pthread와 lwp를 1:1 매핑한다.
- 근데 1:1 매핑에는 단점이 존재한다고 했는데, lwp가 생성될 때마다 kernel thread가 생성되고, context switching의 비용이 굉장히 비싸다는 것이다.
- 또한 관리자 스레드라는 것을 두어서, 관리자 스레드는 다른 모든 스레드에 대해 어떤 이벤트에 반응하고, 이벤트를 보낼 수 있어야 한다.
NPTL(Native POSIX Thread Library)
- 그래서 나오게 된 라이브러리가 NPTL이고, POSIX 구현을 준수하며 대규모 프로세서를 탑재한 시스템에서도 잘 동작해야 한다는 것이다.
- NPTL도 LinuxThreads처럼 동일한 방식에 1:1 매핑을 적용하지만, 커널에서 관리자 스레드를 사용하지 않아서 전체 시스템에 대한 이해가 필요 없고, 그에 따라 좀 더 나은 확장성을 제공할 수 있다.
- 이런 여러 장점이 있어서 2.6 이후 버전에서 NPTL을 사용한 스레드 구현을 적용한다고 한다.
Java의 Thread
- 자바 1.3 이전에서는 green thread라는 방식을 이용했는데 이 방식은 멀티 코어에서는 매우 취약한 점이 많기 때문에 1.3부터 JNI를 통한 Native Thread(kernel thread)를 생성해서 매핑한다.
- c++의 pthread_create를 이용해서 native thread를 생성하고 1:1 매핑한다.
- 아래는 Thread.start()에 대한 jvm native code이다.

- 그래서 위 코드의 pthread_create를 통해 스레드를 생성하면 (Linux인 경우) NPTL에 의해 스레드가 생성되고, JVM에서 native thread로 관리된다.
- 리눅스의 NPTL에 의해 생성된 스레드는 내부적으로 1:1 이기 때문에 커널 수준 스레드와 다를 바가 없다.
- 근데 1:1 매핑에서 단점이 유저 스레드 생성할 때마다 kernel thread가 매번 생성된다는 것인데, 자바에서는 그렇지 않다.
- 작업이 끝난 kernel thread가 있으면 해당 스레드에 매핑해 재사용하기도 한다.
댓글