Две недели назад я похоронил отца. Я не успел к нему примерно на сорок минут. Эта статья не о том, как переживать смерть, и не попытка вызвать сочувствие. Мне стало интересно другое: можно ли посмотреть на смерть и время так, как программист смотрит на сложную систему — через состояния, жизненный цикл, необратимые переходы и задачи, для которых однажды заканчивается возможность
retry. И почему именно Rust неожиданно помогает мне приводить всё это в порядок.
Две недели назад я ехал к отцу и не успел примерно на сорок минут.
Ему было 84 года, последние месяцы он был совсем плох, поэтому сама возможность такого исхода не была неожиданностью. Я девятый ребёнок в семье и самый младший. Я метис: отец был кумыком, мама русской. Маму я похоронил в 2016 году. Из всей большой семьи именно я жил в Ставропольском крае, и когда близкие сообщили, что отец совсем плох, я выехал к нему.
Когда приехал, его уже не было.
Я спокойно могу говорить об этом. Не потому, что мне всё равно, и не потому, что смерть близкого человека можно рационализировать несколькими красивыми фразами. Просто мне не хочется делать из этой истории просьбу о жалости. Отец прожил очень длинную жизнь. У меня была возможность знать его, разговаривать с ним, спорить с ним. Это уже огромное количество времени.
Но эти сорок минут почему-то заставили меня думать не столько о смерти, сколько о времени.
Меня зовут Артур Валиев. Я программист. Раньше я уже пытался разговаривать через Rust о совсем не технических вещах — например, о любви. И чем дольше я пишу код, тем чаще ловлю себя на довольно странной профессиональной деформации: я начинаю смотреть на собственную жизнь как на систему, которую желательно хотя бы не оставлять в заведомо некорректном состоянии.
На этот раз мне захотелось попробовать описать не любовь.
Мне захотелось описать конец жизненного цикла.
У программиста почти всегда существует следующая попытка
Есть вещь, которую я понял после смерти отца. Мне тяжело не только грустить. Мне очень тяжело существовать рядом с нерешёнными задачами, которые больше невозможно решить.
Для разработчика это довольно противоестественное состояние.
Если код не собирается — мы исправляем код. Если сервис упал — поднимаем сервис. Если пакет потерялся — отправляем следующий. Если архитектура оказалась тупиковой — делаем миграцию. Можно потерять неделю работы, откатить commit, переписать модуль, восстановиться из резервной копии. Даже слово failure в инженерии почти никогда не означает конец.
Обычно оно означает следующее состояние автомата.
enum State { Pending, Running, Failed, Completed, }
Failed здесь совершенно не страшное состояние. Оно означает, что попытка завершилась неудачно. Но система всё ещё существует.
Можно сделать:
retry();
И вот мне кажется, что именно эта привычка очень сильно влияет на программистов вне компьютера. Где-то глубоко внутри мы привыкаем к мысли, что почти у любой ошибки существует следующий запуск.
Но в жизни есть состояние, которое я раньше практически никогда не добавлял в свои внутренние автоматы:
enum State { Pending, Running, Failed, Completed, Impossible, }
Failed и Impossible — совершенно разные вещи.
Failed означает: не получилось.
Impossible означает: больше не существует операции, которая могла бы это изменить.
И вот с этим моему мозгу почему-то намного сложнее.
TODO, который больше нельзя закрыть
Мы носим в голове огромное количество задач. Позвонить. Спросить. Рассказать. Приехать. Извиниться. Показать что-нибудь. Доделать начатое. В обычной жизни почти всегда можно сказать себе: хорошо, сегодня не получилось — сделаю позже.
То есть задача фактически остаётся:
TODO
И пока человек жив, это очень удобная модель. Очередь может быть огромной, сроки можно переносить, приоритеты менять. Мы уверены, что scheduler когда-нибудь снова доберётся до этой задачи.
Но смерть делает довольно страшную с инженерной точки зрения вещь.
Она не выполняет TODO.
Она удаляет возможность его выполнения.
И здесь возникает состояние, к которому мы плохо подготовлены. В голове задача ещё существует, а в реальности функция, необходимая для её исполнения, больше никогда не будет доступна.
Условно:
father.call();
Компилятор человеческой жизни на такое не ругается заранее. Никакого borrow checker здесь нет. Никто не говорит: «Артур, эта ссылка скоро перестанет быть валидной, используй её сейчас».
Программа просто однажды доходит до точки, после которой вызов невозможен.
Сорок минут и бессмысленный branch predictor
Самое простое после такого события — начать пересчитывать время.
Если бы я выехал раньше. Если бы мне позвонили раньше. Если бы на дороге было меньше машин. Если бы я не потратил эти десять минут. Если бы…
Получается почти программирование альтернативной версии прошлого:
if departure_time < actual_departure_time { outcome = Different; }
Мозг прекрасно умеет строить такие ветки. Он способен сгенерировать тысячу вариантов execution path, где ты успел.
Проблема только в одном: ни один из них больше нельзя запустить.
У нас нет:
history.checkout(previous_commit);
Нет:
time.rollback();
И даже если ты идеально вычислишь момент, в котором нужно было принять другое решение, результат этого анализа ничего не изменит в production.
Наверное, именно это и зацепило меня в цифре «сорок минут». Не потому, что сорок минут — какая-то драматическая величина. Просто очень наглядно видно, насколько тонкой иногда оказывается граница между двумя совершенно разными состояниями мира.
В одном состоянии ты ещё можешь успеть.
В другом операция уже не существует.
У времени довольно жестокий API
Мы странно разговариваем о времени. Говорим: «у меня есть час», «я найду время», «оставлю на завтра», «через месяц вернусь».
То есть лингвистически ведём себя так, будто время принадлежит нам.
Почти ownership.
let my_time = Duration::from_hours(24);
Но на самом деле никакого владения нет. Мы не знаем продолжительность выделенного нам lifetime и не можем потребовать от системы дополнительный ресурс только потому, что неправильно распланировали предыдущий.
Мы имеем доступ к now().
А вот существование следующего вызова никто не гарантировал.
let tomorrow = now.next().unwrap();
Если бы такой код попался мне на review, я бы спросил: почему unwrap()? Где гарантия?
В жизни мы ставим такой unwrap() постоянно.
«Поговорим потом».
unwrap().
«Когда-нибудь закончу».
unwrap().
«Ещё успею».
unwrap().
И что особенно смешно — именно Rust довольно долго учил меня делать прямо противоположное. Не строить архитектуру на предположении, которое ничем не гарантировано.
Lifetime — это не продолжительность жизни
Слово lifetime идеально подходит для такой статьи, но здесь важно не исказить сам Rust.
Lifetime в Rust — это не таймер, который говорит, сколько секунд проживёт объект. Нас интересует корректность отношений между ссылками и значениями: может ли ссылка существовать тогда, когда значение, на которое она указывает, уже перестало существовать.
В самом грубом примере:
fn name<'a>(person: &'a Person) -> &'a str { &person.name }
Rust не позволит нам честно создать ссылку, которая должна пережить собственный источник.
И вот здесь человеческая жизнь устроена намного интереснее программы.
Память о человеке — это не просто &Person.
Если бы это была ссылка, после смерти человека мы получили бы dangling pointer.
Но мы не получаем его.
Потому что за время существования человек изменяет другие объекты.
Отец что-то говорил мне. Чему-то научил. Иногда раздражал. Иногда смешил. Принимал решения, которые потом повлияли на мою жизнь. Через него появились вещи, которые существуют независимо от него самого. А может теперь и на Вас.
Примитивная модель могла бы выглядеть примерно так:
struct Father { experience: Vec<Experience>, } struct Son { memories: Vec<Memory>, habits: Vec<Habit>, ideas: Vec<Idea>, } fn interact(father: &Father, son: &mut Son) { // После каждого взаимодействия состояние son уже немного другое. }
Однажды Father заканчивает свой lifecycle.
Но состояние Son уже изменено.
И не только его.
Есть дети, родственники, знакомые, вещи, поступки, последствия решений. Огромный граф зависимостей, в котором удаление одной вершины не отменяет изменения, которые она когда-то внесла во все остальные.
Наверное, поэтому фраза «человек продолжает жить в других» не обязательно должна быть исключительно красивой метафорой.
С инженерной точки зрения оригинальный процесс действительно завершился.
Но его side effects остались.
Drop
В Rust есть удивительно подходящее слово.
Drop.
Когда значение выходит из области видимости, Rust может вызвать его деструктор. Освобождаются ресурсы, закрываются файлы, заканчиваются соединения.
impl Drop for Connection { fn drop(&mut self) { self.close(); } }
Но Drop ничего не знает о смысле объекта.
Для машины жизненный цикл очень прост. Ресурс существовал, потом перестал существовать. Всё корректно.
Человеческий Drop устроен иначе.
Сам человек действительно исчезает из системы как активный участник, но всё, что он успел изменить, не откатывается.
И я неожиданно поймал себя на мысли, что смерть — это вовсе не удаление объекта вместе со всем его состоянием.
Скорее это прекращение дальнейших мутаций с его стороны.
writes = disabled reads = impossible effects_already_committed = preserved
Очень странный тип immutable history.
Нельзя добавить новую запись.
Нельзя задать ещё один вопрос.
Нельзя исправить старый разговор.
Но уже созданное состояние остаётся.
Persistence
Каждый разработчик знает: данные, которые существуют только в оперативной памяти, исчезнут вместе с процессом. Если данные важны, мы делаем persistence.
Записываем их на диск. В базу. Реплицируем. Делаем backup.
У людей persistence получился каким-то совершенно диким.
Фотографии. Рассказы. Манера произносить определённое слово. Фамилия. Старый инструмент. Записка. Привычка. Генетика. Чужое воспоминание. Какое-нибудь предложение, которое человек сказал двадцать лет назад и сам забыл через пять минут, а ты почему-то помнишь до сих пор.
Это чудовищно плохая база данных.
Нет схемы.
Нет строгих типов.
Часть записей повреждается.
Некоторые события со временем начинают выглядеть иначе.
Индексы работают непредсказуемо: запах способен найти запись быстрее фотографии, а случайная песня может восстановить кусок памяти, который ты десять лет не мог получить никаким другим запросом.
Удивительная система.
Но другой у нас нет.
Почему после этого мне хочется делать всё архитектурно правильно
Наверное, здесь и начинается главная причина, по которой я вообще решил написать эту статью.
В последние годы я всё чаще переношу инженерные привычки в обычную жизнь.
Не в смысле построить расписание из пяти тысяч задач и оптимизировать семью через Jira. Это было бы ужасно.
Скорее я всё меньше люблю неопределённые состояния.
Если проект хочется сделать — хотя бы собираю прототип.
Если мысль хочется опубликовать — стараюсь не хранить её годами только в голове.
Если понимаю, что сделал что-то неправильно — мне проще признать это и исправить состояние, чем оставлять систему в подвешенном виде.
Если человек важен — желательно, чтобы он узнал об этом не из твоего мысленного backlog.
Раньше я спокойно строил довольно важные части собственной жизни на архитектуре:
сделаю потом
Сейчас она кажется мне подозрительной.
Я бы ведь никогда не пропустил такой код в production:
// Это критически важно. // Наверное, когда-нибудь потом обработаем.
А в жизни почему-то пропускал.
Мы работаем сразу в production
Есть популярное выражение, что жизнь не имеет инструкции.
Мне как программисту больше нравится другая проблема.
У неё нет staging.
Мы не можем сначала прожить тестовую версию недели, посмотреть логи и затем запустить правильную.
Не можем написать integration tests на следующий год.
Нет snapshot перед важным разговором.
Нет git reset --hard.
Нет гарантий, что после изменения внешнего API наша программа вообще продолжит работать так же.
Мы сразу в production.
Причём система legacy настолько древняя, что большей части документации вообще никто не видел.
И всё-таки мы почему-то пытаемся проектировать её так, будто основные зависимости вечны.
Не вечны.
Незакрытые транзакции
Есть ещё одна аналогия, которая после последних двух недель кажется мне неприятно точной.
Мы постоянно открываем транзакции.
Обещали что-то человеку.
Начали разговор.
Начали отношения.
Начали проект.
Решили когда-нибудь приехать.
BEGIN;
И потом не делаем COMMIT.
Это не обязательно плохо. Невозможно прожить жизнь только с пустым backlog. Если все задачи закончились — это уже само по себе довольно тревожное состояние.
Проблема возникает тогда, когда наши системы важности и срочности расходятся.
Рабочий баг срочный.
Ответить на сообщение срочно.
Обновить сервер срочно.
Исправить CI срочно.
А позвонить отцу не срочно.
Потому что отец вроде бы существует независимо от нашего календаря.
И поэтому очень важная задача легко десять раз подряд отправляется в LATER.
Система не сообщает нам, сколько итераций LATER осталось.
Вот это, пожалуй, я сейчас понимаю особенно хорошо.
Rust не лечит смерть
На этом месте очень легко написать какую-нибудь красивую глупость вроде «Rust помог мне пережить потерю».
Нет.
Не помог.
Язык программирования не умеет этого делать.
Drop — механизм освобождения ресурсов. lifetime — часть системы типов. Result описывает успешный или неуспешный результат вычисления. Никакого скрытого философского смысла разработчики Rust туда не закладывали.
Смысл появляется только в моей голове, когда я использую знакомые конструкции, чтобы упорядочить совершенно незнакомое состояние.
Rust не помогает мне не грустить.
Он помогает мне не требовать от реальности невозможной операции.
Я не могу приехать раньше в уже завершившемся времени.
Не могу вызвать функцию у человека, которого больше нет.
Не могу сделать retry.
Следовательно, задача должна поменять тип.
Это уже не:
исправить прошлое
а что-то вроде:
признать зафиксированное состояние сохранить важное изменить следующие решения продолжить выполнение
Не знаю, называется ли это принятием. Мне не очень нравится пытаться дать всему психологическое название.
Мне ближе формулировка:
обновить внутреннюю модель так, чтобы она снова соответствовала реальности.
Архитектурно правильной жизни всё равно не получится
Можно после смерти близкого человека решить: всё, теперь я никогда ничего не буду откладывать.
Это тоже неправда.
Буду.
Мы все будем.
Будем забывать звонить. Ссориться из-за ерунды. Писать плохой код. Бросать проекты. Терять время. Смотреть совершенно бессмысленные видео вместо того, чтобы заниматься чем-то «важным».
И это нормально.
Жизнь, в которой каждый момент оптимизирован по максимальной полезности, кажется мне намного страшнее плохо написанной программы.
Архитектурно идеальной жизни не существует.
Нельзя закрыть все задачи.
Нельзя обработать все исключения.
Нельзя сделать invalid state полностью unrepresentable.
Нельзя гарантировать, что мы никого не обидим.
Нельзя заранее знать lifetime зависимостей.
Но можно хотя бы иногда посмотреть на собственную архитектуру и спросить:
а нет ли у меня критически важной операции, существование которой почему-то полностью основано на предположении, что завтра обязательно будет доступен следующий retry?
Вот это после смерти отца мне кажется хорошим вопросом.
Что остаётся после Drop
Когда я начинал писать этот текст, мне казалось, что он будет о смерти.
Получилось немного иначе.
Он оказался о том, что остаётся.
Мой отец прожил 84 года.
Мамы нет уже десять лет.
Когда-нибудь не станет меня.
Исчезнет большая часть программ, которые я написал. Перестанут собираться репозитории. Домены отключатся. Сервисы, над которыми я сейчас могу сидеть ночами, однажды будут никому не нужны.
И это вообще не страшная мысль.
Наоборот.
Мне очень нравится то, что значение не обязано существовать вечно, чтобы иметь смысл.
Функция может выполниться один раз и навсегда изменить состояние программы.
Человек тоже.
Иногда достаточно разговора.
Иногда ребёнок запоминает одну фразу.
Иногда кто-то берёт твою идею и делает её лучше.
Иногда исходный код переживает собственный проект.
Иногда совершенно неизвестный человек спустя несколько лет находит старую статью и уносит из неё одну мысль.
После этого исходный owner уже не так важен.
Значение оказалось снаружи.
Life Cycle
Наверное, поэтому мне сейчас особенно нравится само выражение life cycle.
Жизненный цикл.
У любой системы есть начало, изменение состояний и завершение.
Мы почему-то воспринимаем последнее слово как противоположность всему предыдущему. Но в программе завершение процесса не означает, что процесс не имел смысла. Наоборот: смысл часто именно в том, какое состояние осталось после его выполнения.
Я опоздал на сорок минут.
Этого уже нельзя исправить.
И, возможно, одна из самых трудных инженерных задач моей жизни — научиться не пытаться проектировать исправление для события, которое исправления не имеет.
Вместо этого я могу изменить следующий участок программы.
Чаще заканчивать важные разговоры.
Не держать все идеи только в голове.
Публиковать то, что сделал.
Не ждать идеального состояния системы.
Говорить людям некоторые вещи до того, как они превратятся в данные, доступные только для чтения.
Не потому, что «каждый день нужно жить как последний». Мне никогда не нравилась эта фраза.
Просто потому, что не у каждой задачи бесконечное количество попыток.
fn life() { while let Some(now) = next_moment() { use_it(now); } }
Мы не знаем, сколько итераций будет у этого цикла.
Но, возможно, это и не главное.
Куда важнее состояние системы после того, как цикл закончится. V