요약
Valen은 참조가 가리키는 경로를 추적해, 여러 참조가 같은 데이터를 고쳐도 안전한지 컴파일할 때 검사합니다.
Vale 언어를 만든 에번 오바디아가 새 언어 Valen의 빌림 검사기(참조를 언제 어떻게 쓰는지 컴파일할 때 따지는 장치)를 소개했습니다. Rust만큼 강력하면서 참조 카운팅이나 가비지 컬렉션만큼 쉬운 메모리 안전성을 찾는 작업의 첫 단계라고 밝혔습니다.
왜 중요한가
- Rust는 데이터를 여럿이 읽거나 한 곳만 고치게 하는데, Valen은 이를 '해제된 메모리를 쓰지 않기'로 완화합니다.
- 그래서 Rust 검사기가 거부하던 올바른 코드, 예를 들어 여러 구조체가 한 하위 시스템을 함께 고치는 코드를 받아들입니다.
- 닉 스미스가 Mojo용으로 제안했던 이 그룹 빌림 방식은 Carbon, Ante 같은 다른 언어들도 받아들이거나 검토하고 있습니다.
핵심 내용
- 참조마다
world.entities[]처럼 자신이 가리키는 대상의 경로를 기억합니다. - 객체를 고치면 그 안쪽을 가리키는 참조만 무효가 되고, 무효인 참조를 쓰면 컴파일 오류가 납니다.
- 함수 선언에 고칠 경로를 밝혀, 월드 전체가 아니라 엔티티 목록만 고치는 함수를 만들 수 있습니다.
- 같은 일을 Rust로 하면 참조를 매번 다시 꺼내야 해서 C++보다 조금 느리고 복잡하다고 했습니다.
- 저자는 Valen의 장점이 속도보다 쓰기 편하고 유연한 데 있다고 했습니다.
HN 반응
- 읽기·쓰기 배타 규칙을 풀면 병렬화가 어려워진다는 반론에, 저자는 Valen도 그 규칙을 쓸 수 있고 공유 수정은
in으로 골라 쓴다고 답했습니다. - 핵심이 그룹보다 경로 추적이니 '경로 빌림'이 더 맞는 이름이라는 저자의 제안에 여러 이용자가 동의했습니다.
글쓴이는 이게 문제인 것처럼 말하지만, 그렇지 않습니다. 이 제약 덕분에 동시성 코드에서 특정 부류의 버그를 아예 신경 쓰지 않아도 됩니다. "fearless concurrency"는 바로 여기서 나옵니다. R^w는 수명(lifetime)과는 상관이 없습니다. 원리상으로는(실제로 가능한지는 모르겠지만) Rust에서 이걸 빼더라도 use-after-free 방지에는 전혀 영향이 없을 겁니다.
물론 이 제약 때문에 완전히 올바른 프로그램도 상당수 쓸 수 없게 되니 더 나은 해법이 있기를 바라지만, 이 제약을 없애는 건 해법이 아닙니다.
그런 부분을 직접 신경 쓰는 쪽(그래서 버그가 생길 수도 있는 쪽)을 택하겠다면 그것도 전혀 문제없습니다. r^w를 요구하지 않는 유연성은 아주 유용하니까요. 저는 그저 잘못 짚은 원인을 바로잡고 싶을 뿐입니다.
분명히 해두자면, Valen의 빌림 검사기도 shared-xor-mutable을 표현할 수 있습니다. 다만 Valen의 강점은 (
in을 통해) 가변 에일리어싱(mutable aliasing)도 함께 표현할 수 있다는 점입니다.어떤 함수가 어느 그룹에 대해 가변 효과(mutable effect)를 갖고 있고, 그 그룹에 에일리어싱되는 다른 그룹을 (
in으로) 선언하지 않았다면, 그 참조는 사실상 유일 참조(unique reference)입니다.어떤 함수가 그룹에 대한 참조를 갖고 있는데 그중 어느 것에도
mut효과를 선언하지 않았다면, 그 참조들은 사실상 공유/불변 참조입니다.이는 특히 Valen의 구조적 동시성(structured concurrency)에 도움이 되고, Rust의 빌림 검사기와 같은 방식으로 단일 스레드 경쟁 상태를 막는 데에도 도움이 됩니다. Rust와의 상호 운용에도 도움이 되고요!
그러니 한 줄로 요약하면, Valen에서는 shared-xor-mutable과 가변 에일리어싱 중에서 고를 수 있습니다.
또 가변 에일리어싱은 (
in을 통해) 직접 선택해야 쓸 수 있게 되어 있다는 점도 눈여겨보세요. 사람들이 shared-xor-mutable 쪽으로 슬며시 기울게 되고, 가변 에일리어싱은 정말 이득이 될 때만 찾게 되는 효과가 있습니다.제가 한 가지 분명히 해두자면, Rust의 빌림 검사기도 내부 가변성(interior mutability), 즉 UnsafeCell<T>와 여러 Cell<T> 타입을 통해 가변 에일리어싱을 표현할 수 있습니다. &Cell<T> 참조는 본질적으로 가변 에일리어싱된 참조입니다. 목표도 정확히 사람들이 "가변 에일리어싱은 정말 이득이 될 때만 찾게 하는 것"이고요. 물론 Cell 타입 주변의 사용성이 개선된다면, 특히 C/C++ 상호 운용에 도움이 된다면 대환영입니다. 다만 기존 borrowck만큼 건전하다(sound)는 것이 증명된다는 전제에서요.
좋은 지적이고 말씀도 잘 하셨습니다. 제가 즐겨 드는 또 다른 좋은 예가 GhostCell인데, Cell보다도 더 유연한 방식으로 Rust에 가변 에일리어싱을 더해 줍니다.
Valen의 진짜 이점은, 함수가 임의의 개수의 GhostToken/브랜드(brand)를 다루고 그 사이의 관계를 추적하는 일을 더 편하게 만들어 준다는 점이라고 생각합니다. (다만 저는 GhostCell 전문가가 아니라서, 틀린 부분은 여기 계신 분이 바로잡아 주시면 좋겠습니다.)
제가 알기로 &Cell<T>의 주된 한계는 (1) 동시성을 전혀 고려하지 않았다는 점입니다. 물론 여러 Atomic* 타입은 내부 가변성으로 비슷하게 동작하지만요. 그리고 (2) 데이터를 읽기, 쓰기, 수정 중 하나의 단일 연산으로만 다뤄야 한다는 점입니다. 하위 객체에 접근하게 해주는 장치 같은 것은 대단히 임시방편적이어서, 다루는 타입마다 따로 구현해야 합니다. GhostCell(그리고 QCell, LCell 등)은 공유 참조의 사용을 컴파일 시점에 추적해서 쓰기가 서로 겹치거나 읽기와 겹치지 않도록 하며, "한 번에 get/set"하는 방식을 강제하지 않는다는 점에서 이를 일반화합니다.
이 문제를 덜어줄 수 있는 언어 기능("field projection")이 현재 작업 중입니다.
글쓴이도 그 점은 이해하고 있다고 생각합니다.
그리고 더 간단히 말해, 글쓴이의 요점은 Valen 프로그램이 Rust 프로그램보다 병렬화하기가 훨씬 어려울 것이라는 얘기라고 봅니다.
Rust에서는 Vec이 나오는 곳마다 Rayon을 끼워 넣기만 해도 단일 스레드 프로그램이 머신의 모든 코어를 쓰게 만들 수 있는데, 이게 놀라울 정도로 쉽습니다. 단일 스레드 프로그램을 돌아가게 만들기 전에, 이미 Rust가 shared^mutable을 증명하는 힘든 일을 먼저 하게 만들기 때문입니다.
이것이 생태계 전체에 미치는 결과로, Rust로 작성된 연산 집약적인 프로그램(연산이 무거운 부분을 Rust 아닌 라이브러리에 맡기지 않는 경우)은 거의 다 자동으로 멀티코어를 씁니다. Rust 프로그래머들이 라이브러리를 찾을 때 가장 먼저 Rust 라이브러리부터 보는 이유 중 하나이기도 합니다. 라이브러리를 도입하느라 한참 공을 들였는데 알고 보니 코어를 하나만 쓴다는 불쾌한 사실을 뒤늦게 알고 낭패를 볼 일이 없다는 걸 아니까요.
이해하지 못해서 죄송합니다. 그리고 추상적인 이야기로는 소통하기가 어려워서, 질문을 아직도 제대로 이해하지 못했을 수 있습니다.
도움이 될까 해서 말씀드리면, 제가 아는 한 Valen의 빌림 검사기는 Rust가 누리는 생태계 전반의 동시성 이점을 그대로 유지합니다. 선례로 GhostCell [0]을 보시면 됩니다. Rust의 동시성과 호환 될 뿐 아니라 흥미로운 새 능력까지 더해 줍니다. Valen의 접근법은 여러 그룹(브랜드) 사이의 관계를 더 잘 추적하는, 더 편하게 쓸 수 있는 GhostCell이라고 생각하셔도 됩니다.
Valen 때문에 코드가 단일 스레드로 묶일 것 같은 예시를 하나 던져 주시면 더 나은 답을 드릴 수 있을 것 같습니다.
Rust가 shared xor mutable을 필요로 하는 이유는, 원자적이지 않게 접근하는 데이터에서 데이터 경쟁을 막기 위해서입니다. Valen이 shared xor mutable을 표현할 수 있다고 하셨는데, 같은 fearless concurrency를 가능하게 하는 Rust의 Send와 Sync에 상응하는 것과도 맞물려 동작하는지는 아직 밝히지 않으신 것 같습니다.
글에서 언어의 이 측면을 다루지 않은 건 맞지만, Send와 Sync는 빌림 검사와는 별개입니다. Valen에서 Rust의 설계를 그대로 쓰지 못할 이유가 없다고 봅니다.
일반적인 Rust 코드에서 락(lock)이 얼마나 널리 쓰입니까?
C 코드에 비하면 아주 드뭅니다.
언어의 안전한 부분집합을 더 제한할수록, 올바른 프로그램도 더 많이 unsafe 부분집합을 써야 합니다. "여기에 버그가 있을 수 있으니 제한하는 편이 더 안전하다"는 논리를 끝까지 밀고 가면 안전한 부분집합이 텅 비게 됩니다. 공간적 메모리 안전성, 그리고 (그보다는 다소 덜하지만) 시간적 메모리 안전성은 이를 어기지 않는 프로그램까지 배제하는 대가를 치르면서도 지킬 만합니다. 범위를 벗어난 접근과 UAF가 심각한 보안 취약점의 원인에서 유독 압도적으로 큰 비중을 차지하기 때문입니다. 동시성은 그렇지 않습니다. Rust의 타입 시스템이 잡아내지 못하는 더 흔한 취약점 원인이 얼마든지 있는데, 경쟁 상태에만 초점을 맞추는 건 임의적으로 보입니다.
그 표현은 득보다 실이 크고, 그걸 계속 되풀이하는 건 Rust 커뮤니티의 흠입니다. 데드락(deadlock)은 Rust가 막아주지 않습니다. 오히려 Rust 입문자들이 데드락에 자주 걸리고, 그중에는 빌림 검사기를 만족시키려다 그렇게 된 경우도 있습니다. 악명 높은 예가 https://fasterthanli.me/articles/a-rust-match-made-in-hell 입니다. 노골적으로 틀린 주장을 해서 Rust 초심자에게 좌절과 고통과 버그를 안겨줄 게 아니라, Rust 커뮤니티는 동시성에 대해 경고하고 초심자가 동시성을 일반적으로도, Rust에서의 방식으로도 공부하도록 안내해야 합니다.
데드락, 메모리 누수, 무한 루프 같은 것들은 모두 Rust가 막아주려 하지 않는 종류의 위험입니다. 제 생각에는 메모리 누수가 있어도 Rust를 메모리 안전하다고 부르는 것이 괜찮듯이, 데드락이 있어도 "fearless concurrency"라고 말하는 건 괜찮습니다.
또 Rust의 타입 시스템 덕분에 Rayon 같은 안전한 인터페이스를 만들 수 있고, 이는 다른 범용 언어에서는 잘 보지 못한 방식으로 진정한 "fearless" 동시성을 가능하게 합니다.
저한테는 "그룹(group)" 빌림이라기보다 "경로(path)" 빌림에 가깝게 들리는데, 아이디어는 정말 훌륭해요! 눈이 번쩍 뜨이네요!
사소한 점인데, 하위 빌림(sub-borrow)에 왜 굳이 "in" 키워드를 써야 했는지 모르겠어요. "entity in world.entities[]" 대신 "entity &world.entities[?]"로 쓰는 쪽이 더 일관되지 않을까요? 그러면 빌림 기호 "&"를 일관되게 쓸 수 있고, 어느 인덱스(범위)가 영향을 받는지에 대한 계약(contract)을 붙일 길도 열릴 테고요.
(안녕하세요 kvark, 오랜만이에요!) 영리한 알고리즘이죠. 참조성 프레임(referentiality frame)을 분해하는 다른 방식이 궁금하시면, 저는 GPU 커맨드 큐 같은 해석 경계를 넘나드는 분석기를 만지작거리고 있어요 :) 글을 좀 정리해야 하는데 말이죠. 이 글에 나온 것 같은 기법은 실제 시스템에서 필요로 하는 것에 비하면 조금 "귀여운" 수준이에요.
그리고 이 글을 쓰는 동안 몇 가지가 더 떠올랐습니다.
entities in world.entities[]대신 함수 매개변수 문법을entities: &world.entities[]로 쓸 수도 있겠습니다."그룹"은 이 아이디어를 이해하는 데 사실 핵심이 아니니, 그룹 빌림(Group Borrowing)보다 경로 빌림(Path Borrowing)이 더 나은 이름일 수 있겠습니다.
의견 환영합니다 =)
참고로 저한테
foo in bar는 경로가 아니라 반복을 떠올리게 해요. 예를 들어 .NET 쪽에서는bar가 순회하는 컬렉션이고, 그 요소를 차례로foo에 대입하는 거니까요.이름 짓기 논쟁에 땔감을 조금 더 보태자면요.
서브트리 빌림(Subtree borrowing).
(main에서 뻗어 나가는 단일 소유권은 소유자들의 트리이고, 각 경로가 그중 하나의 서브트리를 가리킵니다.)
(그나저나 재미있게 읽었습니다.)
"그룹"이 주로 묶음 단위 무효화(grouped invalidation)에 관한 것이라면, 저도 경로 빌림이 더 낫다고 봅니다. 구현 메커니즘이 아니라 핵심 아이디어를 설명하는 이름이니까요.
요즘은 스칼라 자료구조보다 벡터화/SIMD 명령을 쉽게 쓸 수 있게 해주는 언어에 더 집중해야 하지 않을까 싶어요. Vx lang, Mojo, Futhark처럼 CPU와 GPU 양쪽에서 돌아가는 언어들이요. 물론 각자 추상화와 제어의 수준은 다르지만요.
GPU 컴파일은 분명히 Valen이 지원했으면 하는 부분입니다. 2022년에 그 얘기를 썼고 [0], Mojo 컴파일러 작업을 하면서 그걸 어떻게 해야 하는지(그리고 어떻게 하면 안 되는지!)에 대해 많이 배웠습니다.
Valen에서 가장 큰 결정은 무엇으로 낮출(lower) 것인가입니다.
Rust MIR. rustc가 이제 CUDA를 지원하니까요 [1] (다른 카드도 곧 지원할까요?)
SPIR-V. Zig가 GPU 컴파일에서 이걸 씁니다 [2]
MLIR
Rust MIR이 꽤 괜찮아 보입니다. Valen은 이미 rustc에 마법 같은 걸 부려서 Rust 상호 운용을 하고 있고 [3], LLVM을 내보내던 것을 Rust MIR을 내보내는 쪽으로 바꾸는 건 비교적 간단할 겁니다. Valen에 계획해 둔 최적화를 Rust MIR이 지원할 수 있는지만 확인하면 됩니다.
이상적인 세상이라면 Rust가 MIR을 MLIR로 낮출 수 있을 겁니다. MLIR에는 백엔드가 그렇게 많으니까요. 그런 시도가 몇 가지 있었던 걸로 기억하는데, 어떻게 됐는지는 모르겠습니다.
[0] https://verdagon.dev/blog/next-gen-languages-gpu
[1] https://developer.nvidia.com/blog/introducing-cuda-rust-two-tracks-for-writing-gpu-kernels/
[2] https://ziglang.org/devlog/2026/#2026-06-26
[3] https://verdagon.dev/blog/golden-spike-reviving-vale-valen
좋은 동료들이 있으시네요.
https://venge.net/graydon/talks/VectorizedInterpretersTalk-2023-05-12.pdf
링크를 올려서 에번의 글을 가로채고 싶진 않지만, 관심 있으시다면 그 발표의 아이디어 상당수를 제가 만들고 있는 언어에 구현해 두었어요. 어떻게 동작하는지 설명과 데모는 제 글 기록에 있는 최신 링크에서 확인하실 수 있어요.
여기서 편하게 이야기하셔도 됩니다. 꼭 듣고 싶습니다!
조만간 HN에 글을 올릴게요. r/programminglanguages에는 아마 올리지 않을 거예요. 요즘 제 작업에는 그쪽에서 반기지 않을 만큼 AI를 많이 쓰고 있거든요.
mech-lang인가요?
네, 맞아요!
꼭 성공하시기를 바랍니다. 저는 Rust에서 할 수 있는 걸 하나도 빠짐없이 다 하지는 못하더라도, 쓰기 편하고 사용하기 쉬우면서 빌림 검사기가 있고, 그렇다고 원자로를 짓듯 코드를 써야 하지는 않는 작은 언어를 직접 만들어 보고 싶거든요.