본문 바로가기
DevOps

[JVM] 자바 스레드와 리눅스 스레드, LWP, POSIX Thread

by Jayson Jeong 2022. 7. 15.

자바에서 스레드는 어떻게 동작할까?

자바 스레드를 알아 보기 전에 꼭 필요한 개념들부터 자바 스레드까지 알아보도록 하자

 

  1. User-level Thread와 Kernel-level Thread
  2. POSIX Thread(pthread)
  3. Linux Thread와 LWP
  4. Java Thread

 

1. User-level Thread와 Kernel-level Thread

1.1 User-level Thread(사용자 수준 스레드)

Many-to-one model

 

커널이 생성하는 것이 아닌 사용자 영역에서 라이브러리를 이용해 생성한 스레드이다.

 

커널은 프로세스 내 사용자  스레드를 알지 못 한다. 커널은 프로세스 단위로 자원을 할당하기 때문에 스레드가 block되면 프로세스도 block되고 프로세스 내 다른 스레드도 block 되는 현상이 발생한다. 

 

스케줄링이나 동기화가 모두 라이브러리에서 진행되기 때문에 커널을 호출하지 않아도 되고 인터럽트 발생 시 오버헤드가 적다.

스케줄링이 라이브러리에서 진행되기 때문에 시스템 전반에 걸친 스케줄링 우선순위를 지원할 수 없다.(커널이 관여하지 않기 때문에)

커널은 user-level thread 존재를 모르기 때문에 하나의 프로세스로 인식하게 된다.

즉 프로세스 실행을 위한 하나의 kernel-level thread가 여러 개의 user-level thread를 관리하는 Many-to-one 모델이다.

그래서 user-level thread 가 block 된다면 프로세스도 block 된다.

posix thread library를 이용해 생성 할 수 있다.

 

1.2. Kernel-level Thread(커널 수준 스레드)

운영체제 논리적 코어와 매핑되는 시스템의 실제 스레드로 커널 영역에서 스레드의 생성과 관리를 수행한다.

즉 커널이 생성하는 스레드이며 커널이 직접 관리하는 스레드이다.

커널이 각 스레드를 개별적으로 관리하며 프로세스 내 스레드들이 병렬 수행이 가능하다.

커널이 직접 스레드를 제공하기 때문에 안정성과 시스템 전반에 걸친 스케줄링 우선순위 선정 등 다양한 기능을 제공한다.

스레드 중 하나가 block 되더라도 프로세스나 다른 스레드는 계속 작업이 가능하다.

 

solaris, linux, windows 등이 kernel-level thread를 지원한다.

 

POSIX 표준으로 인해 대부분의 스레드 라이브러리는 user-level thread와 kernel-level thread의 매핑 모델을 구현하여 사용한다.

One-to-One 모델, Many-to-many 모델 등을 적용하며 posix thread를 구현하는 OS와 pthread 라이브러리에 따라 적용 모델이나 작업이 모두 다르다.

 

 


2. POSIX Thread(pthread)

POSIX는 유닉스 계열 POSIX 시스템에서 병렬적으로 작동하는 소프트웨어의 작성을 위해 제공되는 표준이다.

유닉스와는 다른 시스템인 Windows도 여러가지의 이유로 posix thread를 지원한다.

 

posix thread는 그 자체로는 스레드 라이브러리가 아니다. posix thread는 특정 스레딩 라이브러리가 해당 플랫폼에서 사용 가능한 동시성 리소스를 사용하여 구현하는데 적용되는 인터페이스이다.

따라서 Linux, bsd, Solaris 등에 posix thread 구현이 있으며 인터페이스(헤더 파일 및 호출의 의미)는 동일하지만 각각의 구현은 다르다. 커널 스레드 개체 측면에서 pthread_create가 실제로 수행하는 작업은 OS와 pthread 라이브러리 구현에 따라 다르다.

그렇기 때문에 모든 posix thread가 리눅스의 posix thread와 구현 방식이 같을 것이라고 생각하면 안 된다.

 

posix thread는 user-level에서 생성되지만 커널이 해당 스레드를 관리하고 스케줄링 하기 때문에 kernel-level thread와 큰 차이가 발생하지 않는다. 

때문에 posix thread는 하이브리드 스레드라고도 불리며 커널 컨텍스트와 사용자 컨테스트를 모두 갖고 커널이 직접 스케줄링 해주는 스레드이다.

 

즉, user-level thread인 pthread를 통해 kernel-level thread를 사용하는 효과를 낼 수 있으며 모델에 따라 구현 방식이 다르다.

 

posix thread는 pthread_create 메소드를 통해 생성할 수 있다.

#include <pthread.h>

       int pthread_create(pthread_t *restrict thread,
                          const pthread_attr_t *restrict attr,
                          void *(*start_routine)(void *),
                          void *restrict arg);

 

pthread_create의 두 번째 아규먼트인 pthread_attr_t를 세팅해 주는 것에 따라 스케줄링 모드를 설정할 수 있다.

  • PTHREAD_SCOPE_SYSTEM :  SCS scheduling을 사용하여 스레드를 스케줄링 한다. One-to-one 모델은 따로 처리할 것이 없지만 Many-to-many 모델을 사용하는 시스템은 LWP를 커널 스레드와 1:1로 매칭할 수 있도록 생성하고 LWP에 user thread를 바인딩함으로써 One-to-one 모델을 사용하는 효과를 낸다. 
  • PTHREAD_SCOPE_PROCESS : PCS scheduling을 사용하여 스레드를 스케줄링한다. Many-to-one, Many-to-many 모델에서 시스템 정책에 따라 스케줄링 한다. kernel-level thread 생성

pthread_attr_t의 default는 PTHREAD_SCOPE_SYSTEM이며, 리눅스는 pthread(user-level thread)와 kernel-level thread 간의 1:1 매핑을 적용하기 때문에 PTHREAD_SCOPE_SYSTEM으로만 지정할 수 있다.

 


3. Linux Thread와 LWP

3.1. Linux LWP(Light Weight Process)

리눅스에서 pthread_create의 구현은 스레드를 에뮬레이트 하기 위해 경량 프로세스(Light Weight Process, LWP)를 사용한다.

 

리눅스는 사용자 영역에서 스레드를 생성하게 되면 커널 수준에서는 해당 스레드의 부모 프로세스와 메모리 공간 기타 리소스 정보를 공유하는 포인터를 갖고 있는 자식 프로세스를 생성한다.

이렇게 생성된 프로세스는 일반적으로 fork()를 사용하여 생성하는 프로세스보다 가볍기 때문에 경량 프로세스(Light Weight Process, LWP)라고 부른다.

경량 프로세스는 부모 프로세스의 정보를 포인터로만 갖고 있어 같은 정보에 접근할 수 있지만 굉장히 가볍고 해당 정보로 필요한 작업만을 처리할 수 있다.

 

또한 리눅스에서는 커널에 의해 생성된 경량 프로세스를 task_struct로 구현하며 커널의 task_list에 저장되고 커널에서 관리 및 스케줄링 한다.

리눅스의 PCB는 task_struct이며 리눅스 커널 내부적으로는 프로세스와 스레드 모두 프로세스로 취급한다.

따라서 리눅스의 스레드와 경량 프로세스는 동일하다고 말할 수 있다. 단지 스레드는 사용자 수준에서 사용되는 용어이고 경량 프로세스는 커널 수준에서 사용되는 용어이다.

 

구현 관점에서 스레드는 Linux의 POSIX 호환 pthread 라이브러리(NPTL)에 thread_create를 이용하여 생성하고 커널 내부적으로 clone() 함수를 이용한 경량 프로세스를 생성한다.

 

※운영체제에 따라 Task를 스케줄링하는 방법이 다르기 때문에 모든 운영체제의 경량 프로세스(LWP)는 Thread가 아니다. 운영체제 중 하나인 리눅스에선 Thread를 에뮬레이트 하기 위해 경량 프로세스(LWP)로 생성 및 스케줄링 하는 것이다.

※리눅스의 모든 스레드는 경량 프로세스(LWP)이지만 모든 경량 프로세스(LWP)는 스레드가 아니다.

 

3.2. Linux Thread

초기의 리눅스(v2.0 이전)는 커널이 스레딩을 지원하지 않았다. 동일한 메모리 공간을 공유하는 두 개의 서로 다른 프로세스 생성을 지원하였다. 그래서 사용자 공간에서 라이브러리를 이용한 스레드 생성만 가능했고 라이브러리가 스케줄링과 관리를 모두 담당했다.

이러한 방식은 스레드의 관리 및 스케줄링을 모두 라이브러리가 감당해야 했고 스레드가 block 되면 프로세스도 block 되는 등 user-level thread의 단점을 갖고 있었다.

 

https://en.wikipedia.org/wiki/POSIX

 

리눅스는 커널의 스레딩 지원 기능과 POSIX Thread를 구현하기 위해 LinuxThreads 프로젝트를 진행한다.

 

3.3. LinuxThreads Project

1996년 LinuxThreads 프로젝트는 POSIX Thread를 일부 구현한 스레드를 지원한다. 리눅스의 스레드는 커널에서 관리하며 스케줄링 하게 된다. LinuxThreads는 v2.4까지 적용된다.

 

LinuxThreads는 사용자 영역에서 pthread를 생성하게 되면 커널 수준에서 해당 스레드의 부모 프로세스와 메모리 공간, 기타 리소스를 공유하는 자식 프로세스인 lwp를 만들고 이를 커널이 처리할 수 있도록 pthread와 lwp를 1:1 매핑한다. 이것이 리눅스의 One-to-one 스레드 모델이다.

One-to-one model

 

user-level thread와 kernel-level thread 를 1:1 매핑한다는 것은 사용자 공간에 제공되는 스레드의 추상화가 커널 공간의 스레드를 사용하여 구현되고 각 사용자 스레드가 커널 구현 스레드로 표시된다는 의미이다. 다르게 말하면 시스템은 1:1 스레딩 모델(모든 사용자 스레드에 대해 하나의 커널 스레드)을 사용한다는 것이다.

 

LinuxThreads는 관리자 스레드를 이용하여 프로세스 내부의 스레드들을 관리하며 스레드 사이에 일어나는 스케줄링은 커널 스케줄러가 처리한다. 또한 LinuxThreads는 각 스레드를 독자적인 프로세스 ID를 부여한 다른 프로세스로 구현하고 각 스레드는 프로세스처럼 행동한다.

 

그래서 LinuxThreads는 커널이 여전히 스레드를 부모 프로세스와 다른 개별의 프로세스로 인식하게 된다. 즉, 프로세스에서 생성하는 스레드는 부모 프로세스와 다른 PID를 갖고 있다.

ps -ef | grep Fork
root 7467 7243 0 16:26 pts/0 00:00:05 ./Fork2
root 7468 7467 0 16:26 pts/0 00:00:00 ./Fork2
root 7469 7468 0 16:26 pts/0 00:00:05 ./Fork2

 

 

하지만 이러한 접근 방식은 성능, 확장성, 사용성에 문제를 일으켰다.

  • 각 프로세스가 소유한 모든 스레드 사이에서 생성과 조율을 위해 관리자 스레드를 사용하고 이는 스레드 생성과 파괴에 부하를 높인다. 
  • 커널이 여전히 이들을 별도의 프로세스로 취급하기 때문에 스레드의 신호 처리, 스케줄링, 프로세스 간 동기화 부분에서 문맥 전환이 상당히 많이 일어나며, 확장성과 성능에 잠재적인 장애를 일으켰다.
  • 스레드 모델이 POSIX 표준을 준수하지 못 했기 때문에 다른 POSIX 관련 스레드 라이브러리와 호환되지 않는다.
 In the obsolete LinuxThreads implementation, each of the threads in a process
   has a different process ID.  This is in violation of the POSIX threads
   specification, and is the source of many other nonconformances to the
   standard; see pthreads(7).

 

3.4. NPTL(Native POSIX Thread Library)

이러한 문제를 개선하기 위해, 몇 가지 커널 지원과 새로 작성한 스레드 라이브러리가 필요함이 명백해졌다. 이런 요구 사항을 충족하기 위해 두 가지 경쟁 관계에 있는 프로젝트가 출발했다. IBM에서 나온 팀은 NGPT(Next-Generation POSIX Threads)를 만들었다. 반면 레드햇에서 나온 팀은 NPTL을 만들고 있었다. NGPT는 2003년 중반에 좌초되었으며 NPTL만 살아남았다.

NPTL은 성공적으로 LinuxThreads를 대체할 수 있었고 v2.6 커널에 포함되었다.(정확히는 v2.6 glibc에 포함되었다.)

 

NPTL의 주요 목표 중 몇 가지는 다음과 같다.

  • 새로운 스레드 라이브러리는 POSIX를 준수해야 한다.
  • 스레드 구현은 대규모 프로세서를 탑재한 시스템에서도 잘 동작해야 한다.
  • 심지어 작은 작업을 위해 새로운 스레드를 생성하더라도 시작 비용이 낮아야 한다.
  • NPTL 스레드 라이브러리는 LinuxThreads와 이진 호환이 가능해야 한다. 이런 목적으로 LD_ASSUME_KERNEL 을 사용할 수 있다. 이 기사 뒷부분에 다루겠다.
  • 새로운 스레드 라이브러리는 NUMA 지원을 활용할 수 있어야 한다.

NPTL도 LinuxThreads처럼 동일한 방식으로 스레드를 구현하고 One-to-one 모델을 적용하지만 관리자 스레드를 사용하지 않고 커널에서 스레드를 관리하는 등 LinuxThreads에 비해 여러 가지 장점이 있다.

 

또한 NPTL로 생성된 스레드는 부모 프로세스와 동일한 PID를 갖고 SPID를 다르게 설정하여 관리한다.

 

리눅스 커널 v2.6 이후 버전에서는 커널에서 실제 스레드와 프로세스는 모두 task_struct로 구현 된다.

 


4. Java Thread

초기에는 JVM이 스케줄링을 진행하는 Green Thread라는 방식을 이용하였나, 이후에 변경되어 현재는 OS의 정책에 따라 진행된다. JVM 1.3 이전의 Green Thread와 JVM1.3 이후의 Native Thread로 나눌 수 있다.

 

4.1. Green Thread

초기 자바에서는

멀티 스레딩을 지원하기 위해 런타임 라이브러리 또는 JVM이 사용자 수준 스레드를 생성하고 관리 및 스케줄링한다.

이렇게 자바에서 생성된 스레드를 Green Thread라고 부르며 Green Thread는 런타임 라이브러리 또는 JVM에 의해 예약된 스레드로 user-level thread이다.

Green Thread는 Many-to-one 모델로 설계되어 아무리 Green Thread가 많아도 커널에서 관리되는 스레드는 단 한 개 뿐이다.

CPU를 하나 밖에 사용하지 못 하기 때문에 단일 코어 시스템에선 장점이었지만 반대로 멀티 코어 환경에선 장점을 전혀 살리지 못 한다.

 

JVM 1.3(J2SE 1.3)부터 JNI를 이용하여 Kernel Level Thread를 생성 및 매핑하여 사용할 수 있도록 기능이 추가되었다.

 

4.2. Native Thread

JNI를 이용하여 c++ 언어로 작성된 코드를 실행하여 스레드를 생성하는 방식으로 바뀌었다. C++의 pthread_create를 이용하여 native thread를 생성한다.

 

JNI(Java Native Interface)는 자바코드가 네이티브 응용 프로그램(하드웨어와 운영 체제 플랫폼에 종속된 프로그램들) 그리고 C, C++ 그리고 어셈블리 같은 다른 언어들로 작성된 라이브러리들을 호출하거나 반대로 호출되는 것을 가능하게 한다.

 

자바의 java.lang.Thread 클래스를 통해 생성하는 java thread는 사용자 수준 스레드지만, 내부적으로 JVM은 커널 수준 스레드를 사용한다. 커널 스레드 풀에 있는 커널 스레드에서 이용할 수 있도록 위임하여, 커널 스레드와 1:1 매핑을 통해 동작하게 된다.

 

 

자바의 java.lang.Thread 클래스를 통해 스레드를 생성하고 start 메소드를 실행하게 되면 start0() 네이티브 메소드가 실행된다. 

Thread.java 중 start 메소드 부분

 

네이티브 메소드가 실행되는 과정은 OpenJDK의 cpp를 확인하였다.

native_thread는 해당하는 OS의 pthread 라이브러리를 통해 생성된다.

native_thread 생성 과정
os_linux.cpp의 os::create_thread 중 pthread_create 부분

 

리눅스 운영체제에서 pthread_create를 통해 스레드를 생성하게 되면 NPTL에 의해 pthread가 생성된다. 이렇게 생성된 pthread는 JVM에서 native_thread로 관리된다. native_thread는 java thread object와 1:1 매핑된다.

 

리눅스의 NPTL에 의해 생성된 스레드는 내부적으로 One-to-one 모델이기 때문에 커널 수준 스레드와 다를 바 없다.

이를 통해 자바 스레드는 OS 스레드와 동일한 동작을 할 수 있고 다중 코어를 사용하는 멀티 스레딩이 가능하게 되었으며 스레드의 관리 및 스케줄링 역시 OS 레벨에서 커널이 수행하게 된다.

 

다만 자바 스레드와 네이티브 스레드가 1:1 대응이라고 자바 스레드를 생성 할 때마다 OS 스레드가 매번 생성되는 것은 아니다. 작업이 끝난 OS 스레드가 있다면 이미 매핑한 적이 있다고 하더라도 가용한 OS 스레드와 다시 매핑을 하여 작업을 진행한다.

 

 

Reference.

리눅스의 프로세스, 스레드, 경량 프로세스 - https://www.thegeekstuff.com/2013/11/linux-process-and-threads/

경량 프로세스(LWP)와 스레드의 차이 - http://www.iamroot.org/xe/index.php?mid=Programming&document_srl=14173

리눅스 v2.4 스레드와  v2.6 스레드 차이 - https://dataonair.or.kr/?kboard_content_redirect=237711

자바 스레드 - https://letsmakemyselfprogrammer.tistory.com/98

자바 스레드와 OS 스레드 - https://medium.com/@may1998/java-thread%EC%99%80-os-thread-1ee766ff3393

POSIX 스레드 - https://www.quora.com/Are-pthreads-kernel-threads

리눅스에서 스레드를 생성하는 방법 - https://www.thegeekstuff.com/2012/04/create-threads-in-linux/