본문 바로가기

CS

스레드와 스케줄러, 스레드의 종류

반응형

프로세스와 스레드

  • 프로세스는 실행된 프로그램을 의미하며, 프로그램이 실행되면 프로그램의 코드가 메모리에 적재되어 CPU가 코드를 한줄씩 실행합니다.
    • 프로세스는 메모리를 크게 4가지(Stack, Code, Data, Heap)영역으로 나누어 관리하는데(관련 글), 이 때 Stack영역은 Thread마다 가지게 되고, 나머지 3개의 영역은 프로세스 내의 스레드 끼리 공유합니다.
    • 스레드는 프로세스 내의 실행 단위로, Stack 영역과 PC 값(다음 명령어의 주소)을 별도로 가지고 있어 Context Switching 시에 Stack과 PC Register만 Context Swtiching하면 되서 적은 비용이 듭니다.

 

 

PCB와 TCB

  • PCB는 Process Control Block의 약자로, 커널이 프로세스를 관리하기 위한 정보(Process 상태, PC 값, 우선순위 값 등)을 포함하고 있습니다.
    • 이렇게 PCB를 Scheduler가 Context Switching 하며 멀티태스킹을 수행합니다.
  • PCB는 TCB를 참조하고 있으며, 위에서 언급했듯 PC 값과 SP 값(Stack 포인터)을 포함하고 있어서, PCB내에서 Context Switching이 이루어져 PCB가 가르키고 있는 TCB의 레지스터 값이 실행되게 됩니다.
    • 따라서 아까 언급한 PCB에서 Register 정보를 가지고 있다는 것은, 하나 이상의 TCB가 존재하고, 그 중 실행되어야 하는 TCB의 레지스터 값을 갖고 있다는 것을 의미합니다.(관련 글)

  • 위에서 언급한 것만 간단하게 도식화 하면 다음과 같은데요, 참고로 PCB와 TCB간에 누가 참조를 가지고 있는지는 OS의 구현에 따라 조금씩 달라질 수 있다고 하니, 단순히 PCB가 TCB의 PC 레지스터 값을 읽을 수 있다 정도로 이해하면 좋을 것 같습니다.

컨텍스트 스위칭

  • 컨텍스트 스위칭은 프로세스간에, 스레드 간에 모두 일어날 수 있습니다. 두 과정의 차이를 살펴보면 다음과 같습니다.(관련 글)
    • 같은 프로세스 내의 다른 스레드 끼리의 컨텍스트 스위칭은 Stack영역만 변경하고, 코드, 데이터, 힙 영역은 그대로 유지할 수 있습니다.
    • 하지만 다른 프로세스 간의 컨텍스트 스위칭은 프로세스 전체 메모리에 대해 컨텍스트 스위칭을 수행합니다.
  • 컨텍스트 스위칭은 인터럽트가 발생할 때 이루어 진다고 하는데요, 인터럽트는 외부의 이벤트로 인해 프로세스를 중단할 때 발생한다고 합니다. 인터럽트의 예시는 다음과 같습니다.(참고 글)
    • IO Request 발생
    • CPU 사용 시간 만료
    • 자식 프로세스를 만들 때
    • 인터럽트 처리를 기다릴 때

HW/ Kernel/ User Thread

  • HW Thread는 하나의 코어 내에서 논리적으로 구분되는 실행 단위, 하이퍼스레딩과 같은 동시 멀티스레딩 기술을 통해 1개의 코어가 2개의 HW 스레드를 지원할 수 있습니다.
  • kernel Thread는 OS의 커널 영역 내에서 관리되는 스레드로, OS 스케줄링의 대상이 됩니다.
  • User Thread는 유저 영역 내(실행중인 프로세스)에서 관리되는 스레드로, User Thread를 사용하기 위해선 kernel Thread와 직접 연결되어 사용됩니다.

HW - Kernel Thread와 Context Switching

  • HW Thread는 앞서 언급한 듯 CPU 코어가 처리하는 논리적 실행단위입니다. Kernel의 스케줄러는 적절한 스케줄링을 통해 실행할 Kernel Thread를 선택하고, 이를 HW Thread에 할당시켜 CPU가 실행할 수 있도록 합니다.

User - Kernel Thread

  • 그럼 프로세스에서 실제로 사용되는 User Thread와 Kernel Thread는 어떻게 연결될까요? 총 3가지의 모델이 존재합니다.
    • 1 : 1 Model : kernel Thread와 User Thread가 1대 1로 매핑되는 모델입니다. 현대의 Java(Virtual Thread를 사용하지 않은)는 이 모델을 적용하고 있다고 알려져 있습니다.
    • 1 : N Model : kernel Thread 1개에 여러 User Thread가 매핑되는 모델입니다. User 영역 내부에서 컨텍스트 스위칭을 통해 User Thread에 실행할 kernel Thread를 매핑해 주어야 하며, 이때의 Context Switching 비용은 kernel에서의 Context Switching 보다 작습니다.
    • N:M Model: 여러개의 kernel Thread에 여러개의 User Thread가 매핑되는 모델입니다. User 영역과 kernel 영역 모두에서 스케줄러가 필요합니다.

Java Thread

  • 저는 Java 개발자기이기 때문에 Java Thread가 kernel에 어떻게 매핑되는지 조금 더 자세히 보도록 하겠습니다.
  • 먼저 java.lang.Thread 클래스는 아래와 같이 native method인 start0()를 호출합니다.
public synchronized void start() {
  // ...
      try {
          start0();
          started = true;
      } finally {
	//...
      }
}

private native void start0();

 

  • 먼저 openjdk/jdk21의 src/java.base/share/native/libjava/Thread.c 파일에서 JNI 네이티브 메서드의 start0 메서드에 대한 선언을 찾을 수 있었습니다.(링크의 39번 라인)
#include "jvm.h"

static JNINativeMethod methods[] = {
    {"start0",           "()V",        (void *)&JVM_StartThread},
    ....
};
  • 링크의 39번 라인은 openjdk/jdk의 src/hotspot/share/prims/jvm.cpp파일에 2808번 줄에 선언되는 것을 알 수 있었고, 여기서 JavaThread 객체를 생성하는 것을 알 수 있었습니다.
#include "runtime/javaThread.hpp"

JVM_ENTRY(void, JVM_StartThread(JNIEnv* env, jobject jthread))
....
	native_thread = new JavaThread(&thread_entry, sz);
	if (native_thread->osthread() != nullptr) {
    native_thread->prepare(jthread);
	}
	
	Thread::start(native_thread);
...
JVM_END

 

  • JavaThread의 생성자는 openjdk/jdk의 src/hotspot/share/runtime/javaThread.cpp 파일의 651번 줄에서 찾아볼 수 있었습니다.
JavaThread::JavaThread(ThreadFunction entry_point, size_t stack_sz, MemTag mem_tag) : JavaThread(mem_tag) {
  set_entry_point(entry_point);
  // ...
  os::ThreadType thr_type = os::java_thread;
  thr_type = entry_point == &CompilerThread::thread_entry ? os::compiler_thread :
                                                            os::java_thread;
  os::create_thread(this, thr_type, stack_sz);
  //...
}

 

  • os::createThread는 실제 운영체제에 따라 갈리는 것으로 보이는데요, 리눅스는 src/hotspot/os/linux/os_linux.cpp에서 찾을 수 있었습니다. 이 코드는 너무 길어서 제가 핵심적인 부분만 요약해놨습니다.
bool os::create_thread(Thread* thread, ThreadType thr_type,
                       size_t req_stack_size) {
	// 1. OSThread 생성
	OSThread* osthread = new (std::nothrow) OSThread();
	osthread->set_state(ALLOCATED);
	thread->set_osthread(osthread);
	
	// 2. pthread 속성 설정
	pthread_attr_t attr;
	pthread_attr_init(&attr);
	pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
	pthread_attr_setstacksize(&attr, stack_size);
	
	// 3. pthread 생성 
	pthread_t tid;
	pthread_create(&tid, &attr, thread_native_entry, thread);
	
	// 4. OSThread에 pthread ID 저장
	osthread->set_pthread_id(tid);
	
	// 5. 스레드 초기화 대기
	Monitor* sync_with_child = osthread->startThread_lock();
	while ((state = osthread->get_state()) == ALLOCATED) {
	   sync_with_child->wait_without_safepoint_check();
	}
}

 

  • 정리하자면 start0가 호출되는 순간 Linux 기준으로 pthread 라이브러리가 호출되어 Kernel Thread가 생성되고, 이것이 실행되는 1:1 모델인 것으로 보입니다.
  • 따라서 Java의 Thread는 Kernel Thread와 연결되어 있기 때문에 운영체제의 스케줄러에 의해 스케줄링 됩니다.

Java Virtual Thread

  • 다음으론 JDK 21에 출시된 Java virtual Thread에 대한 내용인데요, 기존의 Java Thread(Platform Thread로 부르겠습니다.)는 아무래도 Kernel Thread와 1대1로 매핑되기 때문에 스레드가 I/O 상태에 머무른다거나 Context Switching이 발생할 때 비용이 많이 발생한다는 점이 문제였는데요.
  • Java Virtual Thread가 출시됨으로서 JVM이 Carrier Thread를 직접 스케줄링하여 Platform Thread에 1:N 매핑을 하는 구조로 이루어져 있습니다. Carrier Thread는 I/O 상태에 돌입하면 다른 Carrier Thread로 전환되고, 필요한 용량 또한 Platform Thread보다 적기 때문에 Context Switching 비용이 저렴하다는 장점이 있다고 합니다.
  • 참고로 Virtual Thread가 Platform Thread보다 가벼운 이유는
    • Platform Thread는 스택 영역의 크기가 고정되어 있음(1MB)
    • Virtual Thread는 스택이 스택 영역이 아닌 Heap 영역에 존재하며, 크기가 가변적임

결론

  • 프로세스는 실행된 프로그램 전체를, 스레드는 프로세스 내의 실행 단위를 의미합니다.
  • 프로세스는 Stack, Code, Data, Heap 네 가지 메모리 영역을 갖추며, 이 중 Stack은 스레드별로 독립적이고 나머지 영역은 프로세스 내부의 스레드 끼리 공유됩니다.
  • 컨텍스트 스위칭은 프로세스 간이나 스레드 간에 발생하며, 스레드 간 컨텍스트 스위칭은 Stack 영역과 PC, SP 값만 적재하면 되기 때문에 컨텍스트 비용이 적습니다.
  • 스레드는 실제 HW 가 실행하는 HW Thread, 운영체제가 스케줄링하는 Kernel Thread, 프로세스가 관리하는 User Thread로 나뉘며, HW - Kernel Thread 간은 OS Scheduler에 의해, Kernel - User 간은 모델에 따라(1:1, 1:N, N:M) 결정됩니다.
  • Java는 User - Kernel 간 1:1 모델을 사용하며, JDK 21에서 도입된 Carrier Thread는 JVM이 직접 스케줄링하여 효율적인 CPU 사용을 기대할 수 있습니다.
반응형