[Java] Concurrent Processing : Actor Model

actor model에 대한 개념을 정리하려 했는데 간결하지 못하고 장황해져 버렸다. actor model을 둘러싼 이슈에 대한 공유 정도로 읽어주기 바란다.

Concurrent Computing


multi-core CPU가 개인 PC에 일반화되고, 서버 환경에서도 8 CPU 서버는 흔하게 볼 수 있는 게 오늘날의 컴퓨팅 환경이다. 운영체제에서 다중 쓰레드를 지원하는 것은 기본인 데다가, 서버 소프트웨어를 운영하다 보면 발생 확률이 매우 낮을 것 같은 동시성 처리 관련 버그가 몇 달이 못가 심각한 운영 장애의 원인으로 발견되기도 한다. 또, 클라우드 환경에서는 수천대의 컴퓨터를 연결하여 연산을 동시에 분산 처리하여 빠른 결과를 가져오는 것이 일반화된 기술이 되고 있다. 단일 노드, 단일 CPU에서는 발생하기 어렵던 문제들이 서버쪽 소프트웨어에서는 매우 critical한 이슈로 매일 접하게 되었다.

흔히 concurrent programming 이라고 부르는 비동기적이고 동시적인 연산 처리 기법이 이미 보편화되어 있다.
concurrent programming을 위한 여러 가지 모델 중에서 OS 에서 제공하는 thread와 locking을 사용하여 직접 구현하는 방법 외에 수학적 기반에 출발한 처리 모델들이 몇 가지 있다. 많이 사용되는 concurrency 고려 모델에는 여기 소개할 actor model 외에 단방향 그래프 방식으로 워크플로우 구현에 많이 사용되어온 petri-net, WS-BPEL 표준의 수학적 근거로 알려진 pi-calculus(π-calculus)가 있다.

아주 간단하게 설명하자면 Petri Net은 place와 transition으로 구성된 그래프에 token의 이동을 통해 흐름을 제어하는 모델로 동시에 여러 개의 token이 존재할 수 있어 동시성을 표현한다.
Petri Net은 token이 현재의 실행 위치를 나타내므로 제어 흐름을 직관적으로 표현하는 장점이 있지만, 데이터 흐름은 고려할 수 없는 한계가 있다.

Pi Calculus는 통신 채널과 변수를 기본으로 구성한 수학적 모델이며 동시성 표현을 기본으로 지원한다. (WS-BPEL 혹은 BPEL의 프로세스 표현이 Pi Calculus 기반으로 되어 있다고 하지만, 하나의 BPEL 언어 내의 concurrency 표현이 직접적으로 Pi Calculus에 기반한다고 보긴 어려움. BPEL 프로세스의 흐름은 메시지 도달과 타이머에 의해 비동기적으로 제어되며 하나의 프로세스 내에 여러 개의 실행 지점이 있을 수 있음. BPEL은 block structure라는 언어 특성을 가지는데 concurrency control 중 하나로 사용된 Link 개념은 block structure가 아니라 petri-net과 유사한 directed graph 방식을 차용하고 있음.)

여기 소개하는 actor model은 최근 JVM 기반 functional 언어 중 하나인 Scala 에서 동시 처리를 위한 기본 모델로 채택하고, groovy 등 다른 functional 언어들에서도 앞다퉈 도입함에 따라 주목을 받아왔다.

Actor 모델
Actor 모델의 기본 구성 요소는 actor이다.
actor는 actor들 간에 메시지 전달을 통하여 통신을 한다. 즉, send와 receive라는 두 가지 동작을 하는 존재이며 각 actor들은 address를 통해 서로를 인지하게 된다.
또하나의 요소는 function이다. actor는 메시지를 전달받으면 function을 호출할 수 있으며, 또 메시지별로 function을 지정할 수도 있다.

actor 모델이 low level이라고 할 수 있는 thread와 locking을 사용하여 concurrency를 구현하는 것과 크게 다른 점은 간결성이다.
actor model은 actor들 간의 데이터(정확하게는 메시지)를 공유하지 않고, 한 시점에는 하나의 actor만 해당 메시지에 접근할 수 있다는 전제를 두고 있다.
동시에 실행되는 여러 개의 작은 프로세스들(actor이든 thread이든)이 데이터를 공유하면서부터 프로그래밍의 복잡성이 어려워지게 된다. 적어도 actor 모델에서는 actor들이 서로 공유해야 할 데이터 즉, 메시지는 시점적으로 동시에 접근하지 않는다는 전제를 하고 있으므로 locking에 대한 고려를 하지 않아도 된다. (모든 데이터 접근을 메시지 기반 모델로 가져가서 배타적으로 처리할 수 있는 것은 아니다. 요청과 응답과 같은 한시적으로 사용되는 데이터의 경우에는 배타적 처리가 쉽지만, 여러 개의 요청으로 이루어지는 대화형 처리 모델의 경우 공유되는 데이터가 존재하기 마련이고 이런 데이터는 메시지 전달 기법만으로 배타적인 데이터 처리를 할 수가 없다. 다만, 비대화형 처리의 경우에 병렬적 프로그래밍의 복잡성을 완화시켜주는 모델임은 분명하다.)

Java 에서 Worker thread model, Event-driven model 그리고 Continuation
actor model의 구현에 대해 더 나아가기 전에 잠깐 실제 저수준의 thread와 lock이 현실적인 프로세싱에서 어떻게 사용되는지를 짚어보자.

가장 흔하게 만날 수 있는 쓰레드 모델은 boss-worker 혹은 master-slave 관계라고 부르는 worker thread 모델이다. Java Enterprise Edition(Java EE)의 Servlet, EJB 등은 기본적으로 worker thread 모델에 따른 스펙이다.
요청이 들어오면 요청을 처리하기 위한 worker thread를 할당하게 되며, worker thread는 요청을 처리하고 응답을 만드는 일을 담당한다.
이 전통적인 thread 모델의 문제점은 요청을 처리하는 과정에서 blocking operation을 만나게 되면, 그 blocking operation들이 완료될 때까지 해당 worker thread가 block되어야 하기 때문에 한정된 시스템 자원인 쓰레드를 불필요하게 낭비하게 되는 것이다. 이 외에도 worker thread를 요청 시마다 만드는 오버헤드가 있지만, 이 오버헤드는 보통 thread pooling을 통하여 최소화할 수 있다.


불필요한 thread 갯수를 줄이기 위해서는 thread가 block되지 않도록 해야 하는데 이때 흔히 사용되는 방법이 event-driven model이다.
Java EE의 Servlet spec은 3.0 버전에서 AsyncServlet 스펙(JSR 315)을 포함하였다. Java EE 6에 채택된 servlet 3.0 스펙의 AsyncServlet에서는 worker thread가 하나의 메소드에서 요청과 응답을 모두 처리하여 쓰레드를 분리할 수 없는 문제를 해결하기 위해 응답을 위한 별도의 callback 을 호출할 수 있는 AsyncContext 객체를 넘겨주고, 요청 처리 쓰레드를 종료할 수 있도록 하고 있다.

비동기적인 방식으로 요청과 응답을 처리하기 위해서는 내부적으로는 요청, 응답이 준비되는 이벤트를 처리하는 이벤트 전담 처리 쓰레드를 필요로 한다. 흔히 이 방식을  event-driven 모델이라고 한다.
event-driven 모델은 훨씬 적은 갯수의 쓰레드로 context switching 오버헤드를 최소화하여 실행하기 때문에 성능 상의 큰 장점을 가지고 있지만, 동기적인 worker thread 모델에 비해 코드가 복잡해진다는 큰 단점이 있다.
즉, 요청을 처리하여 응답을 만드는 일을 하는 코드를 worker thread 모델의 경우 하나의 메소드에서 요청을 인자로 넘겨받아 차례로 처리한 후 응답을 리턴값이나 인자 등으로 넘겨주면 되지만, 이벤트 모델에서는 각 이벤트가 발생할 때마다 상태에 따라 부분 코드를 실행하도록 한 후 제어를 넘기는 state machine 방식으로 작성해야 한다.

Continuation은 쓰레드를 중지하고 재개하는 개념으로 사용되고 있으며, worker thread 모델처럼 순차적으로 요청과 응답을 처리하는 코드를 작성하고 실제 실행은 이벤트 모델처럼 비동기적으로 실행되도록 하는 데 목적을 두고 있다.
(이상적으로 말하자면, 쓰레드가 실행되다가 blocking call을 만나면 해당 쓰레드는 종료하고 실행 위치만 기억해두었다가, blocking call이 완료되는 시점에서 다른 쓰레드가 기억된 실행 위치부터 실행을 재개하는 개념이 Continuation이다. )
servlet container 중 하나인 JETTY 에서 이 개념을 다음과 같이 구현한 예가 있다.
예를 들어, 동기 처리를 하는 Servlet 클래스는 service(ServletRequest, ServletResponse) : void 라는 메소드에서 요청을 처리하고 응답을 만드는 일을 하게 된다.
JETTY의 경우에는 이 메소드 대신에 doPoll(HttpServletRequest, AjaxResponse) 라는 메소드 안에서 Continuation 객체를 만들고 이 객체에 대해 suspend를 호출하면 내부적으로 특별한 Exception을 만들어 해당 쓰레드를 종료시키고, JETTY 컨테이너는 이 Exception을 catch하여 특별한 자료 구조에 등록을 해둔다. 이후 특정 이벤트가 발생하여 다음 단계로 넘어갈 수 있으면 해당 객체를 resume 시키고 다시 해당 doPoll 메소드를 실행하게 된다.


자바 언어 자체가 thread를 중지, 재개한다거나 thread를 대체하는 방법을 제공하지 않으므로 이를 피하기 위하여 Exception으로 call stack을 벗어나는 trick을 사용하는 것인데 명시적으로 callback을 사용하지 않는다고는 하나 이벤트 모델과 유사한 state machine 방식의 코딩이 일부 포함될 수밖에 없다.
매우 tricky한 반면, 프로그래밍의 복잡성을 줄여주는 측면은 미약하므로 AsyncServlet 스펙에서는 명시적으로 callback 방식을 사용하도록 하고, JETTY의Continuation 객체 개념은 빠졌다.


Actor 모델과 Continuation
Actor 모델을 구현할 때에도 효율적인 처리를 위해서는 이벤트 모델처럼 비동기적인 처리를 할 수 있어야 한다. 명시적으로 쓰레드를 지정하지 않고 Actor 구현체 내부적으로 쓰레드를 관리하므로 효율적 구현은 더 큰 이슈라고 볼 수 있다.

자바의 Actor 모델 구현체 중 하나인 Kilim에서는 bytecode를 추가 변경하는 weaving 기법을 사용하여 Continuation을 구현한다.
Kilim은 코드를 이벤트 기반으로 변경하지 않고서 이를 구현하기 위해서 변수에 특별한 annotation을 통하여 변수의 특성을 지정하도록 하였고, 런타임에서 자바의 call stack을 unwind했다가 다시 wind하도록 call graph를 분석하여 바이트코드를 변경하는 방식으로 구현하였다.
이 방식은 JETTY의 Exception을 사용한 방식에 비하면 훨씬 진보한 방식이지만, 구현이 까다롭고 코더가 변수에 대해 annotation을 지정하는 등 추가적인 코드 작업이 있어 아주 깔끔하지는 않다.

JVM 기반의 functional language인 Scala 에서 제공하는 actor framework 은 메시지 패턴에 따른 함수를 지정할 수 있고, receive 대신 event 기반의 처리를 할 수 있는 react 메소드를 지원하여 Continuation 문제를 해결하려고 한다. 즉, Kilim의 해결 방향은 코드는 그대로 두고 annotation과 static analysis를 사용하여 event-driven과 유사한 효과를 만드는 것이었다면 Scala는 최소한의 코드 변경을 통하여 event-driven 방식의 concurrent programming을 가능하도록 시도하고 있다. Scala의 방식은 AsyncServlet의 코드 패턴에 비해 좀더 직관성을 유지할 수 있다. (inversion of control이 발생하지 않는다.)

결론 : Concurrent Programming과 Performance, Code Complexity
정리하자면 concurrent programming은 크게 두 가지의 문제를 주로 다룬다.

하나는 동시에 여러 개의 쓰레드 혹은 프로세스가 서로 공유하는 자원에 접근할 때의 배타성 보장 문제 즉, locking 문제이다. 이 문제의 가장 좋은 해결책은 동시에 여러 개의 쓰레드가 접근할 필요가 없도록 구성하여 lock-free가 되도록 하는 것이다. actor model은 메시지의 경우 동시에 여러 actor가 같은 메시지에 접근하지 않도록 하여 lock 문제를 피하고 있다. 하지만 공유하는 stateful data 중 일부는 이러한 방법으로 동시 접근 문제를 회피할 수 없는 경우가 있다. 이때에는 전통적인 lock을 사용할 수밖에 없다.

두번째 문제는 쓰레드 갯수가 증가함에 따라 시스템 자원이 낭비되고, 컨텍스트 스위칭 오버헤드에 의한 성능 저하가 심각하게 발생하는 것이다. 이 문제의 가장 좋은 해결책은 Event-driven 방식의 concurrent programming을 하는 것인데, event-driven 방법은  blocking되는 순간 제어를 다른 무엇인가에 넘겨줬다가 block을 해지하는 event가 발생할 때 callback을 호출하는 방법으로 주로 구현되어 코드의 복잡성이 높아지고, 같은 일을 하는 코드 부분들이 떨어져 존재하게 되어 가독성도 떨어진다.
이 복잡성을 줄이기 위해 actor framework의 구현체들은 부분 함수들을 각 메시지 수신별로 지정한다거나, static analysis를 통한 bytecode weaving 기법을 사용하기도 한다.

결론적으로 actor framework은 첫번째 문제는 부분적으로 해결을 하고 있으며, 두번째 문제는 코드를 event-driven 방식으로 scratch부터 작성하는 방법에 비해 성능이 떨어지긴 하지만, 쓰레드 갯수가 많아져서 발생하는 여러 가지 오버헤드와 성능 저하를 간단하게 회피할 수 있는 아주 가벼운 쓰레드인 actor를 지원하며, 또 코드의 복잡성도 상대적으로 줄이는 데까지는 성공하고 있다.
Scala 언어에서 제공하는 actor framework은 어느 정도 완결성을 가지고 있으며 functional language의 장점을 살리고 있으나, 자바 Kilim 라이브러리는 자바 언어의 한계 때문일지 너무 어려운 접근법을 쓰고 있다.

continuation  문제를 Scala가 가장 깔끔하게 풀고 있는 것 같기는 하나, actor framework이 모든 concurrency 문제를 모두 해결해주는 게 아니라 일부는 OS 커널에서 제공하는 쓰레드와 lock을 다시 직접 사용할 필요가 생기게 되니, thread와 lock을 대체하는 concurrent programming model이라고 부를 수는 없을 것 같다. stateless 기반의 구조를 강제하는 Web Oriented Architecture (Web 2.0의 서버 모델) 에서는 상대적으로 여러 세션에 걸쳐 공유하게 되는 데이터의 필요성이 많이 줄게 되므로 Scala의 actor framework 만으로도 구현이 가능하지 않을까 하는 기대는 해볼 수 있겠다.

댓글

이 블로그의 인기 게시물

[Java] Java G1 GC의 특성에 따른 Full GC 회피 튜닝 방법

엄밀한 사고(Critical Thinking)란 무엇일까

일론 머스크의 First Principle Thinking (제1원리 기반 사고)