4.06. Архитектура выполнения
Важно отметить, что недостаточно просто писать команды и обеспечивать их выполняемость. Если вы джуниор или начинающий - да, вам будут давать сначала обычные задания, вроде сделать, чтобы работало, а потом на код-ревью более опытные разработчики будут указывать ошибки, причём порой будут реагировать даже эмоционально, ругаясь и подчеркивая, что так делать нельзя. О том, что можно, а что нельзя, мы узнаём, как правило, на практике - в учебниках этого не пишут.
В этой главе мы рассмотрим весьма сложную тему - мы рассмотрим архитектуру выполнения кода.
Производительность и оптимизация
Возможно, для обычного пользователя слово «производительность» означает показатель скорости выполнения программы. Технически, это не просто «быстро», а то, насколько эффективно система использует ресурсы, чтобы выполнить задачу в нужные сроки и с нужным качеством. Буквально, этот термин пришел из экономики, когда производство использует определённые ресурсы и производит результат - программа работает абсолютно также, ведь в экономике есть место и устройствам. Наша же экономика более узкая и касается нашей разрабатываемой программы для ЭВМ, а ресурсами выступают компьютеры, на которых она работает.
Какой-то разработчик может закрыть глаза на определенные проблемы в производительности - но, как правило, не по своему желанию. Он либо не разбирается, либо тестирование было выполнено плохо, и проблему не заметили. Вряд ли он осознанно оставил вопрос нерешенным, поэтому порой гнев других, более опытных разработчиков, довольно излишний.
Важно не путать с производительностью компьютера - это вычислительная мощность, количественная характеристика скорости выполнения определенных операций на компьютере. Инженеры, разрабатывающие и проектирующие технические устройства, заботятся о том, чтобы вычисления были максимально оптимизированы, а производительность компьютера была максимальной при грамотном распределении ресурсов. А вот уже как использовать эти ресурсы для вычислений, зависит от программы.
Геймеры часто это не учитывают, когда сталкиваются с низкой производительностью в современных играх, и бегут обновлять компоненты своего компьютера - но, купив более мощную видеокарту, обнаруживают, что скачка производительности либо нет, либо он незначительный. В этом и проблема - виновато не устройство, а то, как игра использует вычислительные ресурсы. И это сложнейшее искусство - оптимизация. Написать код можно и с помощью нейросети, и скопировав чужой, и по гайдам, но это будет бездумное копирование текста без углубления в его смысл. На практике именно так и происходит в последние годы - программистов стало много, они работают, как умеют. А что изменилось? Если присмотреться, то программирование сильно ушло от низкоуровневости в сторону высокоуровневости - то есть, сейчас с железом и ресурсами, как правило, работают стандартные или готовые библиотеки, и нет нужды погружаться так глубоко, можно сосредоточиться на бизнес-логике. В IT производительность является комплексной характеристикой, которая включает в себя скорость выполнения, потребление памяти, отклик на действия пользователя и устойчивость под нагрузкой.
Время запуска - это показатель того, сколько времени ждёт пользователь, пока приложение откроется.
Время отклика (latency, англ.) - сколько проходит от действия (например, клика) до результата. Это субъективная производительность, и здесь может быть много факторов, которые придётся учитывать.
Время выполнения операции - сколько работает функция, запрос, алгоритм. Скорость выполнения - это время, за которое система или её часть завершает конкретную задачу, например сколько длится обработка файла. Простыми словами, как быстро код делает своё дело. Измеряется в миллисекундах, секундах, минутах - всё зависит от задачи, алгоритмической сложности, объёма данных и аппаратных ресурсов. Иногда жёсткая оптимизация может ухудшить читаемость и поддержку, что может быть наоборот, минусом, так что как обычно - надо соблюдать баланс. Высокая скорость не всегда основная цель, главное не тормозить пользователя и не создавать узкие места в системе.
Время сборки - сколько ждёт разработчик, пока код скомпилируется.
Время является важным и очевидным аспектом - человек очень чувствителен к задержкам, а в условиях, когда всё работает быстро - уже и привык к скорости. До 100 мс кажется мгновенным, до 1 секунды ощущается как пауза, а больше 3 секунд начинает раздражать. И если что-то логически простое выполняется больше 2 секунд - будет портиться UX. Порой оптимизация может превратиться в игру с хитростями, например, время запуска приложения может быть 5 секунд, что плохо, но время обработки фоновой операции - 10 секунд может быть нормально, и что-то можно выделить.
Иногда система «тормозит» из-за цепочки быстрых, но множественных операций, например, 1000 раз прочитать переменную из памяти будет быстрым, но 1000 раз вызывать функцию, которая читает из базы - катастрофа, ведь здесь уже будет работать диск.
Память является рабочим пространством программы, словно стол, на котором раскладываются важные документы. В принципе, в главе про железо мы так и рассматривали этот элемент, и должны понимать, почему память важна.
Чем больше памяти использует программа, тем больше шанс, что она начнёт «тормозить» из-за нехватки ОЗУ, и начнёт использовать диск (swap, файл подкачки), что в сотни раз медленнее. От этого пострадают другие процессы, и технически, программа может быть убита операционной системой.
Если сейчас в среднем у пользователя 8-16 ГБ оперативной памяти, то раньше, в прошлом веке, её было меньше, чем 128 МБ. Представьте себе, как можно уложиться в такой объём, в совокупности с системными службами и процессами! А сейчас в Windows 11 один только диспетчер задач может «кушать» от 50 до 100 МБ, а Google Chrome - не меньше 1 ГБ.
Как потребляется память?
Можно выделить определенные виды потребления памяти:
- Стек (stack), быстрый и ограниченный, который хранит локальные переменные и вызовы функций;
- Куча (heap), гибкая и медленная, где живут объекты, массивы и структуры.
Как мы помним, объявляя переменную, указывая тип данных, мы «бронируем» определённый участок памяти, чтобы хранить там данные. И когда данные использовались, участок памяти остаётся всё ещё зарезервированным - хотя уже и не нужен. Создав объект в памяти, мы должны его освобождать своевременно, иначе будет утечка памяти.
Сейчас языки программирования уже поддерживают автоматическую очистку памяти от неиспользуемых данных - это называется сборкой мусора. Но об этом позже.
Память должна использоваться эффективно. К примеру, создавать 1000 объектов за секунду может быть дороговато, даже если они быстро удаляются, а частое выделение памяти вызывает такую сборку мусора, из-за чего и вся программа на мгновение зависает. Память не бесконечна, и даже если у сервера 128 ГБ ОЗУ, это не будет значить, что можно создавать сколько угодно, потому что время доступа к памяти и управление ею - тоже часть производительности.
Потребление памяти определяет объём ОЗУ, который использует программа во время работы, включая в себя объекты, массивы, кэши, стек вызовов, промежуточные данные, проще говоря - сколько места в оперативке занимает код, пока выполняется. Память ограничена и даже на мощных серверах она не бесконечна, поэтому высокое потребление приводит к замедлению из-за своппинга (использования диска как виртуальной памяти), перезапускам (особенно в контейнерах или на мобильных устройствах), утечкам памяти. К примеру, приложение может загружать весь большой JSON-файл в память целиком, и потреблять 500 МБ, или читать по частям, и оставаться в пределах 50 МБ.
Оперативная память измеряется не только своим объёмом - ведь, если посмотреть технический процесс, то данные записываются-читаются именно через канал связи, и цепочка «диск-процессор-память» или даже «процессор-память» может спотыкаться, ведь между каждым элементом цепочки есть канал передачи данных. Это тоже важно учитывать.
Задержки (latency) являются временем ожидания между запросом и ответом. Да, это может касаться и сетевых запросов, поэтому тут вопрос не ограничен одним устройством. Пользователь нажал кнопку - через 2 секунды появился результат = задержка 2 секунды. Аналогично работает и запрос данных у базы сервисом, и вызов одной функцией другой функции. Задержки берутся из нескольких факторов:
- Сеть - передача данных между сервисами;
- Диск - чтение или запись файлов (особенно HDD);
- Базы данных - сложные запросы, блокировки;
- Блокировки в коде - поток ждёт, пока другой освободит ресурс;
- Сборка мусора - пауза, пока GC «убирает».
И все эти задержки суммируются, что складывается в общее время. Хорошая система минимизирует ожидание, даже если работа большая. Разработчик может не заметить этого, ведь он проверяет на своём устройстве, с минимальной базой данных. А если пользователей сотни тысяч, записей в таблице миллиарды, сеть максимально загружена, и оперативной памяти мало?
Пропускная способность (throughtput) - это показатель того, сколько работы система может выполнить за единицу времени. К примеру, сколько запросов в секунду обрабатывает веб-сервер, сколько строк данных обрабатывает скрипт за минуту, и сколько транзакций в час проходит через платёжную систему. Отличие от задержки в том, что задержка про один запрос и время ожидания ответа, а пропускная способность про многих - как много можно обслужить? Иногда, чтобы увеличить пропускную способность, жертвуют задержкой, к примеру накапливая 100 событий и отправлять пачкой (batching), и наоборот - чтобы уменьшить задержку, ограничивают нагрузку (rate limiting). В идеале нужно соблюдать баланс, обеспечивая низкую задержку и высокую пропускную способность.
Устойчивость под нагрузкой - это способность системы сохранять приемлемую производительность, когда растёт количество пользователей, запросов или данных. Что будет, если на ваше небольшое веб-приложение вдруг прилетит в 500 тысяч раз больше трафика? Хорошая система масштабируется предсказуемо - при росте нагрузки время отклика растёт плавно, а не взрывается, а плохая система разумеется упадёт, сломается или начнёт «тормозить», зависая или потребляя всю память. Устойчивость, конечно, не ограничивается лишь кодом, здесь нужна грамотная архитектура, использование многопоточности, ограничений ресурсов (лимиты, пулы соединений), обработки ошибок (не допускать каскадных падений). К примеру, сервис спокойно работал с 100 пользователями, но вдруг в каком-то популярном канале про него разошлась новость и всё, с тысячами посетителей уже начинает валиться, отвечать с задержкой в 10 секунд или вообще перестаёт отвечать. Код работает? Работает. А насколько хорошо работает при стрессе?
Оптимизация является целенаправленным улучшением кода или системы с целью повышения производительности при сохранении корректности поведения. Это превращение программы из «работает» в «хорошо работает». Мы хотим, чтобы тот же результат достигался быстрее, но с меньшими ресурсами и надёжнее, без изменения смысла. Важно уточнить, что не «сделать быстрее любой ценой», а учесть контекст, оптимизировав время выполнения, потребление памяти, отклик, время сборки и энергопотребление. Здесь важна также и стабильность, чтобы справляться под нагрузкой, и вообще не нагружать, если это не нужно.
Оптимизировать нужно только тогда, когда проблема есть, и только там, где она действительно есть. Словом, это реакция на измеренные проблемы. Сначала мы пишем работающий код, потом делаем его читаемым, измеряем его производительность, выявляя узкие места. И начинаем с оптимизации именно этих узких мест, проверяя, что ничего не сломали. Не нужно писать «супербыстро» с самого начала, чтобы потом не переделывать, нужно начинать просто, потом изучать то, что получилось. Настрочить код всегда легко, а вот отлаживать его до совершенства можно бесконечно.
Правило 90/10 (порой 80/20) гласит, что 90% времени выполнения приходится на 10% кода. Это закон Парето в контексте производительности, означает, что большая часть тормозов - в маленьком участке кода, и оптимизация 90% «быстрого кода» даст минимальный эффект, поэтому лучше сосредоточиться на 10% «тяжёлого» кода, чтобы получить огромный прирост. Имея на руках приложение с десятками тысяч строк кода, 99% может работать быстро, но есть одна функция, которая вызывается 1000 раз в секунду, делает тяжёлые вычисления и занимает 85% времени процессора. И если её оптимизировать, то будет значительно больший эффект, чем длительная работа со всем остальным. Не тратьте время на оптимизацию того, что почти не влияет на общую производительность.
Профилирование - это измерение того, сколько времени и памяти потребляет каждый участок кода. Это нужно, чтобы найти реальные узкие места, не гадать «а может тут медленно?», и поможет понимать, где реально тратятся ресурсы. К примеру, мы думаем что медленно работает именно база данных, но профилировщик показывает, Что 70% времени в парсинге JSON в цикле, а база в 10%. Итог - мы оптимизируем не то.
Если говорить о «тяжёлом» коде, то основные источники нагрузки - это следующее:
- Вычисления. Это сложные математические операции, вложенные циклы, рекурсия без кэширования, повторные вычисления одного и того же. Для оптимизации нужно кэшировать результаты (и использовать их), упрощать алгоритмы, выносить вычисления из циклов, например, вместо 1000 раз вычисления, лучше посчитать один раз, сохранить и читать сохранённое значение - тогда не будет 999 вычислений.
- Вызовы. Это вызовы функций, методов, API, сервисов. Проблемы могут быть особенными внутри циклов, когда вызовы будут частыми. Есть также синхронные вызовы, которые блокируют поток. Здесь нужно минимизировать количество вызовов, группировать (batching), кэшировать результаты, использовать асинхронность, если возможно. К примеру, вместо 1000 запросов к API, лучше сделать один запрос с 1000 ID.
- Аллокации (выделение памяти). Это создание объектов, массивов, строк, и особенно чувствительны в циклах и часто вызываемых функциях. Частые аллокации (каждый раз выделять память) повышают нагрузку на сборщик мусора и вызывают зависания. Нужно перерабатывать объекты (object pooling), избегать создания временных объектов, использовать примитивы вместо объектов. Например, не создавать каждый раз объект, а переиспользовать один экземпляр.
Существуют и антипаттерны оптимизации, к примеру:
- Преждевременная оптимизация - когда стремятся оптимизировать «на всякий случай, вдруг пригодится». Это усложняет код, затрудняет понимание и тестирование, зачастую бывает лишним, и тратит время разработки. Не занимайтесь преждевременной оптимизацией, но и не пренебрегайте ею - сохраняйте баланс.
- Игнорирование узких мест, когда результат сдаётся с принципом «хотя бы работает». Бывает, что узкое место является редким случаем, но маленькие тормоза накапливаются, и при росте нагрузки система рушится. Важно всё же уделять внимание даже редким, но тяжёлым операциям.
Узкое место (bottleneck) - это участок системы, который ограничивает общую производительность. К примеру, когда один поток обрабатывает 1000 запросов, а остальные простаивают, когда функция вызывает базу данных в цикле - 1000 запросов вместо одного, а обработка файла идёт посимвольно, вместо буферизации. Это найти можно с помощью профилировщиков, логов и мониторинга. Устраняется через ускорение, распараллеливание (многопоточность), кэширование и переработку архитектуру.
Написание кода
Можно сказать, что новичок, джуниор, просто пишет как ему сказано, не задумываясь. Он гуглит, ищет советов или похожих решений, и просто проверяет, что оно работает. Разработчикам уровня миддл и синьор приходится работать внимательнее, а также проводить ревью кода с целью найти проблемы. Две головы всегда лучше, конечно, поэтому ревью как раз отличный способ.
Важно воспринимать код не как инструкции для машины, а как архитектуру поведения, когда каждая строчка влияет на скорость выполнения, потребление памяти, читаемость, поддерживаемость и производительность в целом. Иногда одно небрежное решение может свести на нет все усилия по оптимизации, а иногда одно правильное сделает систему в разы стабильнее.
Именно поэтому существуют уже проверенные повседневные практики написания кода, которые используются в разных языках и компаниях. Есть подходы, принципы, паттерны, которые подразумевают использование какой-то проверенной модели.
Кроме принципов и паттернов, важно придерживаться некоторых практик.
- Не запрашивать слишком большие объёмы данных - это самое важное. Технически, диск является самой медленной частью компьютера, и желательно минимизировать обращение к нему. Самый частый источник проблем именно в избыточности данных. Когда нам нужно получить имя и идентификатор пользователя, не нужно запрашивать всю таблицу (да, тот самый страшный
SELECT *), лучше запросить точечно и фильтровать все запросы. Последствиями избыточного запроса будет долгая загрузка, высокое потребление памяти, загрузка сети и задержки на клиенте (парсинг, рендеринг). Запрашивать нужно строго то, что используется. Разработчику лучше провести анализ того, какие данные понадобятся, запросить только их один раз, и работать уже с ними. Так и памяти будет расходоваться меньше, и запись-чтение будет не с диска. - Сужение объёмов - ограничение данных на источнике, а не в коде. Смысл таков - представим, что у нас есть таблица с заказами в базе данных. И вместо того, чтобы запрашивать всю таблицу, записывать в массив, а потом фильтровать данные в массиве, проще сделать изначальный запрос в базу данных с фильтрацией. БД эффективнее фильтрует, и придётся меньше данных передавать, а GC и вовсе спасибо скажет. Фильтруйте изначально на сервере, а не в памяти.
- Культура кода. Порой культура кода является читаемостью, а порой реальной оптимизацией. Бывают два логичных типа решений - те, что делают код читаемым, и те, что делают его эффективным. Иногда совпадают, а иногда противоречат. Вот тут и сложности. Логично, что если имя переменной хорошее или название метода короткое - будет меньше ошибок, меньше отладки, потому что понять легче. Читаемость важна, но не в ущерб очевидным потерям производительности, ведь порой следует применить нечитаемый и сложный подход, чтобы ускорить работу. Важно балансировать между понятностью без жертв эффективности. Какой-то универсальный пример здесь привести сложнее, ведь культура понятие слишком широкое, и если одна компания может применять бессмысленные практики из принципа, ломая эффективность, то другая может делать код нечитабельным, но невероятно производительным. Тут как повезет.
- Иногда можно получить данные из объектов, не нужно создавать переменные. Это та самая аллокация памяти (при создании переменной) и подразумевает либо создание объекта каждый раз заново, либо переиспользование уже существующего. Иногда это не нужно, и можно не создавать, если значение используется один раз, оно уже есть в объекте и нет риска повторного вычисления. Нельзя обойтись без создания, если вызов метода дорогой (например, вычисления или дополнительные запросы), есть побочные эффекты и метод нестабилен (возвращает разное при каждом вызове). Нужно кэшировать только дорогие значения, чтобы не наполнять код мусором.
- Поля и свойства отличаются и производительностью. Поле - просто значеине, имеет мгновенный доступ, нет логики, подходит для внутреннего состояния.
private string _name;
Свойство же имеет контролируемый доступ, может включать логику (геттер, сеттер), может быть виртуальным, проксируемым, отслеживаемым, но может быть медленнее, если геттер делает что-то тяжёлое.
public string Name { get; set; }
Поле лучше использовать внутри класса, для служебных данных, а свойство для публичного API, когда нужна инкапсуляция, валидация, уведомления. Опасными могут быть свойства, которые вычисляют тяжёлые значения при каждом чтении, что может быть спрятанной аллокацией и нагрузкой:
public List<User> ActiveUsers => LoadAllUsers().Where(u => u.IsActive).ToList();
- Порой циклы могут навредить. Нет, они не зло, конечно, но они могут использоваться некорректно. К примеру, вложенные циклы, лишние вызовы внутри цикла (которые можно вынести), создание объектов в цикле, работа с DOM или UI в цикле, из-за чего происходит зависание интерфейса. Минимизируйте тяжелые операции внутри циклов - выносите, кэшируйте, группируйте.
- Декомпозиция методов. Это разделение большого метода на маленькие - новичок постоянно сталкивается с замечанием «вынести это в отдельный метод». Это улучшает читаемость, упрощает тестирование, позволяет повторно использовать логику метода и нужно не для читаемости, а для контроля. Маленькие методы проще оптимизировать, выявить как источник проблемы (проще понять, что именно тут ошибка, чем искать по громоздкому методу), можно кэшировать результаты подметодов и меньше шансов, что один метод «съест всё время». Поэтому не стесняйтесь, если у вас море методов - это лучше, чем один громадный. Поэтому декомпозиция может не сочетаться с красотой, зато улучшает управляемость и возможность все измерять.
- Валидация и проверки. Входные данные лучше проверять, и не стесняйтесь добавлять проверки - это не трата времени и места в коде, это инвестиция в стабильность. Порой хочется сделать код маленьким, но важно предотвратить ошибки, которые потом всплывают глубоко в стеке, что поможет избегать странного поведения из-за «null», пустых строк, неверных типов. Это упростит отладку, ошибку увидите сразу, а не через 10 вызовов. Раннее падение лучше позднего сбоя - однако важно не проверять один и тот же аргумент несколько раз, не делать тяжёлую валидацию в сложных моментах.
Под проверкой подразумевается стандартный if (такой-то аргумент/переменная = null) то выбросить исключение или что-то ещё. Такие проверки можно делать на что угодно, как этого требует логика.
Null - это пустота. К примеру, если вы используете где-то возраст клиента, но в таблице в поле Age будет пусто, то есть null. И в какой-то момент, в вычислении у вас будет ошибка, или вовсе деление на null. Нужно проверять или ставить значение по умолчанию.
Nullable-значения это способ указать, что значение может отсутствовать. Словом, мы можем показать системе, что вот этот элемент может иметь значение null. Обычно добавляют символ «?» после имени - «string?» будет означать, что может быть null. Компилятор в таком случае поможет найти ошибки, и это предусмотрит явную обработку случаев обработки данных. Однако - если не проверять, то будет NullReferenceException, а при частых проверках будет загромождение кода. Неправильное использование nullable может приводить к null-каскадам. Поэтому будьте внимательны к null и nullable.
Выполнение кода
Когда пишется функция, создаётся инструкция для машины, которая читает код пошагово, управляет памятью и отслеживает, где она сейчас и как вернуться назад. В современных языках программирования уже можно не париться, сразу начинать писать «класс такой-то», «методы такие-то», и запускать. Но раньше такого не было, ведь были сложные инструкции вроде do, goto, когда прямо в коде писали пути, точно указывали, в каком потоке, каким образом выполнять, что делать дальше, а после использования данных - убирать за собой!
Представим, что у нас компьютер без операционной системы, с 64 КБ оперативной памяти, и нужно написать программу для подсчёта суммы чисел. Мы не можем написать то, что пишем сейчас, вызвав какую-то базовую встроенную в язык библиотеку. Придётся всё делать самому. К примеру, Ассемблер:
MOV AX, 5 ; положить 5 в регистр AX
ADD AX, 3 ; прибавить 3
JMP label ; перейти по метке
Каждая строка - одна команда процессора. Регистры, стек, флаги, поток исполнения - всё под контролем. Функций нет. Ошибка в адресе - и программа падает, перезаписывает ОС, или зависает навсегда.
Даже в высокоуровневых языках вроде BASIC или раннего Си основным способом управления потоком был goto. Пример на BASIC:
10 INPUT "Введите число: ", X
20 IF X < 0 THEN GOTO 60
30 PRINT "Число положительное"
40 GOTO 80
60 PRINT "Число отрицательное"
80 END
Тут нет структурного программирования, нет if-else, for, while - только GOTO. Код быстро превращается в спагетти-код (переплетённые переходы, которые невозможно читать). Эдсгер Дейкстра в 1968 году написал знаменитую статью «Go To Statement Considered Harmful», где призвал отказаться от goto ради читаемости и предсказуемости.
В Cи (и до сих пор в C++) мы сами выделяем и освобождаем память:
int* arr = malloc(1000 * sizeof(int)); // выделить
// ... использовать
free(arr); // ОБЯЗАТЕЛЬНО освободить!
Забыли освободить (free()) - утечка памяти. Освободили дважды - ошибка сегментации. Использовали после free() - неопределенное поведение (может заработать, может упасть). И в таких языках буквально нужно убирать за собой вручную. Это ручное управление памятью.
Тут нет стека вызовов «из коробки». Стек есть, но управлять надо вручную, например в Ассемблере CALL кладёт адрес возврата в стек, RET его забирает. Если не забрать - программа уйдёт в никуда, а при переполнении стека - stack overflow.
Ошибки обрабатываются через коды возврата, потоки только через системные вызовы, а многозадачность только через ручное переключение контекста. Сейчас мы уже не паримся, потому что современные языки (Java, C#, Python, JS) берут на себя всю эту «грязную» работу. Однако низкоуровневость никуда не делать - просто всё это работает «под капотом», и мы это не видим, пока что-то не сломается. Появилась сборка мусора (Garbage Collection), переходы генерирует компилятор (тот самый goto), указатели автоматические. При этом сборка мусора - это не волшебство, объекты не удаляются мгновенно, GC «паузит» выполнение (stop-the-world), а порой объект может не удалиться, даже если не нужен, потому что ссылки держатся. Да, мы не пишем free(), но всё ещё отвечаем за ссылки. Стек вызовов всё так же может переполниться, и, словом, осторожность всё ещё важна.
Современные языки построены на слоях абстракций, когда код передаётся в компилятор, затем в виртуальную машину, потом в операционную систему, потом в ядро и лишь в конце к железу. Каждый слой скрывает сложность предыдущего, но если что-то тормозит, придётся спускаться на уровень ниже. Уменьшилась разве что ответственность, ведь раньше отвечали за каждый байт, а сейчас за понимание системы.
Когда выполняется код, то машина отслеживает, где она сейчас и как вернуться назад.
Стек вызовов (Call Stack) - это структурная память, которая отслеживает, какие функции выполняются и в каком порядке. Представим себе стопку тарелок. Кладём сверху - push, берём именно сверху - pop, и последняя добавленная будет первой обработанной. Это порядок LIFO (Last In, First Out). Когда одна функция вызывает другую, текущее состояние (переменные, строка кода) сохраняются в стеке, управление передаётся новой функции - она тоже заносится в стек. И когда новая функция завершается, она удаляется из стека, и управление возвращается к предыдущей.
Можно представить себе три функции - A(), B(), C(), где каждая вызывает следующую:
function A() {
B();
}
function B() {
C();
}
function C() {
console.log("Привет из C!");
}
И если вызывать A() то стек будет таким:
[ C() ] ← текущая
[ B() ]
[ A() ]
[ main ] ← начало программы
Когда C закончит, то она снимается и выполнение возвращается в B. И когда такой вот стек (стопка тарелок) слишком большой, может случиться переполнение стека - чаще всего из-за бесконечной рекурсии (если A() будет вызывать B(), а B() вызывать A() - это будет бесконечно!), или из-за слишком глубокой вложенности вызовов. Это критично, ведь стек ограничен по размеру, а когда места нет - программа упадёт, выполнение будет невозможным.
Куча (Heap) - другая область памяти, для динамического выделения, где живут объекты, массивы, структуры, которые существуют дольше,чем один вызов функции, могут быть переданы между функциями и имеют неизвестный заранее размер. В отличие от стека, куча неупорядоченная, управляемая вручную или сборщиком мусора, медленнее в доступе, но гораздо больше.
Когда мы пишем:
const user = new User("Тимур");
…то мы запрашиваем память в куче.
Система находит свободный блок, выделяет его под объект, возвращает ссылку на этот объект. Объект живёт в куче, а ссылка живёт в стеке и указывает на объект.
Ещё раз.
Объект - живёт в куче.
Ссылка на объект в куче - живёт в стеке.
Ссылка не является значением, это указатель.
И когда память выделена, но больше не используется, и при этом не освобождается, происходит утечка памяти, когда программа «теряет» объект, при этом он продолжает занимать память. Такое происходит, когда объект больше не нужен, но на него всё ещё есть ссылка, и сборщик мусора не может его удалить (!). Тогда и утекает память. Со временем рост потребления памяти увеличивается, а профилировщик памяти показывает живые объекты, которые не должны быть. Итого, приложение тормозит или вовсе падает с OutOfMemoryError.
К примеру, если два объекта ссылаются друг на друга:
Современные языки конечно умеют находит циклы, если на всю группу объектов нет внешних ссылок, но если один из объектов «забыт» в глобальной переменной, в кэше или в обработчике события - цикл не очистится.
Кэш, кстати, может быть и другом, и врагом. Он, конечно, ускоряет повторные вызовы, снижает нагрузку на API, базу, процессор, но если он не ограничен, может расти вечно, хранить ссылки на объекты (что мешает сборке мусора), может хранить устаревшие данные. Поэтому размер кэша ограничивается, добавляются таймауты и очистка при выгрузке компонента.
Сборщик мусора (Garbage Collector, GC) - это автоматическая система управления памятью, которая находит и освобождает объекты, которые больше не используются. Это одно из величайших изобретений в истории программирования, которое позволило нам писать сложные системы, не думая о мелочах. Будто в офис наняли уборщика, который постоянно и своевременно опустощает урны, рабочие столы работников, что позволило им не заниматься этим самостоятельно.
Первый GC появился в Lisp в 1959 году. Lisp работал с списками и рекурсией, где ручное управление памятью было невыносимо сложным, тогда и появилась идея, что если на объект нет живых ссылок - его можно удалить. В 1960-1980-х GC ещё оставался в академических языках (Lisp, Smalltalk), а в мире системного программирования (C, ассемблер) ещё не доверяли, ведь это слишком медленно и непредсказуемо.
Но в 1995 году Java заявила всему миру о себе, встроив GC в ядро платформы. Разработчики больше не должны были трудиться над сборкой мусора, ведь JVM стала делать это за них, после чего началась эра управляемого кода. C# и JavaScript переняли этот подход, что сделало GC стандартом. GC буквально как уборщик спрашивает «этот объект ещё кому-то нужен? нет? тогда убираю!». Начинает работу он с корней (roots) - это глобальные переменные, локальные переменные в стеке, активные потоки. Потом обходит все ссылки от корней - строит граф достижимости, и всё что недостижимо - мусор. Этот мусор удаляется, а память освобождается. Если никто не отозвался, и не помнит, где ключи, значит они «утеряны», и их можно выбросить.
Современные GC используют поколенческий подход, потому что не все объекты одинаковы. Большинство объектов живут очень коротко, например, временные строки, промежуточные вычисления, параметры.
- Поколение 0 (молодое) работает с новыми объектами, что позволяет часто и быстро очищаться (minor GC);
- Поколение 1 (среднее) работает с пережившими первую очистку, проверяются реже;
- Поколение 2 (старое) работает с долгоживущими объектами (кэши, синглтоны), и проверяются редко (major GC).
Сначала объект создаётся в Gen 0. Если выжил после сборки - перемещается в Gen 1. Если и там выжил - в Gen 2. В Gen 2 сборка производится редко и долго. Этот подход экономит время, не проверяя каждый раз всю память.
GC работает в фоне, но может мешать. При полной сборке (major GC) происходит stop-the-world (STW), когда вся программа зависает на 10мс-1сек. А в реальном времени и вовсе паузы будут заметны - это происходит при перемещении объектов. Задержки тогда короткие, но заметные. После частых аллокаций произойдёт фрагментация, из-за чего медленнее будет выделяться память.
Когда GC страдает от нагрузки - лучше не создавать объекты вообще. Есть решение - пул объектов, который используется в буферах (сетевых, файловых), сессиях, подключениях, игровых объектах, логгерах. Без аллокаций нет нагрузки на GC, из-за чего всё работает быстрее и память потребляется более предсказуемо. Если забыть вернуть, то может быть утечка, и данные могут быть «грязными», и это в целом сложнее, но эффективно. Этакий возврат к ручному управлению.
Потоки и синхронизация
Потоки ранее мы уже затрагивали - это последовательность инструкций, которая может выполняться независимо от других, это ветвь исполнения внутри программы.
Каждый поток имеет свой стек вызовов, свои локальные переменные и свой счётчик команд (где он находится в коде). Один процесс может содержать множество потоков, которые делят между собой память (кучу), но имеют отдельные стеки.
Не все потоки одинаковы. Есть тяжёлые (от ОС) и лёгкие (управляемые средой выполнения). Легковесные потоки лучше для масштабирования, создаются быстрее раз в 100, на стек выделяют меньше памяти и переключаются без системного вызова. Go и Kotlin построены вокруг легковесных потоков. Java и C# — поддерживают и те, и другие.
Легковесные потоки — это потоки выполнения, которые управляются на уровне приложения или среды выполнения (runtime), а не операционной системой. Их ещё называют зелёными потоками (green threads) или корутинами (coroutines). Управляются не ядром ОС, а виртуальной машиной (например, JVM), средой выполнения (Go, Erlang) или библиотекой. Они являются очень дешевыми в части ресурсов, переключаются быстро и не используют параллелизм на нескольких ядрах. Часто реализуются как сопрограммы с явным или неявным переключением контекста.
Примеры - goroutines в Go, async/await в Python, C#, JavaScript.
Тяжелые потоки — это системные потоки, управляемые ядром операционной системы. Каждый такой поток — это отдельный объект ОС, который может выполняться параллельно на разных ядрах процессора. Вот они уже управляются ядром ОС, а их создание, уничтожение и переключение между ними являются дорогими по времени и памяти. Они в принципе требуют больше памяти, могут выполняться параллельно на нескольких ядрах (и поддерживают истинный параллелизм).
Примеры - pthread в C, std::thread в C++, Thread в Java (хотя Java может использовать легковесные внутри, но абстракция — тяжёлая). Когда есть независимые задачи (обработка файлов, запросы к API), когда нужно не блокировать основной поток (UI), когда есть длительные операции (расчёты, ввод-вывод), то можно разбивать на потоки и следить за ними.
Разбивать их следует по задачам, по данным или по времени. Для слежения используются пулы потоков (Thread Pool), фьючерсы/промисы, отмена (остановка задачи), логирование и мониторинг.
Есть такие понятия, как конкуренция и параллелизм. Конкуренция (concurrency) подразумевает, что несколько задач управляются «одновременно», но могут выполняться по очереди, для эффективного использования одного ресурса. Параллелизм (parallelism) подразумевает, Что несколько задач выполняются физически одновременно (на разных ядрах), дл ускорения за счёт нескольких ресурсов.
Ещё одно важное понятие - атомарность (atomicity), свойство операции, которая выполняется целиком и неделимо, как единое целое. Другие потоки не могут наблюдать промежуточное состояние. Атомарная операция не может быть прервана другим потоком, гарантирует целостность данных при одновременном доступе, а инкремент counter++ не атомарен, если не защищён (сначала чтение, потом +1, потом запись). Атомарные операции реализуются через специальные CPU-инструкции (например, CAS — compare-and-swap).
Volatile — модификатор, указывающий компилятору не кэшировать значение переменной в регистре, а всегда читать/писать из/в память. Он не гарантирует атомарность, но предотвращает оптимизации типа «загрузи один раз и используй локальную копию». Используется, когда переменная может изменяться извне (например, в прерываниях, в другом потоке, в памяти устройства). В Java volatile даёт дополнительные гарантии упорядоченности (happens-before), но в C/C++ — только запрет кэширования.
Memory barrier (барьер памяти или барьер синхронизации памяти) — инструкция, которая ограничивает порядок выполнения операций чтения/записи в память на уровне процессора или компилятора. Процессоры и компиляторы могут переупорядочивать операции для оптимизации, что может нарушить логику многопоточного кода.
Типы:
- Read barrier — не позволяет переносить чтения через барьер.
- Write barrier — то же для записей.
- Full barrier — запрещает переупорядочивание в обе стороны.
В некоторых языках (например, Java) volatile неявно вставляет барьеры памяти.
Lock-free структура — это структура данных, в которой операции не блокируются с помощью мьютексов, а используют атомарные операции (например, CAS) для обеспечения согласованности. Нет блокировок → меньше риска дедлоков, лучше отзывчивость, прогресс гарантируется (хотя бы один поток продвигается, даже если другие «зависли»). Может быть wait-free (гарантировано завершается за конечное число шагов) или obstruction-free (ещё слабее).
Message passing (передача сообщений) - парадигма параллельного программирования, при которой потоки или процессы не разделяют память, а обмениваются данными через отправку и получение сообщений. Нет разделяемого состояния → меньше гонок данных, потоки изолированы, а общение происходит через очереди (каналы).
Вот мы говорим всё время о потоках. А как их выделять отдельно? Поскольку единый подход лежит в основе многопоточности в Java, C#, Python, C++ и других языках — различается только синтаксис, мы рассмотрим алгоритмическим языком.
- Определить независимые действия. Перед запуском в потоке нужно проверить, что можно выполнять параллельно, словом, выделить независимую задачу:
Действие A: Загрузить файл с диска
Действие B: Отправить данные на сервер
Действие C: Обработать изображение
Если A, B, C не зависят друг от друга — их можно запустить параллельно. Если действия зависят (например, B использует результат A), тогда параллелизм возможен только с синхронизацией.
- Выделить код действия в отдельную единицу. Создаём выполняемую единицу — функцию, задачу, блок кода, который можно запустить в отдельном потоке. То есть, нужно обернуть действие в функцию или лямбду:
ФУНКЦИЯ ЗадачаА():
файл = прочитать_с_диска("data.txt")
вывести("Файл загружен")
ФУНКЦИЯ ЗадачаБ():
ответ = отправить_на_сервер(данные)
если ответ.успех:
вывести("Данные отправлены")
- Создать поток и назначить ему задачу. Используем конструкцию для запуска в отдельном потоке:
ПОТОК потокА = НОВЫЙ ПОТОК(ЗадачаА)
ПОТОК потокБ = НОВЫЙ ПОТОК(ЗадачаБ)
потокА.запустить()
потокБ.запустить()
К примеру, в Java
new Thread(() -> taskA()).start();
или в C#:
new Thread(taskB).Start();
- Дождаться завершения. Этот шаг в принципе опциональный, если нужно продолжить только после завершения всех задач:
потокА.ожидать_завершения()
потокБ.ожидать_завершения()
вывести("Все задачи завершены")
На практике - thread.join() Говоря о практике, вот пример параллельной обработки:
целое[] числа = [1, 2, 3, 4, 5, 6, 7, 8]
// Разделим массив на две части
целое[] часть1 = взять_часть(числа, 0, 4)
целое[] часть2 = взять_часть(числа, 4, 8)
целое результат1 = 0
целое результат2 = 0
ПОТОК поток1 = НОВЫЙ ПОТОК():
результат1 = сумма(часть1)
ПОТОК поток2 = НОВЫЙ ПОТОК():
результат2 = сумма(часть2)
поток1.запустить()
поток2.запустить()
поток1.ожидать_завершения()
поток2.ожидать_завершения()
целое общая_сумма = результат1 + результат2
вывести("Сумма: " + общая_сумма)
Но важно учитывать, что создание потоков - дого. Поэтому используют пул потоков (ThreadPool):
ПУЛ_ПОТОКОВ пул = ПУЛ_ПОТОКОВ.создать(4) // 4 рабочих потока
БУДУЩЕЕ<целое> будущее1 = пул.выполнить(():
вернуть сумма(часть1)
)
БУДУЩЕЕ<целое> будущее2 = пул.выполнить(():
вернуть сумма(часть2)
)
целое сумма1 = будущее1.получить() // ждёт завершения
целое сумма2 = будущее2.получить()
целое итого = сумма1 + сумма2
БУДУЩЕЕ<T> — это Future<T> или Task<T>, объект, представляющий результат асинхронной операции.
Можно выделить некоторые принципы при выделении в потоки:
- Независимость - задачи не должны мешать друг другу (или использовать синхронизацию);
- Изоляция данных - по возможности избегать разделяемого состояния;
- Ограниченные ресурсы - не создавать 1000 потоков - лучше использовать пул;
- Обработка ошибок - исключения в потоке могут пропасть, поэтому лучше ловить их внутри;
- Синхронизация - если нужен общий результат, то использовать join, future.get(), volatile, и атомарные переменные.
В отдельные потоки нужно выделять долгие операции (загрузка, вычисления, обработка файлов), при этом UI не должен зависать (не блокировать основной поток в GUI), можно выделять также независимые задачи (обработка событий) и асинхронные колбэки (обработка ответов от API).
И логично понять, как оно работает в совокупности:
Синхронность:
выполнить(ЗадачаА) → 500 мс
выполнить(ЗадачаБ) → 500 мс
Итого: 1000 мс
Параллельность:
запустить(ЗадачаА) → 500 мс
запустить(ЗадачаБ) → 500 мс
ожидать_оба()
Итого: ~500 мс
Ошибки, исключения и отказоустойчивость
Что такое ошибка
Исключения - как они работают под капотом (раскрутка стека)
Когда исползовать try/catch, а когда - коды ошибок
Неуправляемые исключения и их последствия
Логирование ошибок - что, когда и зачем записывать
Игнорирование ошибок
Принудительные действия - форсинг вызовов, игнорирование валидаций
Отладка и видимость состояния
Значения переменных
Как меняются значения переменных
Как смотреть значения переменных
Логирование - отладочные выводы, трассировка
Отладчики - брейкпоинты, watch, call stack inspection
Инспекция состояния в продакшене - телеметрия, метрики, логи
«Не отвечает» - что это значит? Зависания, бесконечные циклы, блокировки
Как читать ошибки?
Вызовы и иерархия
Цепочки вызовов - кто кого вызвал
Рекурсия - плюсы, минусы, переполнение стека
Хвостовая рекурсия (концептуально)
Инструменты - дерево вызовов, flame graph, профилирование
Обратные вызовы
Ресурсопотребление и метрики
Ресурсы
Что измерять - CPU, память, дисковый I/O, сеть
CPU - на что обращать внимание
Память - на что обращать внимание
Диск - на что обращать внимание
Сеть - на что обращать внимание
Метрики кода - cyclomatic complexity, cognitive complexity, coupling, cohesion
Профилировщики: CPU profiling, memory profiling, allocation tracking
Разбивка по стеку: attribution of resource usage to call paths
Бюджеты производительности: SLA, latency targets
Неиспользуемый код и технический долг
Мёртвый код: переменные, методы, классы, импорты
Как его находить: статический анализ, coverage
Последствия: усложнение поддержки, рост времени сборки
Удаление vs. комментирование: почему второе — зло
Сборка и культура производительности
Ошибки компиляции и предупреждения
Предупреждения можно игнорировать
Что делать если сборка поломалась
Как читать предупреждения и ошибки в консоли IDE
Почему важно сокращать время сборки
Факторы: зависимость, кэширование, параллелизация
Инкрементальная сборка, hot reload
Влияние на разработчика: feedback loop
Культура производительности
Производительность как часть качества кода
Code review: как замечать узкие места
Профилирование в CI/CD
Обучение команды: разбор утечек, анализ дампов