Утечка useState от React

#memory-leaks #react-hooks #garbage-collection #setstate

Вопрос:

Я расследую утечку памяти, которую я заметил в useState . Я не думаю, что это общая проблема, но это определенно происходит при некоторых условиях, и они мне совсем не ясны.

Концептуально я выделяю большие строки (100 МБ, просто чтобы было легко отслеживать номер в профилировщике) и настраиваю их в состояние реакции. Суть вопроса заключается в том, что в зависимости от того, как создаются строки и как они устанавливаются в состояние, для каждой новой строки выделяется все больше ресурсов без сбора мусора, если только компонент фактически не размонтируется.

Я создал крошечный проект Codesandbox, чтобы воспроизвести проблему (я бы посоветовал вам проверить его самостоятельно, если вам интересно, на самом деле забавно поиграть с вариантами этого эксперимента :P), но я просто задокументирую то, что я наблюдаю.

Утечка

Дано:

 const length = 100000000;
function getBuffer() {
  return new Array(length).join("*");
}

function Leaker() {
  const [, setState] = useState();

  return (
    <div>
      <button onClick={() => setState(getBuffer())}>leak</button>
    </div>
  );
}
 

Поведение при нажатии кнопки выглядит следующим образом (числа-это последовательные нажатия кнопок).:

  1. Выделено около ~15 кб (интересно! Я бы на самом деле ожидал 100 МБ).
  2. Выделено 200 МБ: Сейчас мы говорим. На моментальном снимке памяти я вижу, что один экземпляр строки принадлежит memoizedState объекту, а другой-объекту, которого больше нет (еще не собран мусор, даже когда браузер просит сделать это с помощью инструментов разработчика).
  3. Выделено 100 МБ: новый экземпляр строки принадлежит свойствам action и eagerState объекту , который, по-видимому, находится в связанном списке, на который ссылается его next свойство (я думаю, что это так useState реализовано, хотя я не знаю подробностей).
  4. выделено еще 100 МБ: все предыдущие выделения остаются там, никакой сборки мусора, даже если браузер попросит об этом.
  5. yet another 100MB allocated: I guess you see the pattern… I can actually keep going until the tab crashes with SIGILL .

All allocated memory gets garbage-collected though, after then Leaker component is unmounted.

Leaker memory profile

But now, stay tuned because there are some interesting details yet to come.

NonLeaker

If the implementation of the component was instead:

 function NonLeaker() {
  const [, setState] = useState();

  return (
    <div>
      <button
        onClick={() =>
          setState({
            buffer: getBuffer()
          })
        }
      >
        not leak
      </button>
    </div>
  );
}
 

Namely, instead of setting the string directly into the state we set an object with a property containing the string, the same procedure above doesn’t cause any leak whatsoever. Instead:

  1. Around ~15kb are allocated.
  2. Around ~15kb are allocated, and the previous allocation gets garbage-collected.
  3. Around ~15kb are allocated, and the previous allocation gets garbage-collected.
  4. …etc, good job!

NonLeaker memory profile

Leaker variant

And yet! Consider the following implementation of getBuffer instead:

 const length = 100000000;
let i = 0;
function getBuffer() {
  return `${i  }${new Array(length).join("*")}`;
}
 

So, we have changed how the string is created (because now we are concatenating two strings… I imagine this could already make a difference), and every new string will be slightly different (and certainly, the length of the string itself will increase as the number grows).

Then the behaviour of NonLeaker is still the same, but Leaker exhibits the following:

  1. Around ~15kb are allocated.
  2. Around ~15kb are allocated, and the previous allocation gets garbage-collected.
  3. Around ~15kb are allocated, and the previous allocation gets garbage-collected.
  4. Around ~15kb are allocated, and the previous allocation gets garbage-collected.
  5. Around ~15kb are allocated, and the previous allocation gets garbage-collected.
  6. Around ~15kb are allocated, and the previous allocation gets garbage-collected.
  7. Around ~15kb are allocated, and the previous allocation gets garbage-collected.
  8. Around ~15kb are allocated, and the previous allocation gets garbage-collected.
  9. Around ~15kb are allocated, and the previous allocation gets garbage-collected.
  10. Around ~15kb are allocated, and the previous allocation gets garbage-collected.
  11. Around ~15kb are allocated, and the previous allocation gets garbage-collected.
  12. 200MB are allocated.
  13. 100MB are allocated, and the previous 100MB garbage-collected.
  14. 100MB are allocated, and the previous 100MB garbage-collected.
  15. …etc, good job!

From that point onwards, there’s always the last 200MB which keep allocated, and anything older gets properly deallocated (I don’t know the internals of React, but it isn’t unreasonable that it keeps current and previous states for whatever reason).

Leaker variant memory profile

Keep in mind that for this difference in behaviour we have just changed how the string was created, so it seems clear to me that there is some interdependency between the implementation of React’s useState and the way how objects are created, so that under some circumstances a memory leak can be caused.

Closing thoughts

Of course, this is a kind of laboratory setup, but the reason I started to investigate this possible leak is because I’m working on an application where we suddenly experienced a massive increase in memory usage after a refactoring, and I’m considering that by changing how objects are being allocated, some components might be exhibiting the same behaviour observed in Leaker .

A question that remains in my head (and makes me slightly close my eyes as in «wait a second, there’s something spooky here…») is, why I don’t see the allocation of 100MB in the NonLeaker component? The data is actually there because I tried to reference it after setting the state and I can definitely use the string, print it, etc. Is there something I’m overlooking about the memory profiler? Or is the Javascript engine playing some tricks to save memory for us? (I could imagine for example, that given that the string is just a sequence of repeated characters, it could be easily stored as «X concatenations of string Y», instead of a single huge string Y).

В общем, есть ли у кого-нибудь здесь, в сообществе, представление о том, что может происходить? Или какие-то существующие справочные материалы (проще, чем переходить к исходному коду React), которые могли бы указать мне правильное направление?

Кстати, я использовал Chrome (версия 92.0.4515.107 ) для профилирования памяти (также потому, что это основной браузер, поддерживаемый в проекте моего клиента), но я также наблюдал ту же утечку в Firefox, просто тестируя из любопытства.

Фу, большое спасибо! Это было долгое… 😀