全新 C++ 繫結執行緒指南

新的 C++ 繫結可支援多種執行緒模型。視應用程式的架構而定,可供選擇的類別和使用樣式會有所不同。本文將介紹在非簡單執行緒環境中使用 FIDL 的工具和技巧。

背景: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 呼叫之間加入互斥條件為前提:

  1. 排程:確保相關使用者物件的刪除作業不會與任何使用者呼叫一同排程。
  2. 參照計數:為使用者物件建立參照計數,讓這些物件在繫結解除程序完成前不會遭到銷毀。
  3. 兩階段關閉:在繫結解除程序完成時提供通知,以便使用者安排業務物件在之後銷毀。

C++ 繫結會原生支援上述所有方法。在某些情況下,引用計數功能並不適合使用,因此在使用繫結時,這項功能是選擇加入的功能。

用戶端執行緒

有兩種用戶端類型支援非同步作業:fidl::Clientfidl::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

  • Client

  • 如果您的應用程式是多執行緒,且無法保證 FIDL 用戶端會從各自的調度器使用:請使用 SharedClient,並採用兩階段關機的複雜性。

伺服器端執行緒

fidl::Clientfidl::SharedClient 在解構時都會解除繫結。與用戶端不同,伺服器端沒有拆解繫結的 RAII 類型。原因是,在較簡單的應用程式中,伺服器會在用戶端嘗試連線時建立,並且通常會持續處理用戶端要求,直到用戶端關閉端點為止。應用程式關閉時,使用者可以關閉非同步調度器,然後同步拆解與之相關聯的所有伺服器繫結。

不過,隨著應用程式變得越來越複雜,有些情況下需要主動關閉伺服器實作物件,這包括拆除伺服器繫結。舉例來說,駕駛人需要在移除裝置時停止相關伺服器。

伺服器可以透過兩種方式自願解除繫結:

  • fidl::ServerBindingRef::Closefidl::ServerBindingRef::Unbind
  • SomeCompleter::Close,其中 SomeCompleter 是提供給伺服器方法處理常式的完成方法。

如需精確的語意參考資料,請參閱伺服器標頭中的說明文件。

上述所有方法都只會啟動拆解作業,因此透過進行中的作業或平行「對使用者」呼叫 (例如方法處理常式) 可以安全地競爭。因此,我們需要謹慎維護伺服器實作物件的生命週期,以便取得平衡。有兩種情況:

啟動同步調度工具拆除作業

當傳遞至 fidl::BindServer 的非同步調度器 (async_dispatcher_t*) 是 UnbindClose此時可以安全地刪除伺服器物件。

如果指定了未繫結的處理常式,繫結發出一次最終「對使用者」呼叫,而這是不久之後取消繫結處理常式的呼叫,通常是在事件迴圈的下次疊代。未繫結的處理常式具有下列簽章:

// |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 的未繫結處理常式所取得的鎖定項目 (您應該確保在關機時不會保留任何鎖定項目,因為它會加入所有調度器執行緒,而這些執行緒可能會在使用者程式碼中取得鎖定項目)。