1P by GN⁺ | ★ favorite | 댓글 1개
  • Rust의 Pinasync/await가 만든 Future 안에서 자기 참조 상태를 안전하게 다루기 위해 도입된 기반 요소임
  • async Future는 await 지점마다 상태를 저장하므로, 한 객체 안의 필드가 같은 객체의 다른 필드를 참조하는 자기 참조 타입이 될 수 있음
  • move constructor, offset pointer, ?Move 설계는 각각 런타임 추적 비용, 컴파일 가능성, 기존 API와의 하위 호환성 문제로 채택되지 못함
  • 최종 설계는 포인터를 감싸 대상을 pinned typestate로 넣는 Pin이며, Unpin auto trait 덕분에 대부분의 타입은 기존처럼 이동 가능함
  • Pin의 어려움은 불변성 개념 자체보다 라이브러리 타입의 한계에서 오며, reborrowing, Pin::set, pinned projection, Drop과의 상호작용이 사용성을 크게 낮춤

Pin이 필요한 문제

  • Rust async 생태계에서 Pinpinning은 핵심 기반이지만, async Rust를 배우는 사람에게 여전히 어렵고 오해가 많은 영역임
  • Pin의 목적은 안전한 Rust만으로 사용자가 직접 자기 참조 타입을 만들게 하는 것이 아님
    • 컴파일러가 async 함수에서 생성한 자기 참조 Future나 tokio 같은 런타임이 unsafe 코드로 만든 자기 참조 타입을 안전하게 조작하게 하는 데 있음
  • 예시의 async fn barfoo(&mut z).await 지점에서 zz를 참조하는 Foo Future를 같은 Future 상태 안에 함께 저장해야 함
    • 이때 Future 객체 내부의 한 필드가 같은 객체 안의 다른 필드를 참조함
    • 이런 Future 타입이 자기 참조 타입이 됨
  • 객체가 이런 상태에 들어간 뒤 이동하면 내부 참조는 이전 메모리 위치를 가리키고, 그 위치는 죽은 메모리이거나 다른 값에 재사용될 수 있음
  • Pin 이전의 Rust에서는 소유권이 있거나 mutable reference가 있으면 객체를 이동할 수 있었기 때문에, 특정 시점 이후 이동 금지를 표현할 방법이 필요했음

해결책이 되지 못한 접근들

  • move constructor

    • move constructor는 값이 이동될 때 destructor처럼 코드를 실행해 자기 참조 포인터를 새 위치로 고치는 방식임
    • Rust에서는 포인터가 이동되는 값 “안”에만 있지 않고, 예를 들어 자기 상태를 가리키는 포인터들의 벡터 안에도 있을 수 있음
    • 이런 포인터를 모두 추적하려면 결국 가비지 컬렉션과 비슷한 런타임 메모리 관리가 필요함
    • Rust는 초기에 move constructor를 갖지 않기로 했고, 많은 unsafe 코드는 값을 메모리 복사만으로 이동할 수 있다는 가정에 의존함
    • move constructor를 나중에 추가하면 breaking change가 됨
  • offset pointer

    • offset pointer는 자기 참조를 일반 참조 대신 자기 참조 객체 주소 기준의 오프셋으로 컴파일하는 방식임
    • 컴파일 시점에는 어떤 참조가 자기 참조인지 항상 판단할 수 없음
    • 분기마다 같은 값이 자기 객체 내부를 가리킬 수도 있고 외부를 가리킬 수도 있음
    • 이를 처리하려면 참조를 offset과 reference의 enum 같은 형태로 컴파일해야 하며, async/await 작업 당시에는 비현실적인 방식으로 판단됨

pinned typestate의 요구사항

  • 자기 참조 Future는 처음부터 항상 이동 불가능해야 하는 것이 아니라, 생명주기 중 어느 시점까지는 자유롭게 이동하다가 특정 시점 이후부터 이동하면 안 됨
    • Future를 다른 Future와 조합하는 동안에는 이동 가능해야 함
    • poll되는 동안 살 위치에 배치된 뒤에는 더 이상 이동하지 않아야 함
  • Ralf Jung의 모델은 기존의 “owned”와 “shared” typestate에 이어, 자기 참조 Future를 위한 세 번째 상태인 pinned typestate를 추가함
  • 객체가 pinned typestate에 들어가면 다시는 이동하면 안 됨
    • 더 정확히는 destructor를 먼저 실행하지 않고 그 객체의 메모리를 무효화하면 안 됨
    • 실질적으로는 객체를 새 위치로 옮기지 않아야 한다는 요구로 볼 수 있음
  • 대부분의 타입은 자기 참조를 포함할 수 없으므로 pinned typestate가 별다른 의미를 갖지 않음
    • 이런 타입은 pinning의 제약에서 벗어나 다시 이동할 수 있는 편이 바람직함
  • pinned typestate의 자세한 형식 모델은 Ralf Jung의 A Formal Look at Pinning에 정리되어 있음

?Move 설계가 실패한 이유

  • Pin 이전에는 Move라는 새 trait 기반 설계가 시도됨
    • 대부분의 타입은 Move를 구현함
    • 자기 참조를 포함할 수 있는 타입은 Move를 구현하지 않음
    • Move를 구현하지 않는 타입의 값에 참조를 만들면 그 값이 pinned typestate에 들어가 더 이상 이동할 수 없게 함
  • 이 방식은 참조를 만드는 시점과 pinning 전환을 연결해 안전성을 보장한다는 점에서 직관적이었음
    • 실제로 컴파일러 브랜치에 구현되기도 함
  • 근본적인 한계는 나중에 자기 참조가 될 값을 잠깐 참조하되 아직 pinning하고 싶지 않은 경우가 있다는 점임
    • 예를 들어 값을 잠시 Option에 저장한 뒤 Option::take로 꺼내고 싶을 수 있음
  • 더 큰 문제는 하위 호환성이었음
    • Move를 auto trait으로 만들 수 없었음
    • mem::swap처럼 mutable reference에서 값을 항상 이동할 수 있다는 안정 API가 이미 존재했기 때문임
  • ?Move로 추가하는 방식도 associated type 때문에 하위 호환되지 않았음
    • trait의 associated type에 ?Trait bound를 추가하는 위치는 trait 정의부임
    • 기존 trait의 associated type bound를 완화하면 그 bound에 의존하던 코드가 깨질 수 있음
    • IntoFuture의 associated future type, DerefMutTarget, 함수 반환 타입, iterator item, index operator 반환값, 산술 operator 반환값 등 많은 기본 연산이 associated type과 얽혀 있음
  • edition으로도 쉽게 해결할 수 없었음
    • 서로 다른 edition의 crate가 함께 조합되려면 trait 인터페이스가 동일하게 유지되어야 하기 때문임

Pin 설계

  • 최종 설계는 pinned typestate를 객체 타입의 속성이 아니라 특수한 포인터가 만드는 상태로 표현함
  • Pin은 포인터를 감싸는 wrapper type임
    • built-in reference type도 감쌀 수 있음
    • Box 같은 라이브러리 정의 smart pointer도 감쌀 수 있음
  • Pin은 해당 포인터가 가리키는 대상을 pinned typestate로 넣고, 대상은 더 이상 이동하면 안 됨
  • 변경을 최소화하기 위해 이 설계는 컴파일러 기능이 아니라 라이브러리 API로 구현됨
    • pinned 객체를 실제로 변경해야 하는 코드는 unsafe API로 접근해야 함
    • 이때 일반 mutable reference를 통해 객체가 이동하지 않는다는 보장을 제공해야 함
  • 대부분의 타입은 pinned 상태와 일반 상태 사이에 의미 있는 차이가 없기 때문에 Unpin auto trait이 추가됨
    • 타입이 자기 참조일 수 없다면 pinned pointer에서 unsafe 없이 mutable reference를 얻을 수 있음
    • Unpin을 구현한 객체는 Pin 밖으로 이동해도 안전함
  • pinning이 pinned pointer에만 적용되므로 일반 unpinned reference는 Unpin이 아닌 타입과도 계속 동작함
  • 추가 설명은 표준 문서의 Pin 타입과 pin 모듈에 정리되어 있음
  • 이 설계의 가장 큰 장점은 기존 코드를 깨지 않고 추가할 수 있었다는 점임
    • swap처럼 참조된 데이터를 이동할 수 있는 API는 mutable reference가 필요함
    • 객체를 Pin으로 pinning하면 그런 API를 그 객체에 더 이상 호출할 수 없음
    • pinned typestate가 특수한 pinned reference에만 적용되므로 Rust 언어 전체의 하위 호환성 보장을 깨지 않음

Pin의 사용성 문제

  • Pin은 요구사항을 하위 호환 방식으로 만족했지만, 사용자가 직접 다루는 순간 복잡성 절벽이 생김
  • 한 가지 설명은 pinned 객체를 변경하려면 unsafe 코드가 필요하다는 점임
    • 다만 이 문제는 과장하면 안 됨
    • Pin::set으로 pinned 객체에 안전하게 대입할 수 있음
    • 실제로 pinned 객체를 변경해야 하는 코드는 대체로 async 함수를 Future로 낮추는 컴파일러 생성 코드이며, 사용자가 직접 작성하는 경우는 드묾
  • Pin이 조건부로 동작하기 때문에 어렵다는 설명도 핵심 원인은 아님
    • Rust에는 조건에 따라 다르게 동작하면서도 이해를 쉽게 만든 기능들이 있음
    • non-lexical lifetimes는 조건 분기마다 lifetime이 다른 지점에서 끝나도록 만든 사례임
  • 핵심 문제는 Pin이 순수 라이브러리 타입인 반면, 일반 reference type은 언어 내장 타입으로 다양한 문법 지원과 sugar를 받는다는 점임
    • 일반 참조에서 자연스럽게 되던 기능들이 pinned reference에서는 사라짐
    • 사용자가 컴파일러가 받아들이는 참조 동작을 기준으로 만든 mental model이 pinned reference에서 깨짐

reborrowing과 Pin::as_mut

  • 일반 mutable reference &mut TCopy를 구현하지 않지만 같은 인자로 여러 번 넘길 수 있음
    • 컴파일러가 x 대신 &mut *x를 넣은 것처럼 reborrowing을 암묵적으로 수행하기 때문임
  • Pin<&mut T>는 일반 라이브러리 타입이고 Copy를 구현하지 않기 때문에 이런 편의가 없음
    • Pin<&mut T>를 두 번 이상 사용하면 move 이후 값 사용 오류가 나거나, 더 이해하기 어려운 lifetime 오류가 날 수 있음
    • 명시적으로 Pin::as_mut을 호출해 reborrow해야 함
  • 일반 mutable reference에는 dereference와 assignment operator로 바로 대입할 수 있지만, Pin에서는 set 메서드를 배워야 함
    • 이런 특수 API가 많아지는 이유는 Pin이 언어 문법 지원 없는 라이브러리 타입이기 때문임

pinned projection과 Drop

  • pinned projection은 객체에 대한 pinned reference에서 그 객체의 필드에 대한 pinned reference를 얻는 문제임
    • projection은 객체에서 필드로 접근하는 것을 뜻함
  • 일반 참조의 필드 접근보다 훨씬 어렵기 때문에 pin-project-lite 같은 서드파티 crate가 사용됨
    • 이런 crate는 macro를 포함한 복잡한 새 API를 배워야 함
  • 가장 나쁜 상호작용은 pinned projection과 Drop trait 사이에서 발생함
    • Drop::drop은 일반 mutable reference를 받음
    • 어떤 타입에 자기 참조 필드가 있고, 그 필드로 pin project해 poll한 뒤 destructor에서 그 필드를 이동시키면 pinning 보장을 깨뜨릴 수 있음
    • 예를 들어 destructor 안에서 그 future를 stack에 pinning하고 poll하면 기존 pinning 보장이 위반됨
  • pin-project-lite 같은 crate는 destructor 정의 능력을 제한하는 방식으로 이 문제를 다룸
    • 실무적으로는 동작하지만, pinning 보장을 설명할 때 문서화해야 할 복잡성이 추가됨
    • DropPin보다 먼저 stable이었기 때문에 우회가 필요했음

현재 평가와 다음 개선 방향

  • Pin은 임의의 참조를 포함한 async 함수를 안전한 자기 참조 객체로 컴파일할 수 있게 만들었음
    • 참조는 Rust 사용자가 코드를 작성하는 기본 방식의 중요한 부분이므로, 이것이 없었다면 async/await의 사용성은 크게 낮아졌을 것임
  • 동시에 Pin은 기존 Rust와 완전히 하위 호환되는 방식으로 추가됨
  • Pin은 고성능 네트워크 서비스와 비동기 프로그래밍의 다른 사용 사례를 지탱하는 생태계의 기본 구성 요소가 됨
  • 하지만 pinned reference를 다루는 일은 ordinary reference를 다루는 것보다 훨씬 어렵고, Pin은 실제로 복잡성 절벽을 만듦
  • 다음 개선 방향의 핵심 개념은 pinned places

댓글과 토론

Hacker News 의견들
  • Pin은 공식 문서에서 명확히 설명되지 않아 이해하기 어렵다고 늘 생각해 왔음
    특히 “Pin은 객체가 절대 이동하지 않도록 보장한다” 같은 설명이 많은데, 사실이 아님
    객체가 Unpin이 아닐 때만 맞는 말이고, 대부분의 일반 객체는 Unpin이라서 Pin은 보통 아무 일도 하지 않음
    이걸 이해하는 데 아주 오래 걸렸고, Pin이 실제로 의미를 갖는 타입 T의 집합은 꽤 특수하고 이상한데 문서가 이를 충분히 강조하지 않는다고 봄

    • 좋은 피드백이고, 문서가 이 부분을 더 명확히 하면 좋겠음
      물론 실제로 고정된 상태로 다루게 될 타입인 퓨처와 스트림은 그런 특수한 객체일 가능성이 훨씬 큼
      그래도 문서는 몇 년 사이 많이 좋아졌다고 생각함
      이 글을 쓰며 확인했을 때는 꽤 적절한 지점에 집중하고 있어서 놀랐고, 2019년쯤에는 std API 문서보다는 Rust 참조 문서에 들어갈 법한 계약 명세에 훨씬 더 치우쳐 있었다고 기억함
  • 사용자가 Pin을 어려워하는 이유는, Pin 자체만으로는 의미가 없기 때문이라고 봄
    이는 언어의 다른 래퍼들과 다르고, 예외라면 원래 용도로는 거의 아무도 쓰지 않는 AssertUnwindSafe 정도임
    Pin<&mut InnerType>이 있을 때, 언어나 표준 라이브러리 안의 Pin만으로는 무엇을 할 수 있고 없는지 알려주는 것이 없음
    InnerTypeUnpin이라고 선언하면 일반 포인터로 할 수 있는 일은 다 할 수 있다는 뜻이 됨
    대신 Pin은 “의미를 직접 가져오라”는 방식으로 동작하며, InnerType 제공자가 고정된 객체를 안전하게 조작하기 위한 내부적으로 unsafe한 메서드와 API를 추가로 만드는 구조임
    Pin 자체의 목적은 &mut 교체, Box에서 꺼내 이동하기 같은 내재적 능력이 더 적은 포인터를 제공해서, 내부 타입이 그 위에 추가 능력을 안전하게 허용할 수 있게 하는 데 있음
    이 의미의 모호함이 사람들을 가장 혼란스럽게 만든다고 생각하고, 나도 이해하는 데 꽤 오래 걸렸음
    구조적 필드와 비구조적 필드에 관한 개념은 “이 필드는 평범한 데이터지만, 저 필드는 자체적으로 고정되기를 원하는 객체를 담고 있다” 같은 흔한 접근 패턴을 가능하게 하려는 장치일 뿐임

    • Pin에는 의미가 있음. 대상 타입이 Unpin을 구현하지 않는 한, 이 포인터의 대상은 다시는 이동할 수 없다는 뜻임
      더 정확히는 소멸자를 실행하지 않고 대상을 무효화할 수 없다는 뜻이고, 이동이 문제 되는 이유도 거기에 있음
      특정 권리를 포기하면 자기 참조 값을 저장할 권리 같은 다른 권리를 얻음
      컴포넌트 간 계약이 대체로 이런 식으로 동작함
      마찬가지로 참조를 통해 변경할 권리를 포기하면 동시에 그 참조를 별칭으로 만들 수 있음
      이 점을 생각할 때마다 전혀 다르고 훨씬 무거운 주제이긴 하지만 영화 Lincoln의 대사, “우리가 법에 복종한다면, Alex, 자유를 잃는 데까지 복종한다면 — 예컨대 억압할 자유를 — 이전에는 알지 못했던 다른 자유를 발견할지도 모른다”가 떠오름
      다만 안전한 코드에서는 그런 권리를 직접 사용할 수 없다는 점이 교육적으로 문제라는 데는 동의함
      고정된 참조로 무엇을 할 수 있는지 “컴파일러가 만들어 준 poll 메서드를 호출한다” 말고는 쉽게 보여주기 어렵기 때문임
  • Rust로 몇 년간 전문적으로 개발해 왔지만, 솔직히 Pin을 그렇게 잘 이해하지 못함
    이론은 알겠는데 언제 써야 하는지에 대한 직관은 별로 없음
    Pin 사용은 사실상 “뭔가 해봤더니 컴파일러가 뭐라고 해서, 이것저것 고정했더니 컴파일된다”에 가까움
    매일 해야 하는 코딩에서 정말 앉아서 깊이 이해해야 할 정도의 장애물이 된 적은 아직 없음

    • 나도 같음. “그냥 unsafe는 피하고, 똑똑한 컴파일러 사람들이 이미 다 해결해 줬다는 데 감사하자”에 해당하는 가장 흔한 경우 중 하나임
      반면 C++에서는 “모르지만 반드시 써야 하는 것들”의 물가를 자주 걷다가 악어에게 잡아먹히곤 했음
  • 가르칠 때 Unpin 항목은 Pin의 영향을 받지 않는다는 점을 분명히 하려면, 제자리에 붙잡아 두기 위해 만들어진 도구를 써도 영향을 받지 않는 현실의 비유를 쓰면 좋겠음
    벨크로 갈고리는 매끄러운 표면에 붙지 않음: Pin → 벨크로, Unpin → 매끄러운 표면
    자석은 비자성 물질에 영향을 주지 않음: Pin → 자석, Unpin → 비자성/유리/황동
    접착제는 논스틱 표면에 붙지 않음: Pin → 접착제, Unpin → 논스틱
    이렇게 하면 “벨크로”가 물건을 제자리에 고정하지만, 물건이 “매끄럽다면” 벨크로 메커니즘의 영향을 받지 않는다는 점이 분명해짐
    Rust 생태계의 이름 붙이기 분위기를 생각하면 트레이트 이름을 자석과 비자성 같은 쪽으로 바꿨다면 아름다웠을 듯함

    • 하지만 매끄러운 물체는 벨크로로 붙일 수 없고, 나무는 자석을 붙잡아 둘 수 없음
      Unpin은 객체가 언제든 고정될 준비가 되어 있다는 뜻 아닌가 싶음
      어젯밤에 글을 읽었는데, 고정에 보정 단계가 필요한지는 벌써 잊었음
      그래서 T: Pin + !Unpin은 스테이플로만 고정할 수 있는 종이 같은 것이고, T: Pin + Unpin은 고리가 달린 그림이라 못에 걸었다가 고리를 망가뜨리지 않고 다시 내릴 수 있는 것에 가깝다고 봄
  • “값 정체성”이라는 용어가 이 글 어디에도 정의되어 있지 않고 Mojo 문서에서도 찾지 못해서, Modular가 Mojo가 Pin이 해결하려는 문제를 해결한다고 말하는 근거가 분명하지 않음
    나도 답을 안다고 주장하는 건 아니지만, Chris Lattner와 함께 Swift의 값 의미론을 작업했던 Dave Abrahams의 훌륭한 발표가 떠오름
    발표 제목은 “Value Semantics: Safety, Independence, Projection, & Future of Programming”임
    [0] https://www.youtube.com/watch?v=QthAU-t3PQ4

    • Mojo가 어떤 의미에서는 Swift의 값 의미론 개념을 물려받은 건 분명하지만, Rust도 같은 의미의 값 의미론을 갖고 있음
      Rust는 참조도 일급 타입으로 갖고 있는 반면, Swift와 내가 보기엔 Mojo는 참조를 매개변수 전달 방식으로만 허용함
      Mojo는 Swift의 inout 매개변수를 확장해 불변 참조 전달 방식도 둔 것으로 보임
      객체 안에 참조를 저장할 수 없게 하면 Rust가 컴파일하는 식의 코드를 구현할 수 없으므로 “자기 참조 구조체” 문제를 해결하긴 함
      하지만 인용된 문단이 Mojo에 대해 말하는 내용은 전혀 그런 이야기가 아니라서, 무슨 뜻인지 꽤 혼란스러움
  • 내가 보기엔 문제는 어떤 값에 대한 &mut 참조가 있으면 mem::swap/replace 같은 것으로 그 값을 이동할 수 있다는 데 있음
    하지만 실제로 그렇게 해야 하는 경우는 드묾
    그것이 허용되지 않았다면 자기 참조 값에 대한 &mut 참조를 가져도 완전히 안전했을 것 같음
    필요할 때만 참조를 통한 이동을 명시적으로 선택하는 방법이 있었을 수도 있고, swapreplaceunsafe로 만들었다면 이 문제 전체를 피할 수 있었을지도 모름
    누군가 이 설계 공간을 탐구해 줬으면 좋겠음

    • 맞음. 이 문제를 작업하던 당시 Aaron Turon은 &mut너무 강력하다고 표현했음
      &mut이 그 안의 값을 이동시킬 권한을 주지 않았다면 전체 설계는 훨씬 단순했을 것임
      다음 글에서 이 내용을 다룰 예정임
      Rust는 하위 호환성을 지켜야 하고 이미 &mut에서 값을 이동할 수 있다고 결정했지만, 과거 결정에 묶이지 않는다면 훨씬 깔끔한 설계가 분명 가능함
    • 이건 맞지만 확장성이 없음. 기존 코드를 너무 많이 깨뜨리기 때문에 애초에 성립하기 어려웠을 것임
      mem::swap은 가변 참조를 통해 값을 이동하는 방법 중 하나일 뿐이고, 다른 방법도 아주 많음
      Option::take는 내가 꽤 자주 쓰는 예인데, 이게 unsafe라면 정말 이상할 것임
  • 이 배경 이야기를 보니 좋음. WithoutBoats는 이미 매우 시의적인 비동기 반복자, poll, pin 주제로 활발한 논의를 많이 해 왔음
    https://news.ycombinator.com/from?site=without.boats
    언어의 세세한 내부를 이렇게 깊게 공개적으로 파고드는 커뮤니티는 많지 않은 것 같고, 보는 재미가 큼

    • 멋지긴 하지만 그만큼 언어 개발이 아주 느리다는 뜻이기도 함
      비동기는 아직 반쯤 덜 익었고 매우 복잡함
      지난 3년 동안 주 40시간 Rust 코드를 써 온 입장에서 하는 말임
  • 이동 생성자가 있고, 생성되는 모든 Future 하위 타입이 불투명하며 자동으로 힙에 할당되는 Rust 비슷한 언어를 상상할 수 있음
    그러면 사용자가 그것을 파괴할 방법이 없고, 불투명하며 힙의 다른 곳에 있으니 이동할 방법도 없어서 Pin이 필요 없어질 수 있음
    이동 생성자가 있다는 건 이동이 개념적으로 파괴 후 재생성이라는 뜻이기 때문임

    • Pin은 데이터 자체의 속성이라기보다 상태
      이 덕분에 실행 전에 Future들을 병합하고 인라인화할 수 있는 좋은 효과가 있음
      Rust의 불변성과도 비슷함. 불변 메모리가 있는 게 아니라 불변 참조만 있는 것임
    • 모든 퓨처가 힙에 할당된다면 이동 생성자는 필요 없음
      하지만 그러면 비동기 함수 호출마다 별도 할당이 생기고, 이는 메모리 지역성에 매우 나쁨
      어떤 형태의 가상 스택이 그보다 훨씬 낫겠지만, 스택을 기본적으로 작게 최적화하려면 결국 가비지 컬렉션이 필요함
    • 얼마나 파괴적인 변화일지는 짐작되지만, Rust가 정면으로 받아들이고 Copy와 비슷한 수준으로 언어에 내장된 Move 트레이트를 std에 추가했으면 정말 좋겠음
      Move는 값을 메모리의 한 주소에서 다른 주소로 이동하는 함수를 정의하고, impl Move가 없는 구조체는 이동할 수 없게 하는 것임
      거의 모든 타입은 #[derive(Move)]를 붙이고, 이는 바이트를 복사하는 단순 이동 함수를 구현하면 됨
      하지만 이렇게 하면 자기 참조 타입, 퓨처, 그리고 더 복잡한 이동 동작이 필요한 많은 것들의 길이 열림
      실제로는 CopyClone의 차이를 반영해 두 트레이트로 나누는 편이 더 말이 될 수도 있음
      하나는 바이트를 그냥 옮겨도 된다고 컴파일러에 알려주는 마커 트레이트이고, 다른 하나는 사용자 정의 “이동 생성자” 구현을 허용하는 방식임
      Pin은 너무 이해하기 어려워서 Move가 있으면 좋겠음
      복잡한 개념이 이중 부정, 때로는 삼중 부정으로 감싸져 있음. fn(...) 같은 걸 보면 “뭐지?” 싶고, unsafe한 pin 프로젝션쯤 가면 떨어져 나감
      언제 안전하고 언제 안전하지 않은지 모르겠고, 그냥 손을 떼게 됨
      Move 없는 Rust에서 Move 있는 Rust로 옮기는 건 불편할 것임
      지금까지 작성된 거의 모든 구조체에 #[derive(Move)]를 추가해야 하고, std도 마찬가지임
      기존 에디션에서 고정되지 않은 모든 타입에 대해서는 컴파일러가 Move 트레이트 구현을 추론해야 함
      기계적으로 가능하겠지만 일이 많을 뿐임
      비동기 Rust는 끔찍함. 거의 다른 모든 언어의 퓨처/프로미스와 비교하면 특히 그렇음
      언젠가 누군가 Rust의 메모리 안전 모델을 개선하고, Move 트레이트와 더 나은 퓨처를 가진 새로운 Rust 비슷한 시스템 언어를 만들 것임
      개인적으로는 Rust의 매크로 시스템 대신 컴파일 타임 실행도 있었으면 좋겠음
      Rust를 좋아하고, 팀이 수년간 해 온 모든 작업도 좋아함
      하지만 정말 기대하는 언어는 Rust 다음에 올 언어임
      같은 아이디어지만 Rust의 실수에서 배운 언어이고, 그런 더 나은 Rust류 언어가 어떤 모습일지 점점 분명해지고 있음
      정말 기다려짐
  • WithoutBoats의 또 다른 훌륭한 글임
    솔직히 이건 Rust에서 비동기 런타임 내부에 추상화되어 묻혀 있어서 다행이라고 느끼는 구석 중 하나임
    그래도 커스텀 Future 구현 말고 Pin을 실제로 어디서 쓰는지 궁금함

    • 나도 궁금함
      표현만 보면 FFI 안에서 쓸 수 있을 것 같기도 함
      예를 들어 extern 함수가 *mut T를 반환해서 포인터를 받는다면, Pin<&mut T>로 감싸 더 나은 의미론을 줄 수 있을 것처럼 보임
      그런데 글에서는 “고정된 타입 상태에 관한 또 다른 사실은 대부분의 타입에는 완전히 무관하다는 것이다. 타입의 값이 자기 참조를 절대 포함할 수 없다면, 그것을 고정하는 것은 쓸모없다”고 말함
      FFI는 아직 아주 초보라서, 안전한 Rust로 감싸는 가장 좋은 방법을 이해하고 싶음
    • FFI에서 C API가 항목을 참조가 아니라 포인터로 노출해서 이동하면 안 되는 경우가 있음
      주소에 의존하는 시스템 타입과 상호작용할 때도 해당됨
      예컨대 일부 운영체제의 mutex/futex에서 커널 문서가 사용자 공간 잠금 객체는 초기화 후 주소가 바뀌면 안 된다고 말하기 때문에, std가 Pin에 해당하는 것을 쓰는 것으로 알고 있음
      잠겨 있지 않을 때도 주소가 바뀌면 안 된다는 점이 특이함
      보통은 잠긴 동안만 조건이 되고, 그 경우에는 참조가 걸린 대상을 이동할 수 없으므로 Pin이 필요 없음
  • 진짜 문제인 스레드의 비효율성을 고치지 않으려고 엄청난 작업을 하는 것처럼 보임
    모든 비동기 코드는 전부, 예외 없이, 상태 관리를 위한 많은 문법 설탕으로 경량 스레드를 구현하는 해킹임
    Rust 같은 언어에서는 원래 없어도 될 복잡성을 엄청나게 추가함
    스레드의 효율성과 확장성 문제를 고치면 이 모든 게 사라질 것임
    뿅 하고 없어짐
    Java 같은 언어에서 null이 “조 단위 비용의 실수”였던 것과 비슷함
    하나의 설계 결정, 혹은 여기서는 설계 부재 때문에 엄청난 복잡성이 생김

    • 스레드는 합리적인 방식의 취소를 지원하지 않음
      취소는 네트워크 애플리케이션과 GUI에서 매우 유용함
      스레드는 CPU와 네트워크를 둘 다 충분히 활용하되 어느 쪽도 과도하게 점유하지 않도록 만들기 어렵게 함
      스레드 풀 사이에서 작업을 넘기기 시작하면 퓨처를 다시 구현하는 길에 들어선 것임
      아니면 콜백/이벤트로 작업하게 되는데, 코드가 조각나고 async/await가 바로 그 문법 설탕이 되려고 했던 대상임
      취소와 타임아웃의 대안은 Go처럼 Context 객체를 코드 전체에 엮는 것인데, 말단 코드가 Context를 제대로 따르지 않는 함수를 순진하게 호출하는 문제가 생김
      이는 비동기 코드 안의 비비동기 함수 문제보다 아주 조금 나을 뿐임
    • “스레드의 효율성과 확장성 문제를 고치기” 위해 Linux 커널을 다시 쓰는 것이 가능한지 의심스럽고, 가능하더라도 Pin이 동작하도록 만든 Rust 전문가들과 그런 일을 할 수 있는 커널 전문가 집합은 아마 같지 않을 것임
      그래서 구체적으로 무엇을 했어야 한다는 건지 궁금함
      그냥 손을 들고 “언젠가 누군가 Linux를 고쳐서 스레드를 마법처럼 빠르게 만들 수도 있으니, 우리 언어에는 비동기를 추가하지 않겠다”고 했어야 하나?
    • 동시 프로세스와 동기화하는 함수와 그렇지 않은 함수를 구분해 표시하는 것은 실제로 좋은 일임
    • 안타깝게도 사용자 공간 경계를 넘는 데는 “스레드”가 아무리 가벼워도 비용이 듦
      또한 운영체제를 모든 비동기 작업의 스케줄러로 만들면, 모든 런타임이 운영체제 스케줄러를 써야 하므로 다양한 스케줄러 설계가 불가능해짐
    • Rust가 한 해로운 결정들은 이전 실수를 무슨 수를 써서라도 밀어붙이는 문화가 깊이 배어 있음을 보여줌
      “선호하는 접근”이 실행 불가능하다는 게 드러난 뒤에도 비용/편익을 다시 평가하지 않는 것처럼 보임
      “기능 X를 원한다, 결과는 알 바 아니다”는 언어 설계에서 좀처럼 이기는 수가 아님