新的 C++ 繫結可支援多種執行緒模型。您可以根據應用程式的架構,選擇不同的類別和使用方式。本文介紹在非簡單的執行緒環境中使用 FIDL 的工具和技術。
背景:FIDL 連線的生命週期
在 FIDL 連線的生命週期內,從執行緒安全性和防止釋放後使用 (use-after-free) 的角度來看,這些事件非常重要:
繫結呼叫:這是使用者程式碼在 FIDL 訊息傳遞物件上發出的呼叫,也就是從 FIDL 執行階段的角度來看的傳入呼叫。例如:
- 在用戶端上發出 FIDL 方法呼叫是繫結呼叫。
- 使用完成器從伺服器實作回覆也是繫結呼叫。
對使用者物件的呼叫:這些是 FIDL 執行階段對使用者物件 (包括使用者提供的回呼) 進行的呼叫,也就是從 FIDL 執行階段的角度來看,屬於外撥呼叫。例如:
- 伺服器訊息調度器在伺服器實作中叫用 FIDL 方法處理常式,即為對使用者的呼叫。
- FIDL 用戶端透過回呼將雙向 FIDL 方法的回應傳送給使用者,也屬於傳送給使用者的呼叫。
- 錯誤處理常式也是對使用者的呼叫。
從繫結的角度來看,使用者物件比 FIDL 繫結高一層,因此對使用者的呼叫有時也稱為「向上呼叫」。
拆解:停止訊息調度的動作。具體來說,拆除完成後,繫結不會再進行任何對使用者呼叫;對繫結呼叫會失敗,或產生空白/微不足道的效果。例如:
- 在調度期間發生錯誤。
- 銷毀
{fidl,fdf}::[Wire]Client。 - 正在撥打
{fidl,fdf}::[Wire]SharedClient::AsyncTeardown()。
拆除作業通常會導致用戶端/伺服器端點關閉。
取消繫結:停止訊息傳送的動作,並復原用於傳送及接收訊息的用戶端/伺服器端點。因此必須拆解裝置。例如:
- 正在撥打
fidl::ServerBindingRef::Unbind()。
- 正在撥打
拆除期間的釋放後使用風險
銷毀一組相關物件 (包括 FIDL 用戶端或伺服器) 時,請務必依序銷毀,以免 FIDL 繫結執行階段發出的 to-user 呼叫最終呼叫到已銷毀的物件。
舉例來說,假設 MyDevice 物件擁有 FIDL 用戶端,並進行多項雙向 FIDL 呼叫,每次都會傳遞擷取 this 的 lambda 做為結果回呼。如果用戶端在這段期間仍可傳送訊息,則銷毀 MyDevice 就不安全。如果使用者從非分派器執行緒 (也就是監控及分派目前 FIDL 繫結訊息的執行緒) 銷毀 MyDevice (或其他業務物件),通常就會發生這種情況。
處理事件和處理伺服器的方法呼叫時,在銷毀期間也存在類似的釋放後使用風險。
這個問題有幾種解決方法,但都秉持著在使用者物件毀損和「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() 建立單一工作執行緒,並從不同執行緒透過 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 會導致 use-after-free。為避免發生這種情況,請在接收器物件與用戶端一併毀損時,使用 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_;
};
在上述範例中,如果伺服器想重新初始化 bar_,同時保持 FooProtocol 連線有效,則在處理 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。如果您的應用程式是多執行緒,但包含多個 <0x0
Client如果您的應用程式是多執行緒,且無法保證 FIDL 用戶端會從各自的調度器使用,請使用
SharedClient,並處理兩階段關閉的複雜性。
伺服器端執行緒
fidl::Client 和 fidl::SharedClient 都會在銷毀時拆除繫結。與用戶端不同,伺服器端沒有 RAII 型別會拆除繫結。理由是,在較簡單的應用程式中,伺服器是因應用戶端發出的連線嘗試而建立,且通常會持續處理用戶端要求,直到用戶端關閉端點為止。應用程式關閉時,使用者可以關閉非同步調度器,然後同步終止與其相關聯的所有伺服器繫結。
不過,隨著應用程式日趨複雜,有時需要主動關閉伺服器實作物件,這時就必須拆除伺服器繫結。舉例來說,移除裝置時,驅動程式必須停止相關伺服器。
伺服器可透過兩種方式,在自家端自願拆除繫結:
fidl::ServerBindingRef::Close或fidl::ServerBindingRef::Unbind。SomeCompleter::Close,其中SomeCompleter是提供給伺服器方法處理常式的完成器。
如要瞭解這些語意,請參閱伺服器標頭中的說明文件。
上述所有方法只會啟動終止程序,因此可安全地與進行中的作業或平行 to-user 呼叫 (例如方法處理常式) 競爭。因此,我們需要謹慎維護伺服器實作物件的生命週期,有兩種情況:
從同步處理的調度器啟動終止程序
如果傳遞至 fidl::BindServer 的非同步調度器 (async_dispatcher_t*) 是同步調度器,且從該調度器執行的工作 (例如從伺服器方法處理常式內) 啟動終止程序,則繫結會在 Unbind/Close 傳回後,不會在伺服器物件上進行任何呼叫。此時可以放心刪除伺服器物件。
如果指定未繫結的處理常式,繫結會在不久後 (通常在下一次事件迴圈疊代時),發出最後一次to-user呼叫,也就是未繫結的處理常式。未繫結的處理常式具有下列簽章:
// |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 變數,因為該變數現在指向無效的記憶體。
從任意執行緒啟動終止程序
如果應用程式無法保證一律從同步調度器啟動終止程序,則終止程序期間可能會持續進行對使用者的呼叫。為防止釋放後使用,我們可能會實作類似於用戶端上的兩階段關機模式。
假設系統會為每個連線要求在堆積上分配伺服器物件:
// 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* 完成。除了關閉調度器之外,您可預期 to-user 呼叫會在調度器執行緒上執行,且不會巢狀內嵌於其他使用者程式碼中 (沒有重入問題)。
如果關閉調度器時有任何有效繫結,拆除作業可能會在執行關閉作業的執行緒上完成。因此,您不得在執行 async::Loop::Shutdown/async_loop_shutdown 時,取得可由提供給 fidl::SharedClient 的拆除觀察器或提供給 fidl::BindServer 的未繫結處理常式取得的任何鎖定。 (您可能應確保關機時未保留任何鎖定,因為關機會加入所有調度器執行緒,而這些執行緒可能會在使用者程式碼中取得鎖定)。