멀티 스레드 프로그램은 많은 프로그래머들이 직면하게 되는 작업 중 가장 어려운 작업이다. 멀티 스레드 프로그램은 같은 값이 주어져도 다른 값이 나올 수 있으며, 실행 순서가 뒤바뀌어 경합 조건이나 교착 상태가 발생될 수도 있다. 이런 상황은 감지도 디버깅도 쉽지 않다.
대부분의 언어는 동시성 처리에 너무나 빈약한 수단만 제공한다. 스레드와 락은 우리가 다룰 수 있는 거의 유일한 도구인데, 이들을 적절하고 효율적으로 사용하는 것은 쉽지 않다. 어떤 순서로 락이 설정되고 해방되야 하는가. reader는 다른 스레드가 기록하려고 하는 값을 읽기 위해 락을 설정해야 하는가. 락에 의존하는 멀티 스레드 프로그램을 어떻게 충분히 테스트할 것인가. 복잡성이 통제를 벗어나 빠르게 상승하는 동안, 우리가 할 수 있는 일이라고는 이미 출시된 제품에서만 발생되는 경합 조건을 디버깅하거나 이 기계에서는 문제가 없는데 저 기계에서는 발생되는 교착 상태를 디버깅하는 일 밖에 없다.
얼마나 수준이 낮은 일인가. 동시성의 복잡성에 대한 해결책으로 스레드와 락과 그 파생물들에만 단순하게 의존해 온 것과 대조적으로 더 효율적이면서 에러는 적은 추상물 개발 활동은 수 년 간 끊김없이 이어져 왔다. 클로저는 동시성의 복잡성에 대한 대응책을 많이 가지고 있다.
- 챕터 2에서 다룬 것처럼, 여러분의 프로그램에서 가변 상태(state)의 양을 최소화한다. 이것을 불변값과 컬렉션이 돕는데, 이들을 다루는 신뢰할 수 있는 의미론과 효율적인 연산이 제공된다.
- 시간에 따라 변하는 상태(state)를 관리할 필요가 있는데 그것이 동시성 관계에 있는 실행 스레드들과 관계된 때에는, 그 상태를 격리하고 그 상태가 변경될 수 있는 방식도 제한한다. 이는 클로저 레퍼런스 타입의 기초인데, 그에 대하여 간단히 논의할 것이다.
- 달리 선택할 수 있는 방안이 없어 부득이 클로저 레퍼런스 타입의 의미론이 보장하는 이익을 버리고자 결정한 때에는 자바가 제공하는 락과 스레드 그리고 고품질의 동시성 API로 쉽게 물러날 수 있다.
클로저가 동시성 프로그래밍을 한방에 잡아 주는 은 총알을 제공하는 것은 아니다. 하지만 참신하고 검증된 도구들을 제공하여 동시성 프로그래밍을 훨씬 더 다루기 쉽고 신뢰할 수 있게 만들어 준다.
클로저는 몇 가지 개체(entity)들--delay, future 그리고 promise--을 제공하는데 계산 수행의 시기와 방법을 제어하는 사례들을 구별하여 캡슐화한 것이다. 오로지 future만 동시성과 단독으로 관련된다. 하지만 delay, future, promise 모두 구체적인 동시성 의미론과 동작원리 구현을 돕기 위해 자주 사용된다.
delay는 코드의 몸식의 일부를 보류시키는 생성물로, 수요가 있을 때만 몸식을 평가하는데, derefenced 즉 역참조된 때가 수요가 있는 때이다.
(def d (delay (println "Running...")
:done!))
;= #'user/d
(deref d)
; Running...
;= :done!
deref 추상화는 클로저의 clojure.lang.IDeref 인터페이스에 의하여 정의된다. 그 인터페이스를 구현한 어떤 타입도 값에 대한 컨테이너처럼 작동한다. 그런 타입은 deref 아니면 그에 상응하는 reader 문법인 **@**을 통하여 역참조된다.[^1] 많은 클로저 개체들은 역참조 가능한데, delay, future, promise, 그리고 모든 레퍼런스 타입인, atom, ref, agent, var도 포함된다. 우리는 이 챕터에서 이들 전부를 다룰 것이다.
여러분은 그냥 함수를 사용해도 위의 코드와 같은 것을 해낼 수 있다.
(def a-fn (fn []
(println "Running...")
:done!))
;= #'user/a-fn
(a-fn)
; Running...
;= :done!
하지만, delay는 한 쌍의 매력적인 이점을 제공한다. delay는 코드의 몸식을 한 번만 평가하고, 그 리턴 값을 캐싱한다. 따라서, 추후에 다시 deref를 사용하더라도 그 코드는 다시 평가되지 않고 캐시된 값만 리턴한다.
@d
;= :done!
이 코드를 앞전의 delay 코드와 함께 추론해 보면 멀티 스레드들이 처음에 안전하게 delay의 역참조를 시도할 수 있다는 결론에 이른다. delay에 대한 멀티 스레드의 역참조는 delay의 코드가 (오로지 한 번만!) 평가되어, 값이 이용가능하게 되기 전까지 차단될 것이다.
생산비용이 비싼 데이터나 임의의 데이터를 값에 포함시켜서 제공하고 싶을 때가 있는데, 그 값의 최종 "사용자"가 그 데이터에 대한 비용을 선택할 수 있는 경우라면, delay를 유용하게 사용할 수 있다.
예제 4-1. delay로 사용자 선택에 따른 계산을 제공하기
(defn get-document
[id]
; ... do some work to retrieve the identified document's metadata ...
{:url "http://www.mozilla.org/about/manifesto.en.html"
:title "The Mozilla Manifesto"
:mime "text/html"
:content (delay (slurp "http://www.mozilla.org/about/manifesto.en.html"))}) ;;1
; 내용물을 (slurp "https://www.mozilla.org/en-US/about/manifesto/")로 바꾸면 작동한다
;= #'user/get-document
(def d (get-document "some-id"))
;= #'user/d
d
;= {:url "http://www.mozilla.org/about/manifesto.en.html",
;= :title "The Mozilla Manifesto",
;= :mime "text/html",
;= :content #Delay@2efb541d: :pending} ;;2
- delay를 사용해서 잠재적으로 비용이 드는 코드나 임의의 데이터를 저비용으로 보류시킬 수 있다.
- 저 delay 코드는 우리(또는 우리 코드의 호출자)가 그 값의 역참조를 선택하기 전까지 평가되지 않은 채로 보관될 것이다.
우리 프로그램의 어떤 부분은 문서 내용은 전혀 필요 없이 문서의 메타데이터만으로도 완전 충족되기도 하는데, 그러면 문서 내용을 가져오는 비용을 피할 수 있다. 반면에, 다른 부분은 문서 내용이 반드시 필요할 수도 있고, 기타 나머지 부분은 문서 내용이 이미 이용가능한 상황일 경우에만 사용하게 만들 수 있다. 후자의 사례는 **realize?**로 가능한데, 값이 아직 사용가능한 상태인지 알기 위해 delay를 조사하는 것이다.
(realized? (:content d))
;= false
@(:content d)
;= "<!DOCTYPE html>..."
(realized? (:content d))
;= true
**realize?**는 future, promise 그리고 지연된 차례열에도 사용될 수 있다.
**realized?**를 사용해서 이미 역참조 된 delay의 데이터만 골라서 사용할 수도 있다. 하지만, 여러분이 시간의 특정 지점에서 delay의 평가를 강제하고 싶은데 비용이 많이 발생됨을 알고 평가된 값이 없어도 문제가 없음을 안다면 여러분은 아마 다른 선택을 할 것이다.
레퍼런스 타입 같은 더 복잡한 주제로 들어가기 전에, 클로저 프로그래머들은 자주 이런 질문을 던지기 시작한다.
"어떻게 하면 새 스레드를 시작해서 그 안에다 코드를 돌릴 수 있을까요?"
굳이 해야만 한다면 (224페이지의 "자바의 동시성 요소들을 사용하기"를 보라.) JVM의 네이티브 스레드를 사용할 수도 있다. 하지만 클로저는 더 친절하고, 더 젠틀한 옵션을 future에서 제공한다.
future는 다른 스레드에서 코드의 몸식을 평가한다.
(def long-calculation (future (apply + (range 1e8))))
;= #'user/long-calculation
future는 즉시 리턴되어, (여러분의 REPL 같은) 현재 실행 스레드가 진행되게 해준다. 평가 결과는 future에 보관될 것이며, future를 역참조하면 그 결과를 얻을 수 있다.
@long-calculation
;= 4999999950000000
delay와 마찬가지로, future의 역참조는 코드의 평가가 완료되지 않았다면 차단된다. 따라서, 아래의 식은 리턴 전에 5초 동안 REPL을 차단할 것이다.
@(future (Thread/sleep 5000) :done!)
;= :done!
또한 delay처럼, future도 몸식이 평가된 값을 보관하므로, 평가 이후에 다시 deref를 통해 접근하더라도 보관된 값만 즉시 리턴한다. delay와는 달리, future의 역참조에는 타임아웃과 "타임아웃의 결과값"을 제공할 수 있는데, "타임아웃의 결과값"은 명시된 타임아웃에 도달하면 deref가 리턴하는 것이다.
(deref (future (Thread/sleep 5000) :done!)
1000
:impatient!)
;= :impatient!
future는 동시적으로 수행되는 API 연산들의 사용방법을 단순화시키는 수단으로 자주 사용된다. 예를 들면, 예제 4-1에서 get-document함수 사용자는 모두 :content 값이 필요하다. 여러분은 아마도 get-documnet 호출의 스코프 내부에서 문서의 :content를 동기적으로 가져오는 것에서 충격을 받을 수도 있다. 그 내용을 완전히 가져오기 전까지 모든 호출자를 기다리게 만드는데, 심지어 호출자가 그 내용이 즉시 필요한 것이 아닌 경우에도 기다리게 만든다. 대신, 우리는 :content 값을 위하여 future를 사용할 수 있다. future는 다른 스레드에서 내용 가져오기를 시작하고, 호출자가 입출력에 대한 차단 없이 작업으로 돌아가게 해준다. :content 값이 나중에 역참조될 때, 내용 가져오기는 이미 진행 중이었으므로, future 사용 전 보다 더 적은 시간 동안만 차단되기 쉽다.
(defn get-document
[id]
; ... do some work to retrieve the identified document's metadata ...
{:url "http://www.mozilla.org/about/manifesto.en.html"
:title "The Mozilla Manifesto"
:mime "text/html"
:content (future (slurp "http://www.mozilla.org/about/manifesto.en.html"))}) ;;1
- 예제 4-1에서 유일하게 변한 것은 delay가 future로 교체된 것 뿐이다.
이것은 클라이트 쪽의 변화를 요구하지 않는다(클라이언트는 단지 :content 값을 역참조하는데만 관심을 갖기 때문이다.), 하지만 만약 호출자가 항상 그 데이터를 필요로 하기 쉽다면, 이 작은 변화는 처리량에서 엄청난 향상이 있다는 것을 입증할 수 있다. future에서 코드를 돌리는 것은 네이티브 스레드에서 코드를 돌리는 것에 비하여 몇 가지 장점을 가진다.
- future는 스레드 풀 내부에서 평가되는데 이는 agent 작동을 잠재적으로 차단하는 것을 공유한다.(209페이지의 에이젼트에서 논의할 것이다.) 이렇게 자원들을 단일하게 모아두는 것(pooling)은 필요에 따라 네이티브 스레드를 생성하는 것 보다 future를 훨씬 더 효율적으로 만들 수 있다.
- future를 사용하는 것은 네이티브 스레드를 설정하고 시작하는 것 보다 훨씬 더 간결하다.
- future(future에 의해 리턴되는 값)는 java.util.concurrent.Future의 인스턴스이므로, 이 인스턴스를 기대하는 자바 API와의 상호운용을 훨씬 쉽게 만들 수 있다.
promise는 delay와 future의 작동원리의 많은 부분을 공유한다. promise는 선택적 타임아웃과 함께 역참조될 수도 있고, promise의 역참조는 제공하기 위한 값을 가지기 전까지 차단될 것이며, promise는 오로지 항상 하나의 값만 가질 것이다. 그러나, promise의 값을 정의하는 어떤 코드나 함수와 함께 생성되지 않는 한에서 promise는 delay와 future와 뚜렷하게 구분된다.
(def p (promise))
;= #'user/p
promise는 초기에 텅빈 컨테이너이다. 시간의 어떤 뒤의 지점에서, promise는 값을 deliver 받아서 채워지게 된다.
(realized? p)
;= false
(deliver p 42)
;= #core$promise$reify__1707@3f0ba812: 42
(realized? p)
;= true
@p
;= 42
따라서, promise는 하나의 값만 드나드는 일회용 파이프와 유사하다. 데이터는 deliver를 통해 한 쪽 끝에 삽입되고 deref에 의해서 반대쪽 끝에서 가져간다. 그런 것을 데이터흐름 변수라고도 부르는데 선언적 동시성을 만드는 건축 재료이다. 이것은 동시적 프로세스들 사이의 관계가 명시적으로 정의된 경우의 전략인데 그 프로세스들의 입력이 이용가능하게 되자마자 수요에 따라서 그런 관계의 파생적 결과가 계산되므로, 결정적 동작을 이끌어 낸다. 간단한 예제로 3개의 promise를 들어보자.
(def a (promise))
(def b (promise))
(def c (promise))
promise에 전달될 값을 계산하기 위하여 다른 promise들의 (아직 전달받지 않은) 값을 사용하는 future를 생성함으로써 우리는 이 promise들이 관계되는 법을 명시할 수 있다.
(future
(deliver c (+ @a @b))
(println "Delivery complete!"))
;= #object[clojure.core$future_call$reify__6736 0x40c9a74c {:status :pending, :val nil}]
이 경우에, c의 값은 a와 b가 이용가능하기 전까지(realize?) 전달되지 않을 것이다. 그 때가 되기 전까지, 값을 c에 전달하는 future는 a와 b의 역참조를 차단할 것이다. 이 state에서 그 promise들로 (시간 만료 없이) c의 역참조를 시도하는 것은 여러분의 REPL 스레드를 무한정으로 차단할 것이다.
데이터흐름 프로그래밍의 대부분의 경우에, 다른 스레드는 어떤 계산이든 최종적으로 a와 b에게 값을 전달하는 일을 할 것이다. 우리는 REPL에서 값을 전달함으로써 그 과정을 줄일 수 있다.[^5] a와 b 둘 다 값을 갖자마자 곧 바로, future는 그들에 대한 역참조 차단을 해제해서 c에 최종적인 값이 전달 될 수 있게 할 것이다.
(deliver a 15)
;= #core$promise$reify__5727@56278e83: 15 ;구버전
;= #object[clojure.core$promise$reify__6779 0x4fbf1942 {:status :ready, :val 15}] 신버전
(deliver b 16)
; Delivery complete!
;= #core$promise$reify__5727@47ef7de4: 16 ;구버전
;= #object[clojure.core$promise$reify__6779 0x336528c6 {:status :ready, :val 16}] ;신버전
@c
;= 31
promise는 순환 의존성을 감지하지 않는다. 이는 **(deliver p @p)**에서, p가 promise인 경우에, 무제한으로 차단된다는 의미이다. 하지만, 그렇게 차단된 promise들은 봉인되는 것이 아니므로, 그 상황은 해결 가능하다.
(def a (promise)) (def b (promise)) (future (deliver a @b)) ;;1 ;= #object[clojure.core$future_call$reify__6736 0x623b5968 {:status :pending, :val nil}] 버전 1.7.0 (future (deliver b @a)) ;= #object[clojure.core$future_call$reify__6736 0x64ad5ff2 {:status :pending, :val nil}] 버전 1.7.0 (realized? a) ;;2 ;= false (realized? b) ;= false (deliver a 42) ;;3 ;= #core$promise$reify__5727@6156f1b0: 42 @a ;= 42 @b ;= 42
- future는 REPL을 차단하지 않기 위해 사용된다.
- a와 b는 아직 배달되지 않았다.
- a를 배달하는 것은 차단된 배송이 재게되게 해준다--명백하게 **(deliver a @b)**는 실패(nil을 리턴하게 된다)하게 될 것이지만 **(deliver b @a)**는 잘 진행된다.
promise를 실용적으로 응용하면 (비동기식) 콜백 기반 API들을 동기식으로 쉽게 만들 수 있다. 여러분이 다른 함수를 콜백으로 취하는 어떤 함수를 가졌다고 해보자.
(defn call-service
[arg1 arg2 callback-fn]
; ...perform service call, eventually invoking callback-fn with results...
(future (callback-fn (+ arg1 arg2) (- arg1 arg2))))
이 함수의 결과를 동기적인 몸식에서 사용하려면 콜백을 제공해야 하는데, 그러면 다른 (상대적으로 좋지 않은) 기술들을 사용해서 콜백 호출과 그 결과를 기다리게 해야 한다. 그러는 대신, 비동기식 콜백 기반 API 위에다가 간단한 래퍼를 작성할 수 있는데, 이 래퍼가 promise의 deref 차단 기능을 사용하여 동기적 의미론을 강제한다. 여러분이 관심을 갖는 비동기식 함수 전부가 마지막 인자로 콜백을 취한다고 가정해보면, 그 래퍼는 범용 고차 함수로 구현될 수 있다.
(defn sync-fn
[async-fn]
(fn [& args]
(let [result (promise)]
(apply async-fn (conj (vec args) #(deliver result %&))) ;콜백으로 promise 전달.
@result))) ;콜백과 결과가 리턴된 때 역참조. 진짜 골백과 잡스런 기술을 사용하지 않아도 된다.
((sync-fn call-service) 8 7)
;= (15 1)
우리는 클로저의 유연한 동시성 기능들을 전부 조금씩 시험해 볼 것인데, 그들 중 하나인 agent를 사용하면 작업부하를 매우 효율적으로 병렬화되게 조율할 수 있다. 하지만, 때때로 여러분은 가급적 요란하지 않게 어떤 연산을 병렬화하고 싶어하는 스스로를 발견할 수도 있다.
병행성 대 동시성 여러분이 동시성과 병행성을 같은 것이라고 착각하지 않도록, 두 개념을 구별해보자. 동시성은 일부 공유 state를 접근하거나 수정하는 여러 개의 실행 중인 교차 스레드들을 조절하는 것이다. 병행성 또한 state가 개입되지만, 보통은 제한적이다. 연산 성능 향상을 위하여 모든 이용가능한 자원들(보통 계산에 관한 것이지만 때로는 대역폭 같은 다른 자원들도 해당된다)의 효율적인 활용을 위해 사용되는 최적화 기술은, 병렬화 방식으로 접근하는데 보통 조절 오버헤드를 최소화하기 위하여 state(또는 빈번하게 state의 덩어리들)에 배타적으로 접근하는 창구를 최대화하는 것을 목적으로 한다. 실행 중인 교차 스레드들에 관련되기 보다, 병행되는 연산의 다중 평가가 동시적으로 실행된다--때로는 다른 CPU 코어들에서, 다른 때는 완전히 다른 물리 장치에서 동시적으로 실행되는 것이다.
클로저의 seq 추상화의 유연성은 차례열 처리에 관한 많은 루틴의 구현을 매우 쉽게 만든다.[^6] 예를 들면, 우리가 정규 표현식을 사용해서 다른 문자열 내부에서 전화번호를 찾아 리턴하는 함수를 가지고 있다고 해보자.
(defn phone-numbers
[string]
(re-seq #"(\d{3})[\.-]?(\d{3})[\.-]?(\d{4})" string))
;= #'user/phone-numbers
(phone-numbers " Sunil: 617.555.2937, Betty: 508.555.2218")
;= (["617.555.2937" "617" "555" "2937"] ["508.555.2218" "508" "555" "2218"])
충분히 간단해서, 이 함수를 문자열들의 차례열에 적응하는 것은 쉽고, 빠르고, 효율적이다. 이들 차례열은 slurp와 file-seq를 사용해서 디스크로부터 읽은 것일 수도 있고, 메시지 큐에서 들어온 메시지가 될 수도 있고, 데이터베이스로부터 큰 텍스트 덩어리들을 가져와서 얻은 결과일 수도 있다. 일을 간단하게 유지하기 위해, 우리는 100개의 문자열들의 차례열을 대충 만들 수 있는데, 각각의 크기는 대략 1메가 정도 되며, 어떤 전화번호들이 뒤에 붙어 있다.
(def files (repeat 100
(apply str
(concat (repeat 1000000 \space)
"Sunil: 617.555.2937, Betty: 508.555.2218"))))
이들 "파일들" 전부로부터 전화번호 전부를 얼마나 빨리 얻을 수 있는지 보자.
(time (dorun (map phone-numbers files))) ;;1
; "Elapsed time: 2460.848 msecs"
- 우리는 여기서 dorun을 사용해서 map에 의해 생산되는 늦은 차례열을 완전히 실현하고 동시에 그 실현 결과를 해방하는데 그 이유는 발견된 전화번호 전부가 REPL에 출력되는 것을 원치 않기 때문이다.
이것은 간단한 것이지만 병렬화가 가능하다. map의 사촌인 pmap이 있는데 값들의 차례열을 가로질러 함수의 적용을 병렬화해서, map처럼 결과의 미뤄진 차례열을 리턴한다.
(time (dorun (pmap phone-numbers files))) ;;1
; "Elapsed time: 1277.973 msecs"
듀얼 코어 장치에서 실행하니, 이전의 예제에서 map을 사용한 것과 비교하여 처리량이 거의 두 배가 되었다. 이 특정 작업과 데이터집합에 대하여, 4개의 코어 장치에서는 거의 4배의 향상이 기대될 수 있다. 함수 이름에서 문자 한 개만 바꾼 것도 나쁘지 않다! 이것이 무슨 마법처럼 보일 수도 있지만, 그렇지 않다. pmap은 단순히 미래를 몇 개--이용 가능한 CPU 코어의 개수에 맞춰 계산된다--사용하는 것인데 각 파일에서 phone-numbers 평가에 관련된 계산을 각각의 CPU 코어 전체에 확대하기 위함이다.
이것이 많은 연산에서 작동되지만, 아직도 pmap을 반드시 신중하게 사용해야 한다. 이처럼 연산 병렬화와 관계된 오버헤드의 정도가 있다. 만약 병렬화 될 연산이 충분히 큰 실행시간을 가지지 못하면, 그 오버헤드가 수행될 실제 작업을 지배하게 될 것이다. 이것은 같은 것에 map을 적용한 것 보다 pmap을 사용한 소박한 어플리케이션을 더 느리게 만들 수 있다.
(def files (repeat 100000
(apply str
(concat (repeat 1000 \space)
"Sunil: 617.555.2937, Betty: 508.555.2218"))))
(time (dorun (map phone-numbers files)))
; "Elapsed time: 2649.807 msecs"
(time (dorun (pmap phone-numbers files)))
; "Elapsed time: 2772.794 msecs"
우리가 여기서 만든 유일한 변화는 데이터에 대한 것이다. 각 문자열은 이제 크기가 약 1킬로 정도 되는데 1메가 짜리를 대체한다. 심지어 작업 총량이 동일함에도("파일들"은 더 많다), 병렬화 오버헤드는 phone-numbers의 각 평가를 다른 future/코어에 할당함으로써 얻는 이익을 초과한다. 이 오버헤드 때문에, pmap을 사용할 때 N배(N은 CPU 코어의 이용가능한 개수이다) 보다 속도향상이 적게 나타난다. 교훈은 명백하다. 여러분이 수행하는 연산이 첫번째 위치에서 병렬화 가능한 것이고, 차례열에 있는 각 값이 커서 연산의 작업부하가 연산의 병렬화에 고유한 프로세스 조절기능을 가릴 정도로 충분한 때에는 pmap을 사용하라. 재앙이 일어날 가능성이 확실치 않은 경우에만 서비스에 pmap 적용을 시도하자.
하지만, 그런 시나리오에 대한 해결책이 있기 마련이다. 병렬화된 각 작업 단위가 더 커지게 데이터집합을 덩어리로 만들어서 상대적으로 간단한 연산도 효율적으로 병렬화할 수 있다. 위의 예에서, 작업 단위는 딱 텍스트 1000자이다. 그러나, 우리는 작업 단위가 더 커지도록 조치를 취할 수 있으므로, pmap에 의해 처리되는 각 값이 1000개 문자열 250개가 모인 차례열이 되게 하면, future 당 배분된 작업 속도가 향상되고 병렬화 오버헤드가 감소한다.
(time (->> files
(partition-all 250)
(pmap (fn [chunk] (doall (map phone-numbers chunk)))) ;;1
(apply concat)
dorun))
; "Elapsed time: 1465.138 msecs"
- map은 미뤄진 차례열을 리턴할 것이므로, 우리는 doall을 사용하여 pmap에 제공된 함수의 범위 내에서 미뤄진 차례열의 실현을 강제한다. 반면에, phone-numbers는 결코 병렬로 호출되지 않는데, phone-numbers를 각 문자열에 적용하는 작업은 나중에 미뤄진 차례열을 소비하게 될 어떤 프로세스에게 남겨지게 된다.
작업부하의 덩어리 크기를 변경함으로써, 병렬화의 이익을 다시 얻었는데 그럼에도 우리의 연산당 계산 복잡도는 훨씬 더 작은 많은 문자열들에 적용될 때가 대체로 낮았었다.
두 개의 다른 병행성 생성물이 pmap 기반으로 만들어졌는데, pcalls와 pvalues가 그것이다. 전자는 인자로 제공된 인자가 없는 함수를 얼마든지 받아서 평가하고, 그 함수들의 리턴 값을 미뤄진 차례열로 만들어 리턴한다. 후자는 똑같은 일을 하는 매크로인데, 함수 말고 식을 받는다.
클로저에서, 상태와 정체성은 뚜렷이 구분된다. 이들 개념은 대부분 광범위하게 횬용되는데, 그런 혼용이 성행하는 것을 아래에서 볼 수 있다.
class Person {
public String name;
public int age;
public boolean wearsGlasses;
public Person (String name, int age, boolean wearsGlasses) {
this.name = name;
this.age = age;
this.wearsGlasses = wearsGlasses;
}
}
Person sarah = new Person("Sarah", 25, false);
특별히 이상할 게 없어 보이는데, 그런가? 그냥 필드 몇 개를 가진, 인스턴스로 생성할 수 있는 자바 클래스다.[^7] 실제로는, 문제점이 여기에 잔뜩 있다.
우리는 Person에 대한 참조를 수립했는데, 그것은 "Sarah"를 나타내고, 그녀가 25살이라고 표시한다. 시간이 흐르면서, 사라는 많은 다른 상태에 놓여 있었다. 아이일 때의 사라, 십대일 때의 사라, 어른일 때의 사라가 있었다. 시간의 각 지점에서--지난 화요일 오전 11시 7분이라고 쳐보면--사라는 정확하게 하나의 상태에 있었고, 시간의 각 상태는 서로 침범하지 않는다. 사라의 상태들 중 하나를 변경하는 것은 절대로 말이 안 된다. 지난 화요일의 그녀의 특징은 수요일에 변하지 않는다. 그녀의 상태는 시간의 한 점에서 다른 점으로 변할 수 있지만, 그런 변화가 이전의 그녀의 존재를 수정하는 것은 아니다. 불행히도, 이 Person 클래스와 대부분의 언어에서 제공되는 저수준 참조(진짜 포인터들)는 이 간단한--기본적이라고 부를 수 있다--개념 조차 표현하기에 적합하지가 않다. 만약 사라가 26세가 되면, 우리의 유일한 선택은 우리가 이용가능한 특정 상태를 소지하는 것 뿐이다.
sarah.age++;
더 나쁜 것은, 사라의 상태에 대한 특정한 변화가 여러 속성을 수정해야만 할 때 무슨 일이 일어나는가?
sarah.age++;
sarah.wearsGlasses = true;
여기 코드 두 줄이 실행되는 시간의 어떤 지점에서, 사라의 나이는 증가했지만, 아직 안경을 끼지는 않았다. 시간의 어떤 기간 동안(기술적으로, 현대의 프로세서 아키텍쳐와 언어 런타임이 작동하는 방식에서 제공된 시간의 불확정 기간), 사라는 사실상 그리고 아마도 의미론적으로도 불가능한 비일관적인 상태에 존재할 수 있으니, 우리의 객체 모델 덕분이다. 이것이 경쟁 조건의 구성요소가 되고, 교착상태가 발생하기 쉬운 잠금 전략의 핵심 원인인 것이다.
심지어 우리는 이 사라 객체를 바꿔서 완전히 다른 사람으로 표현되게 할 수도 있다.
sarah.name = "John";
이것은 성가신 일이다. 사라 객체는 사라의 단일 상태 뿐만 아니라 정체성으로서의 사라의 개념 조차 표현하지 못한다. 오히려, 사라 객체는 상태와 정체성을 부정하게 혼합한 것이다. 더 일반적으로, 우리는 Person 레퍼런스의 이전 상태들에 관한 믿을 수 있는 statements를 만들 수가 없고, Person의 특정한 인스턴스들은 언제라도 쉽게 변할 수 있는데(특히 동시적으로 실행되는 스레드 프로그램에 관계된 시간에서), 단지 인스턴스들에 비일관된 상태가 들어가기 쉽다는 것만이 아니다. 그것이 기본인 것이다.
클로저의 접근방식. 우리가 진정 말하고 싶은 것은 사라가 그녀를 나타내는 정체성을 가졌다는 것이다. 시간의 특정 지점에서의 그녀가 아니라, 시간을 통틀어 논리적인 개체로서의 그녀라는 것이다. 나아가, 우리는 그 정체성이 시간의 특정 지점에서 특정한 상태를 가질 수 있다고 말하고 싶지만, 그 각각의 상태 이행은 역사를 바꾸지 않는다. 52페이지의 "값의 중요성"과 가변 객체와 불변 값 사이의 대조로 돌아가 생각해 보면, 이 상태의 특성이 많은 실용적인 이익을 가져다 줄 뿐만 아니라 의미론적으로도 더 좋은 것처럼 보일 것이다. 결국, 추가적으로 어떤 정체성의 상태가 결코 내적으로 비일관되지 않게 보장하기를 원한다면(불변 값을 사용함으로써 보장되는 어떤 것), 우리는 지난 화요일의 그녀나 작년의 그녀나 사라로 쉽고 안전하게 참조할 수 있기를 간절히 원할 것이다.
(def sarah {:name "Sarah" :age 25 :wears-glasses? false})
;= #'user/sarah
sarah var에 우리가 저장한 map은 시간의 어떤 지점에서의 사라의 상태이다. map이 불변이기 때문에, 우리는 그 map에 대한 참조를 보관하는 어떤 코드도 map의 다른 버전에서 어떤 변화가 있었는지 또는 var가 보유한 상태에 대하여 어떤 변화가 있었는지 상관없이 언제나 map을 안전하게 사용할 것이다. var 자체는 클로저 레퍼런스 타입 중 하나이며, 본질적으로 어떤 값이라도 보관할 수 있게 정의된 동시성과 변화 의미론을 가진 컨테이너이므로, 안정된 정체성으로 사용된다. 그러므로, 우리는 사라가 sarah var에 의해 표현된다고 말할 수 있으며, sarah var의 상태는 var의 의미론에 따라 시간이 흐르면서 변할 수 있다.
이것은 클로저가 정체성과 상태를 다루는 방법과 우리가 집중할 만한 가치가 있는 것으로써 시간에 따라 그들이 관계되는 방법을 살짝 본 것이다.[^9] 이 챕터의 나머지는 그 취급방법의 동작 원리를 탐험하는데 할애될 것이다. 큰 부분에서, 이것은 클로저의 네 가지 레퍼런스 타입들을 탐험하는 것으로 구성되는데, 각 레퍼런스 타입들은 시간에 따른 상태 변화에 대한 잘 정의된 의미론과 다르지 않게 구현한다. 불변 값에 대한 클로저의 열정과 함께, 이들 레퍼런스 타입들과 그들의 의미론은 우리가 이용할 수 있는 점점 더 성능이 좋아지는 하드웨어를 최대한으로 이용할 수 있는 동시적 프로그램의 디자인을 가능하게 만들며, 동시에 스레드와 락만으로 처리하던 영역에서 일어나는 버그와 실패 조건의 전 범주를 제거한다.