Параллельная обработка

Геопространственные вычисления часто связаны с обработкой больших наборов данных: тысяч точек, сотен полигонов, дорожных сетей, GPS-треков и спутниковых данных. Многие функции Turf.js выполняют сложные математические операции над координатами, что может создавать значительную нагрузку на процессор.

При обработке небольших коллекций объектов производительности JavaScript обычно достаточно. Однако при работе с крупными GeoJSON-файлами возникают следующие проблемы:

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

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


Ограничения однопоточного JavaScript

JavaScript традиционно работает в рамках одного основного потока выполнения.

Рассмотрим пример вычисления буферных зон вокруг десяти тысяч точек:

import * as turf from "@turf/turf";

const buffers = points.features.map(point =>
    turf.buffer(point, 1, { units: "kilometers" })
);

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

  • перестают реагировать кнопки;
  • замедляется масштабирование карты;
  • возникают задержки анимаций;
  • интерфейс выглядит «зависшим».

Причина заключается в том, что все вычисления выполняются в основном потоке.


Подходы к параллельной обработке

Для Turf.js используются несколько основных стратегий:

Разделение данных на части (Chunking)

Большой набор данных разбивается на небольшие блоки.

Например:

function chunkArray(array, size) {
    const result = [];

    for (let i = 0; i < array.length; i += size) {
        result.push(array.slice(i, i + size));
    }

    return result;
}

Использование:

const chunks = chunkArray(points.features, 500);

Каждый блок затем может обрабатываться независимо.


Web Workers

В браузере наиболее распространённым механизмом параллельного выполнения являются Web Workers.

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

Схема работы:

  1. Основной поток подготавливает данные.
  2. Данные отправляются Worker-потоку.
  3. Worker выполняет функции Turf.js.
  4. Результат возвращается обратно.

Worker Threads в Node.js

В серверной среде Node.js используются Worker Threads.

Они обеспечивают реальное многопоточное выполнение JavaScript-кода и особенно полезны для:

  • геоаналитики;
  • пространственной статистики;
  • пакетной обработки картографических данных;
  • генерации тайлов.

Использование Web Workers с Turf.js

Создание Worker-файла

Файл:

// geoWorker.js

importScripts(
    "https://unpkg.com/@turf/turf@latest/turf.min.js"
);

self.onmess age = function(event) {

    const features = event.data;

    const result = features.map(feature =>
        turf.buffer(feature, 2, {
            units: "kilometers"
        })
    );

    self.postMessage(result);
};

Worker получает набор объектов и создаёт буфер вокруг каждого.


Подключение Worker

Основной поток:

const worker = new Worker("geoWorker.js");

worker.onmess age = function(event) {

    const buffers = event.data;

    console.log(buffers);
};

Передача данных:

worker.postMessage(points.features);

Интерфейс при этом остаётся полностью отзывчивым.


Параллельная обработка нескольких блоков

Одного Worker-потока бывает недостаточно.

Допустим, имеется 20 000 точек.

Данные можно разделить на четыре части:

const chunks = chunkArray(
    points.features,
    5000
);

Создание группы Worker-потоков:

const workers = chunks.map(() =>
    new Worker("geoWorker.js")
);

Запуск вычислений:

const promises = workers.map((worker, index) => {

    return new Promise(resolve => {

        worker.onmess age = event => {
            resolve(event.data);
        };

        worker.postMessage(chunks[index]);
    });

});

Получение результатов:

const results = await Promise.all(promises);

const merged = results.flat();

Вычисления выполняются одновременно в нескольких потоках.


Пакетная обработка FeatureCollection

Часто требуется обработать весь GeoJSON-документ.

Исходные данные:

{
    "type": "FeatureCollection",
    "features": [...]
}

Разбиение:

const chunkSize = 1000;

const chunks = [];

for (
    let i = 0;
    i < collection.features.length;
    i += chunkSize
) {
    chunks.push(
        collection.features.slice(
            i,
            i + chunkSize
        )
    );
}

Каждый блок отправляется отдельному Worker-потоку.

После завершения вычислений результаты объединяются:

const mergedCollection = {
    type: "FeatureCollection",
    features: mergedFeatures
};

Асинхронная обработка без Worker-потоков

Иногда создание дополнительных потоков невозможно или нецелесообразно.

В этом случае применяется поэтапная обработка через цикл событий.

Пример:

async function processFeatures(features) {

    const result = [];

    for (let i = 0; i < features.length; i++) {

        result.push(
            turf.centroid(features[i])
        );

        if (i % 100 === 0) {
            await new Promise(resolve =>
                setTimeout(resolve, 0)
            );
        }
    }

    return result;
}

Такой подход не создаёт новые потоки, но периодически освобождает основной поток.


Использование Promise.all

Если вычисления независимы друг от друга, можно запускать их параллельно через Promise.

Пример:

const operations = polygons.features.map(
    polygon => {

        return Promise.resolve(
            turf.area(polygon)
        );

    }
);

const areas = await Promise.all(
    operations
);

Следует понимать, что данный подход не создаёт дополнительные потоки. Он лишь упрощает организацию асинхронного кода.


Worker Threads в Node.js

Создание Worker

Файл:

// worker.js

const { parentPort } =
    require("worker_threads");

const turf = require("@turf/turf");

parentPort.on("message", features => {

    const result = features.map(feature =>
        turf.centroid(feature)
    );

    parentPort.postMessage(result);
});

Основной процесс

const { Worker } =
    require("worker_threads");

Создание:

const worker = new Worker(
    "./worker.js"
);

Получение результата:

worker.on("message", result => {

    console.log(result);

});

Передача данных:

worker.postMessage(features);

Пул Worker-потоков

Создание потока для каждой задачи является дорогостоящей операцией.

Поэтому обычно формируется пул.

Пример:

const workers = [];

for (let i = 0; i < 4; i++) {

    workers.push(
        new Worker("./worker.js")
    );

}

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


Операции Turf.js, наиболее подходящие для распараллеливания

Некоторые функции практически идеально подходят для параллельной обработки.

buffer

Создание буферных зон:

turf.buffer(feature, 5);

Буферы отдельных объектов независимы друг от друга.


centroid

Поиск центроидов:

turf.centroid(feature);

Каждый объект вычисляется отдельно.


area

Расчёт площади:

turf.area(feature);

Отлично масштабируется на несколько потоков.


bbox

Определение ограничивающих прямоугольников:

turf.bbox(feature);

Практически не требует синхронизации между потоками.


simplify

Упрощение геометрий:

turf.simplify(feature);

Особенно эффективно для сложных полигонов с тысячами вершин.


lineChunk

Разделение линий:

turf.lineChunk(line, 1);

Может обрабатываться параллельно для множества маршрутов.


Операции, плохо подходящие для распараллеливания

Некоторые алгоритмы требуют данных из соседних объектов.

Например:

turf.union(poly1, poly2);

или

turf.dissolve(collection);

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

Полное распараллеливание таких алгоритмов значительно сложнее.


Передача данных между потоками

При использовании Worker-потоков возникает дополнительная стоимость сериализации данных.

Передача большого GeoJSON может занимать заметное время:

worker.postMessage(
    hugeFeatureCollection
);

Если коллекция содержит сотни тысяч координат, время передачи становится сопоставимым со временем вычислений.

Поэтому рекомендуется:

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

Балансировка нагрузки

Неравномерное распределение данных приводит к простою части потоков.

Плохой вариант:

Worker 1 → 100 объектов
Worker 2 → 100 объектов
Worker 3 → 100 объектов
Worker 4 → 10000 объектов

Три потока быстро завершат работу и будут ожидать четвёртый.

Более эффективный вариант:

Worker 1 → 2575 объектов
Worker 2 → 2575 объектов
Worker 3 → 2575 объектов
Worker 4 → 2575 объектов

Равномерная нагрузка обеспечивает максимальное использование процессора.


Ограничение количества потоков

Чрезмерное количество Worker-потоков способно ухудшить производительность.

Причины:

  • переключение контекста;
  • рост потребления памяти;
  • увеличение объёма обмена сообщениями;
  • конкуренция за ресурсы процессора.

Практическое правило:

Количество Worker-потоков
≈
Количество логических ядер CPU

Для большинства настольных систем оптимальным является диапазон от 4 до 12 потоков.


Профилирование производительности

Перед внедрением параллельной обработки полезно измерять реальные показатели.

Пример:

console.time("buffer");

Вычисление:

const result = points.features.map(
    feature =>
        turf.buffer(feature, 1)
);

Завершение:

console.timeEnd("buffer");

Сравнение результатов до и после распараллеливания позволяет определить фактический выигрыш.


Типичная архитектура высокопроизводительного приложения на Turf.js

Схема обработки больших геоданных:

  1. Загрузка GeoJSON.
  2. Разбиение данных на блоки.
  3. Создание пула Worker-потоков.
  4. Передача блоков в очередь задач.
  5. Выполнение вычислений Turf.js в потоках.
  6. Сбор результатов.
  7. Объединение итогового FeatureCollection.
  8. Отображение результата на карте.

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