不过好景不长,进入 Android 8.0 时代之后,这个库就逐渐消亡。
一般来说,Android 进程保活分为两个方面:
保持进程不被系统杀死。
进程被系统杀死之后,可以重新复活。
随着 Android 系统变得越来越完善,单单通过自己拉活自己逐渐变得不可能了;因此后面的所谓「保活」基本上是两条路:
提升自己进程的优先级,让系统不要轻易弄死自己;
App 之间互相结盟,一个兄弟死了其他兄弟把它拉起来。
当然,还有一种终极方法,那就是跟各大系统厂商建立 PY 关系,把自己加入系统内存清理的白名单;比如说国民应用微信。当然这条路一般人是没有资格走的。
大约一年以前,大神 gityuan 在其博客上公布了 TIM 使用的一种可以称之为「终极永生术」的保活方法;这种方法在当前 Android 内核的实现上可以大大提升进程的存活率。笔者研究了这种保活思路的实现原理,并且提供了一个参考实现 Leoric。
接下来就给大家分享一下这个终极保活黑科技的实现原理。
1保活的底层技术原理
知己知彼,百战不殆。
既然我们想要保活,那么首先得知道我们是怎么死的。
一般来说,系统杀进程有两种方法,这两个方法都通过 ActivityManagerService 提供:
killBackgroundProcesses
forceStopPackage
在原生系统上,很多时候杀进程是通过第一种方式,除非用户主动在 App 的设置界面点击「强制停止」。
不过国内各厂商以及一加三星等 ROM 现在一般使用第二种方法。
第一种方法太过温柔,根本治不住想要搞事情的应用。
第二种方法就比较强力了,一般来说被 force-stop 之后,App 就只能乖乖等死了。
因此,要实现保活,我们就得知道 force-stop 到底是如何运作的。既然如此,我们就跟踪一下系统的 forceStopPackage 这个方法的执行流程:
首先是 ActivityManagerService里面的 forceStopPackage 这方法:
public void forceStopPackage(final String packageName, int userId) {
// … 权限检查,省略
long callingId = Binder.clearCallingIdentity();
try {
IPackageManager pm = AppGlobals.getPackageManager();
synchronized(this) {
int[] users = userId == UserHandle.USER_ALL
? mUserController.getUsers() : new int[] { userId };
for
资料获取→专栏
(int user : users) {
// 状态判断,省略…
int pkgUid = -1;
try {
pkgUid = pm.getPackageUid(packageName, MATCH_DEBUG_TRIAGED_MISSING,
user);
} catch (RemoteException e) {
}
if (pkgUid == -1) {
Slog.w(TAG, "Invalid packageName: " + packageName);
continue;
}
try {
pm.setPackageStoppedState(packageName, true, user);
} catch (RemoteException e) {
} catch (IllegalArgumentException e) {
Slog.w(TAG, "Failed trying to unstop package "
- packageName + ": " + e);
}
if (mUserController.isUserRunning(user, 0)) {
// 根据 UID 和包名杀进程
forceS
topPackageLocked(packageName, pkgUid, "from pid " + callingPid);
finishForceStopPackageLocked(packageName, pkgUid);
}
}
}
} finally {
Binder.restoreCallingIdentity(callingId);
}
}
在这里我们可以知道,系统是通过 uid 为单位 force-stop 进程的,因此不论你是 native 进程还是 Java 进程,force-stop 都会将你统统杀死。我们继续跟踪 forceStopPackageLocked 这个方法:
final boolean forceStopPackageLocked(String packageName, int appId,
boolean callerWillRestart, boolean purgeCache, boolean doit,
boolean evenPersistent, boolean uninstalling, int userId, String reason) {
int i;
// … 状态判断,省略
boolean didSomething = mProcessList.killPackageProcessesLocked(packageName, appId, userId,
ProcessList.INVALID_ADJ, callerWillRestart, true /* allowRestart /, doit,
evenPersistent, true / setRemoved */,
packageName == null ? ("stop user " + userId) : ("stop " + packageName));
didSomething |=
mAtmInternal.onForceStopPackage(packageName, doit, evenPersistent, userId);
// 清理 service
// 清理 broadcastreceiver
// 清理 providers
// 清理其他
return didSomething;
}
这个方法实现很清晰:
先杀死这个 App 内部的所有进程,然后清理残留在 system_server 内的四大组件信息;我们关心进程是如何被杀死的,因此继续跟踪 killPackageProcessesLocked,这个方法最终会调用到 ProcessList 内部的 removeProcessLocked 方法, removeProcessLocked 会调用 ProcessRecord 的 kill 方法,我们看看这个 kill:
void kill(String reason, boolean noisy) {
if (!killedByAm) {
Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, “kill”);
if (mService != null && (noisy || info.uid == mService.mCurOomAdjUid)) {
mService.reportUidInfoMessageLocked(TAG,
"Killing " + toShortString() + " (adj " + setAdj + "): " + reason,
info.uid);
}
if (pid > 0) {
EventLog.writeEvent(EventLogTags.AM_KILL, userId, pid, processName, setAdj, reason);
Process.killProcessQuiet(pid);
ProcessList.killProcessGroup(uid, pid);
} else {
pendingStart = false;
}
if (!mPersistent) {
killed = true;
killedByAm = true;
}
Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
}
}
这里我们可以看到,首先杀掉了目标进程,然后会以 uid为单位杀掉目标进程组。
如果只杀掉目标进程,那么我们可以通过双进程守护的方式实现保活;
关键就在于这个 killProcessGroup,继续跟踪之后发现这是一个 native 方法,它的最终实现在 libprocessgroup中,代码如下:
int killProcessGroup(uid_t uid, int initialPid, int signal) {
return KillProcessGroup(uid, initialPid, signal, 40 /retries/);
}
注意这里有个奇怪的数字:40。
我们继续跟踪:
static int KillProcessGroup(uid_t uid, int initialPid, int signal, int retries) {
// 省略
int retry = retries;
int processes;
while ((processes = DoKillProcessGroupOnce(cgroup, uid, initialPid, signal)) > 0) {
LOG(VERBOSE) << "Killed " << processes << " processes for processgroup " << initialPid;
if (retry > 0) {
std::this_thread::sleep_for(5ms);
–retry;
} else {
break;
}
}
// 省略
}
瞧瞧我们的系统做了什么骚操作?循环 40 遍不停滴杀进程,每次杀完之后等 5ms,循环完毕之后就算过去了。
看到这段代码,我想任何人都会蹦出一个疑问:假设经历连续 40 次的杀进程之后,如果 App 还有进程存在,那不就侥幸逃脱了吗?
2 实现方法
那么,如何实现这个目的呢?
我们看这个关键的 5ms。假设,App 进程在被杀掉之后,能够以足够快的速度(5ms 内)启动一堆新的进程,那么系统在一次循环杀掉老的所有进程之后,sleep 5ms 之后又会遇到一堆新的进程;如此循环 40 次,只要我们每次都能够拉起新的进程,那我们的 App 就能逃过系统的追杀,实现永生。
是的,炼狱般的 200ms,只要我们熬过 200ms 就能渡劫成功,得道飞升。
不知道大家有没有玩过打地鼠这个游戏,整个过程非常类似,按下去一个又冒出一个,只要每次都能足够快地冒出来,我们就赢了。
现在问题的关键就在于:
如何在 5ms 内启动一堆新的进程?
再回过头来看原来的保活方式,它们拉起进程最开始通过 am命令,这个命令实际上是一个 java 程序,它会经历启动一个进程然后启动一个 ART 虚拟机,接着获取 ams 的 binder 代理,然后与 ams 进行 binder 同步通信。
这个过程实在是太慢了,在这与死神赛跑的 5ms 里,它的速度的确是不敢恭维。
后来,MarsDaemon 提出了一种新的方式,它用 binder 引用直接给 ams 发送 Parcel,这个过程相比 am命令快了很多,从而大大提高了成功率。其实这里还有改进的空间,毕竟这里还是在 Java 层调用,Java 语言在这种实时性要求极高的场合有一个非常令人诟病的特性:
垃圾回收(GC);虽然我们在这 5ms 内直接碰上 gc 引发停顿的可能性非常小,但是由于 GC 的存在,ART 中的 Java 代码存在非常多的 checkpoint;
想象一下你现在是一个信使有重要军情要报告,但是在路上却碰到很多关隘,而且很可能被勒令暂时停止一下,这种情况是不可接受的。因此,最好的方法是通过 native code 给 ams 发送 binder 调用;
当然,如果再底层一点,我们甚至可以通过 ioctl 直接给 binder 驱动发送数据进而完成调用,但是这种方法的兼容性比较差,没有用 native 方式省心。
通过在 native 层给 ams 发送 binder 消息拉起进程,我们算是解决了「快速拉起进程」这个问题。但是这个还是不够。还是回到打地鼠这个游戏,假设你摁下一个地鼠,会冒起一个新的地鼠,那么你每次都能摁下去最后获取胜利的概率还是比较高的;
但如果你每次摁下一个地鼠,其他所有地鼠都能冒出来呢?这个难度系数可是要高多了。如果我们的进程能够在任意一个进程死亡之后,都能让把其他所有进程全部拉起,这样系统就很难杀死我们了。
新的黑科技保活中通过 2 个机制来保证进程之间的互相拉起:
2 个进程通过互相监听文件锁的方式,来感知彼此的死亡。
通过 fork 产生子进程,fork 的进程同属一个进程组,一个被杀之后会触发另外一个进程被杀,从而被文件锁感知。
具体来说,创建 2 个进程 p1, p2,这两个进程通过文件锁互相关联,一个被杀之后拉起另外一个;同时 p1 经过 2 次 fork 产生孤儿进程 c1,p2 经过 2 次 fork 产生孤儿进程 c2,c1 和 c2 之间建立文件锁关联。这样假设 p1 被杀,那么 p2 会立马感知到,然后 p1 和 c1 同属一个进程组,p1 被杀会触发 c1 被杀,c1 死后 c2 立马感受到从而拉起 p1,因此这四个进程三三之间形成了铁三角,从而保证了存活率。
分析到这里,这种方案的大致原理我们已经清晰了。
基于以上原理,我写了一个简单的 PoC,代码在这里:
https://github.com/tiann/Leoric
有兴趣的可以看一下。
为了文章的严谨性(注一位读者Rikka的回复):
文章中说需要“在 5ms 内启动一堆新的进程”,但其实并不需要。
AMS 在执行杀进程时是一个 ProcessRecord 一个地来的( https://android.googlesource.com/platform/frameworks/base/+/4f868ed/services/core/java/com/android/server/am/ActivityManagerService.java#5766),也就是最终会执行多次 libprocessgroup 里的 killProcessgroup。
这样只要在杀死属于某个 cgroup 的进程时,另外的进程只要成功启动一次 android:process 是另外的的进程即可活下来。因为新对应新的 ProcessRecord,不会在上面那个循环里被杀死。此外,循环四十次反而给了超长的时间来启动新的,观察 log 可以发现 killProcessgroup 的间隔长达几十到一百多 ms。
3 改进空间
本方案的原理还是比较简单直观的,但是要实现稳定的保活,还需要很多细节要补充;特别是那与死神赛跑的 5ms,需要不计一切代价去优化才能提升成功率。
具体来说,就是当前的实现是在 Java 层用 binder 调用的,我们应该在 native 层完成。笔者曾经实现过这个方案,但是这个库本质上是有损用户利益的,因此并不打算公开代码,这里简单提一下实现思路供大家学习:
如何在 native 层进行 binder 通信?
libbinder 是 NDK 公开库,拿到对应头文件,动态链接即可。
难点:依赖繁多,剥离头文件是个体力活。
如何组织 binder 通信的数据?
通信的数据其实就是二进制流;具体表现就是 (C++/Java) Parcel 对象。native 层没有对应的 Intent Parcel,兼容性差。
方案:
Java 层创建 Parcel (含 Intent),拿到 Parcel 对象的 mNativePtr(native peer),传到 Native 层。
native 层直接把 mNativePtr 强转为结构体指针。
fork 子进程,建立管道,准备传输 parcel 数据。
子进程读管道,拿到二进制流,重组为 parcel。
4 如何应对?
今天我把这个实现原理公开,并且提供 PoC 代码,并不是鼓励大家使用这种方式保活,而是希望各大系统厂商能感知到这种黑科技的存在,推动自己的系统彻底解决这个问题。
两年前我就知道了这个方案的存在,不过当时鲜为人知。
最近一个月我发现很多 App 都使用了这种方案,把我的 Android 手机折腾的惨不忍睹;毕竟本人手机上安装了将近 800 个 App,假设每个 App 都用这个方案保活,那这系统就没法用了。
系统如何应对?
如果我们把系统杀进程比喻为斩首,那么这个保活方案的精髓在于能快速长出一个新的头;因此应对之法也很简单,只要我们在斩杀一个进程的时候,让别的进程老老实实呆着别搞事情就 OK 了。具体的实现方法多种多样,不赘述。
用户如何应对?
在厂商没有推出解决方案之前,用户可以有一些方案来缓解使用这个方案进行保活的流氓 App。
这里推荐两个应用给大家:
手机折腾的惨不忍睹;毕竟本人手机上安装了将近 800 个 App,假设每个 App 都用这个方案保活,那这系统就没法用了。
系统如何应对?
如果我们把系统杀进程比喻为斩首,那么这个保活方案的精髓在于能快速长出一个新的头;因此应对之法也很简单,只要我们在斩杀一个进程的时候,让别的进程老老实实呆着别搞事情就 OK 了。具体的实现方法多种多样,不赘述。
用户如何应对?
在厂商没有推出解决方案之前,用户可以有一些方案来缓解使用这个方案进行保活的流氓 App。
这里推荐两个应用给大家: