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

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

概览

预期结果如下:

  • 您将对组件的代码进行一些更改,以便它可以决定何时停止。您的组件将在停止之前保留其状态和处理脚本。持久化此类数据称为托管

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

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

  • 组件框架为您的组件提供了 API,以便其存储句柄和数据,并在下次执行时检索这些数据(通常是在句柄可读取后或收到新 capability 请求后)。我们将在后面的部分详细介绍它们的运作方式。

  • 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 会跟踪检查数据的保留情况,即使在组件停止后也是如此。

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

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

所有这些都可以通过充分的理由得到支持。您可以与组件框架团队联系,说明您的用例。

检测空闲状态

停止空闲组件的第一步是增强该组件的代码,以便了解其何时变为空闲状态,这意味着:

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

  • 出站目录处于空闲状态:某个组件提供出站目录,用于发布其出站功能。不应有代表对此组件的 capability 请求的待处理消息,并且除了由 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 消息后,便不会再生成更多 capability 请求。如果您有一些后台工作会阻止组件停止,但 ServiceFs 在此期间变为空闲状态并过早解除出站目录端点的绑定,则可能会出现问题。如需处理这些情况,您可以阻止 ServiceFs 进入空闲状态。ServiceFs 生成的 Item::Request 包含 ActiveGuard。只要有效的守卫器在范围内,ServiceFs 就不会变为空闲状态,并会在有能力请求传入时继续让出这些请求。

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

将句柄和状态托管到框架

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

无状态协议

某些 FIDL 连接不携带状态。无论是通过同一连接还是通过单独的连接发送,每个请求的运作方式都是相同的。您可以按照以下步骤处理这些关联:

  • 在组件清单中声明该 capability(如果尚未声明)。如果此协议连接派生自其他连接,并且通常不是从传出目录提供,则您可能需要声明该 capability。

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

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

    {
        protocol: "fuchsia.net.http.Loader",
        from: "self",
        path: "/escrow/fuchsia.net.http.Loader",
    },
    
  • 从传入命名空间连接到 capability,并从 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::Stalled 中的 ServiceFs 返回的连接),因为该服务器端点是所有其他连接到组件的入口点。对于 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 事件后,将无法监控更多 capability 请求。因此,它应该会在之后立即退出。在下次执行时,您的组件将在其启动信息中收到其在上次运行时发送的相同 outgoing_dir 句柄。

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

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

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

  • 您可以通过使用框架中的 fuchsia.component.sandbox.CapabilityStore 并对 Factory 协议调用 DictionaryCreate 来创建 DictionaryRef

    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 capability。此 capability 由组件本身使用。该组件可能会从其传入命名空间中打开 /escrow/exception_channel,并使用要等待的通道。当该通道可读时,框架会在传出目录中打开 /escrow/exception_channel,并根据需要启动该组件。总而言之,您可以声明功能,并在 self 中使用这些功能将句柄托管到 component_manager

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

测试

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

着陆页和指标

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

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

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