在组件处于空闲状态时将其停止

组件通常不会一直处于工作状态。大多数组件都是异步编写的,这意味着它们通常会等待下一个 FIDL 消息到达。不过,这些组件会占用内存。本指南介绍了如何调整组件,使其在空闲时主动停止并释放资源。

概览

预期体验如下:

  • 您将对组件的代码进行一些更改,以便让组件能够决定何时停止。组件将在停止之前保留其状态和句柄。持久保留这些数据称为“托管”。

  • 组件的客户端不会知道组件已停止。以这种方式停止组件不会中断其与您的组件的 FIDL 连接。

  • Fuchsia 提供了一些库,可让您监控 FIDL 连接和出站目录连接何时进入空闲状态,并在发生这种情况时将这些连接恢复为句柄。

  • 组件框架提供了一些 API,供组件存储句柄和数据,并在下次执行时(通常是在句柄可读后或在收到新的功能请求时)检索这些句柄和数据。我们将在后续部分详细介绍它们的工作原理。

  • Fuchsia 快照和 Cobalt 信息中心将包含有用的生命周期指标。

哪些组件是理想的候选对象?

我们建议您考虑具有以下特征的组件:

  • 流量突增。该组件可以启动并处理这些流量,然后在完成后返回到停止状态。启动和更新路径中的许多组件仅在这些时间需要,但在其他时间会浪费 RAM,例如 core/system-update/system-updater

  • 不会过于有状态。您可以在组件停止之前保留状态。在极限情况下,我们可以编写代码来持久保存所有重要状态。在实践中,我们会在节省内存与持久保留必要状态的复杂性之间做出权衡。

  • 内存用量高。使用 ffx profile memory 查看组件的内存用量。例如,它显示了使用 732 KiB 私有内存的典型系统上的 console-launcher.cm。私有内存是指仅由相应组件引用的内存,因此我们保证在停止相应组件时至少释放该数量的内存。请参阅衡量内存用量

    Process name:         console-launcher.cm
    Process koid:         2222
    Private:              732 KiB
    PSS:                  1.26 MiB (Proportional Set Size)
    Total:                3.07 MiB (Private + Shared unscaled)
    

http-client.cm 是一个示例组件,它不会在 HTTP 加载器连接之间保持状态,仅用于指标和崩溃上传。因此,我们对其进行了调整,使其在配置为这样后,在空闲时停止。

已知限制

  • 检查:如果您的组件通过检查发布诊断信息,那么当您的组件停止时,这些信息将被舍弃。https://fxbug.dev/339076913 跟踪即使在组件停止后仍保留检查数据的问题。

  • 挂起获取:如果您的组件是挂起获取 FIDL 方法的服务器或客户端,则很难保留该连接,因为 FIDL 绑定无法保存和恢复有关正在进行的调用的信息。您可以将该 FIDL 方法转换为事件和单向确认。

  • 目录:如果您的组件提供目录协议,则很难保留该连接,因为目录通常由 VFS 库提供。VFS 库目前不提供用于获取底层渠道和关联状态(例如搜索指针)的方法。

只要有充分的理由,所有这些都可以支持。您可以联系 Component Framework 团队,说明您的使用情形。

检测闲置状态

停止空闲组件的第一步是增强该组件的代码,使其能够知道自己何时进入空闲状态,这意味着:

  • FIDL 连接处于空闲状态:组件通常会声明许多 FIDL 协议功能,客户端会在需要时连接到这些协议。这些连接不应有需要组件注意的待处理消息。

  • 传出目录处于空闲状态:组件提供发布其传出功能的传出目录。不应有表示向此组件发出的功能请求的待处理消息,并且除了 component_manager 建立的连接之外,不应有额外的连接进入传出目录。

  • 其他后台业务逻辑:例如,如果某个组件在后台响应 FIDL 方法时发出网络请求,除非该网络请求已完成,否则我们可能不会认为该组件处于空闲状态。在请求过程中停止该组件可能不安全。

我们提供了 Rust 库来检测每种情况下的空闲状态。https://fxbug.dev/332342122 跟踪了 C++ 组件的相同功能。

检测空闲 FIDL 连接

您可以使用 detect_stall::until_stalled 将 Rust FIDL 请求流转换为一个在连接空闲时间超过指定超时时间时自动解除绑定 FIDL 端点的请求流。您需要将组件添加到 src/lib/detect-stall/BUILD.gn 的可见性列表中。如需了解详情,请参阅 API 文档和测试。以下是 http-client.cm 的使用方式:

async fn loader_server(
    stream: net_http::LoaderRequestStream,
    idle_timeout: fasync::Duration,
) -> Result<(), anyhow::Error> {
    // Transforms `stream` into another stream yielding the same messages,
    // but may complete prematurely when idle.
    let (stream, unbind_if_stalled) = detect_stall::until_stalled(stream, idle_timeout);

    // Handle the `stream` as per normal.
    stream.for_each_concurrent(None, |message| {
        // Match on `message`...
    }).await?;

    // The `unbind_if_stalled` future will resolve if the stream was idle
    // for `idle_timeout` or if the stream finished. If the stream was idle,
    // it will resolve with the unbound server endpoint.
    //
    // If the connection did not close or receive new messages within the
    // timeout, send it over to component manager to wait for it on our behalf.
    if let Ok(Some(server_end)) = unbind_if_stalled.await {
        // Escrow the `server_end`...
    }
}

检测闲置的出站目录

您可以使用 fuchsia_component::server::ServiceFs::until_stalled 方法将 ServiceFs 转换为一个在文件系统中没有工作时自动取消绑定出站目录服务器端点的 ServiceFs。如需了解详情,请参阅 API 文档和测试。以下是 http-client.cm 的使用方式:

#[fuchsia::main]
pub async fn main() -> Result<(), anyhow::Error> {
    // Initialize a `ServiceFs` and add services as per normal.
    let mut fs = ServiceFs::new();
    let _: &mut ServiceFsDir<'_, _> = fs
        .take_and_serve_directory_handle()?
        .dir("svc")
        .add_fidl_service(HttpServices::Loader);

    // Chain `.until_stalled()` before calling `.for_each_concurrent()`.
    // This wraps each item in the `ServiceFs` stream into an enum of either
    // a capability request, or an `Item::Stalled` message containing the
    // outgoing directory server endpoint if the filesystem became idle.
    fs.until_stalled(idle_timeout)
        .for_each_concurrent(None, |item| async {
            match item {
                Item::Request(services, _active_guard) => {
                    let HttpServices::Loader(stream) = services;
                    loader_server(stream, idle_timeout).await;
                }
                Item::Stalled(outgoing_directory) => {
                    // Escrow the `outgoing_directory`...
                }
            }
        })
        .await;
}

等待其他后台业务逻辑

ServiceFs 在生成 Item::Stalled 消息后,不会再生成更多功能请求。如果您有一些后台工作会阻止组件停止,但 ServiceFs 在此期间已变为闲置状态,并过早地取消绑定了出站目录端点,那么这可能会带来问题。为应对这些情况,您可以防止 ServiceFs 进入空闲状态。ServiceFs 生成的 Item::Request 包含 ActiveGuard。只要活跃的防护处于有效范围内,ServiceFs 就不会变为空闲,并且会持续生成传入的功能请求。

同样,您可以创建一个 ExecutionScope 来生成与处理 FIDL 连接相关的所有后台工作,并调用 ExecutionScope::wait() 来等待它们完成。例如,http-client.cm 中的 loader_server 函数在后台工作完成之前不会返回,这反过来会使 Item::Request 中的 active_guard 保持在作用域内,从而阻止 ServiceFs 停止。

向框架托管句柄和状态

当连接处于空闲状态且库已为您提供未绑定的服务器端点时,下一步是托管这些句柄,换句话说,将它们发送到组件框架以妥善保管。

无状态协议

注意:协议需要标记为 @discoverable 才能支持托管。 这样,组件可以在从空闲状态唤醒后重新连接到渠道。

某些 FIDL 连接不携带状态。无论请求是通过同一连接还是单独的连接发送的,其功能都相同。您可以按照以下步骤操作,以建立这些连接:

  • 如果尚未声明,请在组件清单中声明相应功能。如果此协议连接派生自其他连接,并且通常不从传出目录提供服务,您可能需要声明相应的功能。

  • 在声明功能时添加 delivery: "on_readable"。您需要将组件添加到 tools/cmc/build/restricted_features/BUILD.gn 中的 delivery_type 可见性列表中。然后,框架将监控新连接请求的服务器端点上的可读信号,并在有待处理的消息时将服务器端点连接到提供程序组件。示例:

    capabilities: [
        {
            protocol: "fuchsia.net.http.Loader",
            delivery: "on_readable",
        },
    ],
    
  • 为功能添加了来自 self 的 use 声明,以便程序可以从其传入的命名空间连接到该功能。您可以将功能安装在 /escrow 目录中,以便将其与组件使用的其他功能区分开来。示例:

    {
        protocol: "fuchsia.net.http.Loader",
        from: "self",
        path: "/escrow/fuchsia.net.http.Loader",
    },
    
  • 从传入的命名空间连接到功能,并从 detect_stalled::until_stalled 传递未绑定的服务器端点。

    if let Ok(Some(server_end)) = unbind_if_stalled.await {
        // This will open `/escrow/fuchsia.net.http.Loader` and pass the server
        // endpoint obtained from the idle FIDL connection.
        fuchsia_component::client::connect_channel_to_protocol_at::<net_http::LoaderMarker>(
            server_end.into(),
            "/escrow",
        )?;
    }
    

总而言之,这意味着组件框架将监控空闲连接,以确保其再次可读,然后在连接可读时将该功能发送回您的组件。如果组件已停止,此方法将启动组件。

出站目录

我们必须使用不同的 API 来托管主要出站目录连接(即 Item::StalledServiceFs 返回的连接),因为该服务器端点是所有其他连接到组件的入口点。对于 ELF 组件,您可以通过 fuchsia.process.lifecycle/Lifecycle.OnEscrow FIDL 事件将输出目录发送到框架:

  • lifecycle: { stop_event: "notify" } 添加到您的组件 .cml

    program: {
        runner: "elf",
        binary: "bin/http_client",
        lifecycle: { stop_event: "notify" },
    },
    
  • 获取生命周期编号句柄,将其转换为 FIDL 请求流,然后使用 send_on_escrow 发送事件:

          let lifecycle =
        fuchsia_runtime::take_startup_handle(HandleInfo::new(HandleType::Lifecycle, 0)).unwrap();
    let lifecycle: zx::Channel = lifecycle.into();
    let lifecycle: ServerEnd<flifecycle::LifecycleMarker> = lifecycle.into();
    let (lifecycle_request_stream, lifecycle_control_handle) =
        lifecycle.into_stream_and_control_handle();
    
    let outgoing_dir = None;
    // Later, when `ServiceFs` has stalled and we have an `outgoing_dir`.
    lifecycle_control_handle
        .send_on_escrow(flifecycle::LifecycleOnEscrowRequest { outgoing_dir, ..Default::default() })
        .unwrap();
    

    一旦组件发送了 OnEscrow 事件,它将无法再监控更多功能请求。因此,它应在此之后立即退出。 在下次执行时,组件将在其启动信息中重新获得上次运行中发送的同一 outgoing_dir 句柄。

    如需了解如何将所有这些内容组合在一起,请参阅 http-client

有状态协议和其他重要状态

fuchsia.process.lifecycle/Lifecycle.OnEscrow 事件会接受另一个实参,即 escrowed_dictionary client_end:fuchsia.component.sandbox.Dictionary,它是对 Dictionary 对象的引用。字典是可包含数据或功能的键值对映射。

  • 您可以使用框架中的 fuchsia.component.sandbox.CapabilityStore 创建 DictionaryRef,并在 Factory 协议上调用 DictionaryCreate

    use: [
        {
            protocol: "fuchsia.component.sandbox.CapabilityStore",
            from: "framework",
        }
    ]
    
          let capability_store = fuchsia_component::client::connect_to_protocol::<
        fidl_fuchsia_component_sandbox::CapabilityStoreMarker,
    >()
    .unwrap();
    let id_generator = sandbox::CapabilityIdGenerator::new();
    let dictionary_id = id_generator.next();
    capability_store.dictionary_create(dictionary_id).await?.map_err(to_err)?;
    
  • 您可以通过对 Dictionary FIDL 连接调用 Insert,向 Dictionary 添加一些数据(例如字节向量)。如需了解其他方法,请参阅 fuchsia.component.sandbox FIDL 库文档:

          let bytes = vec![1, 2, 3];
    let data_id = id_generator.next();
    capability_store
        .import(data_id, fsandbox::Capability::Data(fsandbox::Data::Bytes(bytes)))
        .await?
        .map_err(to_err)?;
    capability_store
        .dictionary_insert(
            dictionary_id,
            &fsandbox::DictionaryItem { key: "my_data".to_string(), value: data_id },
        )
        .await?
        .map_err(to_err)?;
    let fsandbox::Capability::Dictionary(dictionary_ref) =
        capability_store.export(dictionary_id).await?.map_err(to_err)?
    else {
        panic!("Bad export");
    };
    
  • 在退出之前,在 send_on_escrow 中发送 Dictionary 客户端端点:

          lifecycle_control_handle.send_on_escrow(flifecycle::LifecycleOnEscrowRequest {
        outgoing_dir: outgoing_dir,
        escrowed_dictionary: Some(dictionary_ref),
        ..Default::default()
    })?;
    
  • 在下次启动时,您可以从启动句柄中获取此字典:

          let Some(dictionary) =
        fuchsia_runtime::take_startup_handle(HandleInfo::new(HandleType::EscrowedDictionary, 0))
    else {
        return Err(anyhow!("Couldn't find startup handle"));
    };
    
    let dict_id = id_generator.next();
    capability_store
        .import(
            dict_id,
            fsandbox::Capability::Dictionary(fsandbox::DictionaryRef { token: dictionary.into() }),
        )
        .await?
        .map_err(to_err)?;
    
    let capability_id = id_generator.next();
    capability_store
        .dictionary_remove(
            dict_id,
            "my_data",
            Some(&fsandbox::WrappedNewCapabilityId { id: capability_id }),
        )
        .await?
        .map_err(to_err)?;
    let fsandbox::Capability::Data(data) =
        capability_store.export(capability_id).await?.map_err(to_err)?
    else {
        return Err(anyhow!("Bad capability type from dictionary"));
    };
    // Do something with the data...
    

Dictionary 对象支持多种商品数据类型。如果组件的状态小于 fuchsia.component.sandbox/MAX_DATA_LENGTH,您可以考虑存储 fuchsia.component.sandbox/Data 项,该项可以保存字节向量。

我想等待某个渠道变为可读状态

在停止之前,如果您希望安排组件框架等待某个渠道变为可读状态,然后将该渠道传递回您的组件,则可以使用相同的 delivery: "on_readable" 技术。这可推广到组件未公开的 FIDL 协议,例如服务成员。它甚至支持不使用 FIDL 协议的渠道。举例来说,假设您的组件包含一个 Zircon 异常渠道,并且需要告知框架等待该渠道可读,然后启动您的组件,您可以声明以下 .cml

capabilities: [
    {
        protocol: "exception_channel",
        delivery: "on_readable",
        path: "/escrow/exception_channel",
    },
],
use: [
    {
        protocol: "exception_channel",
        from: "self",
        path: "/escrow/exception_channel",
    }
]

请注意,exception_channel 功能未公开。此功能由组件本身使用。组件可能会从其传入的命名空间打开 /escrow/exception_channel,并带有要等待的渠道。当该渠道可读时,框架将在传出目录中打开 /escrow/exception_channel,并根据需要启动组件。总而言之,您可以声明功能,并从 self 中使用这些功能来托管 component_manager 的句柄。

如果您需要其他类型的触发器(例如等待自定义信号或等待计时器),请与 Component Framework 团队联系。

测试

我们建议您增强现有的集成测试,以测试您的组件是否可以在不中断 FIDL 连接的情况下自行停止并重新启动。如果您已经有启动组件并向其发送 FIDL 请求的集成测试,则可以使用组件事件匹配器来验证组件在没有消息时是否会停止。如需了解具体操作方法,请参阅 http-client 测试

着陆和指标

如果您希望针对特定产品优化此组件,可以向组件添加结构化配置,以控制空闲超时是否发生以及空闲超时时长。

组件框架会记录组件在执行之间启动和停止的时长,并将这些信息上传到 Cobalt。您可以在此信息中心内查看这些提醒,以便微调空闲超时时间。

当拍摄反馈快照时(例如在现场遇到 bug 时),初始组件执行和最新组件执行的时间戳将分别位于选择器 <component_manager>:root/lifecycle/early<component_manager>:root/lifecycle/late 中。您可以将这些事件与其他错误日志相关联,以帮助调查错误是否是由组件停止不当引起的。