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

组件通常不会一直处于工作状态。大多数组件都是异步编写的,这意味着它们通常在等待下一条 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 加载器连接之间保留状态,仅用于上传指标和崩溃信息。因此,我们对其进行了调整,使其在配置为闲置时停止。

已知限制

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

  • 目录:如果您的组件提供目录协议,则很难保留该连接,因为目录通常由 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 消息后,不会再生成更多功能请求。如果您有一些后台工作阻止组件停止,但 ServiceFs 在此期间已进入空闲状态,并过早地解除了传出目录端点的绑定,则可能会出现问题。为了应对这些情况,您可以防止 ServiceFs 进入空闲状态。ServiceFs 生成的 Item::Request 包含 ActiveGuard。只要有活跃的警卫在范围内,ServiceFs 就不会进入空闲状态,并会持续处理传入的功能请求。

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

向框架提供托管句柄和状态

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

无状态协议

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

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

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

  • 在声明 capability 时添加 delivery: "on_readable"。然后,框架将监控新连接请求的服务器端点上的可读信号,并在有消息待处理时将服务器端点连接到提供程序组件。示例:

    capabilities: [
        {
            protocol: "fuchsia.net.http.Loader",
            delivery: "on_readable",
        },
    ],
    
  • 从 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 事件后,将无法监控更多功能请求。因此,它应该在此之后立即退出。 在下次执行时,您的组件将在其启动信息中返回它在上次运行中发送的同一 outgoing_dir 句柄。

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

检查数据

当组件停止时,其 Inspect VMO 将不再提供。不过,您可以将 Inspect 数据的冻结、写时复制版本托管给框架。这样,即使组件已停止,诊断工具仍可读取其 Inspect 树的最后状态。

inspect_runtime::publish 函数会返回一个 PublishedInspectController。在组件停止之前,您可以对此控制器调用 escrow_frozen 方法。这会为框架提供组件的 Inspect VMO 的冻结副本。

以下是使用示例:

// When initializing your component...
let inspector = finspect::Inspector::default();
let inspect_controller =
    inspect_runtime::publish(&inspector, inspect_runtime::PublishOptions::default());

// ...

// When your component is about to stop...
if let Some(controller) = inspect_controller {
    let frozen_inspect_token = controller
        .escrow_frozen(inspect_runtime::EscrowOptions::default())
        .await;
    // The token can be stored in the escrowed dictionary if needed,
    // though often just escrowing is sufficient.
}

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

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

  • 您可以使用框架中的 fuchsia.component.runtime.Capabilities 创建 Dictionary(例如,在 Rust 中通过 fuchsia_component::runtime::Dictionary::new 创建):

    use: [
        {
            protocol: "fuchsia.component.runtime.Capabilities",
            from: "framework",
        }
    ]
    
          let dictionary = Dictionary::new().await;
    
  • 您可以通过对 Dictionary 调用 insert,向 Dictionary 添加一些数据(例如字节向量)。如需了解其他方法,请参阅 fuchsia.component.runtime FIDL 库文档:

          let bytes = vec![1, 2, 3];
    let data = Data::new(DataValue::Bytes(bytes)).await;
    dictionary.insert("my_data", data).await;
    
  • 在退出之前,在 send_on_escrow 中发送 Dictionary 句柄:

          lifecycle_control_handle.send_on_escrow(flifecycle::LifecycleOnEscrowRequest {
        outgoing_dir,
        escrowed_dictionary_handle: Some(dictionary.handle),
        ..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 dictionary = Dictionary::from(zx::EventPair::from(dictionary));
    let Some(Capability::Data(data)) = dictionary.remove("my_data").await else {
        return Err(anyhow!("Bad capability type from dictionary"));
    };
    let DataValue::Bytes(bytes) = data.get_value().await else {
        return Err(anyhow!("Bad data value from dictionary"));
    };
    // Do something with the data...
    

Dictionary 对象支持各种商品数据类型。如果组件的状态小于 fuchsia.component.runtime/MAX_DATA_LENGTH,您可以考虑存储 fuchsia.component.runtime/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,并根据需要启动组件。简而言之,您可以声明 capability 并从 self 使用它们,以将句柄托管到 component_manager。

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

测试

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

着陆页和指标

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

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

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