前言
在 chromium 浏览器中,当鼠标 hover 到标签页上的时候会显示标签页的内存占用,如下图所示:
这个功能中,需要定时采集进程内存,同时在标签页创建首次加载完成后,也需要立即采集一次内存。
chromium 中关于与这个功能相关的是 performance manager 模块,其中一个重要环节是是性能数据的获取。关于数据获取的逻辑在今年也有了比较大的重构,因此借此从变更中了解 chromium 的代码设计思路。
旧设计
旧设计中,由 ProcessMetricsDecorator 来实现这一功能。decorator 是 chromium performance graph 模块中重要的一个概念,即对通过具体的行为逻辑修改节点(Node)的属性。首先通过调用 memoryInstrumentation 来定时采集内存,获取到数据后,根据进程中的 webview 数目来分配内存到单个 webview 上。
因为 chromium 的多进程策略原因,可能多个网页在多个进程中,因此单个标签页的内存是通过均匀分配的方式来估算出来的。
定时采集的流程如下,这个思路是非常自然和顺畅的。即先获取内存数据,接着内存分配给每个页面上,最终设置到和 Webcontents 绑定的 ResourceUsageTabHelper 内部。
这里的 Receiver 设计,这在 chromium 中非常常见,即引入一个第三者来解除 Notifier 和 Manager 之间的依赖关系,这种设计更解耦,但是也会让调用链路更长。
chromium 考虑的更多一些:定时器是否有必要 chromium 运行后就一定要启动呢,并且一直不停止?这实际取决于有没有业务需要使用内存数据。因此 chromium 设计了 ScopedMetricsInterestTokenImpl 类来对关注内存数据的业务方进行计数。业务方数目从 0 变成 1 的时候启动定时器,当业务方数目为 0 的时候停止定时器。
另一个问题是 ProcessMetricDecorator 对外提供了一次性的立即采集所有进程的接口,因此需要考虑到截流限制内存采集频率,因此在真正采集之前需要判断两个条件:
- 如果已经在采集过程中,则不用再触发新的采集,因为采集是后台线程异步过程
- 如果当前时间与上一次采集时间的差<阈值,则不会触发新的采集,因为当前缓存的数据比较新
void ProcessMetricsDecorator::RequestImmediateMetrics() {
if (state_ == State::kWaitingForResponse) {
// A measurement is already being taken and will be available immediately.
return;
}
if (!last_memory_refresh_time_.is_null() &&
base::TimeTicks::Now() - last_memory_refresh_time_ <
kMinImmediateRefreshDelay) {
// The most recent measurement is fresh enough.
return;
}
...// 采集逻辑
}
这些逻辑都很好理解,因此 chromium 的旧版本设计是非常符合直觉的。
新设计
代码越来越复杂的一个原因就是一个模块随着迭代包含越来越多的功能。在最开始的时候避免过度设计,但随着后续功能迭代,可能就需要将模块中的部分功能独立出来,这样既可以复用,同时能够更好的测试,增加可扩展性。
decorator 的核心逻辑是获取内存数据后装饰到节点上(PageNode/ProcessNode),但在旧设计中这个类中包含很多与装饰无关的逻辑,比如内存采集定时器,控制频率,内存分配等。因此可以考虑将内存数据的获取部分单独抽取出为独立的模块。
其次内存分配逻辑是将进程的内存分配到 Frame/Page 上,这部分逻辑也是相对独立的。并且后续 chromium 需要将进程的 cpu 也分配到对应的 frame/page 上,这部分也可以考虑独立出来,模块的功能更加单一。
基于这些背景,chromium 重新设计了一个新的模块 resource_attribution,架构设计图如下:
在这个设计中,最显著变化的是引入了 Qeury 的框架,我们来详细介绍这个框架的设计思路。
QueryParams
在旧版本的设计中,外部业务主要关注 Webcontents(对应节点 PageNode)的内存信息。在新设计里,提供了更多的维度,包括进程/页面/Frame/Worker/同一个 BrowsingInstanceContext,外部可以根据需要使用的灵活性更高。
// A variant holding any type of resource context.
using ResourceContext = absl::variant<FrameContext,
PageContext,
ProcessContext,
WorkerContext,
OriginInBrowsingInstanceContext>;
QueryParams 中可以构造需要关注的 Context 列表(ContextCollection)。
同时支持多种类别的数据获取,定义在 ResourceType 中。在 QueryParams 中也可以构造类别列表(ResourceTypeSet)。
// Types of resources that Resource Attribution can measure.
enum class ResourceType {
// CPU usage, measured in time spent on CPU.
kCPUTime,
// High-level memory information, such as PrivateMemoryFootprint. Relatively
// efficient to measure.
kMemorySummary,
};
QueryScheduler
components/performance_manager/resource_attribution/query_scheduler.cc
QueryScheduler 是 performance manager graph 中的“单例”
这个类是资源采集调度器,它本身并不负责具体的采集逻辑,同时也不包含定时器这些业务逻辑。
它对外提供请求数据的接口,根据 params 参数,调用它内部包含的多种类型资源采集器,在接收到采集器的资源会调后,又会根据 params 参数中关注的 resourceContext 列表返回数据。
同时这个类运行在 performance manager graph 的线程序列上,因此对外提供了一些 static 的工具接口 CallOnScheduler,以便外部可以在任何线程上调用。
SchedulerTaskRunner
这个类非常简单,因为 QueryScheduler 必须运行在 performance manager graph 中的 taskrunner,因此内部的 taskRunner 就是 graph 的 taskRunner。chromium 封装了一下并且对外提供 CallWithScheduler 函数,从注释看只是为了能在 UT 中通过。
void SchedulerTaskRunner::OnSchedulerPassedToGraph(Graph* graph) {
base::AutoLock lock(task_runner_lock_);
CHECK(!task_runner_);
// Use the PM task runner if QueryScheduler is installed on the PM. (In tests
// it might not be.) This is used instead of GetCurrentDefault() because the
// PM task runner might be a wrapper for the default.
if (PerformanceManager::GetTaskRunner()->RunsTasksInCurrentSequence()) {
task_runner_ = PerformanceManager::GetTaskRunner();
} else {
task_runner_ = base::SequencedTaskRunner::GetCurrentDefault();
}
CHECK(task_runner_);
CHECK(!graph_);
graph_ = graph;
}
CPUMeasurementMonitor
components/performance_manager/resource_attribution/cpu_measurement_monitor.cc
该模块没有采集系统的 cpu
该模块用来采集所有进程的 cpu,并且分配给每个 BrowserInstsanceContext。
在 CPUMeasurementMonitor::StartMonitor 的时候会对所有进程采集一次数据。给每个进程节点(ProcessNode)关联 CPUMeasurementData ,具体的采集逻辑由 CPUMeasurementDelegateImpl 完成。
chromium 在这里的抽象程度有点“丧心病狂”的感觉了。
因为对于单个进程的 cpu 采集逻辑是非常简单的,就是根据 process handler 创建对应的 ProcessMetrics(//base),接着调用 ProcessMetrics::GetCumulativeCPUUsage 就可以了。但是 chromium 这里在 CPUMeasurementData 中没有直接创建 ProcessMetrics,而是创建 delegate,这似乎没有必要(可能只是为了测试性更好而写的)。
delegate 创建方式只有一种,但是这里却设计了 factory,通过工厂创建,同时给 delegate 和 factory 都设计虚基类。仿佛是因为这点醋包了一桌子饺子的感觉,很多类都是没有太大的实际用处的。这样的代码设计在 chromium 中实际上还有很多。
最终的采集逻辑如下,即调用 ProcessMetrics 上的 GetCumulativeCPUUsage 方法。
CPUMeasurementDelegateImpl::CPUMeasurementDelegateImpl(
const ProcessNode* process_node) {
const base::ProcessHandle handle = process_node->GetProcess().Handle();
#if BUILDFLAG(IS_MAC)
process_metrics_ = base::ProcessMetrics::CreateProcessMetrics(
handle, content::BrowserChildProcessHost::GetPortProvider());
#else
process_metrics_ = base::ProcessMetrics::CreateProcessMetrics(handle);
#endif
}
在数据采集完成后,monitor 需要对数据进行进一步的整理,包括对 usage 的划分(目前是按照进程下的 frame/worker 数据均分),以及对返回的数据的格式整理等。
这里还包含了在两个采集过程中,进程退出场景下的 cpu usage 计算的问题,后续再展开
MemoryMeasurementProvider
components/performance_manager/resource_attribution/memory_measurement_provider.h
和 cpu 是类似的,区别在于采集进程数据依赖 memory_instrumentation,而不需要给每个 processNode 关联一个 Metric,因此整体设计如下,在 provider 下直接持有了 delegate:
同样的,在采集到数据的回调中会对数据划分分配到 frame/worker 上:
ScopedResourceUsageQuery
当看完 QueryScheduler 的设计后,会发现还少了一些什么。是的,还缺少了定时采集,以及截流的逻辑。
chromium 设计了 ThrottledTimer 来实现这两部分的功能:
- 只针对 memory 进行截流
- last_fire_time_ 为 false 表示一次性的采集,为 true 表示定时器采集
- 定时器采集的无需限流
一次性采集如果在下面三种情况下均不会采集:
- 距离上一次一次性采集时间在 |g_min_memory_query_delay| 时间(2 秒)以内
- 距离上一次定时器采集时间在 |g_min_memory_query_delay| 时间(2 秒)以内
- 距离下一次定时器采集时间在 |g_min_memory_query_delay| 时间(2 秒)以内
bool ScopedResourceUsageQuery::ThrottledTimer::ShouldSendRequest(
internal::QueryParams* params,
bool timer_fired) {
if (!params->resource_types.Has(ResourceType::kMemorySummary)) {
// Only memory queries are throttled.
return true;
}
const auto now = base::TimeTicks::Now();
if (timer_fired) {
// Repeating queries aren't throttled, but need to save the current time to
// throttle QueryOnce().
CHECK(timer_.IsRunning());
last_fire_time_ = now;
next_fire_time_ = now + timer_.GetCurrentDelay();
return true;
}
// Check if this QueryOnce() should be throttled.
if (!last_query_once_time_.is_null() &&
now < last_query_once_time_ + g_min_memory_query_delay) {
// QueryOnce() called recently.
return false;
}
if (!last_fire_time_.is_null() &&
now < last_fire_time_ + g_min_memory_query_delay) {
// Timer fired recently.
return false;
}
if (!next_fire_time_.is_null() &&
now > next_fire_time_ - g_min_memory_query_delay) {
// Timer is going to fire soon.
return false;
}
last_query_once_time_ = now;
return true;
}
QueryBuilder
components/performance_manager/public/resource_attribution/queries.h
QueryScheduler 确实可以直接用来采集一次性能数据了,但是它没有那么好用,因为它需要先构造好 QueryParams 数据结构。因此 chromium 提供了一个工具类来方便的一次性采集数据,或者导出一个 ScopedResourceUsageQuery 来进行定时采集,类似下面的写法:
QueryBuilder()
.AddAllContextsOfType<ProcessContext>()
.AddResourceType(ResourceType::kCPUTime)
.QueryOnce(callback);
这个类实现很简单,因此在这里不再赘述。
小结
代码设计不是一成不变的,也不是一蹴而就的。它是随着项目需求迭代而不断的升级。因此根据需求选择合适的架构设计是非常重要的。
正如 chromium 在 //services 的框架的设计文档中写道:“团队有意识地选择不过度设计代码和架构,直到我们有明显的需求”。尽管 chromium 的代码设计不总是最好的,相反很多时候因为抽象让阅读难度大大增加,但学习它的设计思路对我们设计更为复杂的架构有很多的参考价值。
本文中提到的变更原因仅代表从 commit 上推测而来,欢迎有不同的见解在评论区交流 ☕️。