10

iOS内存abort(Jetsam) 原理探究

 3 years ago
source link: http://satanwoo.github.io/2017/10/18/abort/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

手淘架构组招人 iOS/Android 皆可,地点杭州,有兴趣的请联系我!!

iOS内存abort(Jetsam) 原理探究

苹果最近开源了iOS系统上的XNU内核代码,加上最近又开始负责手淘/猫客的稳定性及性能相关的工作,所以赶紧拜读下苹果的大作。今天主要开始想分析跟abort相关的内存Jetsam原理。

什么是Jetsam

关于Jetsam,可能有些人还不是很理解。我们可以从手机设置->隐私->分析这条路径看看系统的日志,会发现手机上有许多JetsamEvent开头的日志。打开这些日志,一般会显示一些内存大小,CPU时间什么的数据。

之所以会发生这么JetsamEvent,主要还是由于iOS设备不存在交换区导致的内存受限,所以iOS内核不得不把一些优先级不高或者占用内存过大的杀掉。这些JetsamEvent就是系统在杀掉App后记录的一些数据信息。

从某种程度来说,JetsamEvent是一种另类的Crash事件,但是在常规的Crash捕获工具中,由于iOS上能捕获的信号量的限制,所以因为内存导致App被杀掉是无法被捕获的。为此,许多业界的前辈通过设计flag的方式自己记录所谓的abort事件来采集数据。但是这种采集的abort,一般情况下都只能简单的记录次数,而没有详细的堆栈。

MacOS/iOS是一个从BSD衍生而来的系统。其内核是Mach,但是对于上层暴露的接口一般都是基于BSD层对于Mach包装后的。虽然说Mach是个微内核的架构,真正的虚拟内存管理是在其中进行,但是BSD对于内存管理提供了相对较为上层的接口,同时,各种常见的JetSam事件也是由BSD产生,所以,我们从bsd_init这个函数作为入口,来探究下原理。

bsd_init中基本都是在初始化各个子系统,比如虚拟内存管理等等。

跟内存相关的包括如下几步可能:

1. 初始化BSD内存Zone,这个Zone是基于Mach内核的zone构建
kmeminit();

2. iOS上独有的特性,内存和进程的休眠的常驻监控线程
#if CONFIG_FREEZE
#ifndef CONFIG_MEMORYSTATUS
    #error "CONFIG_FREEZE defined without matching CONFIG_MEMORYSTATUS"
#endif
    /* Initialise background freezing */
    bsd_init_kprintf("calling memorystatus_freeze_init\n");
    memorystatus_freeze_init();
#endif>

3. iOS独有,JetSAM(即低内存事件的常驻监控线程)
#if CONFIG_MEMORYSTATUS
    /* Initialize kernel memory status notifications */
    bsd_init_kprintf("calling memorystatus_init\n");
    memorystatus_init();
#endif /* CONFIG_MEMORYSTATUS */

这两步代码都是调用kern_memorystatus.c里面暴露的接口,主要的作用就是从内核中开启了两个最高优先级的线程,来监控整个系统的内存情况。

首先先来看看CONFIG_FREEZE涉及的功能。当启用这个效果的时候,内核会对进程进行冷冻而不是Kill。

这个冷冻的功能是通过在内核中启动一个memorystatus_freeze_thread进行。这个线程在收到信号后调用memorystatus_freeze_top_process进行冷冻。

当然,涉及到进程休眠相关的代码,就需要谈谈苹果系统里面其他相关概念了。扯开又是一个比较大的话题,后续单独开文章来进行阐述。

回到iOS Abort问题上的话,我们只需要关注memorystatus_init即可,去除平台无关的代码后如下:

__private_extern__ void
memorystatus_init(void)
{
    thread_t thread = THREAD_NULL;
    kern_return_t result;
    int i;

    /* Init buckets */
    // 注意点1:优先级数组,每个数组都持有了一个同优先级进程的列表
    for (i = 0; i < MEMSTAT_BUCKET_COUNT; i++) {
        TAILQ_INIT(&memstat_bucket[i].list);
        memstat_bucket[i].count = 0;
    }
    memorystatus_idle_demotion_call = thread_call_allocate((thread_call_func_t)memorystatus_perform_idle_demotion, NULL);

#if CONFIG_JETSAM

    nanoseconds_to_absolutetime((uint64_t)DEFERRED_IDLE_EXIT_TIME_SECS * NSEC_PER_SEC, &memorystatus_sysprocs_idle_delay_time);
    nanoseconds_to_absolutetime((uint64_t)DEFERRED_IDLE_EXIT_TIME_SECS * NSEC_PER_SEC, &memorystatus_apps_idle_delay_time);

    /* Apply overrides */
    // 注意点2:获取一系列内核参数
    PE_get_default("kern.jetsam_delta", &delta_percentage, sizeof(delta_percentage));
    if (delta_percentage == 0) {
        delta_percentage = 5;
    }
    assert(delta_percentage < 100);
    PE_get_default("kern.jetsam_critical_threshold", &critical_threshold_percentage, sizeof(critical_threshold_percentage));
    assert(critical_threshold_percentage < 100);
    PE_get_default("kern.jetsam_idle_offset", &idle_offset_percentage, sizeof(idle_offset_percentage));
    assert(idle_offset_percentage < 100);
    PE_get_default("kern.jetsam_pressure_threshold", &pressure_threshold_percentage, sizeof(pressure_threshold_percentage));
    assert(pressure_threshold_percentage < 100);
    PE_get_default("kern.jetsam_freeze_threshold", &freeze_threshold_percentage, sizeof(freeze_threshold_percentage));
    assert(freeze_threshold_percentage < 100);

    if (!PE_parse_boot_argn("jetsam_aging_policy", &jetsam_aging_policy,
            sizeof (jetsam_aging_policy))) {

        if (!PE_get_default("kern.jetsam_aging_policy", &jetsam_aging_policy,
                sizeof(jetsam_aging_policy))) {

            jetsam_aging_policy = kJetsamAgingPolicyLegacy;
        }
    }

    if (jetsam_aging_policy > kJetsamAgingPolicyMax) {
        jetsam_aging_policy = kJetsamAgingPolicyLegacy;
    }

    switch (jetsam_aging_policy) {

        case kJetsamAgingPolicyNone:
            system_procs_aging_band = JETSAM_PRIORITY_IDLE;
            applications_aging_band = JETSAM_PRIORITY_IDLE;
            break;

        case kJetsamAgingPolicyLegacy:
            /*
             * Legacy behavior where some daemons get a 10s protection once
             * AND only before the first clean->dirty->clean transition before
             * going into IDLE band.
             */
            system_procs_aging_band = JETSAM_PRIORITY_AGING_BAND1;
            applications_aging_band = JETSAM_PRIORITY_IDLE;
            break;

        case kJetsamAgingPolicySysProcsReclaimedFirst:
            system_procs_aging_band = JETSAM_PRIORITY_AGING_BAND1;
            applications_aging_band = JETSAM_PRIORITY_AGING_BAND2;
            break;

        case kJetsamAgingPolicyAppsReclaimedFirst:
            system_procs_aging_band = JETSAM_PRIORITY_AGING_BAND2;
            applications_aging_band = JETSAM_PRIORITY_AGING_BAND1;
            break;

        default:
            break;
    }

    /*
     * The aging bands cannot overlap with the JETSAM_PRIORITY_ELEVATED_INACTIVE
     * band and must be below it in priority. This is so that we don't have to make
     * our 'aging' code worry about a mix of processes, some of which need to age
     * and some others that need to stay elevated in the jetsam bands.
     */
    assert(JETSAM_PRIORITY_ELEVATED_INACTIVE > system_procs_aging_band);
    assert(JETSAM_PRIORITY_ELEVATED_INACTIVE > applications_aging_band);

    /* Take snapshots for idle-exit kills by default? First check the boot-arg... */
    if (!PE_parse_boot_argn("jetsam_idle_snapshot", &memorystatus_idle_snapshot, sizeof (memorystatus_idle_snapshot))) {
            /* ...no boot-arg, so check the device tree */
            PE_get_default("kern.jetsam_idle_snapshot", &memorystatus_idle_snapshot, sizeof(memorystatus_idle_snapshot));
    }

    memorystatus_delta = delta_percentage * atop_64(max_mem) / 100;
    memorystatus_available_pages_critical_idle_offset = idle_offset_percentage * atop_64(max_mem) / 100;
    memorystatus_available_pages_critical_base = (critical_threshold_percentage / delta_percentage) * memorystatus_delta;
    memorystatus_policy_more_free_offset_pages = (policy_more_free_offset_percentage / delta_percentage) * memorystatus_delta;

    /* Jetsam Loop Detection */
    if (max_mem <= (512 * 1024 * 1024)) {
        /* 512 MB devices */
        memorystatus_jld_eval_period_msecs = 8000;    /* 8000 msecs == 8 second window */
    } else {
        /* 1GB and larger devices */
        memorystatus_jld_eval_period_msecs = 6000;    /* 6000 msecs == 6 second window */
    }

    memorystatus_jld_enabled = TRUE;

    /* No contention at this point */
    memorystatus_update_levels_locked(FALSE);

#endif /* CONFIG_JETSAM */

    memorystatus_jetsam_snapshot_max = maxproc;
    memorystatus_jetsam_snapshot = 
        (memorystatus_jetsam_snapshot_t*)kalloc(sizeof(memorystatus_jetsam_snapshot_t) +
        sizeof(memorystatus_jetsam_snapshot_entry_t) * memorystatus_jetsam_snapshot_max);
    if (!memorystatus_jetsam_snapshot) {
        panic("Could not allocate memorystatus_jetsam_snapshot");
    }

    nanoseconds_to_absolutetime((uint64_t)JETSAM_SNAPSHOT_TIMEOUT_SECS * NSEC_PER_SEC, &memorystatus_jetsam_snapshot_timeout);

    memset(&memorystatus_at_boot_snapshot, 0, sizeof(memorystatus_jetsam_snapshot_t));

    result = kernel_thread_start_priority(memorystatus_thread, NULL, 95 /* MAXPRI_KERNEL */, &thread);
    if (result == KERN_SUCCESS) {
        thread_deallocate(thread);
    } else {
        panic("Could not create memorystatus_thread");
    }
}

下面先介绍几个知识点

  • 内核里面对于所有的进程都有一个优先级的分布,通过一个数组维护,数组每一项是一个进程的list。这个数组的大小是JETSAM_PRIORITY_MAX + 1。其结构体定义如下:

    typedef struct memstat_bucket {
        TAILQ_HEAD(, proc) list;
        int count;
    } memstat_bucket_t;
    

    这结构体非常通俗易懂。

  • 线程在Mach下采用了不同的优先级,其中MAXPRI_KERNEL代表的是分配给内核可用范围内最高优先级的线程。其他级别还有如下这些:


* // 优先级最高的实时线程 (不太清楚谁用)
 * 127        Reserved (real-time)
 *                A
 *                +
 *            (32 levels)
 *                +
 *                V
 * 96        Reserved (real-time)
 * // 给内核用的线程优先级(MAXPRI_KERNEL)
 * 95        Kernel mode only
 *                A
 *                +
 *            (16 levels)
 *                +
 *                V
 * 80        Kernel mode only
 * // 给操作系统分配的线程优先级
 * 79        System high priority
 *                A
 *                +
 *            (16 levels)
 *                +
 *                V
 * 64        System high priority
 * // 剩下的全是用户态的普通程序可以用的
 * 63        Elevated priorities
 *                A
 *                +
 *            (12 levels)
 *                +
 *                V
 * 52        Elevated priorities
 * 51        Elevated priorities (incl. BSD +nice)
 *                A
 *                +
 *            (20 levels)
 *                +
 *                V
 * 32        Elevated priorities (incl. BSD +nice)
 * 31        Default (default base for threads)
 * 30        Lowered priorities (incl. BSD -nice)
 *                A
 *                +
 *            (20 levels)
 *                +
 *                V
 * 11        Lowered priorities (incl. BSD -nice)
 * 10        Lowered priorities (aged pri's)
 *                A
 *                +
 *            (11 levels)
 *                +
 *                V
 * 0        Lowered priorities (aged pri's / idle)
 *************************************************************************
  • 从上图不难看出,用户态的应用程序的线程可能高于操作系统和内核。而且,在用户态的应用程序间的线程优先级分配也有区别,前台活动的应用程序优先级高于后台的应用程序。iOS上大名鼎鼎的SpringBoard是应用程序中优先级最高的程序。
  • 当然线程的优先级也不是一成不变。Mach会针对每一个线程的利用率和整体系统负载动态调整优先级。如果耗费CPU太多就降低优先级,如果一个线程过度挨饿CPU则会提升其优先级。但是无论怎么变,程序都不能超过其所在的线程优先级区间范围。

好,预备知识说完,那苹果究竟是怎么处理JetSam事件呢?

result = kernel_thread_start_priority(memorystatus_thread, NULL, 95 /* MAXPRI_KERNEL */, &thread);

苹果其实处理的思路非常简单。如上述代码,BSD层起了一个内核优先级最高的线程VM_memorystatus,这个线程会在维护两个列表,一个是我们之前提到的基于进程优先级的进程列表,还有一个是所谓的内存快照列表,即保存了每个进程消耗的内存页memorystatus_jetsam_snapshot

这个常驻线程接受从内核对于内存的守护程序pageout通过内核调用给每个App进程发送的内存压力通知,来处理事件,这个事件转发成上层的UI事件就是平常我们会收到的全局内存警告或者每个ViewController里面的didReceiveMemoryWarning

当然,我们自己开发的App是不会主动注册监听这个内存警告事件的,帮助我们在底层完成这一切的都是libdispatch,如果你感兴趣的话,可以钻研下_dispatch_source_type_memorypressure__dispatch_source_type_memorystatus

那么在哪些情况下会出现内存压力呢?我们来看一看memorystatus_action_needed这段函数:

static boolean_t
memorystatus_action_needed(void)
{
#if CONFIG_EMBEDDED
    return (is_reason_thrashing(kill_under_pressure_cause) ||
            is_reason_zone_map_exhaustion(kill_under_pressure_cause) ||
           memorystatus_available_pages <= memorystatus_available_pages_pressure);
#else /* CONFIG_EMBEDDED */
    return (is_reason_thrashing(kill_under_pressure_cause) ||
            is_reason_zone_map_exhaustion(kill_under_pressure_cause));
#endif /* CONFIG_EMBEDDED */
}

概括来说:

频繁的的页面换进换出is_reason_thrashing,Mach Zone耗尽了is_reason_zone_map_exhaustion(这个涉及Mach内核的虚拟内存管理了,单独写)以及可用的页低于一个门槛了memorystatus_available_pages

在这几种情况下,就会准备去Kill 进程了。但是,在这个处理下面,有一段代码特别有意思,我们看看这个函数memorystatus_act_aggressive

if ( (jld_bucket_count == 0) || 
     (jld_now_msecs > (jld_timestamp_msecs + memorystatus_jld_eval_period_msecs))) {

    /* 
     * Refresh evaluation parameters 
     */
    jld_timestamp_msecs     = jld_now_msecs;
    jld_idle_kill_candidates = jld_bucket_count;
    *jld_idle_kills         = 0;
    jld_eval_aggressive_count = 0;
    jld_priority_band_max    = JETSAM_PRIORITY_UI_SUPPORT;
}

这段代码很明显,是基于某个时间间隔在做条件判断。如果不满足这个判断,后续真正执行的Kill也不会走到。那我们来看看memorystatus_jld_eval_period_msecs这个变量:

/* Jetsam Loop Detection */
if (max_mem <= (512 * 1024 * 1024)) {
    /* 512 MB devices */
    memorystatus_jld_eval_period_msecs = 8000;    /* 8000 msecs == 8 second window */
} else {
    /* 1GB and larger devices */
    memorystatus_jld_eval_period_msecs = 6000;    /* 6000 msecs == 6 second window */
}

这个时间窗口是根据设备的物理内存上限来设定的,但是无论如何,看起来至少有个6秒的时间可以给我们来做点事情。

当然,如果满足了时间窗口的需求,就会根据我们提到的优先级进程列表进行寻找可杀目标:

proc_list_lock();
switch (jetsam_aging_policy) {
case kJetsamAgingPolicyLegacy:
    bucket = &memstat_bucket[JETSAM_PRIORITY_IDLE];
    jld_bucket_count = bucket->count;
    bucket = &memstat_bucket[JETSAM_PRIORITY_AGING_BAND1];
    jld_bucket_count += bucket->count;
    break;
case kJetsamAgingPolicySysProcsReclaimedFirst:
case kJetsamAgingPolicyAppsReclaimedFirst:
    bucket = &memstat_bucket[JETSAM_PRIORITY_IDLE];
    jld_bucket_count = bucket->count;
    bucket = &memstat_bucket[system_procs_aging_band];
    jld_bucket_count += bucket->count;
    bucket = &memstat_bucket[applications_aging_band];
    jld_bucket_count += bucket->count;
    break;
case kJetsamAgingPolicyNone:
default:
    bucket = &memstat_bucket[JETSAM_PRIORITY_IDLE];
    jld_bucket_count = bucket->count;
    break;
}

bucket = &memstat_bucket[JETSAM_PRIORITY_ELEVATED_INACTIVE];
elevated_bucket_count = bucket->count;

需要注意的是,JETSAM不一定只杀一个进程,他可能会大杀特杀,杀掉N多进程。

if (memorystatus_avail_pages_below_pressure()) {
    /*
     * Still under pressure.
     * Find another pinned processes.
     */
    continue;
} else {
    return TRUE;
}

至于杀进程的话,最终都会落到函数memorystatus_do_kill->jetsam_do_kill去执行。

看苹果代码的时候,发现了不少内核的参数,一一进行了尝试后,发现sysctlnamesysctl的系统调用都被苹果禁用了,比如这些:

"kern.jetsam_delta"
"kern.jetsam_critical_threshold"
"kern.jetsam_idle_offset"
"kern.jetsam_pressure_threshold"
"kern.jetsam_freeze_threshold"
"kern.jetsam_aging_policy"

不过,我试了下通过kern.boottime获取机器的开机时间还是可以的,代码示例如下:

size_t size;
sysctlbyname("kern.boottime", NULL, &size, NULL, 0);

char *boot_time = malloc(size);
sysctlbyname("kern.boottime", boot_time, &size, NULL, 0);

uint32_t timestamp = 0;
memcpy(&timestamp, boot_time, sizeof(uint32_t));
free(boot_time);

NSDate* bootTime = [NSDate dateWithTimeIntervalSince1970:timestamp];

嘻嘻,技术原理研究了一些,心里顿时对解决公司的Abort问题有了一定的眉目。嘿嘿,我写了个DEMO验证了我的思路,是可行的。哇咔咔。等我的好消息吧~


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK