作者:eddiecmchen,PCG客戶端開發工程師
| 導語 把我的iphone XR扶起來,它還能再頂一會兒~
背景
遠在IOS 11時期(2017年),蘋果就發公告要求所有需要上架AppStore的應用都必須支持64位。32位應用不再支持上架與運行。
升級64位應用有什么好處呢?(以下內容純摘抄,客官可以直接跳過)
- 指針字長更長,可使用的虛擬內存更大,擺脫32位下受限的4G內存空間
- 16 bit = 65,536 bytes (64 Kilobytes)
- 32 bit = 4,294,967,296 bytes (4 Gigabytes)
- 64 bit = 18,446,744,073,709,551,616 (16 Exabytes)
- 寄存器更多,減少內存讀寫,加快執行速度
這里我們要注意的是:虛擬內存確實比純32位多了,但是App到底能用多少,是否跟宣傳一樣接近16EB?下面將會展開聊聊,我們先來看一個Crash。
一個長期存在的幽靈
我們先來看下面的一個內存導致的崩潰,JSC在使用bmalloc嘗試進行內存分配時,提示OOM導致了SIGTRAP。
Last Exception :
0 JAVAScriptCore 0x000000018b777570 _pas_panic_on_out_of_memory_error
1 JavaScriptCore 0x000000018b72e918 _bmalloc_try_iso_allocate_impl_impl_slow
2 JavaScriptCore 0x000000018b73d3d8 _bmalloc_heap_config_specialized_local_allocator_try_allocate_small_segregated_slow + 5952
3 JavaScriptCore 0x000000018b7276f8 _bmalloc_allocate_impl_casual_case + 800
4 JavaScriptCore 0x000000018c60d494 JSC::PropertyTable::create(JSC::VM&, unsigned int) + 244
5 JavaScriptCore 0x000000018c66ba74 JSC::Structure::materializePropertyTable(JSC::VM&, bool) + 324
6 JavaScriptCore 0x000000018c66dfac JSC::Structure::changePrototypeTransition(JSC::VM&, JSC::Structure*, JSC::JSValue, JSC::DeferredStructureTransitionWatchpointFire&) + 612
7 JavaScriptCore 0x000000018c559930 JSC::JSObject::setPrototypeDirect(JSC::VM&, JSC::JSValue) + 192
8 JavaScriptCore 0x000000018c559e40 JSC::JSObject::setPrototypeWithCycleCheck(JSC::VM&, JSC::JSGlobalObject*, JSC::JSValue, bool) + 316
9 JavaScriptCore 0x000000018c4f580c JSC::globalFuncProtoSetter(JSC::JSGlobalObject*, JSC::CallFrame*) + 192
10 JavaScriptCore 0x000000018ba1f7a8 _vmEntryToNative + 280
11 JavaScriptCore 0x000000018c1b0cd0 JSC::Interpreter::executeCall(JSC::JSGlobalObject*, JSC::JSObject*, JSC::CallData const&, JSC::JSValue, JSC::ArgList const&) + 616
12 JavaScriptCore 0x000000018c474ecc JSC::GetterSetter::callSetter(JSC::JSGlobalObject*, JSC::JSValue, JSC::JSValue, bool) + 212
13 JavaScriptCore 0x000000018c5b6264 JSC::JSGenericTypedArrayView<JSC::Uint8Adaptor>::put(JSC::JSCell*, JSC::JSGlobalObject*, JSC::PropertyName, JSC::JSValue, JSC::PutPropertySlot&) + 612
14 JavaScriptCore 0x000000018c2c2ecc _llint_slow_path_put_by_id + 3244
// 忽略多余重復堆棧
37 JavaScriptCore 0x000000018ba1f5fc _vmEntryToJavaScript + 264
38 JavaScriptCore 0x000000018c1b0c7c JSC::Interpreter::executeCall(JSC::JSGlobalObject*, JSC::JSObject*, JSC::CallData const&, JSC::JSValue, JSC::ArgList const&) + 532
39 JavaScriptCore 0x000000018bac7ae4 _JSObjectCallAsFunction + 568
40 mttlite 0x0000000102a54914 hippy::napi::JSCCtx::CallFunction(std::__1::shared_ptr<hippy::napi::CtxValue> const&, unsigned long, std::__1::shared_ptr<hippy::napi::CtxValue> const*) (js_native_api_value_jsc.cc:406)
41 mttlite 0x0000000102a664e0 _ZNSt3__110__function6__funcIZN11TimerModule5StartERKN5hippy4napi12CallbackInfoEbE3$_4NS_9allocatorIS8_EEFvvEEclEv (memory:3237)
42 mttlite 0x0000000102a63018 hippy::base::TaskRunner::Run() (memory:3237)
43 mttlite 0x0000000102a64974 ThreadEntry (thread.cc:0)
44 libsystem_pthread.dylib 0x00000001dc129348 __pthread_start + 116
------
Exception Type: SIGTRAP
Exception Codes: fault addr: 0x000000018b777570
Crashed Thread: 48 hippy.js
這個OOM問題,與iOS上常見的OOM不一樣。按照常規的理解,當App內存不足的時候,正常會觸發系統的Jetsam機制殺死App。在系統日志中會留下Jetsam相關日志,理論上不會在Bugly等異常上報中發現。但這一類崩潰卻一直在產生上報,并且低內存的崩潰堆棧表現形式有很多種。
以上的JSC崩潰問題已經存在很長一段時間了(至少2年),而且崩潰堆棧都集中在JSC執行JS代碼的過程中,長期缺乏JS相關的監控與Debug工具導致該問題一直無法解決。
雖然堆棧上有明確的原因說明是OOM,但我們觀察到有不少用戶實際上物理內存空間還是足夠的:
兩年前,沖浪的時候偶然看來了來自微視同學的Case總結:《OOM與內存》
當時跟hippy SDK的同事也討論過是否存在類似的內存不足情況。但由于大家對JSC黑盒都不熟悉,而且崩潰的JS堆棧也不確切。當時的建議是:少在后臺加載JSC。最終也并沒有解決該問題。
兩年后,當瀏覽器集成flutter,類似的JS崩潰直接翻倍(21H2 0.08% -> 22H1 0.16%)。沒辦法,還是要看類似JSC和Dart VM的內存分配機制是怎樣的,再挖掘一下是否存在解(緩)決(解)方案。
JSC、DartVM的虛擬內存分配
翻閱相關虛擬機的內存管理相關代碼,可以找到底層的內存分配基本實現都是基于mmap處理的。
// WebKit bmalloc VMAllocate
inline void* tryVMAllocate(size_t vmSize, VMTag usage = VMTag::Malloc)
{
vmValidate(vmSize);
void* result = mmap(0, vmSize, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON | BMALLOC_NORESERVE, static_cast<int>(usage), 0);
if (result == MAP_FAILED)
return nullptr;
return result;
}
// Dart VM的虛擬內存
VirtualMemory* VirtualMemory::Allocate(intptr_t size,
bool is_executable,
const char* name) {
ASSERT(Utils::IsAligned(size, PageSize()));
const int prot = PROT_READ | PROT_WRITE | (is_executable ? PROT_EXEC : 0);
int map_flags = MAP_PRIVATE | MAP_ANONYMOUS;
#if (defined(DART_HOST_OS_macOS) && !defined(DART_HOST_OS_IOS))
if (is_executable && IsAtLeastOS10_14()) {
map_flags |= MAP_JIT;
}
#endif // defined(DART_HOST_OS_MACOS)
// Some 64-bit microarchitectures store only the low 32-bits of targets as
// part of indirect branch prediction, predicting that the target's upper bits
// will be same as the call instruction's address. This leads to misprediction
// for indirect calls crossing a 4GB boundary. We ask mmap to place our
// generated code near the VM binary to avoid this.
void* hint = is_executable ? reinterpret_cast<void*>(&Allocate) : nullptr;
void* address = mmap(hint, size, prot, map_flags, -1, 0);
if (address == MAP_FAILED) {
return nullptr;
}
return new VirtualMemory(address, size);
}
VirtualMemory::~VirtualMemory() {
if (address_ != nullptr) {
if (munmap(address_, size_) != 0) {
int error = errno;
const int kBufferSize = 1024;
char error_buf[kBufferSize];
FATAL("munmap error: %d (%s)", error,
Utils::StrError(error, error_buf, kBufferSize));
}
}
}
當map_flags包含MAP_ANON時,并且fd傳入-1時,mmap將直接使用虛擬內存進行分配,不需要依賴文件描述符。
mmap在xnu上的實現
/*
* mmap stub, with preemptory failures due to extra parameter checking
* mandated for conformance.
*
* This is for UNIX03 only.
*/
void *
mmap(void *addr, size_t len, int prot, int flags, int fildes, off_t off)
{
/*
* Preemptory failures:
*
* o off is not a multiple of the page size
* o flags does not contain either MAP_PRIVATE or MAP_SHARED
* o len is zero
*/
extern void cerror_nocancel(int);
if ((off & PAGE_MASK) ||
(((flags & MAP_PRIVATE) != MAP_PRIVATE) &&
((flags & MAP_SHARED) != MAP_SHARED)) ||
(len == 0)) {
cerror_nocancel(EINVAL);
return(MAP_FAILED);
}
void *ptr = __mmap(addr, len, prot, flags, fildes, off);
if (__syscall_logger) {
int stackLoggingFlags = stack_logging_type_vm_allocate;
if (flags & MAP_ANON) {
stackLoggingFlags |= (fildes & VM_FLAGS_ALIAS_MASK);
} else {
stackLoggingFlags |= stack_logging_type_mapped_file_or_shared_mem;
}
__syscall_logger(stackLoggingFlags, (uintptr_t)mach_task_self(), (uintptr_t)len, 0, (uintptr_t)ptr, 0);
}
return ptr;
}
上面的調用會傳遞到內核kern_mman.c的實現函數mmap(proc_t p, struct mmap_args *uap, user_addr_t *retval)
/*
* XXX Internally, we use VM_PROT_* somewhat interchangeably, but the correct
* XXX usage is PROT_* from an interface perspective. Thus the values of
* XXX VM_PROT_* and PROT_* need to correspond.
*/
int
mmap(proc_t p, struct mmap_args *uap, user_addr_t *retval)
{
/*
* 上面忽略了一部分代碼
*/
result = vm_map_enter_mem_object(user_map,
&user_addr, user_size,
0, alloc_flags, vmk_flags,
tag,
IPC_PORT_NULL, 0, FALSE,
prot, maxprot,
(flags & MAP_SHARED) ?
VM_INHERIT_SHARE :
VM_INHERIT_DEFAULT);
/* If a non-binding address was specified for this anonymous
* mapping, retry the mapping with a zero base
* in the event the mapping operation failed due to
* lack of space between the address and the map's maximum.
*/
if ((result == KERN_NO_SPACE) && ((flags & MAP_FIXED) == 0) && user_addr && (num_retries++ == 0)) {
user_addr = vm_map_page_size(user_map);
goto map_anon_retry;
}
/*
* 下面忽略了一部分代碼
*/
}
其中又會調用vm_map.c內部的vm_map_enter_mem_object,而該方法最終會在vm_map_enter中依據對象進行內存分配:
// 下面這個只截了個頭,大概帶一下,我也沒調過代碼~
/*
* Routine: vm_map_enter
*
* Description:
* Allocate a range in the specified virtual address map.
* The resulting range will refer to memory defined by
* the given memory object and offset into that object.
*
* Arguments are as defined in the vm_map call.
*/
kern_return_t
vm_map_enter(
vm_map_t map,
vm_map_offset_t *address, /* IN/OUT */
vm_map_size_t size,
vm_map_offset_t mask,
int flags,
vm_map_kernel_flags_t vmk_flags,
vm_tag_t alias,
vm_object_t object,
vm_object_offset_t offset,
boolean_t needs_copy,
vm_prot_t cur_protection,
vm_prot_t max_protection,
vm_inherit_t inheritance)
其中vm_map_enter在分配過程中會對hole_entry→vme_end作判斷,vme_end即最大的可分配空間。
xnu上虛擬內存的分配范圍
本來我只是觀察到蘋果在iOS15上增加了com.apple.developer.kernel.increased-memory-limit的能力聲明。本著死馬當活馬醫的想法,嘗試在新版本上添加該聲明以緩解一部分問題。
結果偶然看到部分開發者提問:該能力可配合com.apple.developer.kernel.extended-virtual-addressing使用。看到后我一下子反應過來,順手搜到了今年二月國外有大佬做了相關的探索:
Size Matters: An Exploration of Virtual Memory on iOS
文章闡述了iOS的內存管理機制和虛擬內存空間分配在不同的機型上存在上限,代碼如下:
#define ARM64_MIN_MAX_ADDRESS (SHARED_REGION_BASE_ARM64 + SHARED_REGION_SIZE_ARM64 + 0x20000000) // end of shared region + 512MB for various purposes
const vm_map_offset_t min_max_offset = ARM64_MIN_MAX_ADDRESS; // end of shared region + 512MB for various purposes
if (arm64_pmap_max_offset_default) {
max_offset_ret = arm64_pmap_max_offset_default;
} else if (max_mem > 0xC0000000) {
max_offset_ret = min_max_offset + 0x138000000; // Max offset is 13.375GB for devices with > 3GB of memory
} else if (max_mem > 0x40000000) {
max_offset_ret = min_max_offset + 0x38000000; // Max offset is 9.375GB for devices with > 1GB and <= 3GB of memory
} else {
max_offset_ret = min_max_offset;
}
并且總結了一個上限值與機型表格:
RAM |
Address Space |
Usable |
Devices |
> 3 GiB |
15.375 GiB |
7.375 GiB |
- iPhone XS – iPhone 13 |
> 1 GiB |
11.375 GiB |
3.375 GiB |
- iPhone 6s – X, SE, XR |
<= 1 GiB |
10.5 GiB |
2.5 GiB |
- iPhone 5s, iPhone 6 |
而xnu的源碼(pmap.c)中還透露了內核內存分配存在jumbo機制。當iOS App帶有指定的能力聲明時,xnu內核將會以jumbo模式運行,虛擬內存地址空間將會直接分配為最大值64GB:
if (option == ARM_PMAP_MAX_OFFSET_JUMBO) {
if (arm64_pmap_max_offset_default) {
// Allow the boot-arg to override jumbo size
max_offset_ret = arm64_pmap_max_offset_default;
} else {
max_offset_ret = MACH_VM_MAX_ADDRESS; // Max offset is 64GB for pmaps with special "jumbo" blessing
}
}
并且該上限值會在進程啟動時進行調整,具體代碼可以在kern_exec.c中找到:
/*
* Apply the requested maximum address.
*/
if (error == 0 && imgp->ip_px_sa != NULL) {
struct _posix_spawnattr *psa = (struct _posix_spawnattr *) imgp->ip_px_sa;
if (psa->psa_max_addr) {
vm_map_set_max_addr(get_task_map(new_task), (vm_map_offset_t)psa->psa_max_addr);
}
}
甚少文檔記錄的entitlement
com.apple.developer.kernel.extended-virtual-addressing
蘋果的文檔僅有一句話說明該能力:
Use this entitlement if your app has specific needs that require a larger addressable space. For example, games that memory map assets to stream to the GPU may benefit from a larger address space.
舉個例子:有的游戲需要將資源通過mmap的形式傳遞到GPU中渲染時,更大的地址空間可提高其運行效率。
描述上看,配置該選項時,將開啟上面xnu的jumbo mode,地址的擴充剛好能解決上面的崩潰問題。
做一次極限測試
為驗證地址分配的極限值,簡單做個實驗(測試設備使用iPhone XR iOS 16 Beta 2):
通過malloc進行連續的內存分配(也可以用vm_allocate,閾值不一樣),閾值卡在1009字節(為什么是1009字節,這里可以參考【ios 內核】源碼解讀(3) 詳解ios是怎么malloc的(上) - 鐘路成的博客 (luchengzhong.github.io))。
for (size_t i = 0; i < SIZE_T_MAX; i++) {
void *a = malloc(1009);
if (a == NULL) {
NSLog(@"error count: %lu", i);
break;
}
}
結果如下:
size = 1009 > SMALL_THRESHOLD (64位系統下1008字節,32位系統下496)
內存擴展前malloc失敗閾值約 7065482 * 1009 = 6.63 GB
內存擴展后malloc失敗閾值約 56753881 * 1009 = 53.33 GB
當然,在xnu的單元測試代碼中,也可找到jumbo mode相關的測試代碼,與上面的測試結果完全一致,即最多可分配53GB的空間。
#define GB (1ULL * 1024 * 1024 * 1024)
/*
* This test expects the entitlement to be the enabling factor for a process to
* allocate at least this many GB of VA space. i.e. with the entitlement, n GB
* must be allocatable; whereas without it, it must be less.
* This value was determined experimentally to fit on applicable devices and to
* be clearly distinguishable from the default VA limit.
*/
#define ALLOC_TEST_GB 53
T_DECL(TESTNAME,
"Verify that a required entitlement is present in order to be granted an extra-large "
"VA space on arm64",
T_META_NAMESPACE("xnu.vm"),
T_META_CHECK_LEAKS(false))
{
int i;
void *res;
if (!dt_64_bit_kernel()) {
T_SKIP("This test is only applicable to arm64");
}
T_LOG("Attemping to allocate VA space in 1 GB chunks.");
for (i = 0; i < (ALLOC_TEST_GB * 2); i++) {
res = mmap(NULL, 1 * GB, PROT_NONE, MAP_PRIVATE | MAP_ANON, 0, 0);
if (res == MAP_FAILED) {
if (errno != ENOMEM) {
T_WITH_ERRNO;
T_LOG("mmap failed: stopped at %d of %d GB allocated", i, ALLOC_TEST_GB);
}
break;
} else {
T_LOG("%d: %pn", i, res);
}
}
#if defined(ENTITLED)
T_EXPECT_GE_INT(i, ALLOC_TEST_GB, "Allocate at least %d GB of VA space", ALLOC_TEST_GB);
#else
T_EXPECT_LT_INT(i, ALLOC_TEST_GB, "Not permitted to allocate %d GB of VA space", ALLOC_TEST_GB);
#endif
}
可見,當開啟com.apple.developer.kernel.extended-virtual-addressing時,內核的可分配空間確實有明顯提升。
上線效果與結論
從QQ瀏覽器的上線效果來看,JS相關的內存分配Crash在14.0以上系統幾乎全部消失。上線第一天App崩潰率環比下降接近50%,效果顯著。
簡單總結:
- 蘋果很少在公開文檔中說明64位App在虛擬內存使用上存在限制。而且很多App也并沒有像瀏覽器內一樣,為業務靈活性而選擇將hippy、flutter等技術進行大規模的組合使用,所以可能很多App其實并不會遇到虛擬內存不足的情況。
- 上線效果也說明瀏覽器在混合開發的場景下,內存優化仍然存在很大的空間。因為Extended Virtual Addressing僅能緩解虛擬內存不足的情況,并不意味著App的物理內存也得到增加,對FOOM的治理仍然需要持續。
- 鑒于司內有不少的著名組件都會使用mmap機制進行內存管理,建議在使用相關組件時,控制好mmap的大小。
- 如果有需要在iPhone 12 Pro、M1 iPad、M1上運行應用,并希望解放更多的物理內存,建議增加com.apple.developer.kernel.increased-memory-limit的能力聲明,實測在iPhone 13 Pro下可以增加1GB的可用物理內存。
- ReactNative和類似框架在項目中使用較多的,建議需要考慮多個Context的復用,減少創建重復內容,司內外都有實踐證明該措施十分有效。
- 對于flutter一類的內存優化,可翻閱engine的相關代碼。flutter vm在創建時允許外部傳參控制vm行為,包括:old heap size、leak vm等。合適的參數可比較有效控制內存占用。
以上源碼相關的內容僅個人閱讀理解,如有錯誤請指出。