Стек вызовов - это не бесконечный ресурс - как избежать переполнения стека в JavaScript
Используйте JavaScript, чтобы понять, как рекурсия может привести к переполнению стека, и уловки, чтобы этого не произошло.
Стек вызовов - это не бесконечный ресурс. Это особенно верно при выполнении глубокой рекурсии. Вызовы функций внутри функций помещаются в стек вызовов выше, что означает, что каждый рекурсивный вызов находится в стеке вызовов, ожидая своего выполнения.
Размер стека вызовов не фиксирован в Javascript, но он не велик. Давайте посмотрим на тривиальный пример:
let ct = 0; const MAX = 100_000 const recurse = () => { if (++ct > MAX) return recurse() } try { recurse() } catch (e) { console.error({ ct, e }) }
Если я запустил это в Node.js, я получу следующий результат:
{ ct: 15711, e: RangeError: Maximum call stack size exceeded at recurse (/home/jlowery2663/projects/stacksmash/overflow.js:4:17) at recurse (/home/jlowery2663/projects/stacksmash/overflow.js:6:3) at recurse (/home/jlowery2663/projects/stacksmash/overflow.js:6:3) at recurse (/home/jlowery2663/projects/stacksmash/overflow.js:6:3) at recurse (/home/jlowery2663/projects/stacksmash/overflow.js:6:3) at recurse (/home/jlowery2663/projects/stacksmash/overflow.js:6:3) at recurse (/home/jlowery2663/projects/stacksmash/overflow.js:6:3) at recurse (/home/jlowery2663/projects/stacksmash/overflow.js:6:3) at recurse (/home/jlowery2663/projects/stacksmash/overflow.js:6:3) at recurse (/home/jlowery2663/projects/stacksmash/overflow.js:6:3) }
На 15711-й итерации происходит переполнение стека. Ниже приведены некоторые уловки, чтобы этого не произошло.
setTimeout
Функция setTimeout планирует вызов функции, который будет обрабатываться циклом событий в некоторый будущий момент времени. Истинной рекурсии в этом случае не происходит: внутренняя «рекурсивная» функция будет выполняться, но к тому времени внешняя функция вернется и, следовательно, больше не будет находиться в очереди сообщений о событиях (той, которая сообщает циклу событий, что делать, и когда это делать).
Метод setTimeout принимает два параметра: функцию и время задержки (в миллисекундах). Задержка может составлять ноль миллисекунд, поэтому выполнение функции может происходить «немедленно». Давай попробуем:
let ct = 0; const MAX = 100_000 const recurse = (cb) => { if (++ct > MAX) { return cb(ct) } setTimeout(() => recurse(cb), 0) } try { const then = process.hrtime.bigint(); recurse((ct) => { const now = process.hrtime.bigint(); const nanos = now - then const runtime = Number(nanos) / 1_000_000_000 ct-- console.log({ ct, runtime }); }) } catch (e) { console.error({ ct, e }) }
Эта программа выводит время выполнения и количество итераций. Результаты:
{ct: 100000, runtime: 144.47516928 }
Таким образом, это позволяет избежать переполнения стека вызовов, но как-то медленно, не так ли?
setImmediate
Несмотря на то, что setTimeout может принимать 0 мс в качестве второго аргумента, это не так быстро, как вызов setImmediate:
let ct = 0; const MAX = 100_000 const recurse = (cb) => { if (++ct > MAX) { return cb(ct) } setImmediate(() => recurse(cb)) } try { const then = process.hrtime.bigint(); recurse((ct) => { const now = process.hrtime.bigint(); const nanos = now - then const runtime = Number(nanos) / 1_000_000_000 ct-- console.log({ ct, runtime }); }) } catch (e) { console.error({ ct, e }) }
Время выполнения этой программы намного, намного быстрее, чем у предыдущей:
{ ct: 100000, runtime: 0.295789227 }
Доля секунды вместо 144+! Почему?
Функция, выполняемая с помощью setImmediate, не запланирована, как с setTimeout. Вместо этого он выполняется сразу после выполнения всех обработчиков ввода / вывода. Обработка цикла событий в JavaScript хорошо объяснена в другом месте, например, здесь, и этот пост посвящен тому, как избежать переполнения стека вызовов.
nextTick
При выполнении в среде Node.js есть еще один доступный ресурс, позволяющий избежать переполнения стека: process.nextTick (). Объект process является глобальным, как и window в браузере. Метод nextTick выполняет функцию при первой возможности, минуя очередь сообщений о событиях.
let ct = 0; const MAX = 100_000 const recurse = (cb) => { if (++ct > MAX) { return cb(ct) } process.nextTick(() => recurse(cb)) } try { const then = process.hrtime.bigint(); recurse((ct) => { const now = process.hrtime.bigint(); const nanos = now - then const runtime = Number(nanos) / 1_000_000_000 ct-- console.log({ ct, runtime }); }) } catch (e) { console.error({ ct, e }) }
Результат:
{ ct: 100000, runtime: 0.109725787 }
Таким образом, nextTick на 60–70% быстрее, чем setImmediate для этого тривиального примера кода.
зацикливание
Вместо использования рекурсии можно преобразовать рекурсивные вызовы в циклы. Для тривиальных примеров, показанных до сих пор, это простая задача.
Однако иногда рекурсия может быть глубоко вложенной. Скажем, функция A вызывает функцию B вызывает функцию C, которая может снова вызвать функцию B или функцию A. Теперь у вас есть рекурсия A- ›B-› C- ›B | A, и преобразование этого типа рекурсии в петлю может быть не так просто.
Вместо прямого вызова функций основной цикл может вызывать функцию через переменную. Переменная функции устанавливается при каждом вызове функции, и ответственность за вызов каждой функции ложится на конструкцию цикла:
let ct = 0; const MAX = 100_000 const A = () => { fp = B; } const B = () => { fp = A; } let fp = B; const then = process.hrtime.bigint(); for (; ;) { if (++ct > MAX) { const now = process.hrtime.bigint(); const nanos = now - then; const runtime = Number(nanos) / 1_000_000_000 ct-- console.log({ ct, runtime }); break; } fp() continue; }
В этом цикле нет функций, вызывающих функции, но каждая функция должна знать и контролировать, какая функция будет выполняться следующей, с помощью переменной функции fp. Это дает самые быстрые результаты:
{ ct: 100000, runtime: 0.015974012 }
Итак, у вас есть четыре способа избежать страшного переполнения стека.