ConcurrentML: Go의 동시성 모델 개선 방안
- events: 값으로서의 통신 연산
- 이벤트를 기반으로 구축하기
- 느슨한 스레드를 감싸기
close
ing thoughts- References
LLM 사용 공개: 본 게시물은 주로 사람이 작성했습니다. 하지만, 음성-텍스트 변환, 기본적인 문법 교정, 글쓰기에 대한 피드백 및 인라인 API 문서 링크 자동 추가에 LLMs를 사용했습니다. Go 프로그래밍 언어는 통신 순차 프로세스 (Communicating Sequential Processes, CSP) 동시성 모델을 광범위하게 사용하는 데 결정적인 역할을 했습니다. CSP 모델에서 채널은 보통 버퍼링되지 않은(unbuffered) 방식으로 작동하며, 단순한 통신 수단일 뿐만 아니라 동기화 역할도 합니다. 즉, 송신하거나 수신할 때 상대방이 준비될 때까지 블록되며, 그 후 값은 한 스레드에서 다른 스레드로 원자적으로 이동합니다.
게다가, select 문은 자신의 절(clause)에 있는 모든 채널 연산 중 오직 하나만 선택되고 나머지는 처리되지 않은 상태로 남도록 보장합니다. 이는 여러 메시징 패턴을 간결하게 표현할 수 있게 합니다.
CSP는 오래된 아이디어입니다 (1970년대 출신). Go가 이 모델에 비교적 가깝게 붙어 있지만, 다른 언어들은 이를 더 발전시켰습니다. 제 생각에 가장 흥미로운 것은 Concurrent ML (CML)입니다. 1990년대 중반에 도입된 CML은 현대 언어 설계자들에게 여전히 배울 점이 많습니다.
본 게시물에서 저는 Concurrent ML이 CSP 위에 개발한 것이 동시성 시스템을 구현하는 데 강력한 이점을 제공하며, Go의 여러 결함을 해결한다고 주장하고자 합니다.
CML의 핵심 아이디어는 통신 연산을 실행 가능한 구문(statement)에서 그 연산을 설명하는 값(value)으로 승격시키는 것입니다. 이 설명의 실행은 별도로 수행될 수 있습니다. 당장 명확하지 않아도 걱정하지 마세요. 이 에세이 전체가 바로 그 아이디어를 상세히 풀어나가는 내용입니다.
이는 Go/CSP에 비해 여러 강력한 개선점을 열어줍니다:
- 선택(choice)의 개념을 통합합니다 (무엇이
select
does)와 취소(cancellation)를 통합된 일반적인 추상화로 만듭니다. - 이 추상화는 Go에서는 불가능한 동시성(concurrency)의 조합과 캡슐화를 가능하게 합니다.
별도로, CML은 Go가 가진 가장 큰 동시성 함정 중 하나인 — 적절히 종료되지 않으면 고루틴(goroutine)이 누수되는 문제 — 를 간단한 가비지 컬렉션(garbage collection)을 통해 해결합니다.
아이디어 자체는 CML에서 나왔지만, 오늘날 Racket이 이 아이디어를 가장 잘 구현하는 프로그래밍 언어입니다. Racket은 활발하게 개발되고 있으며, CML 모델은 그 안에서 포괄적으로 지원되고 사용됩니다. 이러한 점을 염두에 두고, 저는 모든 예제를 Racket으로 시연할 것이며, 문맥에 따라 'CML' 또는 'Racket'이라는 용어를 상호 교환하여 사용할 것입니다.
마찬가지로, goroutine과 thread라는 단어를 상호 교환하여 사용할 것입니다. 적어도 이 글의 목적상 그 의미론적(semantics) 차이는 동일합니다.
이 글은 새로운 것을 제안하는 것이 아닙니다. 이는 일반적으로 학계 커뮤니티에 머물러 있던 모델을 실무자들에게 소개하기 위한 것입니다. 본 글은 독자가 Go 문법과 채널(channel)의 동작 방식에 대해 어느 정도 익숙하다고 가정합니다. Racket 자체에 대한 직접적인 숙련도는 요구하지 않지만, Scheme과 유사한 언어에서 함수 호출 및 재귀(recursion)에 대한 기본적인 아이디어는 이해할 수 있어야 합니다.
Racket 코드 스니펫을 사용해 보려면, Racket을 다운로드하고 DrRacket 에디터를 실행한 다음, 예제를 메인 창에 붙여넣으세요. 'Run' 버튼을 눌러 실행합니다.
events: communication operations as values
Go에서 ch가 채널(channel)이라면, 다음 두 가지 연산이 해당 채널로 메시지를 보내거나 받는 데 사용됩니다.
1// Don't run this, it will deadlock
2ch := make(chan int)
3ch <- 5
...
이러한 채널 연산 구문은 도달하는 즉시 효력을 발휘합니다.
select 문과 함께 사용될 때, 이 표현식들은 case 절에 결합되어 런타임(runtime)이 선택할 수 있는 잠재적 선택지들을 표현할 수 있습니다. 실행이 select에 도달하면...
모든 채널 연산은 어떤 것이 실행 준비가 되었는지 확인하기 위해 폴링(polled)됩니다. 준비된 것들 중에서 하나의 연산이 선택됩니다. 이 실행 동안에는 다른 어떤 채널 연산도 완료되지 않음이 보장됩니다.
1select {
2case ch1 <- 5:
3case y := <- ch2:
...
select문 내에서, 채널 연산은 설명(descriptions) 역할을 하며, select 문이 어떤 채널을 폴링하고 어떤 연산을 수행할지 결정하는 데 사용됩니다. 즉, 첫 번째 케이스를 실행한다고 해서 select 전체가 즉시 블록되는 것은 아닙니다. 하지만 Go에서는 이 설명적인 형태를 select 문과 분리하여 독립적으로 사용할 수 없습니다. 그렇게 하려고 하면 이전 예제처럼 되어, 해당 문이 즉시 실행을 시도합니다. 이는 조합(composition)과 캡슐화(encapsulation)를 방해합니다. 함수가 나중에 select할 수 있는 설명(description)을 반환할 수 없고, 구조체 안에 넣어 가지고 다닐 수도 없습니다.
Racket의 채널도 같은 방식으로 시작하며, 블로킹 전송(send) 및 수신(receive) 연산의 직접적인 유사체를 제공합니다.
1; ch := make(chan int)
2(define ch (make-channel))
3; ch <- 5
...
하지만, 이들은 또한 연산을 설명하는 독립적인 방법도 제공합니다.
1; `case ch1 <- 5`와 비슷한 방식입니다.
2(channel-put-evt ch 5)
3
...
이것은 행동의 문(statement)이 아니라 값을 반환하는 표현식(expression)입니다!
channel-put-event는 값을 반환하며, 이는 CML에서 이벤트(event)라고 불립니다. 이 함수는 값 5를 채널에 즉시 전송하지 않습니다.
이 이벤트는 나중에 실행되어 실제로 그 효과를 적용할 수 있습니다. 이것은 값이므로, 숫자 5와 마찬가지로, Go에서는 불가능한 많은 유용한 작업을 할 수 있습니다:
- 변수에 할당하고,
- 함수에서 반환하고,
- 구조체에 넣고,
- 채널 연산을 수행한다는 사실 자체를 일반 함수 뒤에 숨기고(abstract),
- 패턴을 표현하기 위해 추가적인 동시성 조합자(concurrency combinators)로 감쌀 수 있습니다.
또한 이벤트는 채널 작업보다 더 일반적이며, 다른 언어의 “이벤트 루프”에서 흔히 노출되는 I/O 작업을 표현할 수 있습니다.
이벤트의 실제 효과는 sync 함수에 의해 실행됩니다.
1(sync (channel-put-evt ch 5))
sync를 사용하면 일련의 이벤트들을 동기화하려고 시도합니다. select와 마찬가지로, 여기에 전달된 모든 이벤트가 폴링됩니다. 준비된 이벤트가 선택되고, sync는 선택된 이벤트의 동기화 결과를 반환하며, 이는 각 이벤트가 정의할 수 있는 무언가입니다. 채널에 보내는 경우, 이 결과는 이벤트 자체입니다. 채널에서 받는 경우, 채널로부터 받은 값이 됩니다.
여러 이벤트가 준비되었을 때, Go와 마찬가지로 하나가 무작위로 선택됩니다.
예를 들어, 여기는 숫자 리스트의 합계를 계산하고 채널로 응답하는 Racket 스레드입니다.
1#lang racket/base
2(define (sum items resp-ch)
3 (define total (apply + items))
...
이처럼 설명과 실행을 분리하는 것은 쉽게 재결합될 수 있습니다. channel-put은 다음과 같이 정의될 수 있습니다:
1(define (channel-put ch val)
2 (sync (channel-put-evt ch val)))
이벤트 래핑하기
이 에세이를 통틀어 우리는 많은 익명 함수를 사용할 것입니다. Go에서는 func으로 생성됩니다. Racket에서는 lambda 형태, 또는 문자 그대로 λ가 사용됩니다. 이에 대한 지식을 새로고침하려면 여기에서 Go용 또는 여기에서 Racket용을 참고하세요.
Tour of Go의 첫 번째 select문 예제는 고루틴(goroutine)을 사용하여 피보나치 수를 계산하고 채널을 통해 응답을 중계합니다. 또한 고루틴에게 종료하라고 알리기 위해 추가적인 채널을 사용합니다. 이것이 Racket 번역입니다:
1#lang racket/base
2(define (fibonacci out-ch quit-ch)
3 (let loop ([x 0])
...
for {} 무한 루프를 재귀적으로 표현하기 위해 이름이 지정된 let을 사용하는 것과 같은 함수형 언어 고유의 형태들이 있습니다. in-range는 Racket의 숫자 반복 형태이므로, for
loops 10 times, 첫 번째 피보나치 수 10개를 가져옵니다. select와 같습니다.
여러 채널 연산을 받을 수 있는 sync는 여러 이벤트를 받고, 그중 적어도 하나가 동기화 준비가 될 때까지 블록합니다.
이 첫 번째 예제에서는 새로운 함수인 handle-evt를 소개합니다. 이것은 *이벤트 조합자(event combinator)*입니다. 이벤트에 기능을 추가하고, 그 자체로 이벤트를 반환하는 함수입니다.
Go의 select에서, case 절 뒤에는 일련의 구문이 이어질 수 있으며, 해당 절이 선택되고 연산이 실행된 경우에만 실행됩니다. select 절을 특정 통신 연산이 커밋되는 지점(commit point)으로 간주할 수 있고, 다른 연산들은 포기된다고 생각하면 됩니다.
이러한 사고 모델을 가지고, 우리는 커밋 지점 이전에 무언가를 하거나(setup) 커밋 지점 이후에 무언가를 할 수 있습니다(post-commitment).
우리가 이벤트들을 조합하기 시작하면, 더 이상 sync와 setup 및 post-commitment 동작을 연관시키는 것이 의미가 없습니다. 그렇지 않으면 함수에서 이벤트를 반환할 때마다 별도로 setup 동작도 반환해야 합니다. 대신, 우리는 그러한 단계를 이벤트 자체에 첨부할 수 있습니다. 이것은 이벤트와 함수를 받는 래퍼(wrapper) 함수를 통해 수행됩니다. 각 래퍼는 이벤트를 특정 방식으로 증강합니다. 이것들 역시 이벤트 조합자라고 불립니다.
첫 번째 래퍼가 handle-evt인데, 이는 이벤트에 post-commitment 동작을 연관시킵니다. 새로운, 래핑된 이벤트를 반환합니다. 래핑된 이벤트가 sync에 의해 선택될 때(즉, 경쟁에서 승리할 때), 연결된 함수는 해당 이벤트의 동기화 결과와 함께 실행됩니다. 위 예제에서는 이러한 동작들을 사용하여 루프를 계속할지 아니면 종료할지를 결정합니다.
이 래퍼들은 일반적인 이벤트의 특성을 활용하여 많은 공통 동작을 이 프레임워크 안에 넣고 있다는 점에 유의하십시오. 모든 래퍼는 입력 이벤트를 캡슐화하고, 이를 증강하며, 출력 이벤트를 반환합니다. 해당 이벤트를 사용하는 사용자는 이벤트 뒤에 무엇이 숨겨져 있는지 알아낼 필요도 없고 할 필요도 없습니다.
캡슐화의 또 다른 예시로, quit 패턴은 quit-evt로 끌어올릴 수 있습니다.
구현 외부에서는 quit이 실제로 채널(channels)을 통해 구현된다는 것을 알 필요가 없습니다.
1#lang racket/base
2; quit-evt를 반환하고, quit을 트리거하는 함수를 반환합니다.
3(define (make-quit-evt)
...
채널 연산을 서술적 값(descriptive values)으로 다루는 것이 조합성(composition)을 열었다는 점이 명확해졌기를 바랍니다!
선택(choice) 조합하기
이벤트(events)를 반환할 수 있다는 것은 Racket 코드가 종종 다양한 소스에서 이벤트 목록을 구축한 다음, 그 모든 것에 sync를 호출한다는 것을 의미합니다. sync는 실제로 해당 이벤트들 사이의 선택 연산(choice operation)을 수행하며, Racket은 이 개념이 래퍼 이벤트(wrapper event)로 분리될 수 있도록 허용합니다.
조합자(combinator)는 choice-evt입니다.
다음은 “두 개의 다른 소스에서 데이터를 가져오고, 가장 빠르게 반환하는 것을 받아들이는” 일반적인 패턴입니다.
타임아웃과도 쉽게 조합할 수 있습니다. 눈에 띄는 context.Context가 없습니다!
1#lang racket/base
2
3; 이 timeout-evt는 에세이의 나머지 부분에서 사용됩니다.
...
이러한 종류의 조합은 Go에서는 불가능하게 표현되기 때문에, 기본 연산이나 그에 대한 선택을 재사용 가능한 함수로 래핑하는 것이 불가능하기 때문입니다. 채널을 반환하는 고루틴(goroutines)을 생성하는 것은 같지 않습니다. Racket에서는 실제 가져오기 연산은 sync가 호출되기 전까지 시작되지 않기 때문입니다!
다시 한번 강조하고 싶은 것은 이러한 동시성 조합이 정말 큰 문제라는 것입니다. 라이브러리 코드는 두 이벤트 사이의 선택과 같은 로컬한 관심사들을 조립할 수 있지만, 호출자(callers)가 연산을 실행할 때를 결정할 수 있게 하고, 호출자 스스로는 그들 중 어느 것도 서로에게 맞춰줄 필요 없이 타임아웃과 같은 자신만의 규칙을 부과할 수 있습니다.
이벤트 기반 구축하기
이 글의 나머지 부분에서는 CML이 이벤트라는 기본적인 원시 요소(primitive)를 기반으로 다양한 패턴을 어떻게 표현하는지 확인하기 위해 단일 실행 예제를 사용할 것입니다. 이 시스템이 프로그램 작성에 약간 다른 스타일로 적합하다는 것을 발견하게 될 것입니다. 이는 Go 프로그램 작성 방식과는 거의 반대입니다:
- CML에서는 라이브러리 API 내부에서 스레드를 생성하는 것이 일반적입니다. 반면에, 관용적인(idiomatic) Go 라이브러리는 동시성(concurrency)을 원한다면 호출자에게 고루틴(goroutine)을 생성하도록 요청하는 것을 일반적으로 권장합니다. - 취소(Cancellation)는
context.Context와 같은 것으로 계층적으로 추가되는 것이 아니라 시스템 자체에 내장되어 있습니다.
sync 패키지는 취소 API를 인지하고 적극적으로 참여합니다.
두 언어 모두, 싱글톤 리소스 접근을 중개하는 서비스를 전용 고루틴을 사용하여 모델링하는 것이 일반적입니다. 예를 들어, 하드웨어 프린터를 구동하는 프린트 서비스가 있다고 가정해 봅시다. 채널(Channel)은 요청을 해당 고루틴으로 보내는 데 사용되며, 이 고루틴은 무한 루프를 실행합니다. 만약 서비스가 채널 요청을 수락하면, 결국 장치에 적절한 명령을 전송할 것입니다. 이는 대기열 처리(queuing)를 수행하고 용지 부족 등을 처리하며, 보류 중인 작업들을 추적할 수 있습니다. 당연하게도, 저희 예제에서는 단순히 print만 할 것입니다.