Переполнение стека вызовов JavaScript, SetTimeout и снижение производительности AJAX

в 9:54, , рубрики: javascript, javscript, setInterval, setTimeout, stack overflow, window.postMessage, метки: , , , ,

Проблема

Некоторое время назад в работе над клиентской (javascript) частью движка josi возникла, кстати, достаточно часто встречающаяся проблема переполнения стека:
Uncaught RangeError: Maximum call stack size exceeded (google chrome)
В статье рассматривается решение без использования setTimout или setInterval.

Суть

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

Решение

Классическим решением (имею ввиду подавляющее количество статей предлагающих его) является использование косвенной рекурсии посредством: setTimeout либо setInterval.

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

function f(args) 
{
     var self=this;
     var k=args.k;

     //вызываем себя же
     try
     {
         f({k:k+1});
     }
     catch(ex)
     {
          alert(k);
     }
}

та же бесполезная функция, но теперь теоретически бесконечная, разве что k переполнится

function f(args) 
{
     var self=this;
     var k=args.k;
     
     //косвенно вызываем себя же, через посредника setTimeout
     setTimeout(function(){ f({k:k+1}) }, 0);
}

Текущая функция сразу завершается за счет использования для рекурсии посредника setTimout, а следующий вызов выполняется по событию.
Отрицательной стороной такого подхода является его крайне низкая производительность, несмотря на то, что мы указываем нулевую задержку. Вызвана функция будет в зависимости от браузера в среднем не раньше чем через 10 мс. Но ведь мы боремся с превышением стека вызовов, а значит наша функция вызывается сотни раз, что означает потерю в производительности ~1 с на каждые 100 вызовов. Детальное тестирование нашел тут.
Самое простое, что пришло в голову — организовать симбиоз из попеременного использования прямого и косвенного вызовов, чтобы при достижении некоторого значения счетчика прерывать стек косвенным вызовом. Отчасти такое решение сейчас и используется. Но здесь тоже все не так просто, особенно если рекурсия представлена петлей из нескольких функций.
Вот простенький пример отражающий суть такого решения:

var max_call_i=300;
function f(args) 
{
     var self=this;
     var k=args.k;
     var call_i=args.call_i
     //alert(k);

     if (call_i>=max_call_i)
     {     
            //косвенно вызываем себя же, через посредника setTimeout
            setTimeout(function(){ f({k:k+1, call_i:call_i+1}) }, 0);
     }
     else
     {
           //напрямую вызываем себя же
           f({k:k+1, call_i:call_i+1});
     }
}

В моем коде проблема возникла в шаблонизаторе, который как раз незадолго до этого был переписан согласно новой парадигме. Не хотелось отказываться от принятой архитектуры. В тоже время реальное падение производительности составило 20-30% — что было просто чудовищно. Предложенное выше решение тоже не идеал: сохранялось падение производительности на 5-7%. Это меня не устроило: много гуглил, и напал на то, что нужно.
А это тест от туда же, из которого видно что, предложенный подход, в сравнении с setTimeout 0, гораздо более производительный, что на практике дало не более 3% падения производительности в моем случае…

Данное решение основано на связке window.postMessage и element.addEventListener, для меня достаточно кроссбраузерно (ie8+).

Я переработал функцию из приведенной выше статьи в AMD модуль. Возможно, кому-то это будет полезным…

define([], function () 
{
        
	return
       {
		
		args:							//аргументы
		{
			indirect_call:
			{
				f_arr:[],
				msg_name:"indirect_call-message",
				handler_f:null,
			}
		},
		
		/*** работа с событиями ***/

		f_indirect_call:function(f)
		{
			var self=this;
			
			//если это первый вызов то создаем обработчик события и привязываем к window
			if (t_uti.f_is_empty(self.args.indirect_call.handler_f))
			{
				//создаем обработчик события
				self.args.indirect_call.handler_f=function(event) 
				{
					if (event.source == window && event.data == self.args.indirect_call.msg_name) 
					{
						event.stopPropagation();
						if (self.args.indirect_call.f_arr.length> 0) 
						{
							var f = self.args.indirect_call.f_arr.shift();
							f();
						}
					}
				}
				
				window.addEventListener("message", self.args.indirect_call.handler_f, true);
			}
			
			self.args.indirect_call.f_arr.push(f);
			window.postMessage(self.args.indirect_call.msg_name, "*");
		},
      };
});

Автор: dnclive

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js