原文標題:Async/Await

原文連結:

https://

os。phil-opp。com/async-a

wait/#multitasking

公眾號: Rust 碎碎念

翻譯 by: Praying

Pinning

在本文中我們已經與

pinning

偶遇多次。現在終於可以來討論

pinning

是什麼以及為什麼需要它?

自引用結構體(Self-Referential Structs)

正如上面所解釋的,狀態機的變換把每個暫停點的區域性變數儲存在一個結構體中。對於像

example

函式這樣的小例子,這會很直觀且不會導致什麼問題。但是,當變數開始互相引用時,事情就變得困難了。例如,考慮下面的函式:

async

fn

pin_example

()

->

i32

{

let

array

=

1

2

3

];

let

element

=

&

array

2

];

async_write_file

“foo。txt”

element

to_string

())。

await

*

element

}

這個函式建立了一個

array

,其中包含有

1

2

3

。它接著建立了一個對

array

最後一個元素的引用然後把它存入

element

變數。接下來,它把這個已經轉換為字串的數字非同步地寫入到檔案

foo。txt

中。最後,它返回了被

element

引用的數字。

因為這個函式使用了一個的

await

操作,所以得到的狀態機有三種狀態:啟動(start)、結束(end)和等待寫入(waiting on write)。這個函式沒有傳入引數,所以開始狀態的結構體是空的。和之前一樣,結束狀態也是空的因為函式在這個位置已經結束了。等待寫入狀態的結構體就比較有意思了:

struct

WaitingOnWriteState

{

array

1

2

3

],

element

0x1001c

// address of the last array element

}

我們需要把

array

element

變數都儲存起來,因為

element

在返回值的時候需要,而

array

element

所引用。

element

是一個引用,它儲存了一個指向被引用元素的指標(也就是一個記憶體地址)。這裡我們假設地址是

0x1001c

,在實際中,它需要是

array

的最後一個元素的地址,因此,它取決於結構體在記憶體中所處的位置。帶有這樣的內部指標的結構體被稱為

自引用(self-referential)結構體

,因為它們透過自己的一個欄位引用了它們自身。

自引用結構體的問題

我們的自引用結構體的內部指標導致了一個基本問題,當我們看到它的記憶體佈局後,這個問題就會變得明顯:

【譯】AsyncAwait(四)—— Pinning

array

欄位的起始地址為

0x10014

element

欄位在地址

0x10020

。它指向了地址

0x1001c

,因為

array

的最後一個元素的位置就在這裡。此時,一切都沒有問題。但是,當我們試圖把這個結構體移動到一個不同的記憶體地址時,問題就出現了:

【譯】AsyncAwait(四)—— Pinning

我們把結構體往後移動了一下,因此現在它的起始地址為

0x10024

。當我們把結構體作為函式引數傳遞時或者把它賦值給另一個棧上的變數,就會發生這種情況。問題在於,

element

欄位仍然指向地址

0x1001c

,而

array

的最後一個元素的地址已經變成

0x1002c

。因此,這個指標是懸垂(dangling)的,並會導致下一次呼叫

poll

時發生未定義行為。

可能的解決方案

解決這個懸垂指標問題有三種基本方式:

在移動時更新指標:思路是無論什麼時候,只要結構體在記憶體中被移動,就更新內部的指標,因此這個指標在移動後仍然是有效的。不幸的是,這種方式將會需要 Rust 作出很大的改變並且有可能導致巨大的效能開銷。原因是,執行時需要追蹤所有結構體欄位的型別並且在每次移動操作時都要檢查是否需要更新指標。

儲存一個偏移量來取代自引用:為了避免更新指標的需要,編譯器可以把自引用儲存為個結構體開始位置的偏移量。例如,上面的

WaitingOnWriteState

結構體中的

element

欄位可以儲存為值為 8 的

element_offset

欄位。因為,引用指向的 array 裡的元素起始於結構體開頭的 8 位元組。因為偏移位置在結構體移動時是不變的,所以不需要進行欄位更新。

這種方式的問題在於它需要編譯器去探查所有的自引用。這在編譯時是不可能實現的,因為一個引用的值可能取決於使用者輸入,因此,我們可能再次需要一個執行時系統來分析引用並正確地建立狀態結構體。這不會導致執行時開銷,但是也阻礙了特定的編譯器最佳化,因此,它可能會再度引起巨大的效能開銷。

禁止移動結構體:正如我們上面所見,懸垂指標僅發生於我們在記憶體中移動結構體時,透過完全禁止在自引用結構體上的移動操作,可以避免這個問題。這種方式的一個顯著優勢在於,它可以在型別系統層面上被實現而不需要額外的執行時開銷。缺點在於,它把處理可能是自引用結構的移動操作的負擔交給了程式設計師。

因為要保證提供零成本抽象(zero cost abstraction)的原則,這意味著抽象不應該引入額外的執行時開銷,所以 Rust 選擇了第三種方案。也因此,pinningAPI 在RFC2349中被提出。接下來,我們將會對這個 API 進行簡要介紹,並解釋它是如何與 async/await 以及 future 一同工作的。

堆上的值(Heap Values)

第一個發現是,在大多數情況下,堆分配(heap allocated)的值已經在記憶體中有了一個固定地址。它們透過呼叫

allocate

來建立,然後被一個指標型別引用,比如

Box

。儘管指標型別有可能被移動,但是指標指向的堆上的值仍然保持在相同的記憶體地址,除非它被一個

deallocate

呼叫來釋放。

使用堆分配,我們可以嘗試去建立一個自引用結構體:

fn

main

()

{

let

mut

heap_value

=

Box

::

new

SelfReferential

{

self_ptr

0

as

*

const

_

});

let

ptr

=

&*

heap_value

as

*

const

SelfReferential

heap_value

self_ptr

=

ptr

println

“heap value at: {:p}”

heap_value

);

println

“internal reference: {:p}”

heap_value

self_ptr

);

}

struct

SelfReferential

{

self_ptr

*

const

Self

}

(在 playground 上執行程式碼)

我們建立了一個名為

SelfReferential

的簡單結構體,該結構體僅包含一個單獨的指標欄位。首先,我們使用一個空指標來初始化這個結構體,然後使用

Box::new

在堆上分配它。接著,我們計算出這個分配在堆上的結構體的記憶體地址並將其儲存到一個

ptr

變數中。最後,我們透過把

ptr

變數賦值給

self_ptr

欄位使得結構體成為自引用的。

當我們在 playground 上執行這段程式碼時,我們看到這個堆上的值的地址和它的內部指標的地址是相等的,這意味著,

self_ptr

欄位是一個有效的自引用。因為

heap_value

只是一個指標,移動它(比如,把它作為引數傳入函式)不會改變結構體自身的值,所以

self_ptr

在指標移動後依然是有效的。

但是,仍然有一種方式來破壞這個示例:我們可以擺脫

Box

或者替換它的內容:

let

stack_value

=

mem

::

replace

&

mut

*

heap_value

SelfReferential

{

self_ptr

0

as

*

const

_

});

println

“value at: {:p}”

&

stack_value

);

println

“internal reference: {:p}”

stack_value

self_ptr

);

(在 playground 上執行)

這裡,我們使用

mem::replace

函式使用一個新的結構體例項來替換堆分配的值。這使得我們把原始的

heap_value

移動到棧上,而結構體的

self_ptr

欄位現在是一個仍然指向舊的堆地址的懸垂指標。當你嘗試在 playground 上執行這個示例時,你會看到打印出的

“value at:”

“internal reference:”

這一行確實是輸出的不同的指標。因此,在堆上分配一個值並不能保證自引用的安全。

出現上面的破綻的基本問題是,

Box

允許我們獲得堆分配值的

&mut T

引用。這個

&mut

引用讓使用類似

mem::replace

或者

mem::swap

的方法使得堆上值失效成為可能。為了解決這個問題,我們必須阻止建立對自引用結構體的

&mut

引用。

Pin>和 Unpin

pinning API 以

Pin

包裝型別和

Unpin

標記 trait 的形式提供了一個針對

&mut T

問題的解決方案。這些型別背後的思想是對

Pin

的所有能被用來獲得對 Unpin trait 上包裝的值的

&mut

引用的方法(如

get_mut

或者

deref_mut

)進行管控。

Unpin

trait 是一個auto trait,它會為所有的型別自動實現,除了顯式選擇退出(opt-out)的型別。透過讓自引用結構體選擇退出

Unpin

,就沒有(安全的)辦法從一個

Pin>

型別獲取一個

&mut T

。因此,它們的內部的自引用就能保證仍是有效的。

舉個例子,讓我們修改上面的

SelfReferential

型別來選擇退出

Unpin

use

core

::

marker

::

PhantomPinned

struct

SelfReferential

{

self_ptr

*

const

Self

_pin

PhantomPinned

}

我們透過新增一個型別為PhantomPinned的

_pin

欄位來選擇退出。這個型別是一個零大小標記型別,它唯一目的就是不去實現

Unpin

trait。因為 auto trait 的工作方式,有一個欄位不滿足

Unpin

,那麼整個結構體都會選擇退出

Unpin

第二步是把例子中的

Box

改為

Pin>

型別。實現這個的最簡單的方式是使用

Box::pin

函式,而不是使用

Box::new

建立堆分配的值。

let

mut

heap_value

=

Box

::

pin

SelfReferential

{

self_ptr

0

as

*

const

_

_pin

PhantomPinned

});

除了把

Box::new

改為

Box::pin

之外,我們還需要在結構體初始化新增新的

_pin

欄位。因為

PhantomPinned

是一個零大小型別,我們只需要它的型別名來初始化它。

當我們嘗試執行調整後的示例時,我們看到它無法編譯:

error[E0594]: cannot assign to data in a dereference of `std::pin::Pin>`

——> src/main。rs:10:5

|

10 | heap_value。self_ptr = ptr;

| ^^^^^^^^^^^^^^^^^^^^^^^^^ cannot assign

|

= help: trait `DerefMut` is required to modify through a dereference, but it is not implemented for `std::pin::Pin>`

error[E0596]: cannot borrow data in a dereference of `std::pin::Pin>` as mutable

——> src/main。rs:16:36

|

16 | let stack_value = mem::replace(&mut *heap_value, SelfReferential {

| ^^^^^^^^^^^^^^^^ cannot borrow as mutable

|

= help: trait `DerefMut` is required to modify through a dereference, but it is not implemented for `std::pin::Pin>`

兩個錯誤發生都是因為

Pin>

型別沒有實現

DerefMut

trait。這也正是我們想要的,因為

DerefMut

trait 將會返回一個

&mut

引用,這是我們想要避免的。發生這種情況是因為我們選擇退出了

Unpin

並把

Box::new

改為了

Box::pin

現在的問題在於,編譯器不僅阻止了第 16 行的移動型別,還禁止了第 10 行的

self_ptr

的初始化。這會發生時因為編譯器無法區分

&mut

引用的有效使用和無效使用。為了能夠正常初始化,我們不得不使用不安全的get_unchecked_mut方法:

// safe because modifying a field doesn‘t move the whole struct

unsafe

{

let

mut_ref

=

Pin

::

as_mut

&

mut

heap_value

);

Pin

::

get_unchecked_mut

mut_ref

)。

self_ptr

=

ptr

}

(嘗試在 playground 上執行)

get_unchecked_mut函式作用於

Pin<&mut T>

而不是

Pin>

,所以我們不得不使用

Pin::as_mut

來對之前的值進行轉換。接著,我們可以使用

get_unchecked_mut

返回的

&mut

引用來設定

self_ptr

欄位。

現在,生下來的唯一的錯誤是

mem::replace

上的期望錯誤。記住,這個操作試圖把一個堆分配的值移動到棧上,這將會破壞儲存在

self_ptr

欄位上的自引用。透過選擇退出

Unpin

和使用

Pin>

,我們可以在編譯期阻止這個操作,從而安全地使用自引用結構體。正如我們所見,編譯器無法證明自引用的建立是安全的,因此我們需要使用一個不安全的塊(block)並且確認其自身的正確性。

棧 Pinning 和 Pin<&mut T>

在先前的部分,我們學習瞭如何使用

Pin>

來安全地建立一個堆分配的自引用的值。儘管這種方式能夠很好地工作並且相對安全(除了不安全的構造),但是需要的堆分配也會帶來效能損耗。因為 Rust 一直想要儘可能地提供零成本抽象, 所以 pinning API 也允許去建立

Pin<&mut T>

例項指向棧分配的值。

不像

Pin>

例項那樣能夠擁有被包裝的值的所有權,

Pin<&mut T>

例項只是暫時地借用被包裝的值。這使得事情變得更加複雜,因為它要求程式設計師自己確認額外的保證。最重要的是,一個

Pin<&mut T>

必須在被引用的

T

的整個生命週期被保持 pinned,這對於棧上的變數很難確認。為了幫助處理這類問題,就有了像pin-utils這樣的 crate。但是我仍然不會推薦 pinning 到棧上除非你真的知道自己在做什麼。

想要更加深入地瞭解,請查閱pin 模組和Pin::new_unchecked方法的文件。

Pinning 和 Futures

正如我們在本文中已經看到的,Future::poll方法以

Pin<&mut Self>

引數的形式來使用 pinning:

fn

poll

self

Pin

<&

mut

Self

>

cx

&

mut

Context

->

Poll

<

Self

::

Output

>

這個方法接收

self: Pin<&mut Self>

而不是普通的

&mut self

,其原因在於,從 async/await 建立的 future 例項常常是自引用的。透過把

Self

包裝進

Pin

並讓編譯器為由 async/await 生的自引用的 futures 選擇退出

Unpin

,可以保證這些 futures 在

poll

呼叫之間在記憶體中不被移動。這就保證了所有的內部引用都是仍然有效的。

值得注意的是,在第一次

poll

呼叫之前移動 future 是沒問題的。因為事實上 future 是懶惰的(lazy)並且直到它們被第一次輪詢之前什麼事情也不會做。生成的狀態機中的

start

狀態因此只包含函式引數,而沒有內部引用。為了呼叫

poll

,呼叫者必須首先把 future 包裝進

Pin

,這就保證了 future 在記憶體中不會再被移動。因為棧上的 pinning 難以正確操作,所以我推薦一直使用

Box::pin

組合

Pin::as_mut

如果你想了解如何安全地使用棧 pinning 實現一個 future 組合字函式,可以去看一下map 組合子方法的原始碼,以及 pin 文件中的 projections and structural pinning部分

【譯】AsyncAwait(四)—— Pinning