探索 Android Process Fork 及其限制

前言

由于 API Debug.dumpHprofData() 在获取内存数据的时候会暂停 ART 以防止执行过程中 Runtime Heap 被修改,在实际解决内存问题的时候会发现这个工具的应用的场景实在是少得可怜。为了不阻断既有任务,通常只能在应用处于后台时再调用这个 API 来分析对象泄漏,应对内存抖动等即时性的问题只能两手一摊。

不过 KOOM 的开发者们提供了一个巧妙的思路,借助 fork() 的 copy-on-write 机制来绕过虚拟机暂停的问题,因为子进程的 Runtime Heap 与原进程一致,而且子进程的 Hprof-Dump 不会干扰原进程。这甚至可以做到将 Hprof-Dump 应用于线上。

不过前面说了,这篇文字是来谈 Fork 的。我们最近也在项目上采用了 KOOM 的思路,但在代码实现过程中遇到了一些与 Fork 相关的问题,这些问题都挺有意思的,所以拉出来聊一聊。

The Only

如果看 KOOM的相关代码,会发现 fork() 前后通过动态链接的方式调用了 ART 中暂停 / 恢复虚拟机所有线程的相关函数。在 fork() 被调用后,原进程恢复虚拟机线程,而子进程仍保持所有线程暂停的状态:

pid_t HprofDump::SuspendAndFork() {
  ...

  if (android_api_ < __ANDROID_API_R__) {
    suspend_vm_fnc_();
  }
  if (android_api_ == __ANDROID_API_R__) {
    void *self = __get_tls()[TLS_SLOT_ART_THREAD_SELF];
    sgc_constructor_fnc_((void *)sgc_instance_.get(), self, kGcCauseHprof,
                         kCollectorTypeHprof);
    ssa_constructor_fnc_((void *)ssa_instance_.get(), LOG_TAG, true);
    // avoid deadlock with child process
    exclusive_unlock_fnc_(*mutator_lock_ptr_, self);
    sgc_destructor_fnc_((void *)sgc_instance_.get());
  }

  pid_t pid = fork();

  ...
}

bool HprofDump::ResumeAndWait(pid_t pid) {
  ...

  if (android_api_ < __ANDROID_API_R__) {
    resume_vm_fnc_();
  }
  if (android_api_ == __ANDROID_API_R__) {
    void *self = __get_tls()[TLS_SLOT_ART_THREAD_SELF];
    exclusive_lock_fnc_(*mutator_lock_ptr_, self);
    ssa_destructor_fnc_((void *)ssa_instance_.get());
  }

  // Wait child process ...
} 

Debug.dumpHprofData(),会发现它在内部同样执行了虚拟机线程暂停 / 恢复的函数。如果去掉 fork() 前额外添加的虚拟机暂停请求,直接让 Debug.dumpHprofData() 在子进程暂停虚拟机线程,会发现暂停虚拟机线程的调用将被一直阻塞。

这是因为 fork() 得出的子进程仅会保留调用 fork() 的唯一线程,而虚拟机线程暂停需要当前线程等待其它线程到达 Suspend Check Point 以通知原线程。但子进程中的其它线程已经不复存在了,等待便不会再有任何回音,因此通过在原进程提前暂停虚拟机线程,欺骗子进程对虚拟机线程状态的检测,才能保证逻辑的正常运行。

而我们在使用 fork() 的过程中也遇到了类似的问题,Fork 出来的子进程会概率性挂起。这是 demo 模拟的挂起信息:

/*
 *  ps -A | grep "<process name>"
 */
u0_a182      26111   708 5146704  84524 SyS_epoll_wait 7788703d58 S moe.aoramd.fork
u0_a182      26171 26111 5256380  38884 futex_wait_queue_me 77886b46c0 S moe.aoramd.fork  // child process

/*
 *  cat /proc/<pid>/stack
 */
[<0000000000000000>] __switch_to+0xbc/0xc8
[<0000000000000000>] futex_wait_queue_me+0xc0/0x144
[<0000000000000000>] futex_wait+0xe4/0x204
[<0000000000000000>] do_futex+0x168/0xc40
[<0000000000000000>] SyS_futex+0x11c/0x1b0
[<0000000000000000>] __sys_trace_return+0x0/0x4
[<0000000000000000>] 0xffffffffffffffff 

排查发现,问题根源是创建子进程的线程没有需要的锁资源,而由于 Fork 产生的子进程不包含其它线程,原本持有锁的线程已然不见,于是子进程永远等不到锁被释放的那一天。

private external fun fork()

private external fun waitpid(int pid)

private val lock = Any()

fun test() {
    val latch = CountDownLatch(1)
    thread {
        synchronized(lock) {
            latch.countDown()
            Thread.sleep(10000)
        }
    }
    latch.await()
    when (val pid = fork()) {
        -1 -> Log.e(TAG, "Failed to create process.")
        0 -> run {
            synchronized(lock) {
                Log.i(TAG, "In child process.")
            }
        }
        else -> {
            waitpid(pid)
        }
    }
} 

实际上我们遇到的问题远比 demo 展示的更加隐蔽,synchronized 相关的代码是由其它业务框架通过 ASM 修改字节码插入的,而且由于线程调度的不确定性使得问题难以复现 (╯-_-)╯┴┴。

Abandoned

先说结论,子进程的 Binder 挂了。

但我们并不是尝试在子进程直接使用 Binder 的时候发现的 …

如果在子进程中抛出一个无法被捕获的异常,则子进程会崩溃,但是是个 SIGABRT。

*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
Build fingerprint: 'google/sdk_gphone64_x86_64/emu64xa:Tiramisu/TPP2.220218.008/8250781:user/release-keys'
Revision: '0'
ABI: 'x86_64'
Timestamp: ...
Process uptime: 1s
Cmdline: moe.aoramd.fork
pid: 5837, tid: 5837, name: forked_ps  >>> moe.aoramd.fork <<<
uid: 10158
signal 6 (SIGABRT), code -1 (SI_QUEUE), fault addr --------
Abort message: 'libbinder ProcessState can not be used after fork' 

从 ART 对异常处理的逻辑可以看出,未捕获异常的信息最终会通过 Binder 通知到 AMS,告诉系统这个进程已经挂了,但这触发了 Binder 对进程状态的检查:

/*
 * frameworks/native/libs/binder/ProcessState.cpp
 */

...

static void verifyNotForked(bool forked) {
    LOG_ALWAYS_FATAL_IF(forked, "libbinder ProcessState can not be used after fork");
}

...

sp<ProcessState> ProcessState::init(const char *driver, bool requireDefault) {

    if (driver == nullptr) {
        std::lock_guard<std::mutex> l(gProcessMutex);
        if (gProcess) {
            verifyNotForked(gProcess->mForked);
        }
        return gProcess;
    }

    [[clang::no_destroy]] static std::once_flag gProcessOnce;
    std::call_once(gProcessOnce, [&](){
        ...

        // we must install these before instantiating the gProcess object,
        // otherwise this would race with creating it, and there could be the
        // possibility of an invalid gProcess object forked by another thread
        // before these are installed
        int ret = pthread_atfork(ProcessState::onFork, ProcessState::parentPostFork,
                                 ProcessState::childPostFork);
        LOG_ALWAYS_FATAL_IF(ret != 0, "pthread_atfork error %s", strerror(ret));

        ...
    });

    ...

    verifyNotForked(gProcess->mForked);
    return gProcess;
}

...

void ProcessState::childPostFork() {
    // another thread might call fork before gProcess is instantiated, but after
    // the thread handler is installed
    if (gProcess) {
        gProcess->mForked = true;
    }
    gProcessMutex.unlock();
}

... 

预检查的代码与 Google 对 Binder 不支持 Fork 的说明可见 ee9df90 与 bd98e0f 这两个 commit。

测试用的环境是 Android 13 Developer Preview,而这里是 Google 在 Android 12 之后加入的预检查机制,因为 Binder 的架构设计并不支持 Fork,原因之一在 Binder 核心的内存映射实现无法避免子进程与原进程的写入冲突,相关虚拟地址的内存映射不会被子进程所继承,子进程将因无法访问内存而触发 SIGSEGV。

可以通过旧版本的 Android 复现这个问题:

*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
Build fingerprint: 'google/marlin/marlin:10/QP1A.191005.007.A3/5972272:user/release-keys'
Revision: '0'
ABI: 'arm64'
Timestamp: ...
pid: 12451, tid: 12451, name: forked_ps  >>> moe.aoramd.fork <<<
uid: 10182
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x7ae6bcb010
    ...
backtrace:
      #00 pc 00000000000622d4  /system/lib64/libbinder.so (android::Parcel::readInt32() const+112) (BuildId: bee06b7e2c4579b1ef34fab865761fc1)
      #01 pc 0000000000291db0  /system/framework/arm64/boot-framework.oat (art_jni_trampoline+64) (BuildId: 376afc95f84b0ba63cf2b73598367d6553148e62)
      #02 pc 0000000002000600  /memfd:/jit-cache (deleted) (android.os.Parcel.readInt+48)
      ... 

Please DO NOT TOUCH

如果说 Binder 的问题不大,大不了不在子进程里整 IPC 了,那么下个限制几乎完全破坏了子进程执行 Java 层代码的能力。

在子进程运行 Java 层代码的过程中会概率性产生崩溃,这是一次 demo 的复现记录:

*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
Build fingerprint: 'google/sdk_gphone64_x86_64/emu64xa:Tiramisu/TPP2.220218.008/8250781:user/release-keys'
Revision: '0'
ABI: 'x86_64'
Timestamp: ...
Process uptime: 1s
Cmdline: moe.aoramd.fork
pid: 6018, tid: 6018, name: forked_ps  >>> moe.aoramd.fork <<<
uid: 10158
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x00007f6c03205b30
    ...
backtrace:
      #00 pc 0000000002002ae6  /memfd:jit-cache (deleted) (java.lang.ThreadLocal.get+38)
      ... 

demo 代码如下:

private external fun fork()

private external fun waitpid(int pid)

private val local = ThreadLocal<Int>()

fun test() {
    local.set(1)
    when (val pid = fork()) {
        -1 -> Log.e(TAG, "Failed to create process.")
        0 -> Log.i(TAG, "Child process: ${local.get()}.")
        else -> {
            waitpid(pid)
        }
    }
} 

对比两个进程的 /proc/<pid>/maps 文件,可以发现子进程对应的内存映射记录缺失了。

> diff /proc/<parent pid>/maps /proc/<child pid>/maps

...

-7f2f89e00000-7f2f8de00000 rw-s 00000000 00:01 378                        /memfd:jit-cache (deleted)

... 

这个问题并不会在 Android 10 及以下的版本出现,但这不代表低版本的 Android 没有问题,Android 11 新增的限制其实是对这潜在安全问题的 “Fail-Fast”。

先说说崩溃是如何产生的。ART 的 JIT Code Cache 通过 memfd_create() 创建内存文件生成,此后采用 mmap() 将文件映射到内存地址中。

bool JitMemoryRegion::Initialize(size_t initial_capacity,
                                 size_t max_capacity,
                                 bool rwx_memory_allowed,
                                 bool is_zygote,
                                 std::string* error_msg) {
  ...

  max_capacity_ = RoundDown(max_capacity, 2 * kPageSize);

  ...

  const size_t capacity = max_capacity_;
  const size_t data_capacity = capacity / kCodeAndDataCapacityDivider;
  const size_t exec_capacity = capacity - data_capacity;

  ...

  if (is_zygote) {
    ...
  } else {
    // Bionic supports memfd_create, but the call may fail on older kernels.
    mem_fd = unique_fd(art::memfd_create("jit-cache", /* flags= */ 0));
    ...
  }

  // Map name specific for android_os_Debug.cpp accounting.
  std::string data_cache_name = is_zygote ? "zygote-data-code-cache" : "data-code-cache";
  std::string exec_cache_name = is_zygote ? "zygote-jit-code-cache" : "jit-code-cache";

  ...

  if (mem_fd.get() >= 0) {
    // Dual view of JIT code cache case. Create an initial mapping of data pages large enough
    // for data and non-writable view of JIT code pages. We use the memory file descriptor to
    // enable dual mapping - we'll create a second mapping using the descriptor below. The
    // mappings will look like:
    //
    //       VA                  PA
    //
    //       +---------------+
    //       | non exec code |\
    //       +---------------+ \
    //       | writable data |\ \
    //       +---------------+ \ \
    //       :               :\ \ \
    //       +---------------+.\.\.+---------------+
    //       |  exec code    |  \ \|     code      |
    //       +---------------+...\.+---------------+
    //       | readonly data |    \|     data      |
    //       +---------------+.....+---------------+
    //
    // In this configuration code updates are written to the non-executable view of the code
    // cache, and the executable view of the code cache has fixed RX memory protections.
    //
    // This memory needs to be mapped shared as the code portions will have two mappings.
    //
    // Additionally, the zyzote will create a dual view of the data portion of
    // the cache. This mapping will be read-only, whereas the second mapping
    // will be writable.

    base_flags = MAP_SHARED;

    // Create the writable mappings now, so that in case of the zygote, we can
    // prevent any future writable mappings through sealing.
    if (exec_capacity > 0) {
      // For dual view, create the secondary view of code memory used for updating code. This view
      // is never executable.
      std::string name = exec_cache_name + "-rw";
      non_exec_pages = MemMap::MapFile(exec_capacity,
                                       kIsDebugBuild ? kProtR : kProtRW,
                                       base_flags,
                                       mem_fd,
                                       /* start= */ data_capacity,
                                       /* low_4GB= */ false,
                                       name.c_str(),
                                       &error_str);
      if (!non_exec_pages.IsValid()) {
        // This is unexpected.
        *error_msg = "Failed to map non-executable view of JIT code cache";
        return false;
      }
      // Create a dual view of the data cache.
      name = data_cache_name + "-rw";
      writable_data_pages = MemMap::MapFile(data_capacity,
                                            kProtRW,
                                            base_flags,
                                            mem_fd,
                                            /* start= */ 0,
                                            /* low_4GB= */ false,
                                            name.c_str(),
                                            &error_str);
      if (!writable_data_pages.IsValid()) {
        std::ostringstream oss;
        oss << "Failed to create dual data view: " << error_str;
        *error_msg = oss.str();
        return false;
      }
      if (writable_data_pages.MadviseDontFork() != 0) {
        *error_msg = "Failed to MadviseDontFork the writable data view";
        return false;
      }
      if (non_exec_pages.MadviseDontFork() != 0) {
        *error_msg = "Failed to MadviseDontFork the writable code view";
        return false;
      }
      // Now that we have created the writable and executable mappings, prevent creating any new
      // ones.
      if (is_zygote && !ProtectZygoteMemory(mem_fd.get(), error_msg)) {
        return false;
      }
    }

    ...
  } else {
    ...
  }

  ...
} 

可以看到 ART 的实现思路:为了提升运行效率,Zygote 进程会通过 mmap() 共享内存的方式将自己的 JIT Code Cache 共享给应用进程,而在 Android 11 之前 Zygote 并不会共享 JIT Code Cache。但为了保证安全性,避免应用进程篡改 Zygote 进程的缓存空间,ART 在创建 Cache 的时候进行了以下的操作:

  1. 为 Cache 文件同时创建两份映射,其中一份不可写。

  2. 通过 madvise() 设置 MADV_DONTFORK 确保可写的那份映射无法被子进程访问。Fork 之后,子进程对应的可写映射记录丢失,访问将触发 SIGSEGV。

  3. 通过 fcntl() 设置 F_SEAL_FUTURE_WRITE 确保子进程无法再创建任何可写映射。

问题出在哪里?

前面提到,在 Android 11 之前 Zygote 进程是不共享 JIT Code Cache 的,Android 11 其实是直接搬用了应用进程 JIT Code Cache 的创建方式,在此基础上新增了操作 2 与操作 3(目前还不确定应用进程提供的这种允许共享的模式原本是设计需要和谁共享缓存)。在此之前,因为应用进程创建 JIT Code Cache 时不进行操作 2 ,所以在 Android 11 之前并不会触发这个错误。

因为 Zygote 进程的特殊性,Zygote 进程的 Shared JIT Code Cache 会被特别标记,应用进程可通过特定的地址访问到 Zygote 的 Cache,可我们自行 Fork 出来的子进程访问的仍是原进程继承的内存地址,对应的 mmap() 记录已缺失,访问时自然会有问题。

当然可以通过 madvise() 设置 MADV_DOFORK 来 “解决” 这个问题,不过前文提及,这是一个 “Fail-Fast”,目的是防止多进程修改同一块 JIT Code Cache 产生冲突,这是远比内存映射丢失更难排查的错误,而且一旦错误发生会直接干扰原进程的运行,强行设置 MADV_DOFORK 无非是掩耳盗铃。

最后

Android 的整体架构设计上其实并没有过多考虑 Process Fork 的情况,在 Android 上进行 Process Fork 存在相当多的不稳定因素,这更多的是一种用来巧解特定问题的旁门左道,真的需要实现多进程的场景还是采用 Google 官方提供的方法罢。

不过个人拙见,我们可以从 ART 的源码中看出其设计与 Android 机制本身存在一定的耦合性,ART 的一些内部逻辑会去感知自己是否运行在 Zygote 或 System Server 中,这篇论文也记录了 ART 工程师对 ShareJIT 设计的想法,能够提升性能的缓存共享正是依托于 Zygote Fork 机制。但是否意味着将 ART 这样一个设计精妙的 Runtime 移植到其它操作系统愈发趋近于不可能呢?

最后,如果大伙有什么好的学习方法或建议欢迎大家在评论中积极留言哈,希望大家能够共同学习、共同努力、共同进步。

小编在这里祝小伙伴们在未来的日子里都可以 升职加薪,当上总经理,出任CEO,迎娶白富美,走上人生巅峰!!

不论遇到什么困难,都不应该成为我们放弃的理由!

很多人在刚接触这个行业的时候或者是在遇到瓶颈期的时候,总会遇到一些问题,比如学了一段时间感觉没有方向感,不知道该从那里入手去学习,需要一份小编整理出来的学习资料的关注我主页或者点击扫描下方二维码免费领取~

这里是关于我自己的Android 学习,面试文档,视频收集大整理,有兴趣的伙伴们可以看看~

如果你看到了这里,觉得文章写得不错就给个赞呗?如果你觉得那里值得改进的,请给我留言,一定会认真查询,修正不足,谢谢。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值