move 추적: 건넨 뒤의 사용을 잡기

값이 스레드를 넘어도 되는가는 구조적이지만, 이미 넘었는가는 흐름의 문제다. move 추적은, 다른 스레드에 건넨 능력을 그 뒤 쓰는 것을 잡는다. 설계의 교묘함은, 그것이 얼마나 적게만 하면 되는가에 있다: 소비되는 것은 owned cap뿐, 대여는 다른 데서 다뤄진다, 그래서 borrow checker가 아니라 작은 흐름 pass. 소박한 판이 깨지는 세 방식을, 저마다 보수적으로 닫는다.

mereconcurrencymoveborrowflow-analysislanguage-design

지난 회는 술어를 건전하게 했다: 값을 받아, 타입 시스템은 그것을 다른 스레드로 move해 안전한지를 정할 수 있다. 그것이 보장의 구조적 절반이다. 다른 절반은, 구조가 아니라 시간에 대한 물음이다: 값이 일단 다른 스레드에 건네지면, 그 뒤 그것을 쓰는 것은 에러여야 한다. 자식 스레드에 move된 값은 더는 부모에 속하지 않고, 부모에서 그것에 닿는 것은 use-after-move다. 그것을 잡는 것은 흐름 분석이고, 이번 회는, 그것이 얼마나 작게 되었는가, 그리고 그 작은 판이 하마터면 틀릴 뻔한 세 방식에 대한 것이다.

왜 거의 아무것도 하지 않아도 되는가

Rust에서 오는 본능은, 이것에 borrow checker —— loan과 lifetime의 유명하게 복잡한 기구 —— 가 필요 하다는 것이다. 필요 없다. 그리고 왜인지를 보는 것이, 설계 전체의 열쇠다. 두 좁힘이 문제를 접는다.

첫째, move로 실제 소비되는 것은 거의 없다. 새 스레드에 건네진 클로저가 값을 포획할 때, 그 값은 지난 회의 술어가 정하는 세 경우 중 하나에 떨어진다: 공유 가능(Sync)한 값은 공유되고, 소비되지 않는다; 보낼 수 없는(!Send) 값은 단적으로 거부된다; 보낼 수 있지만 공유할 수 없는 값 —— owned cap, 단일 소유자를 가진 것의 좁은 부류 —— 만이 진짜로 move되고, 그래서 소비된다. Trivial한 값은 그저 복사된다. 그래서 흐름 분석이 좇아야 하는 것은, 작은 binding의 집합뿐이다: owned cap.

둘째, 대여는 누군가 다른 이의 일이다. 누가 무엇을 참조해도 되는가의 기구 전부는, 메모리와 이펙트의 Part에서 이미 세워졌다 —— region, Trivial 제약, 가변 대여의 배타 규칙. move 추적은 그 어느 것도 다시 도출할 필요가 없고, move만 좇으면 된다. loan 없이, lifetime 없이, 그저 “이 binding은 건네졌다; 그 뒤 누가 썼는가?”. 결과는 작은 pass, 수백 줄이지, 풀 borrow checker가 그럴 큰 서브시스템이 아니다 —— 또다시, feature가, 다시 세우는 것이 아니라 이미 세워진 기구에 기대기 때문이다.

소박한 판이 깨지는 세 방식

trial 판은 move를 변수 이름의 글로벌 집합으로 좇았다: 값을 move하고, 그 이름을 집합에 더하고, 그 뒤 그 이름으로의 이후 참조를 표시해 낸다. 이것은 shadowing 없는 직선 코드에는 옳고, 다른 어디서나 틀리다. 세심한 리뷰가, 그것이 깨지는 꼭 세 방식을 파냈고, 그 저마다가 진짜 pass를 빚었다.

shadowing. let l = A in let l = B in ...에서, 이름 l은 두 다른 binding을 가리키고, 이름 기반의 집합은 그것들을 구별할 수 없다 —— 둘째를 move하면 집합은 첫째도 사라졌다고 여기거나, 그 반대다. 분기. 값이 if의 한 팔에서 move되고 다른 팔에서 안 되면, 그 뒤 그것은 쓸 수 있는가? 단일한 평평한 집합은 “한 경로에서 move됨” 개념을 갖지 않는다. 재귀. 한 번보다 많이 도는 함수 본체 안에서 move된 값은, 한 번보다 많이 move된다 —— 직선 추적이 결코 예기하지 않는 double-move다.

셋 모두를 고치는 pass

진짜 분석은, 타입 추론의 에 도는 독립 pass다. unification이 모든 타입을 해결한 뒤 —— 무엇이 Send && !Sync인지를 정하려면 최종 타입이 필요하고, 그것을 추론에 얽으면 흐름 분석이 unification에 얽히기 때문. 그 시점에 돌면, 해결된 타입과 완전한 어휘 구조를 한 번에 본다. 세 설계 선택이 세 파탄에 답한다.

shadowing에 대해서는, 모든 binding이 새 identity를 얻는다 —— 각 let, 각 함수 파라미터, 각 패턴 binder에서 주조되는 id —— 그리고 소비 집합은 이름이 아니라 id를 쥔다. 둘 다 l이라 불리는 두 binding은 단지 두 다른 id이고; shadowing은 녹는다, 검사기가 애초에 그것들을 혼동하지 않았기에. 그것은 언어 전체를 관통하는 같은 본능 —— 하나의 이름이 두 뜻을 나르면, 별개의 identity를 주어라 —— 이, 이제 move 검사기 안에서 적용된 것이다.

분기에 대해서는, pass는 흐름 감응적이고 보수적이다. 소비 집합을 프로그램에 왼쪽에서 오른쪽으로 꿰고; ifmatch에서, 각 가지를 같은 시작 집합에서 돌리고, 그다음 union으로 merge한다 —— 어느 가지에서든 move된 binding은, 그 뒤 move된 취급이다. 그것은, move가 취해지지 않은 경로에서만 일어났다면, 실제로는 문제없는 프로그램을 거부할 수 있지만, 건전하지 않은 것을 결코 받아들이지 않는다. 정밀함보다 건전성이 의도한 기본값이다: 틀린 것을 허용하기보다, 불확실한 것을 거부한다.

재귀에 대해서는, pass는 그저, 되풀이 돌 수 있는 함수 안에서 바깥 scope에서 포획된 값을 move하는 것을 거부한다. 반복 1에서 move된 값은 반복 2에서는 사라졌기 때문이다. 그것은, 나중에 완화될 수 있는 선의 안전한 쪽이다 —— “많아야 한 번 돈다” 개념이 있으면 이것들의 일부를 허용할 것이다 —— 하지만 지금은 정직하고 보수적인 답은 no다.

이음매는 정직하게 남긴다

한 상호작용이 일부러 절반 남겨지고, 숨기는 것이 아니라 이름 붙여진다. with 블록이 정리의 책임을 가진 능력이 spawn된 스레드에 move될 때, 부모는 그 정리를 돌려서는 안 된다 —— 책임은 자식에게 이양되었다. move 추적은, 부모가 move된 능력을 쓰는 것을 올바르게 금하지만, 정리의 의무의 자식으로의 실제 이양은, 뒤 단계로 미뤄진 코드 생성의 일이다. 건전한 임시방편은, pass의 다른 것이 하는 보수적 수 그 자체다: 정리 이양이 구현될 때까지, with 결속의 능력을 스레드에 move하는 것을 그저 거부한다. 자원을 두 번 닫는 것을 뱉는 것보다, valid한 프로그램을 거부하는 편이 낫다.

지난 회와 합쳐, move의 보장은 이제 완전하다: 술어가 값이 스레드 경계를 넘어도 되는가를 정하고, 이 pass가 이미 넘었는가를 좇고, 그래서 능력을 스레드에 건네고 그 뒤 닿는 것은, 런타임의 경쟁이 아니라 컴파일 시간의 에러다. 리뷰가 찾은 건전성의 틈이 아직 하나 있다. 그것은 흐름이 아니라, 아직 확정되지 않은 타입에 대한 것이다 —— 요소 타입이 아직 변수인 채널, 그것을 통해 보낼 수 없는 값을 밀수할 수 있다. 그것을 닫는 것은, 타입 추론 그 자체에 Send를 가르치는 것을 뜻한다. 다음 회: 다형 채널과 Send 바운드.

← Back to Mere: 언어를 만들다