Android 线程挂起超时问题 - 续集

背景

本文是继修复线程挂起超时方案的续集,由于之前写方案的时候还在内部测试,还有部分读者提出一些问题并提供建议,完善了一下方案细节,目前公司已正式上线,暂时没有再出现线程挂起超时问题,这篇文章准备再记录一下问题与解决方案。

规避超时方案调整

前文中提到我们可以使用inline-hook技术去修改libart.so中的ThreadSuspendByPeerWarning()函数,在代理方法中判断如果当前messageThread suspension timed out那么就去修改LogSeverity的级别,这个方案有一个好处是一旦发生了线程挂起超时,那么我们就可以通过代理方法修改日志级别以后进行Call Java进行埋点上报。

但天不遂人愿,这个方案实际上在Android 12.1+ 是存在问题的。

原因还是在于UNREACHABLE()函数。

UNREACHABLE是什么?

Android 源码中的UNREACHABLE()实际上是__builtin_unreachable, 它类似是一个 Java 中的注解,告诉编译器,逻辑不可达。一旦程序执行到了这个函数,那么可能产生未定义(未知)错误。

自己写了一个测试代码,发现了很有趣的现象。

ini 复制代码
int foo() {
    int i = 0;
    if(i == 0){
      // __builtin_unreachable();
    }
    __builtin_unreachable();
}

首先如果一个函数需要一个返回值,理论上 if 与 else 必须都要给出返回内容,但是如果你使用 __builtin_unreachable();作为 else 的部分那么编译器就会忽略返回值类型检查。

如果把这个UNREACHABLE()放到方法体中的最后一行,如果代码执行到了,那么这个方法可能会无限递归。如果 if 中的注释去掉那么会崩溃。

如果编译器认为该函数在最后一行会调用__builtin_unreachable,它可能会优化掉函数的返回代码路径,假设函数会直接跳转到自身的开头重新执行。这种行为可能导致意外的尾递归调用。

ok,我们了解了UNREACHABLE()以后,知道我们修改日志级别的方案在高版本是行不通的,那么我们还有没有其他方案呢?

挂起函数替换

再读一下thread_list.cc中的代码,发现存在另一个挂起线程的函数,只不过函数参数不同。

C++ 复制代码
Thread* ThreadList::SuspendThreadByThreadId(uint32_t thread_id,
                                            SuspendReason reason,
                                            bool* timed_out) {
  const uint64_t start_time = NanoTime();
  useconds_t sleep_us = kThreadSuspendInitialSleepUs;
  *timed_out = false;
  Thread* suspended_thread = nullptr;
  Thread* const self = Thread::Current();
  CHECK_NE(thread_id, kInvalidThreadId);
  VLOG(threads) << "SuspendThreadByThreadId starting";
  while (true) {
    {
      ScopedObjectAccess soa(self);
      MutexLock thread_list_mu(self, *Locks::thread_list_lock_);
      Thread* thread = nullptr;
      for (const auto& it : list_) {
        if (it->GetThreadId() == thread_id) {
          thread = it;
          break;
        }
      }
      if (thread == nullptr) {
        CHECK(suspended_thread == nullptr) << "Suspended thread " << suspended_thread
            << " no longer in thread list";
        // There's a race in inflating a lock and the owner giving up ownership and then dying.
        ThreadSuspendByThreadIdWarning(::android::base::WARNING,
                                       "No such thread id for suspend",
                                       thread_id);
        return nullptr;
      }
      VLOG(threads) << "SuspendThreadByThreadId found thread: " << *thread;
      DCHECK(Contains(thread));
      {
        MutexLock suspend_count_mu(self, *Locks::thread_suspend_count_lock_);
        if (suspended_thread == nullptr) {
          if (self->GetSuspendCount() > 0) {
            // We hold the suspend count lock but another thread is trying to suspend us. Its not
            // safe to try to suspend another thread in case we get a cycle. Start the loop again
            // which will allow this thread to be suspended.
            continue;
          }
          bool updated = thread->ModifySuspendCount(self, +1, nullptr, reason);
          DCHECK(updated);
          suspended_thread = thread;
        } else {
          CHECK_EQ(suspended_thread, thread);
          // If the caller isn't requesting suspension, a suspension should have already occurred.
          CHECK_GT(thread->GetSuspendCount(), 0);
        }
        if (thread->IsSuspended()) {
          if (ATraceEnabled()) {
            std::string name;
            thread->GetThreadName(name);
            ATraceBegin(StringPrintf("SuspendThreadByThreadId suspended %s id=%d",
                                      name.c_str(), thread_id).c_str());
          }
          VLOG(threads) << "SuspendThreadByThreadId thread suspended: " << *thread;
          return thread;
        }
        const uint64_t total_delay = NanoTime() - start_time;
        if (total_delay >= thread_suspend_timeout_ns_) {
          ThreadSuspendByThreadIdWarning(::android::base::WARNING,
                                         "Thread suspension timed out",
                                         thread_id);
          if (suspended_thread != nullptr) {
            bool updated = thread->ModifySuspendCount(soa.Self(), -1, nullptr, reason);
            DCHECK(updated);
          }
          *timed_out = true;
          return nullptr;
        } else if (sleep_us == 0 &&
            total_delay > static_cast<uint64_t>(kThreadSuspendMaxYieldUs) * 1000) {
          // We have spun for kThreadSuspendMaxYieldUs time, switch to sleeps to prevent
          // excessive CPU usage.
          sleep_us = kThreadSuspendMaxYieldUs / 2;
        }
      }
      // Release locks and come out of runnable state.
    }
    VLOG(threads) << "SuspendThreadByThreadId waiting to allow thread chance to suspend";
    ThreadSuspendSleep(sleep_us);
    sleep_us = std::min(sleep_us * 2, kThreadSuspendMaxSleepUs);
  }
}

首先入参是 thread_id,其次挂起线程的方法都是调用ModifySuspendCount函数然后计数标记。再看超时的逻辑,喜出望外,这里的 log 级别只是WARNING

php 复制代码
ThreadSuspendByThreadIdWarning(::android::base::WARNING, "Thread suspension timed out", thread_id);

思路直接有了。当监听挂起函数调用了SuspendThreadByPeer那么间接调用SuspendThreadByThreadId即可。

thread_id 的获取

由于入参变了,需要传入thread_id,梳理了一下这个函数的调用流程,最终这个thread_idtls32_结构体中的thin_lock_thread_id。如何获取这个thin_lock_thread_id的值呢?前文我们知道,JavaNative 中共同维护了一份线程对象,而且他们之间建立了内存映射关系。Java中存在一个nativePeer的一个long值,他就是在Native中的线程类内存地址。所以我们通过反射拿到这个地址,就可以根据结构体的内存结构去计算内存的偏移地址,就可以拿到thread_id的值。

C++ 复制代码
#ifndef KB_ART_THREAD_H_
#define KB_ART_THREAD_H_

#include "macro.h"
#include "tls.h"

#define UN_SUPPORT (-1)
#define NOT_FOUND (0)

namespace kbArt {
class Thread {
  struct PACKED(4) tls_32bit_sized_values {
    using bool32_t = uint32_t;
    std::atomic<uint32_t> state_and_flags;
    int suspend_count;
    uint32_t thin_lock_thread_id;
    uint32_t tid;
  } tls32_;

 public:
  inline int32_t GetThreadId(int api_level) const {
    if (api_level < __ANDROID_API_S__ ||
        api_level > __ANDROID_API_U__) {  // < Android 12 || > android 14
      // now only support Android  12 13 14.
      return UN_SUPPORT;
    }
    uint32_t offset = 0;
    // calculate the offset of the field `thin_lock_thread_id` in the struct.
    offset = offsetof(tls_32bit_sized_values, thin_lock_thread_id);
    return *(int32_t *)((char *)this + offset);
  }
};
}  // namespace kbArt

Android版本兼容问题

由于Android 12.0Android 12.1Native 侧的api-level.h中没有明确的区分。 SuspendThreadByPeer的函数签名在这两个版本是不同的。

arduino 复制代码
// 12.0
#define SYMBOL_SUSPEND_THREAD_BY_PEER_OLD "_ZN3art10ThreadList19SuspendThreadByPeerEP8_jobjectbNS_13SuspendReasonEPb"
// 12.1 +
#define SYMBOL_SUSPEND_THREAD_BY_PEER_NEW "_ZN3art10ThreadList19SuspendThreadByPeerEP8_jobjectNS_13SuspendReasonEPb"

在调用shadowhook_hook_sym_nameSuspendThreadByPeer#OLD时候判断一下如果当前失败了并且当前api==31那么再次rehook 一下SuspendThreadByPeer#NEW,以确保覆盖Android 12版本。

那么 Android 5-11 的版本我们这边依然保持修改 log 级别的方案。

Android 15 修复方案

Hook大法好,一时hook一时爽。然而 Android 15又面临了改变,线上已经存在了一些 Android 15 的设备,已经有超时的 Crash了我晕~ 我们的方案又要进一步优化。

这回 Android 15 在 SuspendThreadByThreadId()函数中如果挂起超时直接调用了abort(),并且依然保留UNREACHABLE()的逻辑。

最贴心的是Android 15的版本加了注释,有1% - 5%的情况挂起超时崩溃,我晕~。 猜想为了系统稳定性,因为线程挂起超时了为了确保行为一致性,就牺牲了这1% - 5%的设备的运行。

同样研究了一下,提出一个解决方案,由于 Android 15 还有一些时间,优先级还不是很高,上线验证暂时待定。这里只是提出一个解决方案。

拿到结构体的地址 然后 hook StringPrintf() 从可变参数中获取地址就可 然后 把barrier_ 设置成 0 即可。

C++ 复制代码
#include <iostream>
#include <atomic>
#include <cstdarg>

struct WrappedSuspend1Barrier {
    static constexpr int kMagic = 0xba8;
    WrappedSuspend1Barrier() : magic_(kMagic), barrier_(1), next_(nullptr) {}
    int magic_;
    std::atomic<int32_t> barrier_;
    struct WrappedSuspend1Barrier* next_;
};

void test(...) {
    va_list args;
    va_start(args, nullptr);
    WrappedSuspend1Barrier* wrapped_barrier = va_arg(args, WrappedSuspend1Barrier*);
    if (wrapped_barrier != nullptr) {
        std::cout << "In test - WrappedSuspend1Barrier address: " << wrapped_barrier << std::endl;
        std::cout << "In test - Magic: " << wrapped_barrier->magic_ << std::endl;
        std::cout << "In test - Barrier value: " << wrapped_barrier->barrier_.load() << std::endl;
    } else {
        std::cout << "Received a null pointer!" << std::endl;
    }
    va_end(args);
}

int main() {
    WrappedSuspend1Barrier wrapped_barrier{};
    std::cout << "In main - WrappedSuspend1Barrier address: " << &wrapped_barrier << std::endl;
    std::cout << "In main - Magic: " << wrapped_barrier.magic_ << std::endl;
    std::cout << "In main - Barrier value: " << wrapped_barrier.barrier_.load() << std::endl;
    test(&wrapped_barrier);
    return 0;
}

其他问题

频繁创建释放线程导致 abort 崩溃问题

这边还发现了一个问题,就是当大量线程短时间频繁的创建与销毁,类似如下的代码

kotlin 复制代码
thread {
    while (true) {
        Thread.sleep(5L)
        Thread.getAllStackTraces()
    }
}
thread {
    while (true) {
        Thread.sleep(5L)
        thread {
            Thread.sleep(3L)
        }
    }
}

最终会导致一个NativeCrash,如下的堆栈

yaml 复制代码
// Crash thread
signal:6 (SIGABRT),code:-1 (SI_QUEUE),fault addr:--------
Abort message:
Check failed: thread_id != kInvalidThreadId (thread_id=0, kInvalidThreadId=0) 

这里分析是由于在线程销毁的时候,Java 与 Native 两部分内存存在释放的先后关系,在频繁的创建又在短时间内销毁的过程中,去dump 堆栈,会导致存在某个时机获取的线程 id 为 0。

如果thread_id为 0,在SuspendThreadByThreadId函数中存在一个检查点,如上图代码,最终如果 thread_id == kInvalidThreadId == 0 那么就会崩溃。

ini 复制代码
void *replaceThreadSuspendFunc(void *thread_list,
                               jobject peer,
                               SuspendReason suspendReason,
                               bool *timed_out) {
    jlong thread_id = getThreadIdByPeer(peer);
    auto *thread = reinterpret_cast<kbArt::Thread *>(thread_id);
    __android_log_print(ANDROID_LOG_INFO, LOG_TAG, "Replace function %p success", thread);
    if (thread == nullptr) {
        return ((SuspendThreadByPeer_t) originalFunctionReplace)(
                thread_list, peer, suspendReason, timed_out);
    }
    const int32_t threadId = thread->GetThreadId(android_get_device_api_level());
    if (threadId == UN_SUPPORT || threadId == NOT_FOUND) {
        return ((SuspendThreadByPeer_t) originalFunctionReplace)(
                thread_list, peer, suspendReason, timed_out);
    }
    return ((SuspendThreadByThreadId_t) suspendThreadByThreadId)(
            thread_list, threadId, suspendReason, timed_out);
}

所以在去调用SuspendThreadByThreadId的时候要判断一下threadId是否是NOT_FOUND(0),如果是 0,则直接调用原方法即可。

thread_id 可以为 0 吗?

我们看一下这个 id 是怎么赋值的,在 thread.cc 中存在一个Init()函数,

thin_lock_thread_id的赋值是通过AllocThreadId()函数而来的,我们看下这个函数的逻辑。

c 复制代码
static constexpr uint32_t kMaxThreadId = 0xFFFF;

std::bitset<kMaxThreadId> allocated_ids_ GUARDED_BY(Locks::allocated_thread_ids_lock_);

uint32_t ThreadList::AllocThreadId(Thread* self) {
  MutexLock mu(self, *Locks::allocated_thread_ids_lock_);
  for (size_t i = 0; i < allocated_ids_.size(); ++i) {
    if (!allocated_ids_[i]) {
      allocated_ids_.set(i);
      return i + 1;  // Zero is reserved to mean "invalid".
    }
  }
  LOG(FATAL) << "Out of internal thread ids";
  UNREACHABLE();
}

一个 bitset 类型中定一个 65535 的最大 ID 值,然后如果当前没有分配 ID ,会 return i + 1; 所以thread_id 一定不会为 0。

但是实际上由于线程的创建与销毁的时机不确定,

ini 复制代码
auto *thread = reinterpret_cast<kbArt::Thread *>(thread_id);

此时 *thread 指针指向的 Class Thread 内存地址可能是无效的(类似野指针),此时去调用

scss 复制代码
thread->GetThreadId(android_get_device_api_level());

获取 id 是不稳定的,得到的指针的值可能为 0,所以针对这段逻辑实际上如果求稳的话最好使用信号量捕获方案进行TRY-CATCH

更多的细节也可以参考字节的西瓜视频稳定性治理体系建设三:Sliver 原理及实践

ClassLoader 崩溃问题

java.lang.NoClassDefFoundError: Class not found using the boot class loader; no stack trace available

上述代码,当在Hook的挂起Proxy方法中去调用 Java 去做一个埋点的时候发生的,由于触发挂起函数SuspendThreadByPeer的时候,所在的线程并非都是PathClassLoader或者DexClassLoader,有可能是BootClassLoader,此时我们需要获取一下加载应用类加载器去调用 Java 方法。

ini 复制代码
void triggerSuspendTimeout() {
    JNIEnv *pEnv = getJNIEnv();
    if (pEnv == nullptr) {
        return;
    }
    jclass clsThread = pEnv->FindClass("java/lang/Thread");
    if (clsThread == nullptr) {
        return;
    }
    // java.lang.NoClassDefFoundError: Class not found using the boot class
    // loader; no stack trace available
    jmethodID midCurrentThread = pEnv->GetStaticMethodID(
            clsThread, "currentThread", "()Ljava/lang/Thread;");
    jobject currentThread =
            pEnv->CallStaticObjectMethod(clsThread, midCurrentThread);
    jmethodID midGetClassLoader = pEnv->GetMethodID(
            clsThread, "getContextClassLoader", "()Ljava/lang/ClassLoader;");
    if (midGetClassLoader == nullptr) {
        return;
    }
    jobject classLoader =
            pEnv->CallObjectMethod(currentThread, midGetClassLoader);
    if (classLoader == nullptr) {
        return;
    }
    jclass clsClassLoader = pEnv->FindClass("java/lang/ClassLoader");
    if (clsClassLoader == nullptr) {
        return;
    }
    jmethodID midLoadClass = pEnv->GetMethodID(
            clsClassLoader, "loadClass", "(Ljava/lang/String;)Ljava/lang/Class;");
    if (midLoadClass == nullptr) {
        return;
    }
    jstring className = pEnv->NewStringUTF(
            "com.thread_hook.ThreadSuspendTimeoutCallback");
    auto jThreadHookClass =
            (jclass) pEnv->CallObjectMethod(classLoader, midLoadClass, className);
    if (jThreadHookClass == nullptr) {
        return;
    }
    jmethodID jMethodId =
            pEnv->GetMethodID(jThreadHookClass, "onTriggerSuspendTimeout", "()V");
    if (jMethodId == nullptr) {
        return;
    }
    pEnv->CallVoidMethod(callbackObj, jMethodId);
    cleanup(pEnv);
}

温馨提示别忘记新增混淆 rules,防止方法被混淆。

写在最后

线程挂起的修复方案到此应该可以告一段落了,后续可能会补充一下Android 15的修复逻辑,总体来说通过修复此问题,引发了对 Native 侧的线程挂起逻辑有了一定的了解。特此还要感谢修武大佬 & 用户59978134298,发现问题完善方案~

相关推荐
watl09 分钟前
【Android】unzip aar删除冲突classes再zip
android·linux·运维
火云洞红孩儿10 分钟前
基于AI IDE 打造快速化的游戏LUA脚本的生成系统
c++·人工智能·inscode·游戏引擎·lua·游戏开发·脚本系统
键盘上的蚂蚁-12 分钟前
PHP爬虫类的并发与多线程处理技巧
android
喜欢猪猪1 小时前
Java技术专家视角解读:SQL优化与批处理在大数据处理中的应用及原理
android·python·adb
FeboReigns1 小时前
C++简明教程(4)(Hello World)
c语言·c++
FeboReigns1 小时前
C++简明教程(10)(初识类)
c语言·开发语言·c++
zh路西法2 小时前
【C++决策和状态管理】从状态模式,有限状态机,行为树到决策树(二):从FSM开始的2D游戏角色操控底层源码编写
c++·游戏·unity·设计模式·状态模式
.Vcoistnt2 小时前
Codeforces Round 994 (Div. 2)(A-D)
数据结构·c++·算法·贪心算法·动态规划
小k_不小2 小时前
C++面试八股文:指针与引用的区别
c++·面试
沐泽Mu2 小时前
嵌入式学习-QT-Day07
c++·qt·学习·命令模式