생각보다 자주 언급되고 자주 사용되는 개념이다.
OS 관점에서 쓰레드와 프로세스는 어떤 차이가 있을까?
프로세스는 프로그램의 인스턴스이며 쓰레드는 프로세스 내에서의 작업 단위이다.
만일 프로그램에 단일 작업 단위(쓰레드)만 허용된다면 싱글 쓰레드, 다수의 작업 단위가 혀용된다면 멀티 쓰레드 프로그램이 된다.
이는 곧 OS에서의 자원 할당은 프로세스 단위로 이루어지며 자원 사용은 쓰레드 단위로 이루어진다는 것을 의미한다.
프로세스는 프로그램의 인스턴스이기때문에 독립적인 자원(Program Control Block, PCB)을 할당받으나 쓰레드의 경우 그렇지 않다는 것이 아래의 그림으로 설명된다.
전공자에겐 너무 당연한 이야기라 굳이 프로세스와 쓰레드의 장단점같은 것을 논하진 않을 것이다.
다만, 그래서 OS는 프로세스와 쓰레드를 어떻게 처리하는데? 에 대한 점은 충분히 다룰 여지가 있다.
Kernel Thread와 User-level Thread
가장 바닥에서부터 시작해보자.
OS는 커널 쓰레드를 스케줄링 단위로 취급한다.
그렇기에 커널 단에서 만들어진 모든 쓰레드는 각기 독립된 개체로서 스케줄링된다.
이제 사용자 수준에서 프로세스가 동작할 때를 생각해보자.
사용자 수준의 프로세스가 OS에 의해 스케줄링되기 위해서는 커널에 존재하는 스케줄링 작업단위, 즉 커널 쓰레드에 매핑되어야 한다.
그렇다면 이 때 사용자 수준의 프로세스가 여러 개의 쓰레드를 가지고있다면 어떻게 될까?
이것이 바로 사용자 수준의 쓰레드를 OS 관점에서 어떻게 처리해야할지에 대한 설계 이슈이다.
가장 중요한 것은 OS가 사용자 수준의 쓰레드를 어떻게 인식할지이다.
간단하게 생각해보면 OS는 사용자 수준의 프로세스를 자원할당의 관점에서만 바라보기때문에 해당 프로세스에서 몇 개의 쓰레드가 돌아가고있는지는 파악할 필요가 없을 것이다.
이 견지대로라면 커널 쓰레드는 사용자수준의 프로세스와 매핑되어 결국 커널 쓰레드와 사용자 수준 쓰레드는 1:N의 매핑관계를 가지게 된다.
하지만 이 견지와 반대로 커널 쓰레드가 사용자 수준의 쓰레드와 매핑된다면 커널 쓰레드와 사용자 수준 쓰레드는 1:1 관계를 가지게 된다.
어느 방식의 매핑을 선택할지는 설계 이슈이기 때문에 무엇이 반드시 더 좋다고는 말할 수 없다.
그런고로 지금부터 각 견지에 대해 정리해보기로 한다.
1:1 vs. 1:N mapping
만약 커널 쓰레드와 사용자 수준 쓰레드가 1:1 매핑관계를 가진다면 사실상 커널의 스케줄링 작업단위는 사용자수준의 쓰레드가 될 것이다.
하지만 1:N 매핑관계에서는 사용자 수준 쓰레드가 얼마나 많더라도 OS 입장으로는 하나의 커널 쓰레드(스케줄링 작업단위)로 인식하기 때문에 OS의 스케줄링 혜택을 받지는 못한다.
별거 아닌 것 같지만 이 차이는 상당히 많은 것을 시사한다.
우선 커널 쓰레드에서 작업이 스케줄링된다는 것은 사용자 모드에서 커널 모드로의 모드 전환(Mode Change)과 쓰레드 간 컨텍스트의 전환(Context Switching)이 발생함을 의미한다.
예를 들어 사용자 수준에서 동작하는 두 프로세스 A, B에 대해 A가 실행되고있는 도중, A대신 더 높은 우선순위를 가진 B가 실행될 때를 생각해보자.
이 과정에서 커널 쓰레드의 컨텍스트 전환을 위해 A는 모드 전환에 들어가게 되며 커널 단에서 컨텍스트 전환을 통해 프로세스 B의 컨텍스트를 로드하게 될 것이다.
그 이후 프로세스 B는 사용자 수준에서 작업을 재개하기 위해 다시 모드 전환에 들어가게 될 것이다.
다시 말해, 사용자 수준의 프로세스 A -> 커널로의 모드 전환 -> 커널 단에서 프로세스 B에 대응하는 커널 쓰레드로의 컨텍스트 전환 -> 사용자 수준으로의 모드 전환 -> 사용자 수준의 프로세스 B 과정을 거치게 될 것이다.
1:1 매핑관계에서는 여기에 추가로 고려할 것이 없지만, 1:N 관계의 경우 여기에 사용자 수준의 쓰레드 스케줄링과 관련된 추가 이슈가 발생하게 된다.
1:1 매핑관계에서는 스케줄링할 것이 오로지 커널 쓰레드밖에 없지만, 1:N 관계의 경우 사용자 수준의 쓰레드 또한 스케줄링되어야 한다.
물론 OS 관점에서는 이러한 1:N 관계에서의 스케줄링 과정을 전혀 인지하지 않고 하나의 작업으로 보게되지만 말이다.
중요한 점은 1:N 매핑관계에서는 사용자 수준의 단일 프로세스에 대한 쓰레드 간 스케줄링이 발생해도 모드 전환이 일어나지 않는다는 점이다.
애초에 이는 커널과 관련이 없는 작업이기 때문에 커널로의 진입(모드 전환) 자체가 필요하지 않은 것이다.
하지만 그러한 이점이 있다고 하더라도 1:1 매핑관계에 비하면 1:N 관계에서의 사용자 수준 쓰레드는 보다 적은 자원(시간 등)을 할당받을 수밖에 없다는 문제가 있다. (1:1 관계에서 할당받는 자원을 N개의 쓰레드가 나눠먹기 때문)
M:N Mapping
다대다 매핑은 이에 대한 절충안이다.
과거에 Solaris(유닉스)에서 채택했던 방식이며 리눅스의 pthread에서 한 때 지원하고자 했던(deprecated) 방식이다.
성능상의 이슈로 요즘은 사용하지 않는 듯 하다.
이 방식에서는 사융자 수준에서 별도의 쓰레드 라이브러리가 돌아가며 M:N 매핑관계를 지원한다.
Solaris(유닉스)에서는 이에 대한 구현을 경량화 프로세스(Light-weight Process, LWP)라 정의하였는데, 리눅스에서는 볼 일이 없는데도 생각보다 LWP에 대한 언급이 많다.
M:N 관계를 지원하는 쓰레드 라이브러리, 혹은 LWP는 커널 쓰레드와 사용자 수준 쓰레드를 중계하는 역할을 하며, 중계 과정에서 1:1 또는 1:N의 매핑을 실현한다
이는 아래의 그림에 잘 설명되어있다.
첫번째 그림은 리눅스에서의 쓰레드 구현 방식에 대한 개요이며 (c)에서 M:N 관계를 나타내고 있다.
두번째 그림은 Solaris에서 LWP를 통해 M:N 관계를 나타낸 것이다.
Implementation
리눅스에서 일반적으로 많이 사용되는 쓰레드 라이브러리로는 pthread가 있다.
pthread는 POSIX thread의 약어인데, POSIX는 API 명세에 불과하기 때문에 pthread가 어떤 형태의 쓰레드 방식을 지원하는지는 POSIX의 구현체(implementation)에 따라 다르다.
옛날부터 pthread의 구현체 후보로서 LinuxThreads, NGTL, NPTL 등이 대두되어왔고, 현재 GNU Libc에 포함된 최후의 승리자는 Native POSIX Thread Library(NPTL)가 되었다.
역사가 난잡해보이지만 평소에 리눅스에서 쓰던 pthread가 모두 NPTL이라 보면 된다.
한편, NPTL은 1:1 관계의 구현체이다.
윈도우에서도 1:1관계의 구현체인 Win32 Thread를 사용한다.
파이썬은 좀 특이하다.
일반적으로 파이썬 구현체로는 CPython을 사용하며 CPython은 pthread를 쓰레드 라이브러리로 사용한다.
그러므로 CPython도 1:1 관계의 구현체처럼 보인다.
그러나 CPython은 전역 인터프리터 락(Global Interpreter Lock, GIL)에 의해 한 번에 하나의 바이트코드만 실행할 수 있기 때문에 쓰레드가 많아도 한 번에 하나의 코어만 사용할 수 있다는 문제가 있다. (역사에 대해서는 Docs를 참고하자) [Docs]
다시 말해 GIL이 걸리는 경우 멀티쓰레딩은 곧 싱글 코어에서 인터리빙하는 것과 동일하여 결국 싱글쓰레드보다도 성능이 더 안좋게 되는 것이다.
이 악명높은 구현은 Github에서 직접 확인해볼 수 있다. [ceval_gil.h][thread.py]
한편 파이썬 오브젝트에 대해 레퍼런스 카운팅을 하지 않는 경우 GIL이 걸리지 않아 온전하게 멀티코어를 사용할 수 있다.
그러므로 파이썬이 싱글 쓰레드 언어라는건 엄연히 잘못된 말이다. (실제로 top만 찍어봐도 OS 수준에서 쓰레드가 관리되는 것을 확인할 수 있다)
하지만 간혹 이를 순수 파이썬 코드(Pure Python Code)만으로 멀티쓰레딩하면 GIL이 걸리지 않는다고 설명하는 곳도 있는데 이 또한 엄연히 틀린 말이다.
GIL은 CPython 레퍼런스 카운팅에 대한 구현체이기 때문에 순수 파이썬 코드로 프로그램을 만들었다고 해도 전역변수나 공유자원 접근 등 레퍼런스 카운팅이 필요한 상황에서는 반드시 GIL이 사용된다. [SOF]
그렇다면 자바는 어떨까?
자바는 딱히 파이썬같은 제약이 없다.
자바는 처음부터 멀티쓰레딩을 염두하고 만들어진 언어이기 때문에 locking mechanism이 잘 설계되어있다.
그러므로 자바 쓰레드는 온전히 OS 쓰레드를 위한 래퍼(쓰레드 추상화)로서 동작한다. [SOF]
한편, 자바는 솔라리스에서 초기 Green Thread라는 유저수준 쓰레드(Many-to-one)로 시작한 역사가 있다.
현재는 솔라리스에서도 커널수준 쓰레드를 채택하여 Green Thread를 거의 사용하지 않으나 이와 관련된 키워드가 아직까지 사용되고 있어 다소 혼동을 준다. [Oracle][Sun Guide][Sun White Paper]
또 한편, Jython같은 JVM 기반 파이썬 구현체는 JVM이 참조문제를 해결해주니 GIL이 존재하지 않는다.
다시 말하자면, 파이썬의 멀티쓰레딩 문제는 파이썬 구현체인 CPython에서 사용되는 GIL 때문에 발생한다.