자바 언어의 태생적 한계와 극복을 위한 발전 과정 (스레드편)

ssongkim·2024년 2월 4일
1
post-thumbnail

Overview

자바는 1995년에 처음 공개된 장수 언어입니다. 요즈음 기술이 빠르게 발전함에 따라 오래된 언어에는 태생적 한계가 있을 수 밖에 없고, 이 때문에 많은 개발자들이 자바에서 go언어나 kotlin 등등으로 갈아타기도 하였는데요.

자바 언어는 시대에 뒤쳐지지 않기 위해 지금 순간에도 발전 중입니다. 최근 JDK21이 릴리즈 되었는데요. JDK21에는 많은 자바 개발자들이 관심있게 보는 가상 스레드가 공식으로 추가되었습니다.

오늘은 가상 스레드가 등장하기 전 자바 언어에는 어떠한 태생적 한계가 있었고 이 한계를 극복하기 위해 어떠한 노력을 해왔는지 알아보고자 합니다.

1. 운영체제 복기하기

먼저 이번 주제와 관련있는 스레드에 대해 간단하게 짚고 넘어가겠습니다.

하드웨어 스레드와 소프트웨어 스레드

하드웨어 스레드

하드웨어 스레드(물리적 스레드)는 중앙처리장치(CPU)에서 동시에 실행되는 독립적인 명령어 스트림입니다. 각각의 하드웨어 스레드는 프로세서 내의 별도의 실행 경로를 가지며, 이것은 다수의 스레드가 동시에 다양한 명령을 처리할 수 있도록 합니다.

CPU는 다수의 코어를 가질 수 있습니다. 그리고 1코어 = 1스레드가 일반적이었으나 기술 발전으로 이미지와 같이 1코어 = 2스레드인 cpu모델도 존재합니다. 이는 가상 하드웨어 스레드라 하며 하나의 물리적인 코어가 여러 가상 스레드를 실행하는데, 이는 소프트웨어적으로 구현됩니다. 가상화 기술을 사용하여 단일 코어에서 여러 물리적 스레드를 실행하게 만듭니다.

소프트웨어 스레드

소프트웨어 스레드(논리적 스레드)는 프로세스 내에서 실행되는 하나의 실행 흐름으로, 프로세스 내에서 독립적으로 실행될 수 있는 가장 작은 단위입니다.

논리적 스레드는 물리적 스레드보다 개수가 많을 수 있으나 병렬적으로 실행될 수 있는 스레드는 물리적 스레드 개수만큼입니다. 사이에 스케쥴러가 스케쥴링을 수행하여 사람 눈에는 마치 수많은 프로그램이 동시에 실행되는 것처럼 보이게 합니다. (동시성과 병렬성)

소프트웨어 스레드에는 커널 레벨 스레드유저 레벨 스레드로 구분할 수 있습니다.

커널 레벨 스레드

OS thread, 네이티브 스레드라고도 합니다. 커널 레벨 스레드는 OS Kernel에 의해 생성되고 관리되는 스레드를 의미합니다.

유저 레벨 스레드

유저 레벨 스레드는 OS Thread 개념을 프로그래밍 레벨에서 추상화 한 것으로, 프로그래밍 언어에서 제공한 라이브러리를 통해 생성한 스레드를 의미합니다.

프로그램 연산을 최종적으로는 물리적 스레드가 해주어야 프로그램이 동작할텐데요. 이를 위해 유저 레벨 스레드는 결국 운영체제가 관리하는 커널 스레드와 매핑되어 동작해야합니다.

커널 레벨 스레드와 유저 레벨 스레드의 매핑 관계

  • Many To One

Many-to-One model은 하나의 Kernel Thread가 다수의 User Thread를 처리하는 구조입니다.

이 구조는 커널 스레드가 블로킹 상태에 빠진 경우 관련 유저스레드가 모두 일을 하지 못하는 단점이 존재합니다.

  • One To One

One-to-One model은 처리해야 할 User Thread 한 개당 Kernel Thread를 대응시켜 작업을 진행하는 구조입니다.

이 구조는 커널 스레드가 과도하게 생성될 수 있으며 부하가 심한 상태에서 커널 스레드가 블로킹에 빠진 경우 스케쥴러에 의해 컨텍스트 스위칭 하는 과정에서 오버헤드가 심하게 발생할 수 있습니다.

  • many To Many

Many-to-Many model은 그림과 같이 다수의 User Thread를 다수의 Kernel Thread가 처리하는 구조입니다.

이 구조에서는 다수의 커널 스레드로 다수의 유저 스레드를 제어하기 위한 별도의 스케쥴러가 필요합니다.

2. 자바에서 스레드를 다루는 법

Reactor 패턴을 사용하지 않는 정통적인 자바는 OS와 유저 레벨 스레드를 1대1 매핑하여 사용, 관리합니다. (one to one 방식)

따라서 스케쥴러도 OS의 구현체에 의존합니다.

버전별 스레드 매핑 방식

JDK 1.1: Many To One 방식 (green thread)

Green Thread는 Many to One 모델로 설계된 쓰레드입니다. (다수의 유저스레드를 하나의 커널스레드와 매핑)

CPU Core가 하나인 환경일 때 설계된 쓰레드이다보니 아무리 Green Thread가 많아도 Native Thread는 단 한 개 뿐이 만들어지지 않습니다.

Native Thread가 하나라는 건 cpu가 아무리 많아도 코어를 하나 밖에 사용하지 못한다는 뜻입니다. 그래서 멀티 코어 환경에서는 장점을 전혀 살리지 못해 현재는 사용되지 않습니다.

JDK 1.3 ~: One To One 방식

JDK 1.3부터는 유저 스레드 생성 시 커널 스레드와 1대1 방식으로 매핑됩니다.

자바에서 스레드를 생성할 때 docs를 한번 보겠습니다.

새로운 플랫폼 스레드를 생성한다고 나와있는데요, 자바에서는 OS스레드를 Wrapping하였다고 하여 이를 플랫폼 스레드라고 정의합니다.
때문에 스레드의 구현 방식은 OS 종속적이고 스케쥴링 정책 또한 OS와 동일합니다.

OS스레드는 생성, 관리비용이 비쌉니다. 따라서 플랫폼 스레드를 효율적으로 관리하기 위해 우리는 주로 스레드풀을 두고 스레드를 다룹니다.

3. 자바의 태생적 한계

Reactor 패턴이나 가상스레드를 사용하지 않는 정통적인 서블릿 기반의 스프링MVC는 thread per request model로, 유저 스레드와 커널 스레드가 One To One 관계이니 하나의 요청 당 하나의 커널 스레드를 생성하여 멀티스레드로 처리하는 구조입니다.

OS 스레드는 생성, 관리비용이 비싸니 스프링에서는 스레드 풀(default 스레드 개수: 200개)을 두는데요.

자바 스레드가 로직을 타다가 네트워크 I/O, DB I/O, File I/O 등을 하는 과정에서 블로킹IO를 만나면 어떻게 될까요.

자바에서 스레드가 블로킹I/O를 만난다면

스레드 로직을 수행하다가(task) 네트워크 Blocking I/O를 만났다고 가정해봅니다.

001스레드는 블로킹 IO를 만나면 스레드가block되고 작업이 완료될 때까지 waiting 상태로 전환됩니다. 이것은 001스레드가 I/O 작업을 완료되기 전에는 다른 작업을 수행하지 못하게 하는 것을 의미합니다.

Task2에 대해 네트워크I/O가 끝나면 001스레드는 runnable 상태로 변경됩니다. 이어서 CPU를 할당받아 task3를 수행합니다. (Running)

시스템 부하가 낮은 상태에서는 이런 일련의 과정이 큰 문제가 되지 않지만 시스템 부하가 높은 상태에서는 스레드 상태가 waiting -> runnable -> running -> waiting.., 즉 컨텍스트 스위칭이 되고 스레드의 데이터가 계속해서 로딩하는 과정에서 발생하는 오버헤드가 큰 부담이 될 수 있습니다.

컨텍스트 스위칭을 하는 동안에는 cpu는 일을 하지 않고 대기하는데 시스템의 부하가 큰 상태에서는 CPU의 대기하는 시간이 점점 길어지고, 이는 시스템의 성능 저하로 이어지기 때문입니다.

스레드풀의 사이즈가 200이고 인스턴스의 물리적인 cpu 코어(실제 일을 수행하는 주체)가 8개라고 해봅니다.
시스템이 과부화된 상태에서 생성된 200개의 스레드는 8개의 코어를 두고 경합을 벌이게 되고, 경합 과정에서 컨텍스트 스위칭이 자주 발생하게 됩니다. 컨텍스트 스위칭이 자주 발생할수록 cpu가 일하지 않고 대기하는 전체 대기 시간은 점점 길어지게 되고, 성능저하는 그만큼 심해집니다.

4. 자바언어와 대비되는 golang, kotlin 등 타 언어의 장점

많은 개발자들이 최근 자바를 버리고 go언어와 코틀린 등 다른 언어로 넘어갔는데요 왜일까요, 다양한 이유가 있겠지만 그 중 하나인 코루틴을 알아보겠습니다.

코루틴

코루틴은 서브 루틴을 일시 정지하고 재개할 수 있는 구성 요소를 말합니다. 쉽게 말해 필요에 따라 일시 정지할 수 있는 함수를 의미합니다.

코루틴을 사용하여 I/O 처리를 극대화할 수 있는데, 이는 단순히 대기하는 작업을 기다리는 동안 다른 작업을 먼저 처리함으로써 CPU의 유휴 시간(Idle time)을 최소화 할 수 있기 때문입니다.

OS 스레드를 멀티스레드로 처리해 동시성을 높이는 자바는 부하가 심한 상태에서 컨텍스트 스위칭을 수행하는 유휴시간 동안 CPU가 일하지 못하는데, 코루틴을 지원하는 다른 언어들은 이러한 비용을 아낌으로써 성능을 극대화 할 수 있습니다.

코루틴을 지원하는 언어

  • C++ : C++20에 stackless coroutine이 추가
  • Rust : 2018 에디션에서 코루틴 관련 문법과 트레이트를 지원한다. 다만, 실행자를 별도로 구현하거나 라이브러리를 사용해야 한다.
  • Go
  • 자바스크립트 : ES6이 제정되기 이전에는 AJAX, 콜백을 사용해 비동기를 구현했다. ES6부터 Promise라는 객체가 등장하였으며, async, await 키워드로 가독성이 더 좋아졌다.
  • PHP : 5.5부터 지원된다.
  • C# : 2.0부터 지원된다.
  • 코틀린 : 1.3부터 지원된다.
  • 파이썬 : Asyncio라는 기본 내장 라이브러리를 통해 3.5부터 지원된다.
  • 루아 : 언어의 여덟가지 기본 type 중 하나로 코루틴이 제공된다

코루틴 단점

코루틴을 사용하려면 go, async, await, suspand 등 별도의 키워드를 사용해주어야 합니다. 이런 구조에서는 중첩 코루틴이 쉽게 나타날 수 있으며 코드의 제어 흐름을 이해하기 어려워지고 관리가 복잡해질 수 있습니다. (근데 아래에서 설명하는 리액티브 프로그래밍보다는 훨씬 쉽습니다.)

5. Reactive Programming

리액티브 프로그래밍은 선언형 프로그래밍의 일종으로 데이터 흐름(dataflow)과 변화 전파에 반응하여 처리하는데 중점을 둔 프로그래밍 패러다임(programming paradigm)입니다. 자세한 내용은 이전 블로그 글을 참고해주세요. 리액티브 프로그래밍이란

자바는 상대적으로 적은 수의 스레드로 점점 더 많은 요청을 처리하는 데 도움이 되는 동시성 모델이 필요했습니다.

리액티브 프로그래밍은 프로그램 흐름을 일련의 동기 작업에서 비동기 이벤트 스트림으로 변환하여 스레드가 차단되지 않도록 합니다.

spring webflux

The reactive-stack web framework, Spring WebFlux, was added later in version 5.0. It is fully non-blocking, supports Reactive Streams back pressure, and runs on such servers as Netty, Undertow, and Servlet containers.

spring webflux는 리액티브 스택 웹프레임워크입니다. 위에서 설명한 리액티브 프로그래밍을 하여 스프링 애플리케이션 개발을 할 수 있도록 지원합니다.

thread per request 모델인 스프링MVC와 달리 스프링 웹플럭스는 이벤트 루프 모델을 채택합니다.

스프링 웹플럭스는 최소 4개의 워커 스레드를 생성하는데 CPU 코어가 4개 이상이면 그 개수만큼 워커 스레드를 생성합니다.

Event Loop

이벤트 루프는 서버를 위한 반응형 비동기 프로그래밍 모델 중 하나입니다.
이벤트 루프는 단일 스레드에서 지속적으로 실행되지만 사용 가능한 코어 수만큼 이벤트 루프를 가질 수 있습니다.

  1. 클라이언트로부터 들어오는 요청을 요청 핸들러가 받습니다.
  2. 전달받은 요청을 이벤트 루프에 푸시합니다.
  3. 이벤트루프는 네트워크, 데이터베이스 연결 작업 등 비용이 드는 작업에 대한 콜백을 등록합니다.
  4. 작업이 완료되면 완료 이벤트를 이벤트 루프에 푸시합니다.
  5. 등록한 콜백을 호출해 처리 결과를 전달합니다.

이벤트 루프는 단일 스레드에서 계속 실행되며 클라이언트 요청이나 네트워크IO, DB IO등을 모두 이벤트로 처리하기 때문에 이벤트 발생 시 해당 이벤트에 대한 콜백을 등록함과 동시에 다음 이벤트 처리로 넘어갑니다.

이벤트 루프 스레딩 플랫폼은 Netty, Apache Tomcat, Undertow 등 다양합니다. 이중에서 스프링 웹플럭스 스타터는 이벤트 루프 플랫폼으로 Netty를 기본적으로 내장해 사용합니다.

리액티브 프로그래밍의 단점

리액티브 프로그래밍의 단점은 명확합니다.

잘못사용하면 오히려 성능이 저하되고 익숙하지 않고 새로우며 어렵습니다. 라이브러리도 잘 알아보고 써야합니다. 블로킹IO로 동작하는 라이브러리라면 쓸 수 없습니다.. 또한 시스템이 복잡하면 복잡해질수록 유지보수는 어려워지는 구조가 되었습니다. 기존 명령형 프로그래밍에 익숙한 개발자들은 "이럴거면 차라리 다른 언어 쓰지"라며 다른 언어로 많이 넘어갔습니다.

6. 언어의 한계를 극복하자, Project Loom

Project Loom은 OpenJDK 커뮤니티에서 시작한 Java에서 처리량이 높고 가벼운 동시성 모델을 지원하기 위한 프로젝트입니다.

자바는 OS 스레드로 멀티스레드를 처리하는 구조이기 때문에 오늘날의 다양한 동시성 요구 사항을 충족하지 못하였고, 커널 스레드와 독립적인 경량 동시성 구성이 필요하다는 니즈가 생겼습니다. 또한 스케쥴러도 운영체제의 구현체에 의존했었는데요.

Project Loom은 이러한 Java 언어가 가진 한계를 벗어나기 위해 OS 대신 Java 런타임(JVM)에 의존하는 유저 레벨 스레드를 통해 이 문제를 해결할 것을 제안합니다. 이를 위해 기존의 스레드(Platform Thread)보다 더 가볍고 효율적인 경량 스레드(Fiber)를 추가하는 목표를 가지고 있습니다.

fiber

최근 Fiber 라는 새로운 클래스가 Thread 클래스 와 함께 라이브러리에 도입되었습니다.

Fiber는 JVM 인스턴스로 생성 및 관리되기 때문에 수백만 개의 Fiber가 생성될 수 있으며, 메모리 사용량 측면에서 커널 스레드보다 훨씬 가볍기 때문에 컨텍스트 스위칭 비용이 월등하게 적습니다.

커널 스레드의 경우 연속과 스케줄러는 OS에 의해 관리되며, 플랫폼 스레드는 커널 스레드의 의해 구현되기 때문에 OS에 의존하게 되는데요.

Fiber에도 동일하게 일시 중지되고 재개되는 연속과 이를 스케줄링하는 스케줄러가 존재하는데, 플랫폼 스레드랑은 다르게 이들 모두 JVM에 의해 관리가 됩니다.

Virtual Thread

처음에는 Fiber 라고하는 별도의 기능으로 개발되었으나 최종적으로는 기존 스레드 문법과 호환될 수 있는 형태로 발전했고, 우리는 이를 Virtual Thread라고 부르기로 하였습니다.

Virtual Thread도 플랫폼 스레드 위에서 동작합니다. Virtual Thread를 참조하는 플랫폼 스레드를 우리는 Carrier Thread라고 합니다.

Virtual Thread는 N:1로 Carrier Thread와 맵핑이 되고, Carrier Thread는 1:1로 OS Thread와 맵핑되는 구조입니다. 이때 OS 스레드와 매핑되는 스레드를 Carrer Thread라고 하며 OS 스레드와 가상 스레드를 스케쥴링해주는 스케쥴러가 JVM에 존재합니다.

가상 스레드는 자원 회수하는 형태가 기존 플랫폼 스레드와 다릅니다. 플랫폼 스레드와 다르게 잡아먹는 리소스가 매우 작기 때문에 일반적으로 스레드풀을 두지 않으며 쓰고 버린다는 의미로 리소스 회수를 임의로 하지 않고 GC에게 위임합니다.

Virtual Thread는 코루틴 모델과 다르게 별도의 키워드를 사용하지 않아 중첩 코루틴이 나타남에 따른 단점이 들어나지 않습니다.

Carrier Thread 스케줄러 설정

Virtual Thread는 N:1로 Carrier Thread(플랫폼 스레드)와 맵핑이 되고, Carrier Thread는 1:1로 OS Thread와 맵핑되는 구조라고 설명했습니다.
우리는 환경변수를 주입하여 이 캐리어 스레드의 수를 지정할 수 있습니다.

jdk.virtualThreadScheduler.parallelism

가상 스레드를 스케줄링하기 위해 사용할 수 있는 플랫폼 스레드의 수입니다. 기본값은 사용 가능한 프로세서의 수입니다.

jdk.virtualThreadScheduler.maxPoolSize

스케줄러에 사용할 수 있는 플랫폼 스레드의 최대 수입니다. 기본값은 256입니다.

자세한 내용은 오라클 링크를 참조해주세요.

Virtual Thread Context Switching

가상 스레드는 Blocking IO를 만나면 Virtual Thread간 컨텍스트 스위칭이 일어납니다.

컨텍스트 스위칭이 발생하면 Carrier Thread 입장에서 Virtual Thread가 들고있던 정보들(로컬 변수, 쓰레드 로컬 변수, 콜스택 정보 등등)을 힙메모리에 올리고 다른 Virtual ThreadCarrier Thread를 점유하는 과정을 거치게 됩니다.

이는 OS 스레드가 컨텍스트 스위칭이 발생한 것이 아니기 때문에 컨텍스트 스위칭 비용이 굉장히 저렴합니다.

Virtual Thread 주의사항

Pooling 금지

virtual thread는 os 스레드와 비교해서 생성 비용이 매우 작습니다. 생성비용이 작기 때문에 스레드 풀을 만드는 행위 자체가 낭비가 될 수 있습니다. 필요할 때마다 생성하고 GC(Garbage Collector)에 의해 소멸되도록 방치해버리시는게 좋습니다.

기존 값비싼 OS 스레드를 효율적으로 공유하여 사용하기 위해 구성했던 스레드풀의 로직의 경우 세마포어 등으로 대체하는게 좋습니다.

ThreadLocal

ThreadLocal을 사용한다면 메모리 사용량에 유의해야합니다.
기존 자바에서 플랫폼 스레드를 다룰 땐 스레드풀을 두고 플랫폼 스레드를 사용하였기 때문에 ThreadLocal 사이즈 또한 제한적이였습니다.

하지만 가상 스레드의 경우 필요한 만큼 수백 수천 수만개 생성될 수 있고 Heap 영역을 사용하기 때문에 ThreadLocal 남발시 그만큼 메모리 사용량이 늘어납니다.

syncronized 키워드 사용

syncronized를 사용하면 Virtual Thread와 연결된 캐리어 스레드가 블로킹될 수 있으니 사용에 주의해야 합니다. (이런경우 pinning이라고 부릅니다.)

이런 경우 ReentrantLock를 사용하여 pinning을 방지할 수 있습니다.

Overwhelming 현상

기존 플랫폼 스레드는 OS 스레드 생성 비용과 개수 한계로 인해 스레드풀을 두었습니다.

스레드풀은 생성 비용 측면에서도 사용되었지만 자체적으로 스레드 추가 생성을 막아 Throttle 역할도 수행해주었는데요.
하지만 Virtual Thread는 이러한 스레드풀을 두지 않기 때문에 메모리 등 리소스만 잘 받아준다면 무한정 생성될 수 있으며 이로 인해 Overwhelming 현상이 발생할 수 있습니다.

예를들어 요청마다 리소스를 생성하는 구조에서는 virtual thread당 리소스 생성으로 인해 의도치 않은 동작이 일어날 수도 있고, 별도 커넥션풀 없이 redis나 RDB 같은 외부 데이터베이스를 사용한다고 했을 때 가상 스레드마다 커넥션을 매번 새로 연결해 사용할 경우 DB의 맥스 커넥션 개수가 넘어 자원을 얻지 못한 가상 스레드는 커넥션을 기다리다가 타임아웃이 뜨는 경우가 있을 것 같은데요.

디비의 경우 커넥션풀을 두면 되겠지만 그 외에는 세마포어로 한정된 자원을 여러 Virtual Thread간 동기화 하는 방법으로도 해결할 수 있습니다. (https://www.youtube.com/watch?v=Ecoq2PVVaSc)

가상스레드를 사용하면 무조건 성능이 향상된다는 의미가 아니다.

I/O 위주 처리가 아닌 CPU 위주 처리 환경에서는 OS Thread가 성능이 더 좋을지도

IO 작업 없이 CPU 작업만 수행하는 경우(컨텍스트 스위칭이 잘 안일어나는 배치성 작업 등등) 플랫폼 스레드만 사용하는 경우보다 성능이 떨어질 수 있습니다. 가상 스레드는 JVM에서의 컨텍스트 스위칭 오버헤드가 존재하기 때문입니다.

리액티브 프로그래밍 너는 죽었다.

Oracle의 개발자이자 Project Loom의 주요 개발자인 브라이언 게츠(Brian Goetz)는 영상에서 다음과 같이 이야기하였습니다.

"Loom이 Reactive Programming을 죽일 것이라고 생각한다.

Reactive Programming은 과도기적 기술이었으며 우리가 가진 문제에 대한 반작용에 불과했다는 것을 머지 않은 미래에 모두가 깨달을 것이라고 생각한다.

만약 그 문제가 사라지게 된다면 Reactive는 우리가 원하던 해결책이 아니라는 것이 분명하게 드러날 거다."

마무리

리액티브 프로그래밍이 등장하고 이를 기반으로 하는 Spring Webflux가 처음 나왔을 때 이들이 기존에 자바의 태생적 한계를 모두 해결해주고 SpringMVC는 한계에 사로잡혀 잊혀져 갈 것만 같았습니다.

하지만 리액티브 프로그래밍은 어렵고 익숙하지 않았으며 디버깅도 힘들고 기능이 추가, 변경됨에 따라 유지보수도 점점 어려워짐을 느꼈습니다.

Virtual Thread의 등장으로 자바 수명이 20년은 늘어나지 않았을까요.

참고자료

https://www.youtube.com/watch?v=I0zMm6wIbRI&t=519s
https://www.baeldung.com/spring-webflux-concurrency
https://www.baeldung.com/openjdk-project-loom

보면 좋은글

https://jaeyeong951.medium.com/virtual-thread-synchronized-x-6b19aaa09af1

profile
鈍筆勝聰✍️

0개의 댓글