Практический пример улучшения блокировки основного потока

Практический пример улучшения блокировки основного потока

1. Обзор

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

В одном из реальных проектов я разработал экран, который должен был обрабатывать десятки тысяч записей и отображать их в Grid. Экран должен был не просто выводить полученные с сервера данные как есть, а связывать отдельные наборы данных по определенным критериям и отображать результат обработки нескольких значений в одной Cell.

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

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

В этой статье, основываясь на данном опыте, я расскажу, как устранил проблемы с производительностью, возникавшие при обработке больших объемов данных, и как эти проблемы связаны с Main Thread Blocking и Long Task.

2. Требования и проблемная ситуация

 Требования и проблемная ситуация были следующими.

Требования

  1. Отображать данные с прокруткой вместо пагинации

  2. Требовалась функция фильтрации более чем по нескольким десяткам столбцов (требовались все данные)

  3. Отображать разные цвета фона в зависимости от значений Cell

Проблемная ситуация

  1. После получения данных требовалось много времени на их обработку и отображение

  2. Экран зависал при выполнении других действий во время загрузки

3. Повторяющиеся операции с большими объемами данных

В первоначальной реализации Related Data, которые хранились отдельно от Main Data, объединялись на уровне Cell.

В упрощенном виде это выглядело следующим образом.

예시)
const columnDefs = [
  {
    headerName: 'Related Data',
    valueGetter: params => {
      const relatedItems = relatedData.filter(
        item => item.targetId === params.data.id
      );
      return relatedItems
        .map(item => item.name)
        .join(', ');
    },
  },
];

При небольшом объеме данных это не было проблемой, однако по мере увеличения Main Data и Related Data количество поисковых операций, необходимых для создания значений Cell, также неоднократно возрастало.

В частности, в Grid значения Cell могут пересчитываться в зависимости от таких операций, как обновление данных, сортировка или фильтрация.

В конечном счете проблема заключалась не просто в том, что «в Grid было много данных».

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

4. Исследование проблемы с точки зрения Main Thread

В Main Thread браузера выполняются различные задачи, необходимые не только для выполнения JavaScript, но и для обработки пользовательских событий и рендеринга.

Поэтому, если операции JavaScript надолго занимают Main Thread, это также может повлиять на ввод пользователя и последующий рендеринг.

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

Ответ API

     ↓

Поиск данных

     ↓

Объединение и преобразование данных

     ↓

Формирование данных Grid

     ↓

Рендеринг Grid

     ↓

Разметка / отрисовка

Хотя каждая операция сама по себе не была крупной, их повторение для десятков тысяч записей увеличивало общие затраты на выполнение JavaScript.

Эта проблема также связана с Long Task. В браузерах задача, выполняющаяся в Main Thread более 50 мс, классифицируется как Long Task.

Если во время выполнения Long Task происходит взаимодействие с пользователем, обработка события может быть отложена до завершения этой задачи.

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

5. Предварительная обработка данных

Сначала я попытался сократить количество повторных поисковых операций, выполняемых при формировании данных Cell.

Ранее при каждом вычислении значения Cell для поиска данных, связанных с текущим Row в Related Data, использовался filter().

Если Main Data и Related Data содержат соответственно N и M записей, Related Data многократно просматриваются при вычислении каждого значения Cell, что в итоге может привести к общей стоимости поиска O(N × M).

Для улучшения ситуации я предварительно обработал Related Data по targetId и создал структуру Lookup.

예시)
const relatedNameMap = new Map<string, string[]>();

relatedData.forEach( item => {
	const names = relatedNameMap.get(item.targetId) ?? [];
	names.push(item.name);
	relatedNameMap.set(item.targetId, names);
});

6. Передача обработанных данных в Grid

После предварительной обработки Related Data я изменил реализацию так, чтобы объединять результат с Main Data и передавать его в Grid.

Ранее необходимые данные искались, а значения объединялись в момент создания Cell.

Создание ячеек

    ↓

Исследование связанных данных

    ↓

Объединение и преобразование данных

    ↓

Отрисовка ячеек

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

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

예시)
const gridData = mainData.map( row => ({
 ...row,
 relatedNames: relatedNameMap.get(row.id)?.join(“, ”) ?? “",
 }));

const columnDefs = [ {
 field: 'relatedNames',
  headerName: 'Related Data',
 },];

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

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

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

7. Проблемы, которые невозможно решить только с помощью виртуализации

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

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

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

8. Итоги

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

Проблема

Направление улучшения

Повторное исследование данных

Создание структуры поиска

Обработка данных на уровне ячеек

Предварительное вычисление

Повторное выполнение одних и тех же вычислений

Повторное использование результатов вычислений

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

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

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

9. Заключение

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


Jung

Site footer