1 điểm bởi GN⁺ 4 giờ trước | 1 bình luận | Chia sẻ qua WhatsApp
  • std::pin::Pin biểu thị một đảm bảo ở cấp kiểu rằng giá trị mà con trỏ trỏ tới sẽ không bị di chuyển thông qua con trỏ đó; điều này cần thiết cho các giá trị phải có địa chỉ ổn định, chẳng hạn như các kiểu tham chiếu đến chính bên trong của chúng
  • Trong async/await, các biến cục bộ và tham chiếu còn sống qua .await có thể trở thành trường của máy trạng thái do compiler tạo ra, nên Future::poll yêu cầu Pin để ngăn future bị di chuyển sau khi đã bắt đầu poll
  • Pin ngăn việc di chuyển giá trị đã được pin bằng code an toàn, nhưng không cấm các thay đổi thông thường; nếu T: Unpin không đúng thì không thể lấy &mut T ra khỏi Pin một cách an toàn
  • Hầu hết kiểu trong Rust mặc định là Unpin, vì vậy các struct tự tham chiếu không được phép di chuyển thường phải thêm trường PhantomPinned để trở thành !Unpin
  • Trong thực tế, khi trực tiếp poll future hoặc truyền vào API yêu cầu pinned future, bạn dùng Box::pin hoặc std::pin::pin!; còn khi tự triển khai Future hay primitive async cấp thấp, bạn phải xử lý cả các bất biến unsafe

Vì sao cần Pin

  • std::pin::Pin là một wrapper con trỏ, biểu thị đảm bảo rằng giá trị mà con trỏ trỏ tới sẽ không bị di chuyển thông qua con trỏ đó
  • Vấn đề cốt lõi xuất hiện ở kiểu tự tham chiếu
    • Struct ví dụ SelfRefdata: i32ptr: *const i32, trong đó ptr trỏ tới self.data
    • Nếu instance của struct được di chuyển sang biến khác hoặc được trả về từ hàm, địa chỉ bộ nhớ có thể thay đổi
    • Con trỏ thô ptr vẫn trỏ tới vị trí bộ nhớ cũ và trở thành con trỏ treo
  • Sau khi tự tham chiếu đã được thiết lập, cần có một cơ chế ngăn giá trị đó bị di chuyển lần nữa

Vấn đề phát sinh trong async/awaitFuture

  • async/awaitFuture là lĩnh vực tiêu biểu nơi Pin xuất hiện thường xuyên
  • Các biến cục bộ còn sống qua điểm .await sẽ trở thành trường của máy trạng thái do compiler tạo ra
  • Nếu tham chiếu tới một biến cục bộ nào đó cũng còn sống qua cùng điểm .await, future được tạo ra có thể mang tính tự tham chiếu
  • Sau khi polling bắt đầu, future có thể phụ thuộc vào các tham chiếu trỏ tới những trường khác bên trong chính nó
    • Nếu future bị di chuyển trong trạng thái này, các tham chiếu đó sẽ mất hiệu lực
  • Để ngăn điều đó, Future::poll nhận Pin thay vì &mut self
pub trait Future {
    type Output;
    fn poll(self: Pin, cx: &mut Context Pin {
      pub const fn get_mut(self) -> &'a mut T
      where
          T: Unpin
      { ... }
  }
  • Nếu kiểu là !Unpin, tức không triển khai Unpin, thì chỉ bằng code an toàn sẽ không thể lấy &mut T
  • Trong trường hợp này phải dùng phương thức unsafe như Pin::get_unchecked_mut, và code phải giữ lời hứa rằng giá trị sẽ không bị di chuyển ra ngoài tham chiếu đó

UnpinPhantomPinned

  • Kiểu triển khai Unpin không phụ thuộc vào pinning để đảm bảo an toàn bộ nhớ
// std::marker
pub auto trait Unpin {}
  • Phần lớn kiểu trong Rust có thể bị di chuyển mà không gặp vấn đề, nên mặc định là Unpin
    • Ví dụ: i32, String, Vec
  • Unpin được tự động triển khai cho mọi kiểu, trừ khi kiểu đó được đánh dấu rõ ràng là !Unpin
  • std::marker::PhantomPinned là một marker struct được đánh dấu rõ ràng là !Unpin
    • Vì auto trait được lan truyền tự động, struct chứa trường PhantomPinned cũng tự động trở thành !Unpin
use std::marker::PhantomPinned;

struct SelfRef {
    data: i32,
    ptr: *const i32,
    _phantom: PhantomPinned, // makes the entire struct !Unpin
}
  • Đây là cách tiêu chuẩn để khai báo rằng một struct do người dùng định nghĩa sẽ không an toàn nếu bị di chuyển sau khi đã được pin
  • Compiler thường không thể tự động phát hiện các tự tham chiếu được tạo bằng con trỏ thô unsafe
  • Vì vậy developer phải từ bỏ Unpin một cách rõ ràng cho các struct tự tham chiếu
    • Thông thường xử lý bằng cách thêm trường PhantomPinned
  • Nếu một kiểu tự tham chiếu vô tình vẫn ở trạng thái Unpin, code an toàn có thể lấy tham chiếu khả biến ra khỏi Pin và di chuyển giá trị
    • Khi đó các giả định của code unsafe đã tạo tự tham chiếu sẽ bị phá vỡ

Cách tạo Pin

  • Bản thân Pin không cố định giá trị

  • Tạo Pin nghĩa là chứng minh rằng pointee đó sẽ ở lại một vị trí bộ nhớ ổn định trong suốt vòng đời của pin

  • Pin::new

    • Cách tạo đơn giản nhất là Pin::new
    let mut value = 42;
    let pinned = Pin::new(&mut value);
    
    • Constructor này chỉ dùng được khi T: Unpin
    • Vì kiểu Unpin không phụ thuộc vào pinning, việc bọc bằng Pin luôn an toàn
    • Trong trường hợp này, đảm bảo pinning về cơ bản là no-op
  • std::pin::pin!

    • Khi cần pin một giá trị cục bộ mà không cấp phát heap, có thể dùng macro pin!
    use std::pin::pin;
    
    let future = pin!(async {
        println!("Hello");
    });
    
    • Macro này tạo một biến cục bộ và trả về Pin trỏ tới biến đó
    • Vì compiler đảm bảo biến cục bộ đó không bị di chuyển trong phần vòng đời còn lại, ta có thể pin an toàn các giá trị !Unpin trên stack
    • Khác với tên gọi, pin! không pin chính bộ nhớ stack
    • Nó chỉ tạo một tham chiếu đã pin gắn với biến cục bộ; khi biến ra khỏi scope, đảm bảo pinning cũng kết thúc
  • Box::pin

    • Constructor phổ biến nhất cho kiểu !UnpinBox::pin
    let pinned = Box::pin(SelfRef { ... });
    
    • pin! tạo một Pin gắn với biến cục bộ, còn Box::pin trả về một Pin do Box sở hữu
    • Bản thân vùng cấp phát heap không di chuyển, nên pointee có vị trí bộ nhớ ổn định trong suốt vòng đời của Box
    • Dù di chuyển chính Box, giá trị mà nó sở hữu không bị di chuyển; chỉ con trỏ bên trong Box bị di chuyển
    • Vùng cấp phát heap vẫn ở cùng địa chỉ
  • Pin::new_unchecked

    • Khi constructor an toàn không thể chứng minh giá trị sẽ ở nguyên vị trí, có thể tự tạo Pin bằng code unsafe
    let pinned = unsafe { Pin::new_unchecked(ptr) };
    
    • Người gọi Pin::new_unchecked cam kết rằng trong suốt vòng đời của Pin được trả về, pointee sẽ không bị di chuyển lại thông qua bất kỳ con trỏ nào
    • Nếu cam kết này bị phá vỡ, code phụ thuộc vào đảm bảo pinning có thể phát sinh hành vi không xác định
    • Vì vậy thường chỉ dùng khi triển khai các abstraction cấp thấp có thể duy trì bất biến này

Khi nào thực sự cần quan tâm

  • Với hầu hết developer Rust, PinUnpin hoạt động âm thầm ở phía sau
  • Các trường hợp cần trực tiếp quan tâm chủ yếu có hai loại
    • Tiêu thụ async code: nếu cần trực tiếp poll future hoặc truyền nó cho API yêu cầu pinned future, hãy pin trên heap bằng Box::pin(future) hoặc pin trên stack cục bộ bằng std::pin::pin!(future)
    • Tự triển khai Future: khi viết máy trạng thái tùy chỉnh hoặc primitive async cấp thấp, bạn phải xử lý Pin, và có thể cần PhantomPinned cùng code unsafe để duy trì các bất biến pinning
  • Pin là giải pháp zero-cost của Rust cho vấn đề các kiểu nhạy cảm với địa chỉ
  • Nhờ đó Rust có thể dùng async/await và các abstraction tự tham chiếu khác mà vẫn duy trì đảm bảo an toàn bộ nhớ, không cần garbage collector

1 bình luận

 
Các ý kiến trên Lobste.rs
  • std::pin::Pin giống như Monad trong thế giới Rust. Một khi đã hiểu rồi thì khó mà không viết một bài blog về nó

    • Những bài như vậy thường dễ mắc phải monad tutorial fallacy
    • Ý là, giống như thời Monad, các bài blog đó thực ra chẳng giải thích được gì cho ra hồn sao?
  • Có lẽ nên đề cập vài điểm mà tôi và những người khác từng vướng khi cố hiểu Pin
    Cái tên Unpin không hay lắm. Những tên chính xác hơn nhưng cũng chẳng hay ho gì có thể là MovableWhenPinned hoặc PinIsNoOp
    Phủ định kép !Unpin trên nightly trông có vẻ kỳ lạ, nhưng để giữ các kiểu hiện có là trường hợp mặc định 99%, người ta phải thêm auto trait Unpin để kiểu có thể “thoát” ra, nên mới thành ra vậy. Nếu nghĩ là !MovableWhenPinned thì sẽ hợp lý hơn
    Phương án thay thế trên bản ổn định là PhantomPinned cũng không phải cái tên hay, vì trạng thái pinned là trạng thái tạm thời phát sinh do có tham chiếu pinned, chứ không phải đặc tính của kiểu. Một tên thay thế có lẽ là PhantomNotMovableWhenPinned
    Khi bắt đầu tự “dịch” trong đầu theo cách này, tôi thấy dễ hiểu hơn nhiều. Tất nhiên vẫn còn rối, cũng có thể chỉ là tôi may mắn

    • Hoàn toàn đồng ý. Trước đây !Unpin làm tôi đau đầu, nhưng khi bắt đầu đọc UnpinSafeToUnpin thì thấy dễ chịu hơn một chút
  • Tôi từng hỏi câu này trước đây và hình như có ai đó đã trả lời rất thấu đáo, nhưng tôi không nhớ nữa. Theo tôi hiểu, Pin xuất phát từ async, và vấn đề là các tham chiếu tới biến cục bộ trở thành tự tham chiếu bên trong khối dữ liệu biểu diễn state machine của một hàm cụ thể
    Nếu trạng thái async bị di chuyển, các tham chiếu tới biến cục bộ đó sẽ trỏ về vị trí cũ không còn đúng nữa
    Nhưng chẳng phải điều đó chỉ xảy ra vì tham chiếu là con trỏ thật với địa chỉ tuyệt đối đầy đủ sao? Tôi thắc mắc vì sao lời giải lại là loại bỏ khả năng di chuyển, thay vì biến tham chiếu thành địa chỉ tương đối
    Tôi muốn biết câu trả lời chủ yếu có phải là “vì hàng triệu kỹ sư-năm đã được đổ vào để compiler, CPU và OS xử lý con trỏ thật tốt, nên con trỏ tốt hơn theo nhiều nghĩa, và vì vậy dùng Pin rải rác sẽ tốt hơn”, hay có lý do cứng rắn nào khiến tham chiếu tương đối thực sự không phải là một phương án khả thi

    • Vấn đề không chỉ là biến cục bộ trong trạng thái async trực tiếp tham chiếu tới một biến cục bộ khác trong cùng trạng thái. Nếu chỉ vậy thì compiler biết toàn bộ biến cục bộ, nên có thể làm truy cập theo kiểu tương đối. Nhưng nếu một tham chiếu nằm sâu trong một kiểu trỏ tới một giá trị nằm sâu trong kiểu khác thì phức tạp hơn nhiều
      Nếu tham chiếu là tương đối, các kiểu đó sẽ phải có biểu diễn bộ nhớ khác nhau tùy vào việc chúng có được dùng trong trạng thái async hay không, và cũng cần có khái niệm con trỏ gốc được truyền kèm để khôi phục con trỏ thật từ tham chiếu tương đối
      Các đối tượng lồng nhau bên trong tham chiếu pinned vẫn có thể được tự do di chuyển dù đối tượng gốc đã được pinned, nên cũng không thể nói rằng mọi tham chiếu tương đối giả định đều tương đối so với cùng một con trỏ gốc
      Cuối cùng vẫn cần con trỏ tuyệt đối, và tham chiếu tương đối không phù hợp lắm. Vậy nếu compiler Rust biết các kiểu ở đây, thì sao không theo dõi toàn bộ đồ thị đối tượng rồi khi di chuyển đối tượng thì sửa các tham chiếu trỏ tới nó sang vị trí mới, để khiến đối tượng có thể di chuyển được? Làm vậy về cơ bản là đã tạo ra một tracing garbage collector
      Hơn nữa, compiler Rust không biết mọi kiểu trong đồ thị đối tượng. Tham chiếu có thể được truyền qua FFI, và thư viện bên ngoài có thể lưu giữ tham chiếu đó. Việc sửa các tham chiếu di chuyển qua ranh giới FFI thực chất là một vấn đề khó xử lý
      Vì thế chuyện này thật sự hóc búa. Cũng đáng lưu ý rằng bản thân việc di chuyển đối tượng là một kỹ thuật tương đối mới. Trong hầu hết chương trình C/C++, có thể xem mọi đối tượng đều ngầm được pinned. Lý do pinning ít được bàn tới hơn ở phía đó là vì đối tượng đơn giản là không di chuyển, hoặc nếu có di chuyển thì trách nhiệm đảm bảo không còn tham chiếu treo thuộc về lập trình viên
    • Pin cũng cần cho khả năng tương tác với các ngôn ngữ khác, nơi Rust không thể tùy ý di chuyển bộ nhớ như thể đó là một khối bit mờ nghĩa
      Theo tôi hiểu, một trong các vấn đề của khả năng tương tác với C++ là đối tượng không phải chỉ là một khối bit đơn giản có thể tự do di chuyển, và rốt cuộc khá nhiều kiểu sẽ cần pinning, khiến tính dễ dùng trở nên khó chịu
      Tuy nhiên, điều này dựa trên các cuộc trò chuyện của tôi với những người đang làm việc này ít nhất khoảng 6 tháng trước, nên tôi không biết tình hình đã cải thiện đến đâu kể từ đó
  • Nhìn chung, tôi cho rằng đây là một phần giải thích đáng đọc bên cạnh tài liệu Rust chính thức. Cách dẫn vào vấn đề mềm mại hơn một chút
    Tuy nhiên, tôi nghĩ bắt đầu bằng struct tự tham chiếu lại khiến mọi thứ dễ rối hơn là bỏ hẳn phần đó. Đặc biệt câu ở phần mở đầu “vì vậy sau khi một tự tham chiếu như thế được tạo ra, cần có cách ngăn SelfRef bị di chuyển” khiến tôi nghĩ tới “vấn đề ngăn việc di chuyển hoàn toàn” hơn là trọng tâm chính
    Trọng tâm thật sự nằm ở đoạn xuất hiện muộn hơn nhiều: “Pin không ngăn giá trị bị di chuyển về mặt vật lý. Thay vào đó, nó là bảo đảm ở cấp kiểu rằng giá trị sẽ không bị di chuyển thông qua con trỏ đó”
    Vì không thể ngăn chính việc di chuyển, Pin được dùng để trong API an toàn, dữ liệu tự tham chiếu chỉ được lộ ra sau tham chiếu độc quyền. Có thể tôi đã hiểu Pin quá nhiều rồi, nhưng nếu chỉnh lại cách giải thích một chút thì độc giả sẽ đỡ lạc hơn

    • Tôi sẽ thử sửa lại bài viết theo hướng đó
      Bài này lấy từ ghi chú của tôi về pinning, và ban đầu tôi cũng hiểu như vậy. Tôi thấy đẹp ở chỗ có thể giải quyết một vấn đề như “ngăn di chuyển” bằng bảo đảm ở cấp kiểu
      Tất nhiên đó không phải điều Pin thực sự làm, nên đúng là nên sửa bài để thể hiện rõ phần đó
  • Có lẽ nên ghi ở đâu đó trong bài rằng !UnPin chỉ có thể biểu diễn trên nightly Rust. Đó là lý do chính khiến PhantomPinned tồn tại

  • Bài nói là “pointer wrapper”, nhưng ngay cả trong Rust cũng hiếm khi phải xử lý con trỏ. Tôi không hiểu vì sao lại phải dùng nó
    *const khó tìm tài liệu Rust trên Google, tôi thắc mắc không biết nó có được tài liệu hóa không
    Có cần phải biết rằng “nó trở thành một field của state machine do compiler sinh ra” không? Hay đó là một lỗi compiler kỳ quặc đang cố nói rằng chuyện đó thực sự đã xảy ra?
    “Future được tạo ra trở thành tự tham chiếu” cũng là chuyện ngầm xảy ra khi dùng future sao?
    Tôi nghĩ mình chưa từng trực tiếp dùng Future::poll
    Bài nói “code an toàn không thể khôi phục &mut T thông thường”, nhưng lại nói “vẫn cho phép thay đổi thông thường”, vậy thì làm thế nào?
    Những điều như thế khiến tôi ngừng đào sâu thêm vào Rust

    • Con trỏ thô là một trong các kiểu nguyên thủy của Rust. Tài liệu ở đâyđây
      Tuy nhiên, đúng là nếu không xuống mức thấp thì hầu như không cần dùng tới. Bản thân tôi cũng chỉ biết đến khi cần gọi thư viện C
      Future::poll là nền tảng của code bất đồng bộ trong Rust. Bạn không gọi trực tiếp nó; executor sẽ gọi. Rust không có executor mặc định, nên cần thêm những thứ như Tokio, smol, pollster, và chúng dùng các method như poll được định nghĩa trong trait Future để xử lý công việc
    • Tôi không phải tác giả bài gốc và đây cũng không phải những lý do duy nhất, nhưng các lý do khiến tôi phải xử lý con trỏ trong Rust là FFI và các cấu trúc dữ liệu tự tham chiếu như đồ thị
      Tài liệu có ở nhiều nơi, bao gồm đây
      Kỳ vọng người khác chỉ được giải thích đúng những gì bản thân bạn cần thì hơi quá
      Tôi không rõ bạn đang hỏi gì ở đoạn “vậy thì làm thế nào?”