iOS启动过程分析

iOS启动时间

背景

产品用户规模越来越大,用户的挑剔性也越来越高。点击app icon进入首页是用户对app的第一印象,也是app用户体验中非常重要的一环。
而产品承接的业务会越来越多,为了适应庞大的业务,需要对启动项做严格的把关。启动优化的第一步就是启动时间段的精确测量。

调研及实现

先说下结论。
启动过程可以粗略的分为main前t1和main后t2,总启动时间 t = t1 + t2; 更精准的一些,我们把时间段做了细分,如下:

进程创建 ----> load方法 ----> main方法 ----> didFinishLaunch ----> (AD页展示 ----> AD页结束 ---->) 首页加载开始 ----> 首页加载结束

其中,从进程创建到main函数执行,属于main前;后面的属于main后

启动模式

由苹果wwdc的ppt可以看到,启动分为冷启动、热启动、resume三种case.
其中最耗时的加载过程其实是冷启动,我们的记录和优化也针对于冷启动。


1.png

启动过程

启动过程及时间记录-3.png

用dyld分析main前的流程

执行demo程序,main函数打断点,可以看到只有一个start


image.png

bt查看,可知是libdyld.dylib 的start


image.png

这个信息太少,通过main之前的+load调试来看一下


image.png

+load的调用栈就比较丰富了,我们通过+load的调用栈来粗浅探究一下dyld的调用流程。
首先内核创建进程位于dyldStartup.s,该类中声明了内核最初的过程。sp就是内存加载最初的入口。

/*
 * C runtime startup for interface to the dynamic linker.
 * This is the same as the entry point in crt0.o with the addition of the
 * address of the mach header passed as the an extra first argument.
 *
 * Kernel sets up stack frame to look like:
 *
 *  | STRING AREA |
 *  +-------------+
 *  |      0      |
*   +-------------+
 *  |  apple[n]   |
 *  +-------------+
 *         :
 *  +-------------+
 *  |  apple[0]   |
 *  +-------------+
 *  |      0      |
 *  +-------------+
 *  |    env[n]   |
 *  +-------------+
 *         :
 *         :
 *  +-------------+
 *  |    env[0]   |
 *  +-------------+
 *  |      0      |
 *  +-------------+
 *  | arg[argc-1] |
 *  +-------------+
 *         :
 *         :
 *  +-------------+
 *  |    arg[0]   |
 *  +-------------+
 *  |     argc    |
 *  +-------------+
 * sp-> |      mh     | address of where the a.out's file offset 0 is in memory
 *  +-------------+
 *
 *  Where arg[i] and env[i] point into the STRING AREA
 */

在汇编代码中可以看到下面关键信息

__dyld_start:
    popq    %rdi        # param1 = mh of app
    pushq   $0      # push a zero for debugger end of frames marker
    movq    %rsp,%rbp   # pointer to base of kernel frame
    andq    $-16,%rsp       # force SSE alignment
    subq    $16,%rsp    # room for local variables

    # call dyldbootstrap::start(app_mh, argc, argv, slide, dyld_mh, &startGlue)
    movl    8(%rbp),%esi    # param2 = argc into %esi
    leaq    16(%rbp),%rdx   # param3 = &argv[0] into %rdx
    movq    __dyld_start_static(%rip), %r8
    leaq    __dyld_start(%rip), %rcx
    subq     %r8, %rcx  # param4 = slide into %rcx
    leaq    ___dso_handle(%rip),%r8 # param5 = dyldsMachHeader
    leaq    -8(%rbp),%r9
    call    __ZN13dyldbootstrap5startEPK12macho_headeriPPKclS2_Pm

注释中的# call dyldbootstrap::start(app_mh, argc, argv, slide, dyld_mh, &startGlue) 解释了下面汇编代码的含义,和+load中的栈完全吻合。那就继续顺着栈跟吧~

dyldbootstrap::start(app_mh, argc, argv, slide, dyld_mh, &startGlue)位于dyldInitialization.cpp中,做用是dyld的引导程序。对dyld进行预先rebase、初始化c++静态函数,架构矫偏等等工作。然后进入主角:dyld.cpp的_main函数:dyld::_main(appsMachHeader, appsSlide, argc, argv, envp, apple, startGlue)。这个函数是整个main前的核心逻辑。非常复杂,我们先顺着+load的栈进行单线跟踪,以免淹没在汪洋大海。好,接着寻找下一个帧:initializeMainExecutable(),该函数如下:

void initializeMainExecutable()
{
    // record that we've reached this step
    gLinkContext.startedInitializingMainExecutable = true;

    // run initialzers for any inserted dylibs
    ImageLoader::InitializerTimingList initializerTimes[allImagesCount()];
    initializerTimes[0].count = 0;
    const size_t rootCount = sImageRoots.size();
    if ( rootCount > 1 ) {
        for(size_t i=1; i < rootCount; ++i) {
            sImageRoots[i]->runInitializers(gLinkContext, initializerTimes[0]);
        }
    }
    
    // run initializers for main executable and everything it brings up 
    sMainExecutable->runInitializers(gLinkContext, initializerTimes[0]);
    
    // register cxa_atexit() handler to run static terminators in all loaded images when this process exits
    if ( gLibSystemHelpers != NULL ) 
        (*gLibSystemHelpers->cxa_atexit)(&runAllStaticTerminators, NULL, NULL);

    // dump info if requested
    if ( sEnv.DYLD_PRINT_STATISTICS )
        ImageLoader::printStatistics((unsigned int)allImagesCount(), initializerTimes[0]);
    if ( sEnv.DYLD_PRINT_STATISTICS_DETAILS )
        ImageLoaderMachO::printStatisticsDetails((unsigned int)allImagesCount(), initializerTimes[0]);
}

在这个函数中,我们可以看到两个点:
一:环境变量的设置 DYLD_PRINT_STATISTICS & DYLD_PRINT_STATISTICS_DETAILS,通过设置他们,就能在程序run的阶段,看到dyld帮我们统计的各个时间点段。哇~ 那是不是我们可以把dyld统计的字段拿出来就行了,何必自己再统计呢?

image.png

马上试一下:#include #include
通通不支持...
理想很丰满啊! 苹果并没有对开发者公开!

回归现实,继续学习:
二:先初始化mainexcutable以外的images,最后初始化mainexcutable image,也就是我们自己的二进制。很好理解,其他的都是我们的依赖。但是,其他的images有app引入的吗?如果有,他们都是在这个阶段初始化的!mach-o view打开我们的二进制,打开load_commands


image.png

哇~!竟然一个三方库都没有!
打开demo工程


image.png

可以看到AFNetworking和PFLAPM两个库在其中。为什么呢?
因为我们的push repo全部使用了 --use-libraries也就是强制要求生成.a静态库

image.png
image.png

.a和.framework有什么区别呢?
mach-o view会帮我们解释。分别打开两个来查看


image.png

image.png

可以清楚地看到:.framework是一个标准的可执行文件,有mach header, load commands section等等,构建了各种依赖关系,依赖可以支持动态库。而.a文件则是一堆.o,每一个.o各自独立去包含自己的依赖。所以.a一般要比.framework大。
使用.a和.framework的关系是,.a有利于启动速度降低,.framework有利于包体积缩小。介于我们当前绘本只有30几兆,通通使用.a是可以的。但后续如果业务扩充,则需要权衡考虑,可以用framework减小包大小,再多framework合并,降低递归初始化和校验,节省时间。

补充个mach_header (mach-o/loader.h)

    uint32_t    magic;      /* mach magic number identifier */
    cpu_type_t  cputype;    /* cpu specifier */
    cpu_subtype_t   cpusubtype; /* machine specifier */
    uint32_t    filetype;   /* type of file */
    uint32_t    ncmds;      /* number of load commands */
    uint32_t    sizeofcmds; /* the size of all the load commands */
    uint32_t    flags;      /* flags */
    uint32_t    reserved;   /* reserved */
};

对应着mach-o view中的Mach64 Header,如下:


image.png

关于--use-libraries,在cocoapods的源码可以搜到定义,最简单的描述是在changelog.md中 解释如下:

* Lint as framework automatically. If needed, `--use-libraries` option
  allows linting as a static library.  
  [Boris Bügling](https://github.com/neonichu)
  [#2912](https://github.com/CocoaPods/CocoaPods/issues/2912)

继续+load的加载帧
从上文源码中可以看到,调入了ImageLoader::runInitializer,我们进入ImageLoader.cpp
ImageLoader::runInitializer -> ImageLoader::processInitializers -> ImageLoader::recursiveInitialization -> dyld::notifySingle(dyld.cpp)

然后通过一个通知runtime的回调函数进入runtime中:

(*sNotifyObjCInit)(image->getRealPath(), image->machHeader());

这个函数指针的注册位于

dyld.cpp
void registerObjCNotifiers(_dyld_objc_notify_mapped mapped, _dyld_objc_notify_init init, _dyld_objc_notify_unmapped unmapped)
{
    // record functions to call
    sNotifyObjCMapped   = mapped;
    sNotifyObjCInit     = init;
    sNotifyObjCUnmapped = unmapped;

//省略后面代码
}

dyldAPIs.cpp
void _dyld_objc_notify_register(_dyld_objc_notify_mapped    mapped,
                                _dyld_objc_notify_init      init,
                                _dyld_objc_notify_unmapped  unmapped)
{
    dyld::registerObjCNotifiers(mapped, init, unmapped);
}

后面的调用简写了,在runtime库中

runtime库中objc-os.mm
void _objc_init(void)
{
    //省略前面代码
    _dyld_objc_notify_register(&map_images, load_images, unmap_image);
}

void
load_images(const char *path __unused, const struct mach_header *mh)
{
    //省略前面代码
    // Discover load methods
    {
        mutex_locker_t lock2(runtimeLock);
        prepare_load_methods((const headerType *)mh);
    }

    // Call +load methods (without runtimeLock - re-entrant)
    call_load_methods();
}

void call_load_methods(void)
{
 
//省略前面代码
    do {
        // 1. Repeatedly call class +loads until there aren't any more
        while (loadable_classes_used > 0) {
            call_class_loads();
        }

        // 2. Call category +loads ONCE
        more_categories = call_category_loads();

        // 3. Run more +loads if there are classes OR more untried categories
    } while (loadable_classes_used > 0  ||  more_categories);
//省略后面代码
}

至此,我们的load流程就通了,顺便往回追一下

context.notifySingle(dyld_image_state_dependents_initialized, this, &timingInfo);
            
            // initialize this image
            bool hasInitializers = this->doInitialization(context);

ImageLoaderMachO.cpp            
bool ImageLoaderMachO::doInitialization(const LinkContext& context)
{
    CRSetCrashLogMessage2(this->getPath());

    // mach-o has -init and static initializers
    doImageInit(context);
    doModInitFunctions(context);
    
    CRSetCrashLogMessage2(NULL);
    
    return (fHasDashInit || fHasInitializers);
}           

其中doModInitFunctions用于调用cpp构造函数。明显的,attribute(constructor)的函数,我们要慎重书写。

image.png

image.png

然后返回至dyld.cpp的调用起点 initializeMainExecutable();

    #else
        // run all initializers
        initializeMainExecutable(); 
    #endif

//省略代码。。。
        // find entry point for main executable   main函数入口!!
        result = (uintptr_t)sMainExecutable->getEntryFromLC_MAIN();
        if ( result != 0 ) {
            // main executable uses LC_MAIN, we need to use helper in libdyld to call into main()
            if ( (gLibSystemHelpers != NULL) && (gLibSystemHelpers->version >= 9) )
                *startGlue = (uintptr_t)gLibSystemHelpers->startGlueToCallExit;
            else
                halt("libdyld.dylib support not present for LC_MAIN");
        }
        else {
            // main executable uses LC_UNIXTHREAD, dyld needs to let "start" in program set up for main()
            result = (uintptr_t)sMainExecutable->getEntryFromLC_UNIXTHREAD();
            *startGlue = 0;
        }
#if __has_feature(ptrauth_calls)
        // start() calls the result pointer as a function pointer so we need to sign it.
        result = (uintptr_t)__builtin_ptrauth_sign_unauthenticated((void*)result, 0, 0);
#endif
    }
    catch(const char* message) {
        syncAllImages();
        halt(message);
    }
    catch(...) {
        dyld::log("dyld: launch failed\n");
    }

    CRSetCrashLogMessage("dyld2 mode");

    if (sSkipMain) {
        if (dyld3::kdebug_trace_dyld_enabled(DBG_DYLD_TIMING_LAUNCH_EXECUTABLE)) {
            dyld3::kdebug_trace_dyld_duration_end(launchTraceID, DBG_DYLD_TIMING_LAUNCH_EXECUTABLE, 0, 0, 2);
        }
        result = (uintptr_t)&fake_main;
        *startGlue = (uintptr_t)gLibSystemHelpers->startGlueToCallExit;
    }
    
    return result;

可以看到关键的一句

// find entry point for main executable   main函数入口!!
        result = (uintptr_t)sMainExecutable->getEntryFromLC_MAIN();

这就是从main mach-o中,拿到了main函数的入口地址。


image.png

之后的过程是一系列的偏移,修正,可以不用管。最终返回出去到dyld.start,也就是最初的main函数调用栈

image.png

由此可见,dyld贯穿了main前的整个过程,runtime是中间被启动的一个库之一,配合dyld的加载过程,main后的app程序中使用。同样被启动的库还有gcd、security等等。

iOS时间的记录方式

Foundation: NSDate 网络对齐
CoreFoundation: CFAbsoluteTimeGetCurrent 网络对齐
QuartzCore: CACurrentMediaTime() 内置 单位是秒
Mach: mach_absolute_time 内置 单位是纳秒 滴答数 无法记录

优化项

一:framework数量控制,结合.a 合并操作权衡
二:+load __attribute__(constructor)函数减少调用
三:无用类和方法
四:didfinish中耗时任务后置
五:AD加载的同时,做首屏的准备工作
六:二进制重排,把先依赖的类放到前面。

整治思路

(参照美团外卖https://tech.meituan.com/2018/12/06/waimai-ios-optimizing-startup.html)
冷启动性能问题的治理目标主要有三个:
解决存量问题:优化当前性能瓶颈点,优化启动流程,缩短冷启动时间。
管控增量问题:冷启动流程规范化,通过代码范式和文档指导后续冷启动过程代码的维护,控制时间增量。
完善监控:完善冷启动性能指标监控,收集更详细的数据,及时发现性能问题。

11246e7f2fa16eb6281f0eb855a3ed4f183207.png

目前难题及后续优化

难题

其中,前三者都是依赖runtime的,理论上用于记录runtime前的不太合适,前两个又有网络对齐的操作,误差会大一些。只有mach_absolute_time最适合记录,精度也最高。但是目前我们统计进程创建的时间是利用了进程的kinfo_proc信息,从这个信息中能获取到的时间戳其实是NSDate 1970 匹配的。

+ (BOOL)processInfoForPID:(int)pid procInfo:(struct kinfo_proc*)procInfo
{
    int cmd[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, pid};
    size_t size = sizeof(*procInfo);
    return sysctl(cmd, sizeof(cmd)/sizeof(*cmd), procInfo, &size, NULL, 0) == 0;
}

+ (NSTimeInterval)processStartTime
{
    struct kinfo_proc kProcInfo;
    if ([self processInfoForPID:[[NSProcessInfo processInfo] processIdentifier] procInfo:&kProcInfo]) {
        return kProcInfo.kp_proc.p_un.__p_starttime.tv_sec * 1000.0 + kProcInfo.kp_proc.p_un.__p_starttime.tv_usec / 1000.0;
    } else {
#if DEBUG
        NSAssert(NO, @"无法取得进程的信息");
#endif
        return 0;
    }
}

好在经过反复测试,用NSDate或CFAbsoluteTimeGetCurrent计算出来的时间误差和mach_absolute_time计算出来的相比,在1ms以下。对于我们当前统计启动时间,做启动项优化的需求来说,这个误差完全可以接受。

后续优化

一、时间精度上的优化。
二、和壳工程配合,做到业务完全无感知
三、二进制重排

你可能感兴趣的:(iOS启动过程分析)