WKWebView 线程终止的原因——之 OOM 的控制逻辑

4,322 阅读9分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第1天,点击查看活动详情

最近在 WKWebView 中展示三维图像渲染的功能时,经常遇到 WKWebView 莫名其妙的 reload 的现象。

WKWebView 会在 APP 的进程外执行其所有的工作,并且 WKWebView 的内存使用量与 APP 的内存使用量分开计算。这样当 WKWebView 进程超出其内存限制时,就不会导致 APP 程序终止,最多也就是导致空白视图。

为了定位具体的原因,先查看一下 WKWebView 的代理提供的回调方法。在 WKNavigationDelegate 中定义了回调方法 webViewWebContentProcessDidTerminate(_)

/** @abstract Invoked when the web view's web content process is terminated.
@param webView The web view whose underlying web content process was terminated.
*/
@available(iOS 9.0, *)
optional func webViewWebContentProcessDidTerminate(_ webView: WKWebView)

当 web 视图的内容进程终止时,将通过此回调通知 APP,然而并没有提供更多的错误信息。

可以通过 github.com/WebKit/WebK… 看到 WKWebView 的源码。通过搜索 webViewWebContentProcessDidTerminate的方法,可以一步步知道 WKWebView 的异常流程。

WKWebView 进程异常的流程

WKWebView 的终止代理流程

在 WebKit 的 NavigationState.mm 文件中,调用了 webViewWebContentProcessDidTerminate 方法:

bool NavigationState::NavigationClient::processDidTerminate(WebPageProxy& page, ProcessTerminationReason reason)
{
    if (!m_navigationState)
        return false;

    if (!m_navigationState->m_navigationDelegateMethods.webViewWebContentProcessDidTerminate
        && !m_navigationState->m_navigationDelegateMethods.webViewWebContentProcessDidTerminateWithReason
        && !m_navigationState->m_navigationDelegateMethods.webViewWebProcessDidCrash)
        return false;

    auto navigationDelegate = m_navigationState->m_navigationDelegate.get();
    if (!navigationDelegate)
        return false;

    if (m_navigationState->m_navigationDelegateMethods.webViewWebContentProcessDidTerminateWithReason) {
        [static_cast<id <WKNavigationDelegatePrivate>>(navigationDelegate.get()) _webView:m_navigationState->m_webView webContentProcessDidTerminateWithReason:wkProcessTerminationReason(reason)];
        return true;
    }

    // We prefer webViewWebContentProcessDidTerminate: over _webViewWebProcessDidCrash:.
    if (m_navigationState->m_navigationDelegateMethods.webViewWebContentProcessDidTerminate) {
        [navigationDelegate webViewWebContentProcessDidTerminate:m_navigationState->m_webView];
        return true;
    }

    ASSERT(m_navigationState->m_navigationDelegateMethods.webViewWebProcessDidCrash);
    [static_cast<id <WKNavigationDelegatePrivate>>(navigationDelegate.get()) _webViewWebProcessDidCrash:m_navigationState->m_webView];
    return true;
}

processDidTerminate() 方法中,当线程终止时的处理流程为:

  • 若未设置代理方法,则返回 false;
  • 如果代理实现了 _webView:webViewWebContentProcessDidTerminateWithReason:,则回调,并返回 true;
  • 如果代理实现了 webViewWebContentProcessDidTerminate:,则回调,并返回 true;
  • 调用回调方法:_webViewWebProcessDidCrash:,并返回 true。

代理方法的设置方法如下:

void NavigationState::setNavigationDelegate(id <WKNavigationDelegate> delegate)
{
    // ....
    m_navigationDelegateMethods.webViewWebContentProcessDidTerminate = [delegate respondsToSelector:@selector(webViewWebContentProcessDidTerminate:)];
    m_navigationDelegateMethods.webViewWebContentProcessDidTerminateWithReason = [delegate respondsToSelector:@selector(_webView:webContentProcessDidTerminateWithReason:)];
    // ....
}

processDidTerminate() 方法中,参数 reason说明了异常原因,类型为 ProcessTerminationReason,定义如下:

enum class ProcessTerminationReason {
    ExceededMemoryLimit, // 超出内存限制
    ExceededCPULimit,    // 超出CPU限制
    RequestedByClient,   // 主动触发的terminate
    IdleExit,
    Unresponsive,        // 无法响应
    Crash,               // web进程自己发生了crash
    // Those below only relevant for the WebContent process.
    ExceededProcessCountLimit,
    NavigationSwap,
    RequestedByNetworkProcess,
    RequestedByGPUProcess
};

通过代理方法获取异常原因

可以看到回调方法有两个:webViewWebContentProcessDidTerminate:_webView:webContentProcessDidTerminateWithReason:,一个不带 reason 参数,一个带有 reason 参数,并且带有 reason 参数的回调方法优先级更高。

在 WKWebView 的 WKNavigationDelegate 代理中,我们只看到了不带 reason 的回调方法,那 _webView:webContentProcessDidTerminateWithReason: 是怎么回事呢?

通过检索发现,它定义在 WKNavigationDelegatePrivate 在代理中:

@protocol WKNavigationDelegatePrivate <WKNavigationDelegate>

@optional

// ...
- (void)_webView:(WKWebView *)webView webContentProcessDidTerminateWithReason:(_WKProcessTerminationReason)reason WK_API_AVAILABLE(macos(10.14), ios(12.0));
// ...
@end

WKNavigationDelegatePrivate 并没有公开让 App 使用。不过,我们依然可以通过实现上面的代理方法,获取到 reason 信息。

不过需要注意:WebKit 内部的异常类型为:ProcessTerminationReason,而此处 reason 参数的类型为:_WKProcessTerminationReason

typedef NS_ENUM(NSInteger, _WKProcessTerminationReason) {
    _WKProcessTerminationReasonExceededMemoryLimit,
    _WKProcessTerminationReasonExceededCPULimit,
    _WKProcessTerminationReasonRequestedByClient,
    _WKProcessTerminationReasonCrash,
} WK_API_AVAILABLE(macos(10.14), ios(12.0));

_WKProcessTerminationReasonProcessTerminationReason 的转换关系如下:

static _WKProcessTerminationReason wkProcessTerminationReason(ProcessTerminationReason reason)
{
    switch (reason) {
    case ProcessTerminationReason::ExceededMemoryLimit:
        return _WKProcessTerminationReasonExceededMemoryLimit;
    case ProcessTerminationReason::ExceededCPULimit:
        return _WKProcessTerminationReasonExceededCPULimit;
    case ProcessTerminationReason::NavigationSwap:
    case ProcessTerminationReason::IdleExit:
        // We probably shouldn't bother coming up with a new API type for process-swapping.
        // "Requested by client" seems like the best match for existing types.
        FALLTHROUGH;
    case ProcessTerminationReason::RequestedByClient:
        return _WKProcessTerminationReasonRequestedByClient;
    case ProcessTerminationReason::ExceededProcessCountLimit:
    case ProcessTerminationReason::Unresponsive:
    case ProcessTerminationReason::RequestedByNetworkProcess:
    case ProcessTerminationReason::RequestedByGPUProcess:
    case ProcessTerminationReason::Crash:
        return _WKProcessTerminationReasonCrash;
    }
    ASSERT_NOT_REACHED();
    return _WKProcessTerminationReasonCrash;
}

可以看出,在转换过程中,并不是一一对应的,会损失掉具体的 crash 类型。也就是,当我们实现_webView:webContentProcessDidTerminateWithReason:代理时,可以获取到一个相对笼统的 reason。

内存超限的逻辑(ExceededMemoryLimit)

下面先分析内存超限的逻辑。

初始化 web 线程的方法:initializeWebProcess(),实现如下:

void WebProcess::initializeWebProcess(WebProcessCreationParameters&& parameters)
{    
    // ...
    if (!m_suppressMemoryPressureHandler) {
        // ...
        #if ENABLE(PERIODIC_MEMORY_MONITOR)
        memoryPressureHandler.setShouldUsePeriodicMemoryMonitor(true);
        memoryPressureHandler.setMemoryKillCallback([this] () {
            WebCore::logMemoryStatistics(LogMemoryStatisticsReason::OutOfMemoryDeath);
            if (MemoryPressureHandler::singleton().processState() == WebsamProcessState::Active)
                parentProcessConnection()->send(Messages::WebProcessProxy::DidExceedActiveMemoryLimit(), 0);
            else
                parentProcessConnection()->send(Messages::WebProcessProxy::DidExceedInactiveMemoryLimit(), 0);
        });

        // ...
        #endif
        // ...
    }
    // ...
}

其中:

  • setShouldUsePeriodicMemoryMonitor() 设置是否需要定期检测内存;
  • setMemoryKillCallback() 设置内存超限后,被终止后的回调。

定期内存检测

设置定期内存检测的方法setShouldUsePeriodicMemoryMonitor的实现如下:

void MemoryPressureHandler::setShouldUsePeriodicMemoryMonitor(bool use)
{
    if (!isFastMallocEnabled()) {
        // If we're running with FastMalloc disabled, some kind of testing or debugging is probably happening.
        // Let's be nice and not enable the memory kill mechanism.
        return;
    }

    if (use) {
        m_measurementTimer = makeUnique<RunLoop::Timer<MemoryPressureHandler>>(RunLoop::main(), this, &MemoryPressureHandler::measurementTimerFired);
        m_measurementTimer->startRepeating(m_configuration.pollInterval);
    } else
        m_measurementTimer = nullptr;
}

其中,初始化了一个 Timer,时间间隔为 m_configuration.pollInterval(pollInterval 的值为 30s),执行方法为 measurementTimerFired()。也就是每隔 30s 调用一次 measurementTimerFired() 对内存使用量进行一次检查。

内存检查的方法 measurementTimerFired() 定义如下: 

void MemoryPressureHandler::measurementTimerFired()
{
    size_t footprint = memoryFootprint();
#if PLATFORM(COCOA)
    RELEASE_LOG(MemoryPressure, "Current memory footprint: %zu MB", footprint / MB);
#endif
    auto killThreshold = thresholdForMemoryKill();
    if (killThreshold && footprint >= *killThreshold) {
        shrinkOrDie(*killThreshold);
        return;
    }

    setMemoryUsagePolicyBasedOnFootprint(footprint);

    switch (m_memoryUsagePolicy) {
    case MemoryUsagePolicy::Unrestricted:
        break;
    case MemoryUsagePolicy::Conservative:
        releaseMemory(Critical::No, Synchronous::No);
        break;
    case MemoryUsagePolicy::Strict:
        releaseMemory(Critical::Yes, Synchronous::No);
        break;
    }

    if (processState() == WebsamProcessState::Active && footprint > thresholdForMemoryKillOfInactiveProcess(m_pageCount))
        doesExceedInactiveLimitWhileActive();
    else
        doesNotExceedInactiveLimitWhileActive();
}

其中,footprint来为当前使用的内存量,killThreshold为内存的最大限制。如果 killThreshold 大于等于 footprint,则调用 shrinkOrDie()

当前使用的内存量

当前使用的内存量是通过 memoryFootprint()来获取的。定义如下:

namespace WTF {

size_t memoryFootprint()
{
    task_vm_info_data_t vmInfo;
    mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
    kern_return_t result = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t) &vmInfo, &count);
    if (result != KERN_SUCCESS)
        return 0;
    return static_cast<size_t>(vmInfo.phys_footprint);
}

}

其中,使用到了 task_info来获取线程的信息,传递的参数有:

  • mach_task_self() :获取当前进程
  • TASK_VM_INFO:取虚拟内存信息
  • vmInfo、count:两个参数传递的为引用地址,用于接收返回值。
    • vmInfo:task_vm_info_data_t 里的 phys_footprint 就是进程的内存占用,以 byte 为单位。

内存的最大限制

内存最大的限制由 thresholdForMemoryKill() 方法实现,定义如下:

std::optional<size_t> MemoryPressureHandler::thresholdForMemoryKill()
{
    if (m_configuration.killThresholdFraction)
        return m_configuration.baseThreshold * (*m_configuration.killThresholdFraction);

    switch (m_processState) {
    case WebsamProcessState::Inactive:
        return thresholdForMemoryKillOfInactiveProcess(m_pageCount);
    case WebsamProcessState::Active:
        return thresholdForMemoryKillOfActiveProcess(m_pageCount);
    }
    return std::nullopt;
}

static size_t thresholdForMemoryKillOfActiveProcess(unsigned tabCount)
{
    size_t baseThreshold = ramSize() > 16 * GB ? 15 * GB : 7 * GB;
    return baseThreshold + tabCount * GB;
}

static size_t thresholdForMemoryKillOfInactiveProcess(unsigned tabCount)
{
#if CPU(X86_64) || CPU(ARM64)
    size_t baseThreshold = 3 * GB + tabCount * GB;
#else
    size_t baseThreshold = tabCount > 1 ? 3 * GB : 2 * GB;
#endif
    return std::min(baseThreshold, static_cast<size_t>(ramSize() * 0.9));
}

可以看出,最大的可用内存由:当前的 webview 的页数(m_pageCount),线程的状态(Inactive 和 Active)和 ramSize() 计算得来。

当前的页数和线程的状态比较易容理解,下面来看 ramSize() 的计算方法:

namespace WTF {

size_t ramSize()
{
    static size_t ramSize;
    static std::once_flag onceFlag;
    std::call_once(onceFlag, [] {
        ramSize = computeRAMSize();
    });
    return ramSize;
}

} // namespace WTF

ramSize 只会计算一次,由 computeRAMSize() 计算得来,定义如下:

#if OS(WINDOWS)
static constexpr size_t ramSizeGuess = 512 * MB;
#endif

static size_t computeRAMSize()
{
#if OS(WINDOWS)
    MEMORYSTATUSEX status;
    status.dwLength = sizeof(status);
    bool result = GlobalMemoryStatusEx(&status);
    if (!result)
        return ramSizeGuess;
    return status.ullTotalPhys;

#elif USE(SYSTEM_MALLOC)

#if OS(LINUX) || OS(FREEBSD)
    struct sysinfo si;
    sysinfo(&si);
    return si.totalram * si.mem_unit;
#elif OS(UNIX)
    long pages = sysconf(_SC_PHYS_PAGES);
    long pageSize = sysconf(_SC_PAGE_SIZE);
    return pages * pageSize;
#else
#error "Missing a platform specific way of determining the available RAM"
#endif // OS(LINUX) || OS(FREEBSD) || OS(UNIX)
#else
    return bmalloc::api::availableMemory();
#endif
}

computeRAMSize() 中,根据不同的操作系统(Windows,LINUX、Unix)和一个默认方式来计算。需要注意的是:虽然iOS是基于 Unix 的,但是这里的 Unix 不包括 iOS 系统。所以,在 iOS 系统中,会执行 return bmalloc::api::availableMemory();。定义如下:

inline size_t availableMemory()
{
    return bmalloc::availableMemory();
}

它只是简单的调用了 bmalloc::availableMemory()。再来看 bmalloc::availableMemory() 的实现:

size_t availableMemory()
{
    static size_t availableMemory;
    static std::once_flag onceFlag;
    std::call_once(onceFlag, [] {
        availableMemory = computeAvailableMemory();
    });
    return availableMemory;
}

availableMemory()方法中的 availableMemory只会计算一次,由  computeAvailableMemory()计算而来。

static constexpr size_t availableMemoryGuess = 512 * bmalloc::MB;

static size_t computeAvailableMemory()
{
#if BOS(DARWIN)
    size_t sizeAccordingToKernel = memorySizeAccordingToKernel();
#if BPLATFORM(IOS_FAMILY)
    sizeAccordingToKernel = std::min(sizeAccordingToKernel, jetsamLimit());
#endif
    size_t multiple = 128 * bmalloc::MB;

    // Round up the memory size to a multiple of 128MB because max_mem may not be exactly 512MB
    // (for example) and we have code that depends on those boundaries.
    return ((sizeAccordingToKernel + multiple - 1) / multiple) * multiple;
#elif BOS(FREEBSD) || BOS(LINUX)
    //...
#elif BOS(UNIX)
    //...
#else
    return availableMemoryGuess;
#endif
}

computeAvailableMemory()方法中,

  1. 先通过 memorySizeAccordingToKernel() 获取内核的内存大小;
  2. 如果是 iOS 系统,再获取 jetsam 的限制:jetsamLimit(),在内存大小和 jetsamLimit() 中取较小的值;
  3. 将结果向上取整为 128M 的倍数。

所以,此处的结果依赖于 memorySizeAccordingToKernel()jetsamLimit()

先看 memorySizeAccordingToKernel()的实现:

#if BOS(DARWIN)
static size_t memorySizeAccordingToKernel()
{
#if BPLATFORM(IOS_FAMILY_SIMULATOR)
    BUNUSED_PARAM(availableMemoryGuess);
    // Pretend we have 1024MB of memory to make cache sizes behave like on device.
    return 1024 * bmalloc::MB;
#else
    host_basic_info_data_t hostInfo;

    mach_port_t host = mach_host_self();
    mach_msg_type_number_t count = HOST_BASIC_INFO_COUNT;
    kern_return_t r = host_info(host, HOST_BASIC_INFO, (host_info_t)&hostInfo, &count);
    mach_port_deallocate(mach_task_self(), host);
    if (r != KERN_SUCCESS)
        return availableMemoryGuess;

    if (hostInfo.max_mem > std::numeric_limits<size_t>::max())
        return std::numeric_limits<size_t>::max();

    return static_cast<size_t>(hostInfo.max_mem);
#endif
}

#endif

逻辑为:

  1. 如果是模拟器,则内存设定为 1024M。
  2. 如果是真实设备,则通过 host_info获取结构体为 host_basic_info_data_t 的信息,读取 max_mem的数值,然后与 std::numeric_limits<size_t>::max()进行比较,取其中较小的值。其中 std::numeric_limits<size_t>::max() 为当前设备可以表示的最大值。
  3. 计算失败时,返回 availableMemoryGuess,即 512 M。

再来看jetsamLimit()的实现:

#if BPLATFORM(IOS_FAMILY)
static size_t jetsamLimit()
{
    memorystatus_memlimit_properties_t properties;
    pid_t pid = getpid();
    if (memorystatus_control(MEMORYSTATUS_CMD_GET_MEMLIMIT_PROPERTIES, pid, 0, &properties, sizeof(properties)))
        return 840 * bmalloc::MB;
        
    if (properties.memlimit_active < 0)
        return std::numeric_limits<size_t>::max();

    return static_cast<size_t>(properties.memlimit_active) * bmalloc::MB;
}
#endif

jetsamLimit()中,

  1. 通过 memorystatus_control() 获取结构体为 memorystatus_memlimit_properties_t 的信息,返回值不为 0,则返回 840M;
  2. 如果获取的 memoryStatus 的限制属性 memlimit_active 小于 0 时,则返回当前设备可以表示的最大值;
  3. 如果运行正常,则返回系统返回的数值。

至此,就看到了 ramSize() 的整个计算过程。

总结一下内存最大限制的计算方法:

  1. 判断线程当前的状态:
    1. 激活状态
      1. 计算 ramSize();
        1. 计算内核的内存大小和 jetsam 的限制,取较小值,
        2. 向上取整为 128M 的倍数。
      2. 计算 baseThreshold = ramSize() > 16 * GB ? 15 * GB : 7 * GB;
      3. 最终结果为:baseThreshold + tabCount * GB;
    2. 非激活状态:
      1. CPU(X86_64) || CPU(ARM64) 下,baseThreshold = 3 * GB + tabCount * GB;,否则 baseThreshold = tabCount > 1 ? 3 * GB : 2 * GB;
      2. 最终结果为:smin(baseThreshold, (ramSize() * 0.9))

内存超限的处理

内存超限之后,就会调用 shrinkOrDie(),定义如下:

void MemoryPressureHandler::shrinkOrDie(size_t killThreshold)
{
    RELEASE_LOG(MemoryPressure, "Process is above the memory kill threshold. Trying to shrink down.");
    releaseMemory(Critical::Yes, Synchronous::Yes);

    size_t footprint = memoryFootprint();
    RELEASE_LOG(MemoryPressure, "New memory footprint: %zu MB", footprint / MB);

    if (footprint < killThreshold) {
        RELEASE_LOG(MemoryPressure, "Shrank below memory kill threshold. Process gets to live.");
        setMemoryUsagePolicyBasedOnFootprint(footprint);
        return;
    }

    WTFLogAlways("Unable to shrink memory footprint of process (%zu MB) below the kill thresold (%zu MB). Killed\n", footprint / MB, killThreshold / MB);
    RELEASE_ASSERT(m_memoryKillCallback);
    m_memoryKillCallback();
}

其中,m_memoryKillCallback 就是在初始化 web 线程时设置的回调。

由于 OOM 导致 reload/白屏,看起来并不是iOS的机制。从方法的调用关系进行全局检索,目前发现内存超出导致的白屏只有这么一条调用链。

OOM 之后的默认处理流程

苹果对 WebContentProcessDidTerminate 的处理逻辑如下:

void WebPageProxy::dispatchProcessDidTerminate(ProcessTerminationReason reason)
{
    bool handledByClient = false;
    if (m_loaderClient)
        handledByClient = reason != ProcessTerminationReason::RequestedByClient && m_loaderClient->processDidCrash(***this**);
    else
        handledByClient = m_navigationClient->processDidTerminate(*this, reason);

    if (!handledByClient && shouldReloadAfterProcessTermination(reason)) {
        // We delay the view reload until it becomes visible.
        if (isViewVisible())
            tryReloadAfterProcessTermination();
        else {
            WEBPAGEPROXY_RELEASE_LOG_ERROR(Loading, "dispatchProcessDidTerminate: Not eagerly reloading the view because it is not currently visible");
            m_shouldReloadDueToCrashWhenVisible = true;
        }
    }
}

其中 m_loaderClient 只在苹果的单元测试中有使用,所以,正式版本的 iOS 下应该会执行:

handledByClient = m_navigationClient->processDidTerminate(*this, reason);

如果开发者未实现 webViewWebContentProcessDidTerminate(_) 的代理方法,将返回 false,进入苹果的默认处理逻辑:通过 shouldReloadAfterProcessTermination() 判断是否需要进行重新加载,如果需要则在适当时候进行重新加载。

shouldReloadAfterProcessTermination() 根据终止原因来判断是否需要进行重新加载:

static bool shouldReloadAfterProcessTermination(ProcessTerminationReason reason)
{
    switch (reason) {
    case ProcessTerminationReason::ExceededMemoryLimit:
    case ProcessTerminationReason::ExceededCPULimit:
    case ProcessTerminationReason::RequestedByNetworkProcess:
    case ProcessTerminationReason::RequestedByGPUProcess:
    case ProcessTerminationReason::Crash:
    case ProcessTerminationReason::Unresponsive:
        return true;
    case ProcessTerminationReason::ExceededProcessCountLimit:
    case ProcessTerminationReason::NavigationSwap:
    case ProcessTerminationReason::IdleExit:
    case ProcessTerminationReason::RequestedByClient:
        break;
    }
    return false;
}

tryReloadAfterProcessTermination() 的刷新逻辑如下:

static unsigned maximumWebProcessRelaunchAttempts = 1;

void WebPageProxy::tryReloadAfterProcessTermination()
{
    m_resetRecentCrashCountTimer.stop();
    if (++m_recentCrashCount > maximumWebProcessRelaunchAttempts) {
        WEBPAGEPROXY_RELEASE_LOG_ERROR(Process, "tryReloadAfterProcessTermination: process crashed and the client did not handle it, not reloading the page because we reached the maximum number of attempts");
        m_recentCrashCount = 0;
        return;
    }
    WEBPAGEPROXY_RELEASE_LOG(Process, "tryReloadAfterProcessTermination: process crashed and the client did not handle it, reloading the page");
    reload(ReloadOption::ExpiredOnly);
}

每次 crash 后,苹果会给 crash 标识(m_recentCrashCount)进行 +1,在不超过最大限制(maximumWebProcessRelaunchAttempts = 1)时,系统会进行刷新,当最近 crash 的次数超过限制时,它便不会刷新,只是将标示归位为0,下次就可以刷新。

总结一下:如果开发者未实现 webViewWebContentProcessDidTerminate(_) 的代理方法:

  1. 则根据 crash 的原因判断是否要重新刷新;
  2. 重新刷新有最大次数限制(一次),超过则不会进行刷新。

后记:我们在iOS的Safari上测试了safari的白屏处理逻辑,当第一次发生白屏时Safari会默认重刷,第二次时safari会展示错误加载页,提示当前页面多次发生了错误。这个逻辑和上面webkit的默认处理逻辑时相似的。

至此,就总结了 WKWebView 检测内存的方法,计算最大内存限制的方法和默认的处理方法。

参考