Итак, друзья, садитесь в кружок и послушайте историю самой большой инженерной катастрофы, в которой я участвовал. Это история о политике, архитектуре и логической ошибке невозвратных затрат (вы уж извините, просто сейчас пью Aberlour Cask Strength Single Malt Scotch).


Шёл 2016 год. Трампа ещё не избрали президентом, поэтому движение #DeleteUber пока не началось. Трэвис Каланик оставался гендиром, мы переживали фазу гиперактивного роста с открытием филиалов в других странах, общественные настроения в целом позитивные, все довольны, Uber на высоте.

Но гиперрост не обошёлся без проблем, и само приложение начало давать сбои. До этого количество разработчиков удваивалось почти каждый год, а когда вы растёте так быстро, то получаете невероятный разброс навыков. В сочетании с хакерским менталитетом, который мы называли «Let builder's build», это означало сложную и хрупкую архитектуру приложения. В то время приложение Uber отличалось крайне тяжёлой логикой, так что оно часто падало. Мы постоянно выпускали хотфиксы, патчи, внеплановые релизы и т. д. Также архитектура плохо масштабировалась.

В результате всех этих проблем на всех уровнях организации началось растущее движение, которое сплотилось вокруг идеи «переписать приложение с нуля». Была сформирована команда для создания новой мобильной архитектуры для нового приложения. Предполагалось создание такой архитектуры, которая «поддерживала бы мобильную разработку Uber в течение следующих пяти лет». Мы разрабатывали сразу под обе платформы. Весь цикл разработки начинался сначала.

Отдел iOS воспользовался этой возможностью, чтобы внедрить Swift (тогда в версии 2.x). Раньше Uber уже пробовал Swift, но как и многие другие на том раннем этапе развития технологии, испытал множество проблем и отложил внедрение.

Однако общее ощущение состояло в том, что большинство проблем Swift в то время объяснялись слабостью взаимодействия с Objective-C. А если написать чистое приложение Swift, мы могли бы избежать основных проблем.

Была также идея использовать одни и те же основные архитектурные шаблоны как на Android, так и на iOS. Разработчики под Android в то время были большими поклонниками RxJava. Соответствующая библиотека RxSwift использовала преимущества парадигмы функционального программирования в Swift. Казалось, всё просто.

Таким образом, небольшая команда разработчиков (Design, Product, and Architecture) на несколько месяцев ушла с головой в новые функциональные/реактивные паттерны, новый язык и новое приложение. Всё шло хорошо. Архитектура в значительной степени опиралась на передовые языковые возможности Swift.

UI мог масштабироваться на большое количество приложений Uber, парадигма функционального программирования казалась мощной (хотя и немного трудной в освоении), архитектура базировалась на новом потоковом сетевом протоколе реального времени (эту часть написал я).

Через пару месяцев и несколько ярких демок движение набирало обороты. Проект выглядел успешным. С небольшим количеством инженеров удалось разработать отличную функциональность за короткое время. Большая часть продукта готова. Руководство довольно.

Затем начался деплой на всю компанию. Различные команды начали добавлять свои функции в новое приложение. Поначалу возбуждение от нового создавало шквал мотивации и продуктивности. Архитектура предусматривала изоляцию функций, что позволяло быстро продвигаться вперёд.

Но как только Swift освоили больше десяти инженеров, слаженный механизм стал разваливаться. Компилятор Swift и сегодня значительно медленнее Objective-C, но тогда был практически непригоден для использования. Время сборки стало зашкаливать. Отладка полностью прекратилась.

Где-то есть видеозапись с одной из демонстраций, там инженер Uber набирает однострочный оператор в Xcode, а затем ждёт 45 секунд, пока буквы медленно, одна за другой, появятся в редакторе.

Потом мы упёрлись в стену с динамическим линкером. В то время библиотеки Swift можно было связывать только динамически. К сожалению, компоновщик выполнялся за полиномиальное время, поэтому рекомендуемое Apple максимальное количество библиотек в одном бинарном файле составляло 6. У нас было 92, и число продолжало расти…

В результате после нажатия на значок приложения требовалось 8-12 секунд, прежде чем даже вызвать main. Наше новое блестящее приложение оказалось медленнее, чем старое неуклюжее. Затем возникла проблема размера бинарника.

К сожалению, когда проблемы начали проявляться всерьёз, мы уже прошли точку невозврата. Это и есть логическая ошибка невозвратных затрат (sunk cost fallacy). В тот момент вся компания вкладывала в новое приложение всю свою энергию.

Тысячи людей с разных направлений, миллионы и миллионы долларов (не могу назвать реальную цифру, но гораздо больше одного). Всё руководство единодушно в поддержке проекта. У меня был приватный разговор с начальником на тему, что нужно остановиться.

Он сказал, что если этот проект провалится, ему придётся паковать вещи. То же самое было верно и для его босса вплоть до вице-президента. Выхода не было.

Поэтому мы засучили рукава и поставили лучших разработчиков заниматься каждой из проблем, расставили приоритеты по критическим вопросам (динамическое связывание, размер бинарника). Мне назначили и динамическое связывание, и размер бинарника — в таком порядке.

Мы быстро обнаружили, что проблему связывания при запуске приложения можно решить размещением всего кода в основном исполняемом файле. Но, как мы все знаем, Swift объединяет пространство имён с фреймворками; поэтому потребуются огромные изменения в коде, включая бесчисленные проверки пространства имён.

Именно тогда блестящий Ричард Хауэлл изучил выходные данные сборки Xcode и обнаружил, что после завершения сборки может взять все промежуточные объектные файлы и повторно связать их обратно в основной бинарник с помощью пользовательского скрипта.

Поскольку Swift при компиляции искажает пространство имён объектов, значит, может им оперировать. Это позволило нам эффективно статически связать наши библиотеки и сократить время запуска main с 10 секунд практически до нуля.

Следующая проблема: размер. В то время в качестве подстраховки мы планировали включить новое приложение в пакет со старым — и аккуратно развернуть его во время выполнения. Чтобы сократить размер, первым делом мы просто удалили старое приложение. Эту стратегию мы назвали «Йоло». Трэвис лично дал добро.

Мы также заменили классами все Swift-структуры. Типы значений в целом дают большой оверхед из-за выравнивания объектов и дополнительного машинного кода, который необходим для поведения копирования, автоинициализаторов и т. д. Это сэкономило место.

Но приложение продолжало расти. Вскоре мы упёрлись в лимит загрузки (100 МБ) бинарников в iOS 8 и более ранних версий. Это означает значительное количество потерянных установок ($10+ млн потерянных доходов из-за того, что многие пользователи iOS ещё не обновились).

В этот момент оставалось несколько недель до публичного запуска. Нам оставалось или вернуться на Objective-C, или отказаться от поддержки iOS 8. Поскольку в iOS 9 появилась возможность разделения архитектуры, эта версия была реально вдвое меньше по размеру (плюс-минус). Когда осталась всего неделя, мы решили выбросить десятки миллионов долларов — и отказаться от поддержки iOS 8.

Общее мнение состояло в том, что при уменьшении размера вдвое у нас появлялось большое пространство для манёвра, а проблему с размером можно решить когда-нибудь в будущем, когда разгребём остальное. К сожалению, мы сильно ошибались.

После релиза приложения мы устроили огромную вечеринку. Приложение хорошо восприняли пользователи и пресса. Оно было быстрым, с ярким новым дизайном.

Куча людей получила повышение. Мы все вздохнули с облегчением. После 90 непрерывных недель работы ребята наконец-то получили передышку.

Но затем общественное мнение начало меняться. Новое приложение фокусировалось на расчёте точной цены поездки по конкретному маршруту (в прежние времена вы просто видели тариф и текущий множитель). Для расчёта цены нужно было ввести текущее местоположение.

Для удобства пользователей мы также установили автоматическое определение местоположения, разрешив сбор данных о местоположении в фоновом режиме, чтобы водитель точно видел, где конкретно подобрать пассажира в текущий момент времени. Люди начали сходить с ума. Некоторые из моих бывших коллег в твиттере призывали уволиться из злой компании, которая вот так отслеживает людей.

В результате этих волнений люди начали отключать разрешение на местоположение в iOS. Но новое приложение не предусмотрело такой вариант использования.

Поэтому мы изо всех сил старались вернуть стандартный вариант. Мы обсуждали, что можно отключить фоновое отслеживание местоположения, но это снова разрушит удобство использования перед посадкой в такси.

Затем к власти пришёл Трамп (это случилось примерно через три месяца после выпуска нового приложения), что вызвало цепную реакцию, которая привела к движению #DeleteUber.

Всё это время кодовая база Swift стремительно росла. Продолжающиеся проблемы и медленная IDE породили среди наших iOS-разработчиков две враждующие фракции. Назову их фанатиками Swift и занудами Objective-C.

Сумма внешнего и внутреннего давления довела напряжённость до максимума. Фанатики отрицали проблемы Swift. Зануды жаловались на всё, что только можно себе представить, не предлагая особых решений.

Примерно в это время нас настигла проблема с размером бинарника. Я как раз был дежурным, когда у команды возникли проблемы с релизом. Оказывается, наше блестящее решение проблемы динамического связывания создало слишком большой для некоторых архитектур исполняемый файл.

Решив проблему на этих архитектурах, мы с коллегой @aqua_geek немного покопались и обнаружили, что размер скомпилированного кода растёт со скоростью 1,3 МБ в неделю. Я поднял тревогу. Если ничего не сделать, с такой скоростью мы через три недели упрёмся в лимит скачивания по сотовой сети.

Но внутренняя напряжённость достигла такой стадии, что фанатики всё отрицали. Один из технических лидеров из лагеря Swift написал двухстраничную статью о том, что лимит загрузки сотовой связи не имеет значения (Facebook, в конце концов, давно его превысил). Да мы и сами устали тушить пожары.

Поэтому один из наших дата-сайентистов разработал тест, искусственно сдвинув один из архитектурных слоёв за пределы лимита — и измерив влияние на бизнес-показатели. На следующей неделе мы вытянули этот слой обратно и выдвинули ещё один за пределы лимита (для контроля над архитектурами).

Эффект был катастрофическим. Негативное влияние на бизнес оказалось на несколько порядков больше, чем все затраты на годовое внедрение Swift. Оказывается, множество людей находится вне зоны действия WiFi, когда первый раз скачивают приложение Uber (кто бы мог подумать?)

Поэтому мы сформировали ещё одну ударную группу. Начали декомпилировать объектные файлы и изучать строку за строкой, чтобы определить, почему размер кода Swift так вырос. Удалили неиспользуемые функции. Тайлеру пришлось переписать приложение watchOS обратно в objc.

(Приложение watch составляло всего 4400 строк, но из-за другой процессорной архитектуры и отсутствия совместимости ABI пришлось бы включить в комплект приложения полный рантайм Swift).

Мы были на пределе. Так устали. Но собрались. Вот тогда и проявили себя по-настоящему гениальные инженеры. Один из разработчиков в Амстердаме придумал, как переставить оптимизационные проходы компилятора. Для тех, кто не специалист по компиляторам, я объясню.

Современные компиляторы делают тонну проходов. Например, один может встроить функции. Другой — заменить постоянные выражения их значениями. В зависимости от порядка выполнения машинный код может оказаться меньше или больше.

Если встроенные функции передают значение, компилятор может это распознать и заменить весь блок. Например:

int x = 3
func(x) {
X + 4
}

станет просто постоянным значением 7, если сначала состоится проход компилятора на встроенные функции (что означает намного меньше кода).

Если этот проход компилятора идёт вторым, то он может не распознать такие функции, и вы получите больше кода. Всё это конечно полностью зависит от того, как выглядит конкретный код, поэтому трудно оптимизировать порядок проходов в целом.

Так сказал гениальный инженер из Амстердама, который встроил алгоритм в релизную сборку, чтобы переупорядочить оптимизационные проходы и минимизировать размер. Это сняло колоссальные 11 МБ с общего размера машинного кода и дало нам немного времени, чтобы продолжать разработку.

Но такой подход приводил в ужас специалистов по компиляторам Swift, они боялись, что непроверенные проходы компилятора выявят непроверенные баги (хотя каждый проход должен быть внутренне безопасным, но трудно рассуждать о возможных комбинациях проходов). Однако мы не испытали никаких серьёзных проблем.

Мы также применили кучу других решений (линтинг для особенно дорогих шаблонов кода). Измерили каждое из них в количестве недель разработки, которые они нам дают. Но настоящей проблемой была кривая роста. В конце концов весь выигрыш всегда съедался.

В итоге мы добыли достаточно времени, чтобы дождаться хода Apple, которая подняла лимит загрузки по сотовой связи до 150 МБ. Они также добавили ряд функций компилятора, чтобы помочь с оптимизацией размера (-Osize). По их собственному признанию, Swift никогда не даст такой же малый размер после компиляции, как Objective-C.

Но по состоянию на этот год мы оптимизировали Swift до 1,5х размера машинного кода Objective-C, и в конце концов Apple снова подняла опциональный лимит до 200 МБ. Этого достаточно, чтобы нам продержаться ещё несколько лет.

Но мы едва не потерпели неудачу. Если бы Apple не увеличила лимит, пришлось бы переписывать приложение Uber обратно на ObjC. В конце концов, мы смогли решить и другие проблемы. Блестящий @alanzeino с его командой добились включения поддержки Swift в инструмент сборки Buck, что значительно уменьшило время сборки.

На этом пути мы потеряли кучу выгоревших людей. Потратили тонну денег и извлекли тяжёлые уроки. Удивительно, но по сей день большинство настаивают на том, что переписывание стоило того. Архитектурная последовательность нравится новым инженерам, которые приходят в компанию. Они даже не подозревают, какой боли стоило добиться её.

Сообщество извлекло пользу из наших знаний. @ellsk1 собрал потрясающую презентацию и отправился в лекционный тур, чтобы поделиться знаниями. Я тоже смог использовать этот опыт, помогая новым компаниям и группам разработчиков принимать лучшие решения.

Так что вот совет. Всё в программировании — это компромисс. Нет универсально лучшего языка. Что бы вы ни делали, поймите, какой здесь компромисс и почему вы на него идёте. Не допускайте политической войны между упрямыми фракциями внутри компании.

Прикладывайте усилия в точках отказа. Выясните, как определить компромиссы и оставьте путь для отступления, если дойдёте до определённого момента и поймёте, что совершили ошибку. Большие усилия дорого обходятся, но чем позже вы осознаете неправильный компромисс, тем выше цена.

Не будьте занудой, который только брюзжит и не вносит свой вклад. Не будьте фанатиком, который создаёт большие проблемы для всех. Самые лучшие инженеры не попадают ни в одну из этих ловушек.