image

Я работаю в Ramblr, это ИИ-стартап, где мы строим на React сложные приложения для аннотирования видео. Недавно мне попалась сложная утечка памяти, которая возникает при одновременном использовании замыканий JavaScript и хука useCallback в React. Поскольку я вырос на .NET, мне потребовалось немало времени, чтобы разобраться в происходящем. Поэтому я решил написать этот пост и рассказать вам, чему меня научила эта ситуация.

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

Краткое напоминание о том, как работают замыкания


Замыкания — фундаментальная концепция JavaScript. Благодаря замыканиям функция запоминает те переменные, которые были в области видимости на момент создания этой функции. Вот простой пример:

function createCounter() {
  const unused = 0; // Эта переменная не используется во внутренней функции
  let count = 0; // Эта переменная используется во внутренней функции
  return function () {
    count++;
    console.log(count);
  };
}

const counter = createCounter();
counter(); // 1
counter(); // 2

В данном примере функция createCounter возвращает новую функцию, обладающую доступом к переменной count. Это возможно, поскольку на момент создания внутренней функции переменная count находится в области видимости функции createCounter.

Замыкания в JavaScript реализуются при помощи объекта контекста, в котором содержатся ссылки на переменные, находившиеся в области видимости на момент создания функции. От реализации движка JavaScript зависит, какие именно переменные сохраняются в объекте контекста, и этот аспект поддаётся различным оптимизациям. Например, в V8 (этот движок JavaScript применяется в браузере Chrome), неиспользуемые переменные могут не сохраняться в объекте контекста.

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

function first() {
  const firstVar = 1;
  function second() {
    // Это замыкание, заключающее переменную firstVar 
    const secondVar = 2;
    function third() {
      // Это замыкание, заключающее переменные firstVar и secondVar 
      console.log(firstVar, secondVar);
    }
    return third;
  }
  return second();
}

const fn = first(); // Этот код вернёт третью функцию
fn(); // логирует 1, 2

В данном примере у функции third() через цепочку областей видимости есть доступ к переменной firstVar.


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

Кстати, почитайте замечательную статью, в которой подробно разобрана эта тема: Grokking V8 closures for fun (and profit?). Пусть эта статья и была написана в 2012 году, она по-прежнему актуальна и даёт отличный обзор, позволяющий понять, как в V8 действуют замыкания.

Замыкания и React


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

Вот пример:

import { useState, useEffect } from "react";

function App({ id }) {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1); // Это замыкание, в котором заключена переменная count 
  };

  useEffect(() => {
    console.log(id); // Это замыкание, в котором заключён пропс id 
  }, [id]);

  return (
    <div>
      <p>{count}</p>
      <button onClick={handleClick}>Increment</button>
    </div>
  );
}

Как правило, сама по себе такая структура — не проблема. В вышеприведённом примере замыкания будут воссоздаваться на каждом этапе рендеринга App, а старые экземпляры будут попадать под сборку мусора. В результате возможны некоторые ненужные аллокации и деаллокации памяти, но вообще эти операции достаточно быстрые.

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

Замыкания и useCallback


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

import React, { useState, useCallback } from "react";

function App() {
  const [count, setCount] = useState(0);

  const handleEvent = useCallback(() => {
    setCount(count + 1);
  }, [count]);

  return (
    <div>
      <p>{count}</p>
      <ExpensiveChildComponent onMyEvent={handleEvent} />
    </div>
  );
}

В данном примере мы хотим избежать лишних этапов рендеринга ExpensiveChildComponent. Для этого можно попытаться поддержать в стабильном виде ссылку на функцию handleEvent(). Мы мемоизируем handleEvent() при помощи useCallback лишь для того, чтобы переприсвоить новое значение, когда состояние count изменится. Затем можно обернуть ExpensiveChildComponent в React.memo(), чтобы избежать повторного рендеринга во всех тех случаях, когда рендеринг выполняет родительский элемент App. Пока всё нормально.

Но давайте немного видоизменим этот пример:

import { useState, useCallback } from "react";

class BigObject {
  public readonly data = new Uint8Array(1024 * 1024 * 10); // 10MB of data
}

function App() {
  const [count, setCount] = useState(0);
  const bigData = new BigObject();

  const handleEvent = useCallback(() => {
    setCount(count + 1);
  }, [count]);

  const handleClick = () => {
    console.log(bigData.data.length);
  };

  return (
    <div>
      <button onClick={handleClick} />
      <ExpensiveChildComponent2 onMyEvent={handleEvent} />
    </div>
  );
}


Вы догадываетесь, что происходит?

Поскольку handleEvent() заключает в замыкание переменную count, именно в ней будет содержаться ссылка на объект контекста данного компонента. Кроме того, пусть мы и никогда не обращаемся к bigData в функции handleEvent(), в handleEvent() всё ещё будет содержаться ссылка на bigData. Это делается через объект контекста компонента.

Все замыкания совместно используют общий объект контекста, существовавший на тот момент, когда они создавались. Поскольку handleClick() замыкается на bigData на bigData будет ссылаться этот объект контекста. Таким образом, bigData не попадёт под сборку мусора до тех пор, пока стоит ссылка на handleEvent(). Эта ссылка будет действовать до тех пор, пока не изменится count и не будет воссоздана handleEvent().



Бесконечная утечка памяти при сочетании useCallback с замыканиями и большими объектами


Рассмотрим последний пример, где все вышеперечисленные проблемы доводятся до крайности. Этот пример — сокращённая версия кода, присутствующего в нашем приложении. Поэтому, даже если пример и кажется искусственным, он очень хорошо демонстрирует общую проблему.

import { useState, useCallback } from "react";

class BigObject {
  public readonly data = new Uint8Array(1024 * 1024 * 10);
}

export const App = () => {
  const [countA, setCountA] = useState(0);
  const [countB, setCountB] = useState(0);
  const bigData = new BigObject(); // 10 МБ данных

  const handleClickA = useCallback(() => {
    setCountA(countA + 1);
  }, [countA]);

  const handleClickB = useCallback(() => {
    setCountB(countB + 1);
  }, [countB]);

  // Этот код демонстрирует проблему
  const handleClickBoth = () => {
    handleClickA();
    handleClickB();
    console.log(bigData.data.length);
  };

  return (
    <div>
      <button onClick={handleClickA}>Increment A</button>
      <button onClick={handleClickB}>Increment B</button>
      <button onClick={handleClickBoth}>Increment Both</button>
      <p>
        A: {countA}, B: {countB}
      </p>
    </div>
  );
};

В данном примере мемоизированы два обработчика событий: handleClickA() и handleClickB(). Также здесь есть функция handleClickBoth(), которая и вызывает оба обработчика событий, и логирует длину bigData.

Догадываетесь, что произойдёт, если попеременно щёлкать кнопки “Increment A” и “Increment B”?

Давайте откроем «Инструменты разработчика» в браузере Chrome и посмотрим, что происходит в инспекторе памяти после того, как по пять раз нажать каждую из этих кнопок:


По-видимому, bigData вообще не попадает под сборку мусора. С каждым нажатием расход памяти только растёт. В нашем примере приложение держит ссылки на 11 экземпляров BigObject, каждый размером по 10 МБ. Один экземпляр создаётся для первичного рендеринга, ещё по одному — с каждым щелчком.

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

0. Первый шаг рендеринга:

При первичном рендеринге App создаётся область видимости замыкания, в которой содержатся ссылки на все переменные, поскольку все они у нас используются не менее чем в одном замыкании. Это касается bigData, handleClickA() и handleClickB(). Мы ссылаемся на них в handleClickBoth(). Давайте назовём область видимости замыкания AppScope#0.


1. Щелчок по “Increment A”:
  • При первом щелчке по “Increment A” будет воссоздана handleClickA(), поскольку мы меняем countA – давайте назовём новый экземпляр handleClickA()#1.
  • handleClickB()#0 не воссоздаётся, поскольку countB не меняется.
  • Но, в то же время, это означает, что handleClickB()#0 по-прежнему удерживает ссылку на предыдущую AppScope#0.
  • Новый экземпляр handleClickA()#1 будет удерживать ссылку на AppScope#1, в которой удерживается ссылка на handleClickB()#0.


2. Щелчок по “Increment B”:
  • При первом щелчке по “Increment B” будет воссоздана handleClickB(), поскольку мы меняем countB – давайте назовём новый экземпляр handleClickB()#1.
  • React не воссоздаёт handleClickA(), поскольку countA не меняется.
  • Следовательно, handleClickB()#1 будет удерживать ссылку на AppScope#2, which holds a reference to handleClickA()#1, в которой удерживается ссылка на AppScope#1, в которой удерживается ссылка на handleClickB()#0.


3. Второй щелчок по “Increment A”:

Таким образом, может получиться в бесконечную цепочку замыканий, которые ссылаются друг на друга и никогда не попадают под сборку мусора. Всё это время в системе подвисает отдельный объект bigData на 10 МБ, который воссоздаётся на каждом шаге рендеринга.


Суть проблемы


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

Грозит ли такая проблема именно вам?


Вот несколько факторов, при наличии которых вы сильнее рискуете столкнуться с подобными проблемами:
  1. У вас есть крупные компоненты, которые едва ли когда-либо воссоздаются, например, оболочка приложения, в которой запрограммировано значительное число деталей состояния.
  2. Вы пользуетесь useCallback, чтобы минимизировать операции повторного рендеринга.
  3. Из мемоизированных функций вы вызываете другие функции.
  4. Вам приходится обрабатывать крупные объекты — например, картинки или большие массивы.
Если вам не приходится обрабатывать какие-либо крупные объекты, то, пожалуй, ccылки на пару лишних строк или чисел проблемы не представляют. Большинство из этих перекрёстных ссылок между замыканиями самоустранятся, когда изменится достаточно много свойств. Просто учитывайте, что ваше приложение может загрести больше памяти, чем вы ожидали.

Как избежать утечек памяти при работе с замыканиями и useCallback?


Дам вам несколько советов, которые, вероятно, помогут избежать подобных проблем:

Совет 1: области видимости ваших замыканий должны быть как можно меньше

В JavaScript очень сложно отследить все захватываемые переменные. Лучший способ, помогающий не удерживать слишком много переменных — уменьшить саму функцию вокруг замыкания. Это значит:
  1. Писать более мелкие компоненты. Так вы сократите количество переменных, которые окажутся в области видимости в момент создания нового замыкания.
  2. Писать собственные хуки. Ведь в таком случае любой обратный вызов сможет замкнуться только на области видимости функции-хука. Зачастую это означает, что в него будут заключены только аргументы этой функции.
Совет 2: Старайтесь не захватывать другие замыкания, в особенности мемоизированные.

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

Совет 3: Избегайте мемоизации, когда она не является необходимой.

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

Совет 4 (аварийный люк): при работе с большими объектами пользуйтесь useRef.

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

Заключение


Замыкания — это паттерн, широко используемый в React. С их помощью мы добиваемся, чтобы другие функции запоминали, каковы были пропсы и состояния в области видимости, когда данный компонент отображался в предыдущий раз. Но в сочетании с инструментами мемоизации, например, с useCallback, такая техника может приводить к неожиданным утечкам памяти, в особенности при работе с большими объектами. Чтобы избежать таких утечек, старайтесь, чтобы область видимости каждого замыкания оставалась как можно меньше, избегайте мемоизации, когда она не является необходимой. При работе с большими объектами попробуйте использовать useRef в качестве резервного варианта.

Большое спасибо Дэвиду Глассеру за его статью A surprising JavaScript memory leak found at Meteor, написанную в 2013 году. Она стала для меня путеводной.

Комментарии (12)


  1. loltrol
    26.06.2024 14:29
    +4

    Ээээ, так вы же нарушили одно из главных правил написания react компонентов - функциональный компонент должен быть pure function. А у вас там при каждом рендере создается обьект на 10 мегабайт хД. Думаю в этом корень проблемы, который даст о себе знать рано или поздно и при других обстоятельствах. Запаковать его в memo, передать в props, через useEffect что то там накалькулейтить. Но не в render функции. У вас тот же обьект будет создаватся 2 раза при strict render....


    1. ViktorVovk
      26.06.2024 14:29
      +2

      Это вы где про такое правило прочитали?) не совсем представляю, как в большом функциональном компоненте можно соблюсти этот принцип. Это вообще не возможно, так как этот принцип, если я не ошибаюсь, гласит об том, что pure function может использовать только те данные, которые она получает в качестве аргумента, не создавать сайд эффектов, не использовать из вне данные. Любой другой компонент подключенный в компонент, любой хук, который вы используете нарушает этот принцип, так как это что то, что извне попадает в вашу функцию)


      1. loltrol
        26.06.2024 14:29

        https://react.dev/learn/keeping-components-pure - прочитал тут. props + state = input фукнции, кусок jsx = output. В вашем случае вы не ломаете чистоту функции хуками и другими компонентами, НО простая алокация и использование ее дальше по коду уже ломает, потому что это не state и не props. А для настоящих sideEffect есть useEffect, который прям так и называется, и который потом скидывает свой side-effect в state.


    1. Kergan88
      26.06.2024 14:29
      +1

      Выделение памяти, равно как и задержка выполнения и нагрев процессора при работе не считаются сайдэффектами. Потому что если считать - то чистых функций не существует. Любая функция при вызове аллоцирует как минимум адрес возврат (если только вызов не хвостовой) и делает джамп.


  1. winkyBrain
    26.06.2024 14:29
    +1

    Стоп, а почему не

    const handleEvent = useCallback(() => {
      // используем предыдущее состояние внутри setCount
      setCount(prevCount => prevCount + 1);
      // оставляем массив зависимостей пустым, что позволяет избежать
      // пересоздания ссылки на handleEvent
      }, []);
    
    // profit?

    ? И всё, нет пересоздаваемых ссылок на функции, нет созависимых замыканий, сколько бы useCallback вы ни использовали

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


    1. ViktorVovk
      26.06.2024 14:29
      +1

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


  1. Mauzzz0
    26.06.2024 14:29
    +1

    Я сначала подумал, что автор оригинала работает в рамблере :)


  1. zkcc
    26.06.2024 14:29

    const bigData = useRef(new BigData())

    const handleClickA = useCallback(() => setCountA(state => state + 1),[])
    const handleClickB = useCallback(() => setCountB(state => state + 1),[])


  1. danielzagumennyi
    26.06.2024 14:29

    Хорошо статья. Но проблема искусственная и решается просто мемоизацией const bigData = new BigObject();


    1. mayorovp
      26.06.2024 14:29

      Скорее скрывается, корень проблемы-то - в неограниченно растушей цепочке замыканий.


  1. neko911
    26.06.2024 14:29

    Какая растущая цепочка замыканий. Куча бреда в статье и в коментариях.

    Создание 10мб объекта происходит при каждом рендере компонента. Именно его надо мемоизировать при помощи useMemo в первую очередь. Тогда и с useCallback не будет проблем. Мемоизационные хуки (внезапно) держат в памяти не только последний результат, а и несколько предыдущих (возможно и без ограничений вообще) и очищаются при удалении компонента из дерева. И это не про реакт, в так впринцыпе работает мемоизация.

    Это

    const bigData = new BigObject()

    Должно выглядеть так

    const bigData = useMemo(() => new BigObject(), []);

    Или вынесено из компонента (например в стейт-менеджер).

    Автор я понимаю что ты пришел из .Net, но тут проблема не в понимании реакта, а в понимании мемоизации. Посмотри как работает например код функции memoize из библиотеки lodash. Ты увидишь что происходит мемоизация (сохранения в памяти) нескольких результатов вызова с привязкой каждого к аргументам (зависимостям). Считай что это кеш, который конечно можно переполнить. И в твоём примере ты постоянно создаёшь новый экземпляр bigData и передаешь его как зависимость в useCallback в итоге твоя мемоизация вообще не работает и забивает память. В случае с мемоизированым bigData - если у нехо нет зависимости от пропсов (или чего-то ещё) то он будет создан один раз для экземпляра компонента. И да, того же можно добиться с useRef, но там дело не в больших объектах, а в мутабельности. При мутации объекта который хранится в useRef - реакт вообще не будет ничего рендерить. Есть задачи которые можно решить и тем и тем, но по принципу работы это разные вещи, и далеко не всегда взаимозаменяемые.


    1. mayorovp
      26.06.2024 14:29

      Какая растущая цепочка замыканий. Куча бреда в статье и в коментариях.

      Вот эта:

      И в твоём примере ты постоянно создаёшь новый экземпляр bigData и передаешь его как зависимость в useCallback

      И где же он это делает-то?