Убийственная связка из NSCache и UINib

в 16:43, , рубрики: crash, iOS, nscache, objective-c, разработка под iOS, метки:

Хочу поделиться еще одним крешем, с которым разбирался пару месяцев назад. Сейчас, по прошествии времени, крешрепорты такого типа больше не наблюдаются в HockeyApp, а раньше были одними из самых популярных. Собственно, проблема наблюдалась уже довольно давно, но тогда наше приложение еще использовало TestFlight и информации для анализа не доставало. Креш характеризовался примерно таким стеком:

Thread 0 Crashed:
0   libobjc.A.dylib                      0x39abcf42 objc_msgSend + 2
1   CoreFoundation                       0x2bfe0c61 __CFNOTIFICATIONCENTER_IS_CALLING_OUT_TO_AN_OBSERVER__ + 10
2   CoreFoundation                       0x2bf3c6d5 _CFXNotificationPost + 1782
3   Foundation                           0x2cc6e129 -[NSNotificationCenter postNotificationName:object:userInfo:] + 70
4   Foundation                           0x2cc72c8f -[NSNotificationCenter postNotificationName:object:] + 28
5   UIKit                                0x2f750883 -[UIApplication _performMemoryWarning] + 132
6   libdispatch.dylib                    0x3a0107a7 _dispatch_client_callout + 20
7   libdispatch.dylib                    0x3a021253 _dispatch_source_latch_and_call + 624
8   libdispatch.dylib                    0x3a0122ed _dispatch_source_invoke + 210
9   libdispatch.dylib                    0x3a013e1f _dispatch_main_queue_callback_4CF + 328
10  CoreFoundation                       0x2bfee3b1 __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__ + 6
11  CoreFoundation                       0x2bfecab1 __CFRunLoopRun + 1510
12  CoreFoundation                       0x2bf3a3c1 CFRunLoopRunSpecific + 474
13  CoreFoundation                       0x2bf3a1d3 CFRunLoopRunInMode + 104
14  GraphicsServices                     0x332cf0a9 GSEventRunModal + 134
15  UIKit                                0x2f5487b1 UIApplicationMain + 1438
16  xxx                                  0x0015bb81 main (main.m:18)
17  libdyld.dylib                        0x3a030aaf start + 0


По вызову -[UIApplication _performMemoryWarning] понятно, что проблема произошла при обработке memory warning. По всей видимости, какой-то объект подписался на UIApplicationDidReceiveMemoryWarningNotification и забыл отписаться перед своим уничтожением. Но проверка по коду проекта не выявила подозрительных ситуаций — все, кто использовал эту нотификацию, либо были синглтонами, либо более-менее корректно отписывались. На тот момент дело этим и ограничилось, идей для фикса пока что не было.

Затем, когда Apple купила TestFlight, мы перешли на HockeyApp. Они используют крутую крешрепортилку (PLCrashReporter), и в целом работа с крешами там обстояла гораздо лучше (можно еще и свои логи/инфу аттачить при посылке репорта с девайса). Но, возвращаясь к проблеме, в добавок к стеку, приведенному выше, появились еще и такие строчки:

Application Specific Information:
objc_msgSend() selector name: setArchiveData:

Теперь мы знаем, какой селектор посылался умершему объекту. В нашем коде таких методов/свойств не присутствовало, что подтверждало прежний анализ. Соответственно, встает задача найти класс, у которого есть такой селектор. В этом помогают функции obj-c рантайма objc_getClassList (выдает список зарегистрированных классов) и class_copyMethodList (позволяет получить методы экземпляров и самого класса). Пройдясь по всем классам и проверив все их селекторы, я получил единственный вариант — UINibStorage. Это приватный класс, и с помощью свизлинга его методов видим, что он создается и держится UINib'ами. Дальше, опять же с помощью свизлинга и дизассемблирования, выясняем, что UINib подписывается на UIApplicationDidReceiveMemoryWarningNotification, и при ее получении очищает содержимое своего UINibStorage (в т.ч. вызывает setArchiveData) — этот вызов и падает в крешлоге. Отписка от нотификации происходит в деаллоке UINib. Как же получилось, что UINib умер, но при этом получил нотификацию?

Проблема, по всей видимости, возникла из-за того, что мы использовали NSCache для кеширования нибов. При нехватке памяти NSCache очищает свое содержимое в фоновом потоке, т.е. по сути асинхронно с memory warning в главном потоке. Т.о. в фоновом потоке вызывается -[UINib dealloc], в котором тот отписывается от нотификаций, а в главном идет их обработка. Это неправильный и опасный подход к использованию NSNotificationCenter. Вообще говоря, за время работы на проекте нам доводилось фиксить немало багов, связанных с асинхронностью, т.к. там выполняется много асинхронных операций. Одна из частых ошибок, с которой доводилось сталкиваться — отмена или отписывание от чего-либо в деаллоке. Это слишком поздний момент, т.к. объект уже фактически умирает, и если асинхронная операция в этот же момент пытается с ним работать, то это плохо кончится. К сожалению, суровая реальность такова, что не всегда есть хорошее место, где можно было бы отписываться. В случае с UINib понятно, что такого удобного места нет, поэтому сложно упрекнуть за это (скорее тогда стоит упрекать инфраструктуру или NSNotificationCenter).

В качестве решения проблемы я написал тривиальный кеш для хранения нибов. Вообще же, это не первый креш с NSCache. Раньше мне уже доводилось фиксить креш связанный с хранением NSCache в NSCache — так делать тоже не стоит. Но и NSCache я тоже не могу назвать явно виноватым, т.к. он не должен думать, что нельзя послать release объекту в любом фоновом потоке из-за того, что этот release может быть последним, а dealloc делает больше, чем ничего. Пожалуй, эта ситуация из тех, когда понятные и простительные решения дают негативный результат.

Автор: PavelTretyakov

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js