Refs와 트랜잭션
Refs와 트랜잭션 (Refs and Transactions)
여러 스레드가 같은 가변 저장 공간을 안전하게 나누어 쓰게 하려면 어떻게 하면 될까요? Clojure의 Vars가 스레드 *격리(isolation)*를 통해 가변 저장 위치를 안전하게 쓰게 해 준다면, 트랜잭션 레퍼런스(Refs)는 소프트웨어 트랜잭셔널 메모리(software transactional memory, STM)를 통해 가변 저장 위치를 안전하게 공유하도록 해 줘요. Refs는 수명이 끝날 때까지 단 하나의 저장 위치에 묶여 있고, 그 위치의 변경은 오직 트랜잭션 안에서만 일어날 수 있어요.
출처: Clojure 공식문서
본문
트랜잭션은 이렇게 동작해요
한 번이라도 데이터베이스 트랜잭션을 써 봤다면 Clojure 트랜잭션은 금방 이해할 수 있어요. Refs에 가해지는 모든 작업이 원자적(atomic), 일관적(consistent), 격리적(isolated) 으로 처리되는 걸 보장하거든요.
- 원자적: 트랜잭션 안에서 Refs에 일어난 모든 변경은 전부 일어나거나, 하나도 일어나지 않거나 둘 중 하나예요.
- 일관적: 트랜잭션이 커밋되기 전에 각 새 값은 검증 함수(validator)로 확인할 수 있어요.
- 격리적: 트랜잭션이 실행되는 동안 다른 트랜잭션의 효과를 전혀 보지 못해요.
또 STM이 공통으로 갖는 특징이 하나 더 있는데, 실행 중에 충돌이 생기면 자동으로 재시도된다는 점이에요.
STM을 구현하는 방식은 다양해요(잠금/비관적, 잠금 없는/낙관적, 그리고 그 하이브리드까지). 아직 연구 문제로 남아 있는 분야이기도 하죠. Clojure의 STM은 멀티버전 동시성 제어(multiversion concurrency control, MVCC)에 적응형 히스토리 큐를 더해 스냅샷 격리(snapshot isolation)를 구현하고, 뚜렷하게 구분되는 commute 연산을 제공해요.
실제로는 이렇게 보장돼요
구체적으로 말하면 다음을 보장받아요.
- Refs를 읽을 때마다 트랜잭션이 시작된 시점(그 '읽기 지점') 기준의 일관된 스냅샷을 보게 돼요. 트랜잭션은 자기 자신이 만든 변경은 분명히 볼 수 있죠. 이를 트랜잭션 내 값(in-transaction-value) 이라고 불러요.
- 트랜잭션 동안 ref-set, alter, commute로 Refs에 가한 모든 변경은 'Ref 세계' 타임라인의 한 지점(그 '쓰기 지점')에서 일어난 것처럼 보여요.
- 이 트랜잭션이 ref-set/alter/ensure한 Refs에는 다른 어떤 트랜잭션도 변경을 가하지 못해요.
- 이 트랜잭션이 commute한 Refs에는 다른 트랜잭션이 변경을 가했을 수도 있어요. 그래도 괜찮아요. commute가 적용하는 함수는 교환 법칙(commutative)을 만족해야 하기 때문이에요.
- 읽는 쪽과 commute 하는 쪽은 쓰는 쪽, 다른 commute 쪽, 다른 읽는 쪽을 절대 블로킹하지 않아요.
- 쓰는 쪽은 commute 하는 쪽이나 읽는 쪽을 절대 블로킹하지 않아요.
- 트랜잭션은 재시도될 수 있으므로, 트랜잭션 안에서는 I/O나 다른 부수 효과(side effect)가 있는 작업을 피해야 해요. io! 매크로를 쓰면 트랜잭션 안에서 순수하지 않은 함수를 사용하는 걸 막을 수 있어요.
- 변경 중인 Refs 값의 유효성 조건이, 변경되지 않는 다른 Refs의 그 순간 값에 의존한다면, ensure를 호출해 그 두 번째 Refs를 수정으로부터 보호할 수 있어요. 이렇게 'ensure'된 Refs는 항목 3처럼 보호되지만, 항목 2처럼 세계를 바꾸지는 않아요.
- Clojure의 MVCC STM은 영속 컬렉션(persistent collections)과 함께 쓰도록 설계됐어요. 그래서 Refs의 값으로 Clojure 컬렉션을 쓰는 걸 강력히 권장해요. STM 트랜잭션에서 하는 모든 작업은 추측적(speculative)이라, 복사와 수정 비용이 낮아야 하거든요. 영속 컬렉션은 복사가 거의 공짜고(원본을 그대로 쓰면 되고, 바뀔 일이 없으니까요), '수정'도 구조를 효율적으로 공유해요. 어쨌든:
- Refs에 넣는 값은 반드시, 또는 반드시 그렇게 간주돼야, 불변(immutable)이에요! 그렇지 않으면 Clojure가 도와줄 방법이 없어요.
예시: 셔플
아래 예시는 벡터에 대한 레퍼런스들을 담은 벡터를 만들고, 각 벡터에는 (처음에는 순서대로) 고유한 숫자들을 넣어요. 그다음 여러 스레드를 시작해 두 개의 무작위 벡터에서 두 개의 무작위 위치를 골라, 트랜잭션 안에서 서로 바꾸는 작업을 반복해요. 피할 수 없는 충돌을 막으려는 특별한 장치 없이 트랜잭션만 사용하고 있어요.
(defn run [nvecs nitems nthreads niters]
(let [vec-refs (vec (map (comp ref vec)
(partition nitems (range (* nvecs nitems)))))
swap #(let [v1 (rand-int nvecs)
v2 (rand-int nvecs)
i1 (rand-int nitems)
i2 (rand-int nitems)]
(dosync
(let [temp (nth @(vec-refs v1) i1)]
(alter (vec-refs v1) assoc i1 (nth @(vec-refs v2) i2))
(alter (vec-refs v2) assoc i2 temp))))
report #(do
(prn (map deref vec-refs))
(println "Distinct:"
(count (distinct (apply concat (map deref vec-refs))))))]
(report)
(dorun (apply pcalls (repeat nthreads #(dotimes [_ niters] (swap)))))
(report)))
swap은 트랜잭션이니 dosync로 감싸 두 값의 교환이 원자적으로 일어나요. 실행해 보면 셔플 과정에서 값이 하나도 유실되거나 중복되지 않아요.
(run 100 10 10 100000)
([0 1 2 3 4 5 6 7 8 9] [10 11 12 13 14 15 16 17 18 19] ...
[990 991 992 993 994 995 996 997 998 999])
Distinct: 1000
([382 318 466 963 619 22 21 273 45 596] [808 639 804 471 394 904 952 75 289 778] ...
[484 216 622 139 651 592 379 228 242 355])
Distinct: 1000
두 번의 Distinct: 1000이 모두 1000을 출력하는 걸 볼 수 있어요. 백 개의 벡터가 각각 열 개의 숫자로 총 1000개를 담고 있는데, 아무리 많이 섞어도 숫자가 사라지지도, 중복되지도 않는다는 뜻이에요.
관련 함수
- Refs 생성: ref
- Refs 조회: deref (함께 보기:
@리더 매크로) - 트랜잭션 매크로: dosync, io!
- 트랜잭션 안에서만 허용: ensure, ref-set, alter, commute
- Refs 검증자: set-validator!, get-validator
더 알아보기
- Vars와 환경 (Vars and Environments) — 스레드 격리를 통한 가변 저장
- Agents — 비동기적 상태 변경
- 소프트웨어 트랜잭셔널 메모리 (STM) — 개념 배경
- 멀티버전 동시성 제어 (MVCC) - Clojure STM의 기반
- 스냅샷 격리 (Snapshot isolation)