2018년 9월 27일 (목)

Project Loom: Fiber와 Continuation


자바에 Fiber와 Continuation이 추가된다

최근에 관심이 가는 OpenJDK 프로젝트 중에 GraalVM 외에 Project Loom이 추가되었다. 이유는 이 프로젝트의 목표가 Fiber를 구현하는 것인데, 이를 위해서는 Continuation을 구현해야 하기 때문이다. 결국 자바에 Continuation이 추가되는 것이 되는데…​ 오…​마이…​갓. 과연 이보다 Java를 프로그래밍 언어로서 강력하게 만들어주는 것이 있을까? 뭐 사람마다 견해의 차이가 있겠지만 나는 Continuation을 절대 반지로 본다. 왜냐하면 Continuation은 그동안 컴파일러 작성자들 사이에서 제어 구문을 만들 때 사용되던 프로그래밍 기법이었는데, 이것을 프로그래머들이 사용할 수 있게 되면 프로그래머들은 그 언어가 제공하는 제어 구문(if나 for 등)의 제약에서 벗어나서 자신의 필요에 맞는 제어구조를 스스로 만들어 낼 수 있게 되기 때문이다. 쉽게 말하자면, 좀 억지 비유이긴 하지만 Continuation은 세련된 GOTO라고 할 수 있다. 좀 더 구체적인 억지 비유라면 파라미터와 스택이 달린 GOTO라고 할까? 확실히 데익스트라의 해로운 GOTO와는 다르지만, GOTO 만큼 제어를 마음대로 옮겨다니게 만들 수 있는데(함수 중간에 다른 함수의 중간으로 점프했다가 다시 돌아온다든지, map으로 컬렉션을 이터레이션을 하다가 특정 조건의 요소(element)에서 중단하게 한다든지) 게다가 그게 세련된 방식이라는 것이다.

하지만 Continuation에 대한 본격적인 소개는 다음으로 미루기로 하고, 이글에서는 Project Loom의 목표인 Fiber와 그와 관련되는 수준에서의 Continuation에 대해 설명하고자 한다. 자바에서의 Fiber의 도입은 그 자체만으로도 프로그래밍 관점에서 매우 특기할 만한 일이어서 하나의 주제로 다루어지기에 충분히 가치가 있기 때문이다.

왜 Fiber인가?

사실 자바의 Fiber 도입은 좀 늦은 감이 있다. 다른 언어들은 이미 Fiber 혹은 그와 비슷한 것들을 갖고 있다. C++은 Boost.Fiber 라이브러리를, Ruby는 Fiber 클래스를 공식적으로 지원하고 있다. Fiber는 아니지만, Fiber와 비슷한 것으로 Python은 Greenlet, Go는 goroutine, C#과 Javascript는 Async/Await를 지원하고 있으며, 이를 통해 Fiber로 해결할 문제를 나름의 방식으로 해결하고 있는 상황이다. 아니 Erlang의 Actor까지와 비교를 한다면 많이 늦었다고 해야 할지도 모르겠다. 자, 그렇다면 이건 하나의 경향이 있다고 볼 수 있지 않을까? 즉, 앞에서도 언급했듯이 현재 대부분의 주류 언어들이 Fiber 혹은 그 비슷한 것의 도입으로 풀려고 하는 그 어떤 고약한 문제들이 우리 프로그래밍 세계에서 지속적으로 발생하고 있고, 자바 역시 바로 그 고약한 문제를 풀기 위해 Fiber를 도입하려는 그런 경향 말이다.

자 그럼, 그 고약한 문제는 과연 무엇일까?

동시성 프로그래밍의 변화 : 중량 스레드에서 경량 스레드로

동시성 프로그래밍은 어렵다. 하지만 동시성 프로그래밍은 멀티코어 시대와 클라우드의 시대에 더욱 중요해지고 있다. 점점 증가하는 동시성 프로그래밍의 요구사항을 충족시키는 데 있어 동시성 프로그래밍의 복잡성은 프로그래머들에게 최대의 적으로 부각되었다. (뭐 여기까지는 다들 아시는 내용이리라 짐작한다)

보통 프로그래머들은 동시성 프로그래밍을 할 때 보통 운영체제에서 제공하는 스레드를 사용한다. 운영체제에서 제공하기 때문에 특히 커널 스레드라고 부른다. 그런데 문제는 이 커널 스레드가 상당히 무겁다는 것이다. 해서 중량 스레드라고 부르기도 한다. 동시성 프로그래밍이 복잡한 이유는 바로 이 커널 스레드가 중량 스레드이기 때문인데, 위에서 언급한 그 고약한 문제란 이 중량 스레드가 야기하는 문제이다. 즉 멀티코어와 클라우드의 시대에 중량 스레드가 야기하는 문제는 매우 심각했고, 결국 이를 해결하기 위해 나온 것이 Fiber, Greenlet, goroutine, Actor(혹은 재발견?) 등이었다. (이들은 중량 스레드와 비교해서 가벼워서 경량 스레드라고 부른다)

중량 스레드의 문제점

스레드, 즉 중량 스레드의 문제는 그 이름이 말해주듯이, 그것이 어플리케이션에서 사용하기에는 지나치게 무겁다는 것이다.

무겁다는 표현은 3가지 의미가 있는데, 이것이 중량 스레드의 특성이다.

  1. 리소스 부하가 많다.

  2. 동시성 작업 단위가 크다.

  3. 선점형 스케줄러

하나씩 순서대로 좀 더 자세히 알아보자.

첫째로 스레드는 리소스를 많이 차지하는데, 그래서 서버당 수천 개 정도만 생성할 수 있다. 반면 소켓은 수백만 개 생성할 수 있다. 이는 거의 몇 승수(order of magnitude)라는 매우 심각한 차이다. 실제로 웹서버는 하나의 스레드가 하나의 Request를 처리하기 때문에 동시에 처리할 수 있는 Request는 스레드 생성 개수에 제한받아서 수 천 개 정도이다. 또한 스레드 간 문맥 전환에 드는 비용도 만만치 않다.

둘째는, 사실 이는 첫째 때문에 생기는 것인데, 스레드가 리소스를 많이 차지하기(또 관리가 어렵기) 때문에, 작은 단위의 작업을 처리하기 위해 가볍게 쓰고 버리는 식으로 쓰기보다는, 큰 단위의 작업을 처리하고 Pooling 한 후 재사용하는 식으로 이용하게 된다는 것이다. 하지만 어플리케이션은 그 특성에 따라 동시성의 작업 단위가 각각 다르다. 비디오 레코딩 프로그램과 웹서버의 동시성 요구 사항의 수준이 같을 수 없다. 또한 어플리케이션 수준에서는 작은 단위의 동시성 작업이 많다. 유저 단위 작업, 트랜잭션 단위 작업, 심지어 단일 연산 작업이라도 동시성 처리가 필요한 경우가 많지만, 이때마다 스레드 만들어 처리하기에는 너무 비용이 크기 때문에, 꼭 필요한 경우를 제외하고는 대부분 세션같이 큰 단위의 작업에 스레드를 사용한다.

셋째는, 정말 이것이야말로 개인적으로는 가장 치명적 요소라고 보는데, 스레드, 그러니까 OS가 제공하는 커널 스레드는 선점형 스케줄러에 의해 처리된다는 사실이다. 선점형이라는 말은 한 스레드에서 다른 스레드로 제어(CPU 할당)가 넘어가는 문맥전환(Context Switch)이 전적으로 OS 담당이라는 의미이다. 이로 인해, 어플리케이션 수준에서 작업하는 프로그래머는 스레드에 맡긴 작업이 어느 순간에 중단될지를 알 수가 없다. 더욱이 그 중단되는 분절이 프로그래머가 작성한 코드 레벨이 아니라 컴파일된 코드(바이트 코드나 기계어 코드) 레벨이기 때문에, 프로그래머에게는 비가시성 영역에서의 중단이라는 점에서, 프로그래머는 전혀 예측할 수 없는 암흑 상태에 빠지게 된다. 이 암흑 상태는 더욱 심각한 문제를 야기하는데, 이런 암흑 상태에서 실행되는 작업이 처리하는 데이터들이 엉망이 될 수 있다는 사실이다. 마치 컴컴한 한밤중에 여러 대의 드론을 운전하면서 골목길을 통과하고 피자를 배달하는 것과 마찬가지 상황이 되는 것이다. 그래서 드론들이 주변과 충돌하지 않도록 골목길에 칠 가드레일과 교차로에서 서로 충돌하지 않도록 하는(데드락) 신호등이 필요하게 되는데, 그게 바로 멀티 스레드 프로그래밍 시에 반드시 사용하게 되는 세마포어, 뮤텍스, 아톰, 크리티컬 섹션이다. 하지만 이들로 인해 바로 그만큼 멀티 스레드 프로그래밍의 복잡성이 더욱 증폭된다.

사실 스레드(커널 스레드)는 모든 어플리케이션의 동시성 요구에 맞추기 위한 일반 목적으로 만들어진 것이다. 즉 스레드는 가장 최악의 경우에도 대처할 수 있도록 만들어야 했는데, 예를 들어 하나의 스레드가 엄청난 계산으로 CPU를 독점하고 있는 상황에 대처할 필요가 있었다. 그러나 개개의 어플리케이션들은 각자 자신만의 특수한 동시성 수준이 갖고 있을 뿐이다. 프로그래머들은 자신이 만들고 있는 어플리케이션이 요구하는 동시성 수준에 대해 잘 알고 있으며, 사실 그 특수한(일반적이지 않은) 요구사항의 수준에 맞는 정도의 동시성 작업 자체만 할 수 있다면 매우 간단하게 해결할 수 있다. 프로그래머들은 결코 스레드와 같은 일반성 수준의 동시성 작업을 만나지 않는다. 웹 어플리케이션을 만들면서 동시에 비디오 레코딩의 동시성에 대해 고민하지 않는다. 그런데 스레드는 개개의 어플리케이션의 특수한 동시성을 해결하기에는 너무 일반적인 도구이며, 위의 3가지 이유로 인해서 매우 불편한 도구인 것이다.

해결사 경량 스레드

해결책의 방향은 이것이다. 만일 프로그래머들이 해당 어플리케이션의 요구사항에 대응하는 동시성 수준의 특수성에 대해 잘 알고 있다면, 더 이상 커널이 강제한 일반적인 방식이 아닌, 해당 어플리케이션의 동시성 수준의 방식으로 스스로 동시성 작업을 관리할 수 있도록 자유를 주는 것이다. (한마디로 동시성 프로그래밍에 있어서 커널 독재 시대에서 어플리케이션의 자유 시대로 바뀌는 것인데, 이를 위해 때론 Callback이나 Promise 그리고 심지어 Monad까지 동원되는 다양한 시도들이 있었지만, 근본적인 변화를 위해서는 일급시민(First Class)이 된 Continuation이 필요했다)

그래서 위에서 언급한 중량 스레드의 특성과는 반대되는 특성을 가진 경량 스레드가 주목받게 된 것이다.

경량 스레드는 다음과 같은 특성이 있다:

  1. 리소스 부하가 매우 적다.

  2. 작은 수준의 동시성 작업 단위 처리가 매우 수월하다.

  3. 유저 레벨 스케줄링.

자바는 경량 스레드 중에서 Fiber를 채택하게 되었다. 사실 Fiber와 Continuation이라면 다른 경량 스레드를 다 만들어낼 수 있다. 보다 근본적이기 때문이다.

Project Loom의 Fiber

위에서 언급한 바로 이러한 이유로 해서 자바에도 경량 스레드의 필요성이 오래전부터 대두되었다. 그래서 자바에서 경량 스레드를 도입하려는 프레임웍들이 나오게 되었는데, Vert.x, akka, RxJava, Quasar 등이 그것이다. 특히 개인적으로는 Matthias Man의 Continuation으로 Fiber를 구현한 Quasar와 그의 Clojure 랩퍼인 Pulsar에 관심을 두고 있었는데, Quasar 개발자인 Ron Pressler가 바로 Project Loom을 제안했고 프로젝트 리더로 활동하고 있음을 알게 되었다(Quasar의 개발이 왜 뜸한가 했더니 그가 Project Loom 활동하느라 바빠서 그랬던 모양이다). 즉 드디어 Java 언어 차원에서 경량 스레드가 본격 지원되게 된 것이다.

자바에 경량 스레드를 도입하려는 목적으로 출발한 Project Loom이 구현하는 것은 Fiber이다. 제안서에 따르면 대략 다음과 같은 기능을 갖게 될 것이라고 한다:

  • 매우 적은 리소스.

    • 수백 바이트 정도.

    • 스위칭 오버헤드는 거의 제로 수준.

    • 하나의 JVM에서 수백만 개 생성 및 원활한 동작 가능.

  • synchronous, blocking 콜 가능.

    • 성능때문에 비동기 코드 작성 필요 없음. (node의 콜백헬이 없다)

    • 동시성 프로그래밍이 단순해지며, 또한 손쉽게 규모 확장이 가능해진다.

  • Fiber의 API들은 Thread 클래스와 거의 비슷.

    • 다만, Fiber를 중단/재시작하는 park/unpark 관련 메소드가 추가됨.

    • unpark 메소드는 인수로 스케줄러를 받을 수 있어서 fiber의 스케줄링을 바꿀 수 있다.

    • Thread와 공통되는 부분은 부모 클래스 Strand로 추출.

  • Serializable

    • Fiber는 스토리지 저장 및 네트웍을 통한 전송이 가능해진다.

    • 이를 통해 데이터가 있는 곳에서 실행되는 함수(Function As Service)가 가능해진다.

    • Financial Transaction이나 실행 블록체인.

  • Continuation

    • Fiber = Continuation + Scheduler

    • Scheduler는 훌륭하게 구현된 기존의 ForkJoinPool을 그대로 사용

    • Continuation(정확히는 Delimited Continuation)의 구현이 서브 과제

    • channel, actor, dataflow 등을 구현할 수 있다.

  • UAI(Unwind And Invoke)

    • tail call

Project Loom의 목표는 기존 자바 코드의 수정 없이, 혹은 최소한의 변경만으로 사용 가능하게 하는 것이라고 한다. 하지만 JNI를 통한 native 코드는 Fiber에서 실행되지 못한다. 또한 기존 자바 API 중 java.io 는 Native blocking 코드가 있어서 Fiber 용으로 다시 변경되어야 하며, java.util.concurrent 도 커널 스레드 동기 때문에 역시 변경이 될 것이라고 한다. 기타 자바 디버거나 프로파일러 등도 Fiber에 맞게 수정이 필요하다고 한다.

Tags: Continuation Fiber Thread OpenJDK Java