std::pin::Pin của Rust là gì?
(vrong.me)std::pin::Pinbiể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.awaitcó thể trở thành trường của máy trạng thái do compiler tạo ra, nênFuture::pollyêu cầuPinđể ngăn future bị di chuyển sau khi đã bắt đầu poll Pinngă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ếuT: Unpinkhông đúng thì không thể lấy&mut Tra khỏiPinmộ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
pollfuture hoặc truyền vào API yêu cầu pinned future, bạn dùngBox::pinhoặcstd::pin::pin!; còn khi tự triển khaiFuturehay primitive async cấp thấp, bạn phải xử lý cả các bất biếnunsafe
Vì sao cần Pin
std::pin::Pinlà 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ụ
SelfRefcódata: i32vàptr: *const i32, trong đóptrtrỏ tớiself.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ô
ptrvẫn trỏ tới vị trí bộ nhớ cũ và trở thành con trỏ treo
- Struct ví dụ
- 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/await và Future
async/awaitvà Future là lĩnh vực tiêu biểu nơiPinxuất hiện thường xuyên- Các biến cục bộ còn sống qua điểm
.awaitsẽ 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::pollnhậnPinthay 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 khaiUnpin, 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 đó
Unpin và PhantomPinned
- Kiểu triển khai
Unpinkhô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
- Ví dụ:
Unpinđược tự động triển khai cho mọi kiểu, trừ khi kiểu đó được đánh dấu rõ ràng là!Unpinstd::marker::PhantomPinnedlà 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
PhantomPinnedcũng tự động trở thành!Unpin
- Vì auto trait được lan truyền tự động, struct chứa trường
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ỏ
Unpinmộ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
- Thông thường xử lý bằng cách thêm trường
- 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ỏiPinvà 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
Pinkhông cố định giá trị -
Tạo
Pinnghĩ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
Unpinkhông phụ thuộc vào pinning, việc bọc bằngPinluôn an toàn - Trong trường hợp này, đảm bảo pinning về cơ bản là no-op
- Cách tạo đơn giản nhất là
-
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ề
Pintrỏ 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ị
!Unpintrê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
- Khi cần pin một giá trị cục bộ mà không cấp phát heap, có thể dùng macro
-
Box::pin- Constructor phổ biến nhất cho kiểu
!UnpinlàBox::pin
let pinned = Box::pin(SelfRef { ... });pin!tạo mộtPingắn với biến cục bộ, cònBox::pintrả về mộtPindoBoxsở 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 trongBoxbị di chuyển - Vùng cấp phát heap vẫn ở cùng địa chỉ
- Constructor phổ biến nhất cho kiểu
-
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
Pinbằng code unsafe
let pinned = unsafe { Pin::new_unchecked(ptr) };- Người gọi
Pin::new_uncheckedcam kết rằng trong suốt vòng đời củaPinđượ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 constructor an toàn không thể chứng minh giá trị sẽ ở nguyên vị trí, có thể tự tạo
Khi nào thực sự cần quan tâm
- Với hầu hết developer Rust,
PinvàUnpinhoạ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
pollfuture hoặc truyền nó cho API yêu cầu pinned future, hãy pin trên heap bằngBox::pin(future)hoặc pin trên stack cục bộ bằngstd::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ầnPhantomPinnedcùng code unsafe để duy trì các bất biến pinning
- Tiêu thụ async code: nếu cần trực tiếp
Pinlà 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/awaitvà 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::Pingiố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ó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
PinCái tên
Unpinkhô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àMovableWhenPinnedhoặcPinIsNoOpPhủ định kép
!Unpintrê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 traitUnpinđể kiểu có thể “thoát” ra, nên mới thành ra vậy. Nếu nghĩ là!MovableWhenPinnedthì sẽ hợp lý hơnPhương án thay thế trên bản ổn định là
PhantomPinnedcũ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àPhantomNotMovableWhenPinnedKhi 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
!Unpinlàm tôi đau đầu, nhưng khi bắt đầu đọcUnpinlàSafeToUnpinthì thấy dễ chịu hơn một chútTô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,
Pinxuấ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
Pinrả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ả thiNế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
Pincũ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ĩaTheo 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
SelfRefbị 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ínhTrọng tâm thật sự nằm ở đoạn xuất hiện muộn hơn nhiều: “
Pinkhô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ểuPinquá 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ơnBà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
Pinthự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
!UnPinchỉ có thể biểu diễn trên nightly Rust. Đó là lý do chính khiếnPhantomPinnedtồn tạiBà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ó
*constkhó 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ôngCó 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::pollBài nói “code an toàn không thể khôi phục
&mut Tthô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
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::polllà 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 traitFutuređể xử lý công việcTà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?”