找回密码
 会员注册
查看: 12|回复: 0

西瓜Android子进程优化治理实践

[复制链接]

2万

主题

0

回帖

7万

积分

超级版主

积分
73021
发表于 2024-10-2 14:32:17 | 显示全部楼层 |阅读模式
背景App在启动时会通过Zygote fork出对应的应用进程,来运行应用上的Activity/Service等Android组件。通常,一个简单的App就是一个单独的进程;不过较为大型的App会设计成多进程的模式,将其他复杂的子功能抽离,放在子进程中运行,与主进程保持隔离,避免影响主进程的稳定性。西瓜视频对启动阶段的子进程做了较为全面的治理,取得了不错的收益,接下来将结合原理与案例为大家介绍下我们所做的优化。进程启动介绍创建进程是个复杂的流程,在讲解具体的优化思路之前,先简单讲解下进程的启动过程,以 Android 10.0 为例,整体流程分为三个部分,如下图所示:system_server进程调用 Process.start() 方法,通过socket向zygote进程发送创建新进程的请求;Process.start->ZYGOTE_PROCESS.start//该过程生成argsForZygote参数数组,保存进程的uid、gid、runtime-flags等一系列参数->ZygoteProcess.startViaZygote//根据abi选择合适的ZygoteState来通信->ZygoteProcess.openZygoteSocketIfNeeded->attemptConnectionToPrimaryZygote|->attemptConnectionToSecondaryZygote->ZygoteProcess.zygoteSendArgsAndGetResult// usap介绍:https://juejin.cn/post/6922704248195153927->attemptUsapSendArgsAndGetResult|//通过socket向Zygote进程发送参数列表并进入阻塞等待状态,直到接受到新创建的进程pid->attemptZygoteSendArgsAndGetResultzygote进程执行 ZygoteInit.main() 后便进入 ZygoteServer.runSelectLoop() 循环体内,当有客户端连接时便会执行ZygoteConnection.runOnce()方法,再经过层层调用后fork出新的应用进程;ZygoteInit.main->ZygoteServer.runSelectLoop//接收客户端connect()请求,Zygote服务端执行accept()操作->ZygoteServer.acceptCommandPeer//处理客户端的一次请求,读取其中的参数列表->ZygoteConnection.processOneCommand->Zygote.forkAndSpecialize->nativeForkAndSpecialize->com_android_internal_os_Zygote_nativeForkAndSpecialize->ForkCommon//fork出子进程->fork->SpecializeCommon//子进程处理一些特殊特殊逻辑:比如设置随机数->CallStaticVoidMethod(...,gCallPostForkChildHooks,...)->Zygote.callPostForkChildHooks->ZygoteHooks.postForkChild->nativePostForChild->ZygoteHooks_nativePostForkChild在Zygote fork出新进程后,即pid=0,子进程开始运行,执行 ZygoteConnection.handleChildProc 方法;ZygoteConnection.processOneCommand->ZygoteConnection.handleChildProc->ZygoteInit.zygoteInit->ZygoteInit.nativeZygoteInit->com_android_internal_os_ZygoteInit_nativeZygoteInit->AppRuntime.onZygoteInit//启动binder线程->rocessState.startThreadPool->RuntimeInit.applicationInit->findStaticMain//返回的是Runnable->returnnewMethodAndArgsCaller//此时会回到ZygoteInit.main里->MethodAndArgsCaller.run//通过反射调用到ActivityThread.main->ActivityThread.main根据上面的描述,我们对创建进程有了直观了解,可以看到相关逻辑是比较复杂的,并且进程作为系统分配资源的单位,多一个进程就会多占用一部分内存资源,运行时挤占CPU的运行时间,增加系统压力从而影响到应用性能。那为什么我们需要关注启动阶段的进程情况?因为应用在启动阶段,主进程执行的任务较重,如果再去启动其他子进程,那对应用的启动速度,以及1min流畅性上都会造成明显影响,容易影响到用户体验,等启动阶段结束,再启动其他子进程,资源竞争的情况会好很多,对性能的影响就比较小。不过我们虽然知道进程会占用系统资源,但是并没有量化的手段来确定子进程对主进程的影响,因为这种影响会在运行期间动态变化,在本地看单个机器波动较大,所以需要通过后续的实验来看实际收益。其他启动进程情况除上面常见的启动进程方式,还有其他的方式:通过Runtime.exec运行shell命令启动进程:Runtime.exec->rocessBuilder.start->rocessImpl.start->UNIXProcess.->UNIXProcess.forkAndExec->[UNIXProcess_md.c]UNIXProcess_forkAndExec->[UNIXProcess_md.c]startChild通过native代码直接fork进程。这两种方式只要了解即可,本文主要关注业务代码中通过Android组件启动的进程,不过后面会对 Runtime.exec 启动的进程做个简单介绍。治理思路按照解决问题的一般思路,我们对问题进行具体分析,确定问题原因后再着手解决;对于进程治理也是一样的,在治理前我们先了解下业务侧添加新进程的过程,确定进程的启动情况:在AndroidManifest.xml中增加组件,并配置android:process属性;使用的时候通过启动对应的组件来创建进程;通过AS的界面窗口,我们可以在本地清楚地看到西瓜在启动阶段会启动:push 进程、小程序进程、downloader进程、sandboxed进程这四个常见的子进程,也可以通过 ps 命令来查看进程信息。确定要优化的进程后,我们可以通过 manifest 中的组件申明,枚举出来所有会拉起对应进程的组件,也可以通过 adb logcat | grep {进程名},过滤进程的启动日志就能看到对应的首个组件。找到对应的组件后,接下来就要对组件进行处理,对于子进程的治理有三个通用思路:按需加载:在使用的时候才去加载对应的功能模块,避免在早期进行多余的调用;对于进程的代码实现来说,我们最好在使用进程组件时,能有个统一的入口,方便管理和维护;延迟加载:按需加载一般来说是更优先的选择,但要有确定的加载时机;如果在时机不能确定,或者进程本身需要通过提前加载来优化后续体验时,可以考虑延迟加载;延迟到什么时候是个需要考虑的问题,结合应用使用时长和进程功能的特点,一般来说有两个较为常见的选择点:1min之后,或者退后台,两个时机的出发点是一致的,都为了尽量不影响用户的使用体验;合并到主进程:将进程中的逻辑合并到主进程中,避免创建子进程;但需要对功能进行完整的验证,确保在单进程模式下功能的可用性和稳定性;上面的思路都比较温和,不是一味地去除功能,我们希望在保证功能稳定可用的前提下,尽量降低进程启动带来的影响,结合自身应用的特点进行选择,采用合适的方案。治理过程Push进程西瓜push的主要工作可以分为两个部分:push SDK初始化:进行配置与监控操作,在Application.onCreate阶段进行,这部分不涉及进程启动,不用干预;push进程启动:用于处理保活、红点、长连接的建立;从业务功能的角度来说应用处于前台时不需要接收 push,而应用在启动阶段基本上是处于前台的,并且启动时间较短,这段时间不需要接收push相关的通知,可以考虑将push进程延迟处理。那接下来的问题就是如何在启动阶段抑制 push 进程?为了解决这个问题,先后产生了三个方案:手动方案最早做的方案主要有两个部分:其中push的必要逻辑,比如长连接逻辑,可以合并到主进程;另外一部分非必要逻辑,则通过阻止相应的 service 启动来避免push进程创建;长链接合并到主进程方案最终使用的是后面讲到的SDK方案,直接将长链接服务关联到主进程,因为单进程版本改动不是很大,且不影响业务功能,并且由SDK同学提供支持,整体方案比较稳妥;而阻止相应的 service 启动,需要寻找对应 service,在启动阶段添加判断条件,避免触发启动,等退后台时再手动调用 service 来启动 push 进程;但这种方式比较花费人力,而且 push 进程的启动时机比较复杂,对业务不了解的情况下难以做到全面的梳理,有可能遗漏某些场景,导致 push 进程抑制失败。SDK方案上面的第一种方案修改成本较高,维护性不友好,后续push SDK有功能逻辑的修改,需要再次进行排查;考虑后决定在 SDK 层面去做会是个更好的方案,所以和相关同学进行沟通,让他帮忙支持。通过沟通了解到,Push会通过 settings 开关配置平台下发对应的开关,判断是否需要延迟处理,如果需要就不启动对应的进程,等到退后台再启动;功能逻辑上也比较完备,考虑了在不启动子进程的情况下,对原有逻辑进行补充:比如注册厂商通道,新pull接口、拉活逻辑放在主进程等。使用 SDK方案做了几次实验,均取得了较大的性能收益,但并没有取得人均活跃天数的业务收益,按照过往的经验,这种幅度的性能收益是能带来对应业务收益的,因此怀疑该方案可能拦截并不彻底。同时得知抖音也做了 push 进程延迟的相关实验,取得了较大收益,进行沟通后,了解到他们是通过拦截 service 来实现的,所以参考抖音产生了第三个方案。拦截方案和第一种手动方案的思路是类似的,都是对push进程的组件进行拦截,然后退后台再启动,只是修改方案不同:该方案直接对 startService、bindService 方法进行插桩处理,判断启动的是 push 组件,就通过 runnable 保存下来,等退后台再执行,从而成功抑制push进程的启动,不用像第一种方案对每个 service 进行手动处理:从 packageInfo 中收集在 manifest 申明过的 push 进程组件信息,目前push的相关进程是 push、pushservice,收集这两个进程的组件即可;在 startService 和 bindService 的插桩方法添加过滤逻辑,判断是否是push进程组件,如果是保存在 runnable 中;利用 andoridx 工具库的 LifecycleObserver 监听退后台,西瓜封装成 ActivityStack.OnAppBackGroundListener,退后台时将之前保留的 Runnable 执行一遍,同时移除监听。在拦截方案的同时加了 push 进程的监控逻辑,方便实验中的问题排查,这部分会在后续进程启动监控部分中具体讲解。拦截方案和 SDK 方案对比,拦截方案和SDK方案的性能效果接近,证明SDK方案的拦截没有问题,但产品指标不如SDK方案;SDK 方案考虑全面,不启动进程的同时补充其他的功能逻辑,更加严谨,不容易出问题,并且SDK同学会继续帮忙维护,节省后续的开发维护成本。综合考虑最后上线的是 SDK 方案。小程序进程方案实现相较于 push 进程,小程序进程的优化方案选择就简单很多,一开始想使用 service 拦截方案,直接在 startService 和 bindService 处拦截小程序进程组件的启动。但这种方案对小程序进程不太可行,因为小程序功能逻辑主要在小程序插件里的,需要在插件中对相关代码进行插桩处理,比较麻烦,而更大的问题是可能会影响到业务功能。因为与 push 进程启动相比,小程序进程并不存在退后台启动的逻辑,在使用过程中就可能会使用到小程序,并没有合适的时机进行启动,所以需要结合业务逻辑去分析,看看业务上延迟小程序进程启动是否可行。西瓜小程序入口逻辑都在小程序服务 MiniAppService 这个类上,通过对入口方法进行梳理,发现有合适的处理方案:小程序进程启动大都是由于预加载任务 PreloadMiniAppTask 导致的,去除这个任务就可以完成小程序进程按需的逻辑;修改完成后经过测试,小程序功能没有问题,符合预期。后续在代码上梳理了小程序的加载逻辑,发现在视频广告(包括长视频、中视频等多题材),游戏中心等场景会使用到小程序功能,这部分功能的加载逻辑是按需的,因此我们没有进行改动,保证原先功能可以正常使用。指标异常排查不过实验期间遇到个问题,实验的性能和产品指标都不错,但核心指标中使用时长有明显的劣化,花费了较多的时间进行排查:首先想到的是影响了推荐质量,因为使用时长能体现用户的使用意愿,可能是推荐质量不太行,导致用户不愿意继续使用,不过与其他指标不太符合,其他核心指标是显著正向的,单一指标劣化说不通;并且从代码改动上看,只涉及到小程序预加载任务,改动小,理论上不会对Feed请求造成影响;接着猜测子进程可能会上报使用时长,抑制小程序进程后,导致上报数据变少。但排查日志上报的初始化逻辑后,发现埋点上报的工具类并没有在子进程初始化,也就是说除主进程外,子进程并不会上报埋点,并且实验组的使用时长次数反而更多一些,说明上报的数据量并没有变少,排查过程一度陷入困境。多次实验后,每次使用时长都稳定保持在相近的劣化幅度,所以仍然怀疑是埋点导致的问题,打算朝这个方向再努力下,找到DA同学沟通后,了解到使用时长是通过相关埋点的会话时长这个参数进行上报的,而时长是根据页面的 onResume、onPause 计算出来的,查看代码并过滤相关埋点日志,发现在优化未开启时,每次页面 onPause 后会多上报一次 1s 的使用时长,那接下来需要排查:为什么每次页面切换会多上报一次的使用时长?为什么每次多余的上报时长是1s?检查小程序相关代码,发现小程序插件在加载后,会在主进程添加ActivityCallback,并调用埋点上报的 onResume、onPause逻辑,即加载插件后,只要页面发生切换,会调用一次使用时长的埋点记录。而西瓜宿主原先在自己的ActivityCallback就有onPause调用,加上小程序插件里面的ActivityCallback的调用,一次onPause会触发连续两次埋点上报;并且埋点记录有使用时长的保护逻辑,前后时间戳差值在 getString("ro.product.brand")->SystemProperties.get->native_get->SystemProperties_getSS->android::base::GetProperty->__system_property_find->__system_property_read_callback接下来我们看看 getprop 命令的实现,这个链路会短不少:getprop_main->rintProperty->android::base::GetProperty->__system_property_find->__system_property_read_callback从代码实现看,最终获取的值都是从同一个地方取的,以防万一我们通过独立灰进行了验证,线上这两个方法取得值也是一致的,所以根据上述的结果,我们采用的方案是:brand 直接通过 Build.BRAND 获取;其他没有暴露出来的信息,通过反射走 SystemProperties.get。我们全局梳理后对这类错误的使用情况进行了插桩替换处理,目前该优化还在实验中。除 getprop 外,还有一些不必要的Shell命令调用,比如 cat 命令,这种完全可以直接读取对应的文件来替换实现。进程启动监控最后讲一下进程启动监控的逻辑,一开始是为了拦截push进程的组件启动,后面则是为了防止进程启动优化失效,最终的方案在 push 拦截监控的基础上做了一些修改,也都是常见的技术方案:首先从 PackageInfo 中收集各个进程的组件信息,这一步和拦截方案是相同的:intflags=PackageManager.GET_ACTIVITIES|PackageManager.GET_RECEIVERS|PackageManager.GET_SERVICES|PackageManager.GET_PROVIDERSackageInfopackageInfo=context.getPackageManager().getPackageInfo(context.getPackageName(),flags);ActivityInfo[]activities=packageInfo.activities;if(activities!=null){for(ActivityInfoactivityInfo:activities){if(activityInfo.processName.contains(processSuffix)){components.add(activityInfo.name);}}}...//处理receiver、service、provider对 ActivityManager 中接口对象 IActivityManager 进行代理,这样可以在主进程启动组件时进行判断:ClassactivityManagerClass;FieldgDefaultField;if(Build.VERSION.SDK_INT[]{iActivityManagerInterface},newInvocationHandler(){@OverridepublicObjectinvoke(Objectproxy,Methodmethod,Object[]args)throwsThrowable{returnproxyMethodInvoke(iActivityManagerObject,method,args);}});对各个启动的组件进行解析,过滤后符合条件的进行上报;privateObjectproxyMethodInvoke(Objectproxy,Methodmethod,Object[]args)throwsInvocationTargetException,IllegalAccessException{try{ComponentStartInfostartInfo=parseComponentStartInfo(method.getName(),args);if(startInfo!=null){reportProcessStartInfo(startInfo,method);}}catch(Exceptionignore){}finally{returnmethod.invoke(proxy,args);}}除在运行期间通过 PackageInfo 获取的方式外,也可以通过编译期对 manifest 文件进行扫描,同样能够知道有哪些申明进程的组件。优化收益经过大半年时间的治理,西瓜视频对上述的子进程都进行了优化,取得了较好的收益:优化进程核心业务收益品质收益push进程有效播放次数显著+0.189%冷启耗时-140ms,大盘帧率+0.5%,丢帧次数-1%~2%小程序进程(含downloader进程)人均活跃天数显著+0.0892%,有效播放次数显著+0.207%启动后1min帧率+0.405%,人均 anr -26.223%Sandboxed进程人均活跃天数显著+0.0712%,有效播放次数显著+0.168%冷启耗时-120ms,首帧耗时-200ms,启动后1min帧率 +0.1,人均anr -6.7%团队介绍我们是字节跳动西瓜视频客户端团队,专注于西瓜视频 App 的开发和基础技术建设,在客户端架构、性能、稳定性、编译构建、研发工具等方向都有投入。如果你也想一起攻克技术难题,迎接更大的技术挑战,欢迎点击内推链接,或者投递简历到xiaolin.gan@bytedance.com。最 Nice 的工作氛围和成长机会,福利与机遇多多,在上海和杭州均有职位,欢迎加入西瓜视频客户端团队 !
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 会员注册

本版积分规则

QQ|手机版|心飞设计-版权所有:微度网络信息技术服务中心 ( 鲁ICP备17032091号-12 )|网站地图

GMT+8, 2025-1-12 12:26 , Processed in 0.488137 second(s), 26 queries .

Powered by Discuz! X3.5

© 2001-2025 Discuz! Team.

快速回复 返回顶部 返回列表