新的 C++ 繫結可支援多種執行緒模型。視應用程式的架構而定,可供選擇的類別和使用樣式會有所不同。本文將介紹在非簡單執行緒環境中使用 FIDL 的工具和技巧。
背景:FIDL 連線的生命週期
在 FIDL 連線的生命週期內,從執行緒安全性和防止使用後釋放的角度來看,這些事件非常重要:
To-binding 呼叫:這是使用者程式碼對 FIDL 訊息物件發出的呼叫,也就是從 FIDL 執行階段的角度來看的入站呼叫。例如:
- 對用戶端發出 FIDL 方法呼叫是繫結呼叫。
- 使用完成符從伺服器實作中回覆,也是至繫結呼叫。
使用者呼叫:這些是 FIDL 執行階段在使用者物件 (包括使用者提供的回呼) 上發出的呼叫,從 FIDL 執行階段的角度傳出。例如:
- 在伺服器實作上叫用 FIDL 方法處理常式的伺服器訊息調度器,是使用者呼叫。
- 透過回呼將雙向 FIDL 方法的回應傳送給使用者的 FIDL 用戶端,也是使用者呼叫。
- 錯誤處理程序也是對使用者的呼叫。
使用者呼叫有時也稱為「上行呼叫」,因為從繫結的角度來看,使用者物件位於 FIDL 繫結層之上。
Teardown:停止訊息調度作業的動作。具體來說,解構完成後,綁定作業就不會再發出使用者呼叫;to-binding 呼叫會失敗或產生無效/瑣碎效果。例如:
- 調度期間發生錯誤。
- 刪除
{fidl,fdf}::[Wire]Client
。 - 正在撥打
{fidl,fdf}::[Wire]SharedClient::AsyncTeardown()
。
Teardown 通常會導致用戶端/伺服器端點關閉。
解除繫結:動作會停止分派訊息,並額外復原用於收發訊息的用戶端/伺服器端點。這麼做勢必會涉及拆解作業。例如:
- 正在撥打
fidl::ServerBindingRef::Unbind()
。
- 正在撥打
解構期間的 Use-after-free 風險
當您要刪除一組相關物件 (包括 FIDL 用戶端或伺服器) 時,請務必小心安排刪除順序,以免 FIDL 繫結執行階段發出的「to-user」呼叫,最後呼叫至已刪除的物件。
舉例來說,假設 MyDevice
物件擁有 FIDL 用戶端,並發出多個雙向 FIDL 呼叫,傳遞 lambda,每次都將 this
擷取為結果回呼。在這個期間,如果用戶端仍在調度訊息,則刪除 MyDevice
會造成安全性風險。使用者從非調度器執行緒中銷毀 MyDevice
(或其他業務物件) 時,通常會發生這種情況,也就是說,不是為目前 FIDL 繫結監控及調度訊息的執行緒。
在處理事件和處理來自伺服器的方法呼叫時,也會出現類似的使用後釋放風險。
這個問題有幾種解決方法,所有方法都以在使用者物件銷毀和 to-user 呼叫之間加入互斥條件為前提:
- 排程:確保相關使用者物件的刪除作業不會與任何使用者呼叫一同排程。
- 參照計數:為使用者物件建立參照計數,讓這些物件在繫結解除程序完成前不會遭到銷毀。
- 兩階段關閉:在繫結解除程序完成時提供通知,以便使用者安排業務物件在之後銷毀。
C++ 繫結會原生支援上述所有方法。在某些情況下,引用計數功能並不適合使用,因此在使用繫結時,這項功能是選擇加入的功能。
用戶端執行緒
有兩種用戶端類型支援非同步作業:fidl::Client
和 fidl::SharedClient
。如需精確的語意參考資料,請參閱用戶端標頭中的說明文件。
fidl::Client
的執行緒行為也適用於 fidl::WireClient
。同樣地,fidl::SharedClient
的執行緒行為會擴充至 fidl::WireSharedClient
。
用戶端
fidl::Client
會強制執行解決方案 #1 (排程),並讀取及處理管道中的訊息:
- 您只能在該調度器上執行的任務中發出 FIDL 方法呼叫。
- 用戶端物件本身無法移至其他物件,而該物件會在其他調度器上執行的工作中遭到銷毀。
這可確保在發送 FIDL 訊息或錯誤事件時,不會刪除包含使用者物件的物件。適合單一執行緒和物件導向使用樣式。
fidl::Client
只能與同步的非同步調度工具搭配使用。async::Loop
的一個特別用途是透過 loop.StartThread()
建立單一 worker 執行緒,然後從其他執行緒透過 loop.Shutdown()
加入並關閉迴圈。這裡,兩個執行緒的技術上涉及實作,但從互專屬存取權的角度來看,這不會造成安全問題,而 fidl::Client
的設計宗旨就是為了讓使用者能夠使用。
fidl::Client
會透過事件處理常式的 on_fidl_error
虛擬方法回報錯誤。使用者啟動的拆解作業 (例如透過摧毀用戶端) 不會向事件處理常式回報為錯誤。
fidl::Client
不屬於事件處理常式。相反地,擁有用戶端的使用者物件可能會實作事件處理介面,並將借用的指標傳遞至用戶端物件。
fidl::Client
的一般用法可能如下所示:
class MyDevice : fidl::AsyncEventHandler<MyProtocol> {
public:
MyDevice() {
client_.Bind(std::move(client_end), dispatcher, /* event_handler */ this);
}
void on_fidl_error(fidl::UnbindInfo error) {
// Handle errors...
}
void DoThing() {
// Capture |this| such that the |MyDevice| instance may be accessed
// in the callback. This is safe because destroying |client_| silently
// discards all pending callbacks registered through |Then|.
client_->Foo(args).Then([this] (fidl::Result<Foo>&) { ... });
}
private:
fidl::Client<MyProtocol> client_;
};
請注意,在 MyDevice
遭到刪除時,並不需要特別處理,因為用戶端繫結會在程序中拆解,而 fidl::Client
執行的執行緒檢查作業足以防止這類使用後釋放的情況。
ThenExactlyOnce
的額外使用風險
當用戶端物件遭到銷毀時,透過 ThenExactlyOnce
註冊的待處理回呼會非同步收到取消錯誤。請小心處理,確保任何 lambda 擷取作業都仍處於運作狀態。舉例來說,如果物件包含 fidl::Client
,並在非同步方法回呼中擷取 this
,那麼在銷毀物件後,在回呼中操控所擷取的 this
就會導致使用後釋放。為避免這種情況,請在接收器物件與用戶端一併銷毀時,使用 Then
註冊回呼。使用上述 MyDevice
範例:
void MyDevice::DoOtherThing() {
// Incorrect:
client_->Foo(request).ThenExactlyOnce([this] (fidl::Result<Foo>& result) {
// If |MyDevice| is destroyed, this pending callback will still run.
// The captured |this| pointer will be invalid.
});
// Correct:
client_->Foo(request).Then([this] (fidl::Result<Foo>& result) {
// The callback is silently dropped if |client_| is destroyed.
});
}
當回呼擷取需要使用一次的物件時,您可以使用 ThenExactlyOnce
,例如在傳播用於滿足伺服器要求的用戶端呼叫錯誤時:
class MyServer : public fidl::Server<FooProtocol> {
public:
void FooMethod(FooMethodRequest& request, FooMethodCompleter::Sync& completer) override {
bar_.client->Bar().ThenExactlyOnce(
[completer = completer.ToAsync()] (fidl::Result<Bar>& result) {
if (!result.is_ok()) {
completer.Reply(result.error_value().status());
return;
}
// ... more processing
});
}
private:
struct BarManager {
fidl::Client<BarProtocol> client;
/* Other internal state... */
};
std::unique_ptr<BarManager> bar_;
};
在上述範例中,如果伺服器想在保持 FooProtocol
連線的情況下重新初始化 bar_
,則可能會在處理 FooMethod
時使用 ThenExactlyOnce
回覆取消錯誤,或引入重試邏輯。
SharedClient
fidl::SharedClient
支援解決方案 #2 (參照計數) 和解決方案 #3 (兩階段關機)。您可以從任意執行緒對 SharedClient
發出 FIDL 呼叫,並使用任何類型的非同步調度器的共用用戶端。與 Client
不同的是,立即銷毀用戶端可保證不會再有 to-user 呼叫,但銷毀 SharedClient
只會啟動非同步繫結拆解作業。使用者可能會以非同步方式觀察解構作業的完成情形。這可讓您將 SharedClient
移至與調度器執行緒不同的執行緒,或將其複製到該執行緒,並在有平行 to-user 呼叫 (例如回應回呼) 時,在用戶端上毀損/呼叫拆除作業。這兩個動作會競爭 (如果客戶端及早毀損,回應回呼可能會取消),但 SharedClient
一旦通知拆解作業完成,就不會再向使用者發出呼叫。
觀察解構完成情況的方法有兩種:
擁有的事件處理常式
繫結用戶端時,將事件處理常式的擁有權轉移至用戶端,做為 std::unique_ptr<fidl::AsyncEventHandler<Protocol>>
的實作。完成卸除後,事件處理常式便會被刪除。在事件處理常式解構函式中,摧毀任何用戶端回呼所參照的使用者物件是安全的。
以下是此模式的示例:
void OwnedEventHandler(async_dispatcher_t* dispatcher, fidl::ClientEnd<Echo> client_end) {
// Define some blocking futures to maintain a consistent sequence of events
// for the purpose of this example. Production code usually won't need these.
std::promise<void> teardown;
std::future<void> teardown_complete = teardown.get_future();
std::promise<void> reply;
std::future<void> got_reply = reply.get_future();
// Define the event handler for the client. The event handler is always
// placed in a |std::unique_ptr| in the owned event handler pattern.
// When the |EventHandler| is destroyed, we know that binding teardown
// has completed.
class EventHandler : public fidl::AsyncEventHandler<Echo> {
public:
explicit EventHandler(std::promise<void>& teardown, std::promise<void>& reply)
: teardown_(teardown), reply_(reply) {}
void on_fidl_error(fidl::UnbindInfo error) override {
// This handler is invoked by the bindings when an error causes it to
// teardown prematurely. Note that additionally cleanup is typically
// performed in the destructor of the event handler, since both manually
// initiated teardown and error teardown will destroy the event handler.
std::cerr << "Error in Echo client: " << error;
// In this example, we abort the process when an error happens. Production
// code should handle the error gracefully (by cleanly exiting or attempt
// to recover).
abort();
}
~EventHandler() override {
// Additional cleanup may be performed here.
// Notify the outer function.
teardown_.set_value();
}
// Regular event handling code is also supported.
void OnString(fidl::Event<Echo::OnString>& event) override {
std::string response(event.response().data(), event.response().size());
std::cout << "Got event: " << response << std::endl;
}
void OnEchoStringResponse(fuchsia_examples::EchoEchoStringResponse& response) {
std::string reply(response.response().data(), response.response().size());
std::cout << "Got response: " << reply << std::endl;
if (!notified_reply_) {
reply_.set_value();
notified_reply_ = true;
}
}
private:
std::promise<void>& teardown_;
std::promise<void>& reply_;
bool notified_reply_ = false;
};
std::unique_ptr handler = std::make_unique<EventHandler>(teardown, reply);
EventHandler* handler_ptr = handler.get();
// Create a client that owns the event handler.
fidl::SharedClient client(std::move(client_end), dispatcher, std::move(handler));
// Make an EchoString call, passing it a callback that captures the event
// handler.
client->EchoString({"hello"}).ThenExactlyOnce(
[handler_ptr](fidl::Result<Echo::EchoString>& result) {
ZX_ASSERT(result.is_ok());
auto& response = result.value();
handler_ptr->OnEchoStringResponse(response);
});
got_reply.wait();
// Make another call but immediately start binding teardown afterwards.
// The reply may race with teardown; the callback is always canceled if
// teardown finishes before a response is received.
client->EchoString({"hello"}).ThenExactlyOnce(
[handler_ptr](fidl::Result<Echo::EchoString>& result) {
if (result.is_ok()) {
auto& response = result.value();
handler_ptr->OnEchoStringResponse(response);
} else {
// Teardown finished first.
ZX_ASSERT(result.error_value().is_canceled());
}
});
// Begin tearing down the client.
// This does not have to happen on the dispatcher thread.
client.AsyncTeardown();
teardown_complete.wait();
}
自訂拆卸觀察器
將 fidl::AnyTeardownObserver
的例項提供給繫結。拆解完成後,觀察器就會收到通知。您可以透過下列幾種方式建立解構觀察器:
fidl::ObserveTeardown
會取得任意可呼叫項目,並將其包裝在解構觀察器中:
fidl::SharedClient<Echo> client;
// Let's say |my_object| is constructed on the heap;
MyObject* my_object = new MyObject;
// ... and needs to be freed via `delete`.
auto observer = fidl::ObserveTeardown([my_object] {
std::cout << "client is tearing down" << std::endl;
delete my_object;
});
// |my_object| may implement |fidl::AsyncEventHandler<Echo>|.
// |observer| will be notified and destroy |my_object| after teardown.
client.Bind(std::move(client_end), dispatcher, my_object, std::move(observer));
fidl::ShareUntilTeardown
會取得std::shared_ptr<T>
,並安排繫結,以便在拆解後刪除其共用參照:
fidl::SharedClient<Echo> client;
// Let's say |my_object| is always managed by a shared pointer.
std::shared_ptr<MyObject> my_object = std::make_shared<MyObject>();
// |my_object| will be kept alive as long as the binding continues
// to exist. When teardown completes, |my_object| will be destroyed
// only if there are no other shared references (such as from other
// related user objects).
auto observer = fidl::ShareUntilTeardown(my_object);
client.Bind(std::move(client_end), dispatcher, my_object.get(), std::move(observer));
使用者可以建立自訂的拆解觀察器,以便與其他指標類型 (例如 fbl::RefPtr<T>
) 搭配使用。
SharedClient
適用於由架構管理商業邏輯狀態的系統 (驅動程式就是其中一個例子,其中驅動程式庫執行階段就是管理架構)。在這種情況下,繫結執行階段和架構會共同擁有使用者物件:繫結執行階段會通知架構已放棄所有使用者物件參照,此時架構就能排定使用者物件的銷毀作業,並在同一個物件群組中進行其他持續進行的非同步拆除程序。非同步的拆解作業不需要在任意使用者呼叫之間同步處理,有助於防止死結。
先啟動拆解作業,然後在拆解作業完成後銷毀使用者物件,這種模式有時稱為「兩階段關閉」。
簡單的決策樹
如有疑慮,在決定要使用的用戶端類型時,請依循下列準則:
如果應用程式採用單一執行緒,請使用
Client
。如果您的應用程式是多執行緒,且無法保證 FIDL 用戶端會從各自的調度器使用:請使用
SharedClient
,並採用兩階段關機的複雜性。
伺服器端執行緒
fidl::Client
和 fidl::SharedClient
在解構時都會解除繫結。與用戶端不同,伺服器端沒有拆解繫結的 RAII 類型。原因是,在較簡單的應用程式中,伺服器會在用戶端嘗試連線時建立,並且通常會持續處理用戶端要求,直到用戶端關閉端點為止。應用程式關閉時,使用者可以關閉非同步調度器,然後同步拆解與之相關聯的所有伺服器繫結。
不過,隨著應用程式變得越來越複雜,有些情況下需要主動關閉伺服器實作物件,這包括拆除伺服器繫結。舉例來說,駕駛人需要在移除裝置時停止相關伺服器。
伺服器可以透過兩種方式自願解除繫結:
fidl::ServerBindingRef::Close
或fidl::ServerBindingRef::Unbind
。SomeCompleter::Close
,其中SomeCompleter
是提供給伺服器方法處理常式的完成方法。
如需精確的語意參考資料,請參閱伺服器標頭中的說明文件。
上述所有方法都只會啟動拆解作業,因此透過進行中的作業或平行「對使用者」呼叫 (例如方法處理常式) 可以安全地競爭。因此,我們需要謹慎維護伺服器實作物件的生命週期,以便取得平衡。有兩種情況:
啟動同步調度工具拆除作業
當傳遞至 fidl::BindServer
的非同步調度器 (async_dispatcher_t*
) 是
Unbind
Close
此時可以安全地刪除伺服器物件。
如果指定了未繫結的處理常式,繫結會發出一次最終「對使用者」呼叫,而這是不久之後取消繫結處理常式的呼叫,通常是在事件迴圈的下次疊代。未繫結的處理常式具有下列簽章:
// |impl| is the pointer to the server implementation.
// |info| contains the reason for binding teardown.
// |server_end| is the server channel endpoint.
// |Protocol| is the type of the FIDL protocol.
void OnUnbound(ServerImpl* impl, fidl::UnbindInfo info,
fidl::ServerEnd<Protocol> server_end) {
// If teardown is manually initiated and not due to an error, |info.ok()| will be true.
if (info.ok())
return;
// Handle errors...
}
如果伺服器物件先前已遭到銷毀,回呼就不得存取 impl
變數,因為該變數現在會指向無效的記憶體。
從任意執行緒啟動拆解程序
如果應用程式無法保證拆解作業一律由同步調度器啟動,則在拆解期間可能會有持續的「to-user」呼叫。為避免使用後釋放,我們可能會實作類似於用戶端的兩階段關閉模式。
假設伺服器物件會針對每個傳入的連線要求在堆積上分配:
// Create an instance of our EchoImpl that destroys itself when the connection closes.
new EchoImpl(dispatcher, std::move(server_end));
我們可以在 unbound_handler
回呼結束時銷毀伺服器物件。此處的程式碼會在回呼結尾處刪除堆積分配的伺服器,藉此達成這項目標。
class EchoImpl {
public:
// Bind this implementation to a channel.
EchoImpl(async_dispatcher_t* dispatcher, fidl::ServerEnd<fuchsia_examples::Echo> server_end)
: binding_(fidl::BindServer(dispatcher, std::move(server_end), this,
// This is a fidl::OnUnboundFn<EchoImpl>.
[this](EchoImpl* impl, fidl::UnbindInfo info,
fidl::ServerEnd<fuchsia_examples::Echo> server_end) {
if (info.is_peer_closed()) {
FX_LOGS(INFO) << "Client disconnected";
} else if (!info.is_user_initiated()) {
FX_LOGS(ERROR) << "Server error: " << info;
}
delete this;
})) {}
// Later, when the server is shutting down...
void Shutdown() {
binding_->Unbind(); // This stops accepting new requests.
// The server is destroyed asynchronously in the unbound handler.
}
};
為了在啟動拆解作業時,同時處理伺服器方法處理常式的呼叫,因此需要採用兩階段關閉模式。這些 to-user 呼叫傳回後,繫結執行階段會呼叫未繫結的處理常式。特別是,如果伺服器方法處理常式需要很長的時間才能傳回,則解除繫結程序可能會延遲相同的時間。建議將長時間執行的處理常式工作卸載至執行緒集區,並透過 completer.ToAsync()
以非同步方式回覆,藉此確保方法處理常式能迅速傳回,並及時解除繫結。如果伺服器繫結已在期間內解除,系統會捨棄回應。
與非同步調度器互動
所有非同步要求/回應的處理、事件處理和錯誤處理,都是透過繫結用戶端或伺服器時提供的 async_dispatcher_t*
完成。除了關閉調度工具之外,您可以預期使用者呼叫將在調度器執行緒上執行,不會在其他使用者程式碼內以巢狀形式執行 (不會發生重複性問題)。
如果在有任何有效繫結的情況下關閉調度器,則系統可能會在執行關閉作業的執行緒上完成解構作業。因此,您不得使用任何可由提供給 fidl::SharedClient
的解構觀察器或執行 async::Loop::Shutdown
/async_loop_shutdown
時提供給 fidl::BindServer
的未繫結處理常式所取得的鎖定項目 (您應該確保在關機時不會保留任何鎖定項目,因為它會加入所有調度器執行緒,而這些執行緒可能會在使用者程式碼中取得鎖定項目)。