Android 线程栈压缩方案

113 阅读13分钟

背景

公司项目一直以来存在一个Firebase Push的崩溃问题,如下图所示 image.png image.png 创建线程数 1357 个,问题的原因在于应用离线,Firebase Message 积压,导致当应用启动时,一次性倒灌过来,Firebase 内部的 CloudMessagingReceiver 创建多个名称为 firebase-iid-executor 的线程,导致 pthread_create 内存溢出,需要推送平台处理一下过期时间等等。因此对线程治理提上议程。

线程创建流程

thread_create.png

上述创建流程在流程图中很清晰了,就不过多阐述了。

不过内部线程栈大小的设置流程,我们需要快速过一下代码。

线程栈空间大小设置流程

我们在 Java 侧创建线程,最终会通过 JNI 调用到 thread.cc 中的 CreateNativeThread 函数。

void Thread::CreateNativeThread(JNIEnv* env, jobject java_peer, size_t stack_size, bool is_daemon) {
  CHECK(java_peer != nullptr);
  Thread* self = static_cast<JNIEnvExt*>(env)->GetSelf();

  if (VLOG_IS_ON(threads)) {
    ScopedObjectAccess soa(env);

    ArtField* f = WellKnownClasses::java_lang_Thread_name;
    ObjPtr<mirror::String> java_name =
        f->GetObject(soa.Decode<mirror::Object>(java_peer))->AsString();
    std::string thread_name;
    if (java_name != nullptr) {
      thread_name = java_name->ToModifiedUtf8();
    } else {
      thread_name = "(Unnamed)";
    }

    VLOG(threads) << "Creating native thread for " << thread_name;
    self->Dump(LOG_STREAM(INFO));
  }
  Runtime* runtime = Runtime::Current();
  bool thread_start_during_shutdown = false;
  {
    MutexLock mu(self, *Locks::runtime_shutdown_lock_);
    if (runtime->IsShuttingDownLocked()) {
      thread_start_during_shutdown = true;
    } else {
      runtime->StartThreadBirth();
    }
  }
  if (thread_start_during_shutdown) {
    ScopedLocalRef<jclass> error_class(env, env->FindClass("java/lang/InternalError"));
    env->ThrowNew(error_class.get(), "Thread starting during runtime shutdown");
    return;
  }

  Thread* child_thread = new Thread(is_daemon);
  child_thread->tlsPtr_.jpeer = env->NewGlobalRef(java_peer);
  // 代码 1...
  stack_size = FixStackSize(stack_size);
  SetNativePeer(env, java_peer, child_thread);
  std::string error_msg;
  std::unique_ptr<JNIEnvExt> child_jni_env_ext(
      JNIEnvExt::Create(child_thread, Runtime::Current()->GetJavaVM(), &error_msg));

  int pthread_create_result = 0;
  if (child_jni_env_ext.get() != nullptr) {
    pthread_t new_pthread;
    pthread_attr_t attr;
    child_thread->tlsPtr_.tmp_jni_env = child_jni_env_ext.get();
    CHECK_PTHREAD_CALL(pthread_attr_init, (&attr), "new thread");
    CHECK_PTHREAD_CALL(pthread_attr_setdetachstate, (&attr, PTHREAD_CREATE_DETACHED),
                       "PTHREAD_CREATE_DETACHED");
    CHECK_PTHREAD_CALL(pthread_attr_setstacksize, (&attr, stack_size), stack_size);
    // 代码 2...
    pthread_create_result = pthread_create(&new_pthread,
                                           &attr,
                                           gUseUserfaultfd ? Thread::CreateCallbackWithUffdGc
                                                           : Thread::CreateCallback,
                                           child_thread);
    CHECK_PTHREAD_CALL(pthread_attr_destroy, (&attr), "new thread");

    if (pthread_create_result == 0) {
      child_jni_env_ext.release();
      return;
    }
  }
  {
    MutexLock mu(self, *Locks::runtime_shutdown_lock_);
    runtime->EndThreadBirth();
  }
  child_thread->DeleteJPeer(env);
  delete child_thread;
  child_thread = nullptr;
  SetNativePeer(env, java_peer, nullptr);
  {
    std::string msg(child_jni_env_ext.get() == nullptr ?
        StringPrintf("Could not allocate JNI Env: %s", error_msg.c_str()) :
        StringPrintf("pthread_create (%s stack) failed: %s",
                                 PrettySize(stack_size).c_str(), strerror(pthread_create_result)));
    ScopedObjectAccess soa(env);
    soa.Self()->ThrowOutOfMemoryError(msg.c_str());
  }
}

上述代码是CreateNativeThread函数实现细节,主要看代码 1 与代码 2 处。由于我们在 Java 构建线程时,多数情况下很少指定线程栈大小,所以未指定的情况下,调用这个函数时,入参中的 stack_size = 0。 那么通过代码看,对 stack_size 设置值的地方在代码 1处。

static size_t FixStackSize(size_t stack_size) {
  if (stack_size == 0) {
    // 这里的 GetDefaultStackSize 是可以通过对虚拟机的配置进行外部设置,
    // 比如通过 -Xss= 去配置,但是针对 Android 并没有特别设置,所以这里的赋值为0。
    stack_size = Runtime::Current()->GetDefaultStackSize();
  }
  // 设置为1M。
  stack_size += 1 * MB;
  if (kMemoryToolIsAvailable) {
    // ignore...
    stack_size = std::max(2 * MB, stack_size);
  }
  if (stack_size < PTHREAD_STACK_MIN) {
    // PTHREAD_STACK_MIN是一个常量,在 Linux中这个值为 16k。
    stack_size = PTHREAD_STACK_MIN;
  }
  // 下面的这个方法见名之意,为了处理 StackOverFlow 的检查逻辑,这里默认给一个栈空间专门处理这个检查逻辑。经过测试,这个空间为 16kb。
  if (Runtime::Current()->GetImplicitStackOverflowChecks()) {
    stack_size += Thread::kStackOverflowImplicitCheckSize +
        GetStackOverflowReservedBytes(kRuntimeISA);
  } else {
    stack_size += GetStackOverflowReservedBytes(kRuntimeISA);
  }
  stack_size = RoundUp(stack_size, gPageSize);
  return stack_size;
}

经过代码注释的分析,开辟线程池大小为 1M + StackOverFlow处理的专属空间。 所以我们 push 创建的 1357 个线程 至少开辟了 1357 * 1 * 1024 * 1024 byte,也就是1357M

ok,到这里我们知道了线程栈空间分配,后续会执行pthread_create函数去创建一个线程。

实际上我们日常开发,线程栈空间占用实际上不会占用太多,一般我们常见的递归操作如果控制合理能够递归好几轮,所以Android默认分配 1M 的大小,为了提前确保足够的空间用于逻辑处理,不然假设默认分配 512KB,那么StackOverFlow的可能就比较大了,所以考虑性能和兼容性考虑最终选择了1M。 不过我也问了一下 GPT4, 它的回答是:

为什么默认栈大小是1M?
  1. 平衡性能与资源消耗:线程栈大小决定了线程可以使用的内存量。较大的栈大小可以让线程执行更深层次的函数调用和拥有更多的局部变量,但同时也会增加每个线程的内存消耗。在多线程应用中,过大的线程栈会显著增加整体的内存占用。1M的默认大小是在允许足够的函数调用深度和局部变量空间的同时,尽量减少每个线程的内存消耗,为多线程并发留出空间。
  2. 兼容性和通用性:设置一个中等大小的默认值可以确保在多种不同的应用场景中都能有较好的表现,不需要针对特定应用进行大幅调整。1M的栈大小对于大多数应用来说都是足够的,同时也不会因为过大而浪费资源。
  3. 线程安全:线程栈大小与线程安全直接关系不大,但合理的栈大小可以间接影响程序的稳定性和错误处理能力。过小的栈可能会导致栈溢出,尤其是在深层次递归调用或大量局部变量使用的情况下。适当的栈大小可以减少这类问题的发生。
与线程安全和性能的平衡
  • 线程安全:通过确保每个线程有足够的栈空间来执行其任务,减少了因栈溢出而导致的程序崩溃风险,从而间接提升了线程安全。此外,合理的栈大小还可以避免在多线程环境下因内存不足而导致的竞态条件和其他线程安全问题。
  • 性能:合理的默认栈大小可以提高应用的性能。过小的栈可能导致频繁的栈溢出异常,影响性能;而过大的栈会消耗过多内存资源,降低系统的并发能力。因此,1M的默认值是在保证单个线程性能的同时,也优化了整体的内存使用,使得应用可以在有限的资源下运行更多的线程,从而提升并发性能。

当然我们也可以优化他,我们可以去设置这个栈空间大小。

栈空间设置

线程 Thread 创建时设置

long stackSize = -512 * 1024; // -512KB 
Thread thread = new Thread(null, new ThreadTask(), "DemoThread", stackSize);

但是由于我们应用一般会接入二方三方库,内部也存在线程的分配,无法做到统一处理,除非已经对线程进行了收拢,不然很难做到统一处理。 所以我们只能选择 hook 的方案。

关于 PLT & Inline

针对 Native hook,有两种方式 PLT HookInline Hook, 上述两个技术点不详细说明了,比较高深,需要花很多时间去研究。 有兴趣可以看下这篇赵子健大佬的PLT Hook从入门到实战。 本文采用 Inline hook 去处理这个技术任务,由于公司的项目使用的是字节的ShadowHook,对其进行了脱敏,所以就直接采用它了。

寻找 hook 点

通过上述 Thread Native 创建流程,我们其实已经可以知道可以选择两个 hook 的点。

pthread_create()

Hook pthread_create()函数将更加底层,如果我们 hook 这个函数,将无视FixStackSize函数的逻辑,当然是可以无视的,不过为了不必要的风险,要保留这个FixStackSize中的逻辑我们最后选择了第二个 hook 点。

当然如果有足够的测试,其实 pthread_create()可以直接使用 PLT hook,性能更好, 别忘记 16kbStackOverFlow 的栈空间。

示例代码

ThreadHook.cpp
#include <jni.h>
#include <string>
#include <shadowhook.h>
#include <android/log.h>
#include <pthread.h>
#include "thread_hook.h"
#include "thread_compressor.h"

#define LOG_TAG "thread_hook"
#define TARGET_LIB "libc.so"
#define TARGET_FUNC "pthread_create"


extern "C" JNIEXPORT void JNICALL
Java_com_sample_thread_1hook_ThreadHook_threadHook(JNIEnv *env,jobject /* this */) {
    thread_hook::thread_hook();
}

extern "C" JNIEXPORT void JNICALL
Java_com_sample_thread_1hook_ThreadHook_setStackSize(JNIEnv *env,jobject /* this */, jint size) {
    thread_compressor::thread_stack_size(size);
}

extern "C" JNIEXPORT void JNICALL
Java_com_sample_thread_1hook_ThreadHook_threadUnhook(JNIEnv *env,jobject /* this */, jint size) {
    thread_hook::thread_unhook();
}

namespace thread_hook {
    void *orig = NULL;
    void *stub = NULL;

    typedef void (*type_t)(pthread_t *pthread_ptr, pthread_attr_t const *attr,void *(*start_routine)(void *), void *args);

    void proxy(pthread_t *pthread_ptr, pthread_attr_t const *attr, void *(*start_routine)(void *),void *args) {
        __android_log_print(ANDROID_LOG_INFO, LOG_TAG, "proxy pthread_create called.");
        thread_compressor::compress(attr);
        ((type_t) orig)(pthread_ptr, attr, start_routine, args);
    }

    void bind_proxy() {
        stub = shadowhook_hook_sym_name(
                TARGET_LIB,
                TARGET_FUNC,
                (void *) proxy,
                (void **) &orig);

        if (stub == NULL) {
            int err_num = shadowhook_get_errno();
            const char *err_msg = shadowhook_to_errmsg(err_num);
        }
    }

    void thread_hook() {
        bind_proxy();
    }

    void thread_unhook() {
        shadowhook_unhook(stub);
        stub = NULL;
        orig = NULL;
    }
}
ThreadStackCompressed.cpp
#include "jni.h"
#include <string>
#include <android/log.h>
#include <pthread.h>
#include <cerrno>
#include <sys/prctl.h>

// Logging levels
#define LOG_TAG "thread_hook"
#define LOG_ERROR ANDROID_LOG_ERROR
#define LOG_DEBUG ANDROID_LOG_DEBUG

// Logging macros for convenience
#define LOGE(TAG, fmt, ...) __android_log_print(LOG_ERROR, TAG, fmt, ##__VA_ARGS__)
#define LOGD(TAG, fmt, ...) __android_log_print(LOG_DEBUG, TAG, fmt, ##__VA_ARGS__)

// Branch prediction hints
#ifndef LIKELY
#define LIKELY(cond) (__builtin_expect(!!(cond), 1))
#endif
#ifndef UNLIKELY
#define UNLIKELY(cond) (__builtin_expect(!!(cond), 0))
#endif

// Size constants for stack adjustments
#define SIZE_16K (16 * 1024)
#define SIZE_1M (1 * 1024 * 1024)

// Thread name size
#define THREAD_NAME_SIZE 16

// Error codes for clarity
#define ERROR_GET_STACK_SIZE_FAILED -1
#define ERROR_ADJUST_STACK_SIZE_FAILED -2
#define ERROR_IGNORE_COMPRESS -3
#define ERROR_INVALID_STACK_SIZE -4
#define ERROR_STACK_SIZE_TOO_SMALL -5

namespace thread_compressor {
    long stack_size = SIZE_1M;

    // Checks if the given size is within an acceptable range for stack size adjustment
    bool IsSizeValid(size_t originSize) {
        constexpr size_t STACK_SIZE_OFFSET = SIZE_16K;
        constexpr size_t ONE_MB = SIZE_1M;
        return ONE_MB - STACK_SIZE_OFFSET <= originSize && originSize <= ONE_MB + STACK_SIZE_OFFSET;
    }

    static void currentThreadName();

    // Adjusts the stack size for a thread, if possible and necessary
    static int AdjustStackSize(pthread_attr_t *attr) {
        size_t origStackSize = 0;
        int ret = pthread_attr_getstacksize(attr, &origStackSize);
        if (UNLIKELY(ret != 0)) {
            LOGE(LOG_TAG, "Fail to call pthread_attr_getstacksize, ret: %d", ret);
            return ERROR_GET_STACK_SIZE_FAILED;
        }

        if (!IsSizeValid(origStackSize)) {
            LOGE(LOG_TAG, "Origin Stack size %u, give up adjusting.", origStackSize);
            return ERROR_INVALID_STACK_SIZE;
        }

        if (origStackSize < 2 * PTHREAD_STACK_MIN) {
            LOGE(LOG_TAG, "Stack size is too small to reduce, give up adjusting.");
            return ERROR_STACK_SIZE_TOO_SMALL;
        }
        if (stack_size > origStackSize){
            stack_size = origStackSize >> 1U;
        }
        size_t final_stack_size = stack_size;
        ret = pthread_attr_setstacksize(attr, final_stack_size);
        if (LIKELY(ret == 0)) {
            LOGE(LOG_TAG, "min size is %d", final_stack_size);
            currentThreadName();
        } else {
            LOGE(LOG_TAG, "Fail to call pthread_attr_setstacksize, ret: %d", ret);
            return ERROR_ADJUST_STACK_SIZE_FAILED;
        }
        return ret;
    }

    static void currentThreadName() {
        char threadName[THREAD_NAME_SIZE];
        if (prctl(PR_GET_NAME, (unsigned long)threadName, 0, 0, 0) != 0) {
            LOGD(LOG_TAG, "Acquire current thread name failed.");
            return;
        }
        LOGD(LOG_TAG, "Shrink thread stack size successfully, thread name: %s", threadName);
    }

    // Public interface to attempt stack size compression on a thread
    int compress(pthread_attr_t const *attr) {
        if (attr == nullptr) {
            LOGD(LOG_TAG, "attr is null, skip adjusting.");
            return ERROR_IGNORE_COMPRESS; // Using a defined error code here might improve clarity
        }
        return AdjustStackSize(const_cast<pthread_attr_t *>(attr));
    }

    void thread_stack_size(long size) {
        stack_size = size;
    }

    long get_thread_stack_size() {
        return stack_size;
    }
}

Thread::CreateNativeThread()

这个函数是存在于 libart.so 中,由于 thread.cc是 C++ 代码,因此它存在一个函数名改编(mangling)的动作,因此我们可以使用 adb 命令将设备中的 libart.so 文件拉出来,然后使用readelf工具找到对应的函数签名。

readelf -Ws /path/to/your/file | grep CreateNativeThread

执行上述命令以后会得到下述函数改编值

_ZN3art6Thread18CreateNativeThreadEP7_JNIEnvP8_jobjectmb

当然遇到别的类似的改编值,我们也可以反解析出来。

c++filt _ZN3art6Thread18CreateNativeThreadEP7_JNIEnvP8_jobjectmb
art::Thread::CreateNativeThread(_JNIEnv*, _jobject*, unsigned long, bool)

ok,我们拿到了对应的改编值,可以开搞了。

示例代码

thread_hook.cpp
#include <jni.h>
#include <string>
#include <shadowhook.h>
#include <android/log.h>
#include <pthread.h>
#include <linux/prctl.h>
#include <sys/prctl.h>
#include "com_deliverysdk_thread_hook.h"
#include "thread_stack.h"

#define LOG_TAG "thread_hook"
#define TARGET_ART_LIB "libart.so"
#define THREAD_NAME_SIZE 16

#if defined(__arm__) // ARMv7 32-bit
#define TARGET_CREATE_NATIVE_THREAD "_ZN3art6Thread18CreateNativeThreadEP7_JNIEnvP8_jobjectjb"
#elif defined(__aarch64__) // ARMv8 64-bit
#define TARGET_CREATE_NATIVE_THREAD "_ZN3art6Thread18CreateNativeThreadEP7_JNIEnvP8_jobjectmb"
#endif

#define SIZE_1M_BYTE (1 * 1024 * 1024)
#define SIZE_1KB_BYTE (1 * 1024)

namespace thread_hook {
    jobject callbackObj = nullptr;

    void *originalFunction = nullptr;
    void *stubFunction = nullptr;

    typedef void (*ThreadCreateFunc)(JNIEnv *env, jobject java_peer, size_t stack_size,
                                     bool is_daemon);

    bool currentThreadName(char *name) {
        return prctl(PR_GET_NAME, (unsigned long) name, 0, 0, 0) == 0;
    }

    void createNativeThreadProxy(JNIEnv *env, jobject java_peer, size_t stack_size, bool is_daemon) {
        char threadName[THREAD_NAME_SIZE];
        if (stack_size == 0) {
            long adjustment_size = thread_stack::get_thread_stack_size();
            long default_size = SIZE_1M_BYTE;
            long final_size = default_size - adjustment_size;
            __android_log_print(ANDROID_LOG_INFO,
                                LOG_TAG,
                                "Adjusting thread size, target adjustment: %ld, thread name %s",
                                -final_size,
                                currentThreadName(threadName) ? threadName : "N/A");
            ((ThreadCreateFunc) originalFunction)(env, java_peer, -final_size, is_daemon);
        } else {
            ((ThreadCreateFunc) originalFunction)(env, java_peer, stack_size, is_daemon);
        }
    }

    void setNativeThreadStackFailed(JNIEnv *pEnv, const char *errMsg) {
        jclass jThreadHookClass = pEnv->FindClass("com/sample/thread_hook/ThreadSizeCallback");
        if (jThreadHookClass == nullptr) {
            return;
        }
        jmethodID jMethodId = pEnv->GetMethodID(jThreadHookClass, "setNativeThreadStackFailed",
                                                "(Ljava/lang/String;)V");
        if (jMethodId != nullptr) {
            pEnv->CallVoidMethod(callbackObj, jMethodId, pEnv->NewStringUTF(errMsg));
        }
    }

    void hook_create_native_thread(JNIEnv *pEnv) {
#if defined(__arm__) || defined(__aarch64__)
        stubFunction = shadowhook_hook_sym_name(TARGET_ART_LIB, TARGET_CREATE_NATIVE_THREAD,
                                                (void *) createNativeThreadProxy,
                                                (void **) &originalFunction);
        if (stubFunction == nullptr) {
            const int err_num = shadowhook_get_errno();
            const char *errMsg = shadowhook_to_errmsg(err_num);
            if (errMsg == nullptr || callbackObj == nullptr) {
                return;
            }
            setNativeThreadStackFailed(pEnv, errMsg);
            delete errMsg;
        }
        // this hook exist in the APP's whole lifecycle, so we do not need to release 'stubFunction' pointer.
#else
        setNativeThreadStackFailed(pEnv, "Unsupported architecture.");
#endif
    }
} // namespace thread_hook


extern "C" JNIEXPORT void JNICALL
Java_com_sample_thread_1hook_ThreadHook_setNativeThreadStackSize(JNIEnv *env,
                                                                      jobject /* this */,
                                                                      jlong stackSizeKb,
                                                                      jobject callback) {
    long const target_size = stackSizeKb * SIZE_1KB_BYTE;
    thread_stack::set_thread_stack_size(target_size);
    if (target_size > 0 && target_size < SIZE_1M_BYTE) {
        thread_hook::callbackObj = env->NewGlobalRef(callback);
        thread_hook::hook_create_native_thread(env);
    }
}

其中 15-19 行宏定义请注意,针对32 位 & 64 位CPU-Arch,对应的函数签名不相同,区别是 stack_size 的类型对应是 int & long,所以函数签名也发生了改变。

除此之外就是一些小的case,比如设置的stack_size是 大于0M或者小于1M才进行 hook,其他的就丢弃掉了好了。 还有就是最后传入给原始 CreateNativeThread()函数中的 stack_size一定要是一个负值哦。

Demo 测试

将线程栈极限压缩到512kb

栈压缩前

image.png

创建了1136个线程,发生了OOM

栈压缩后

image.png 创建了2087个线程,发生了OOM

风险控制

我们项目的风险控制可以采用 Firebase远程开关进行控制冷启动的时候是否开启全局Hook, 之前想要使用全局 JavaCrashHandler 捕获所有的StackOverFlowError的错误,一旦发生了1例的错误,立即关闭 hook,但是在测试的过程中发现并不能被全局捕获,其实它类似于 OOM error,是预测性错误,在可能发生类似的 Error 的代码处进行 Try... 才可以捕获,但是全局性的捕获由于已经是 error 了即便是全局捕获,其实也阻挡不了系统的不稳定性崩溃。其次可能是多线程的错误,存在异步问题,因此也很难兜住。

如何测试

先设置一个最大期望值,比如 512Kb,直接砍一半,然后丢给 QA 测试。

其次是对项目中的业务进行梳理,哪些是复杂的业务逻辑,比如地图导航流程,复杂的接单流程,或者一些二方三方的 sdk 业务流程,这些流程一定要详细测试。

结合 Monkey 测试脚本,去跑对应的核心流程。如果核心流程没有任何问题,在上线时 可以制定 900kb -> 800kb -> ... 依次递减,直到找到一个最小阈值。

建议

最好在做这个技术需求时可以先去做完线程治理,毕竟 hook 的方案是有风险的,除非收益很大才会做。 当然如果做完了线程治理,其实可以在线程池工厂中自定义一个线程栈大小,这样风险更小。

项目收益(TODO)

还没有上线,等上线后会补充这部分内容。

感谢

感谢字节提供的稳定的Inline Hook 方案!以及字节的技术分享!受益很多!