Pull to refresh

Прогресс-индикатор со стеком

Reading time5 min
Views1.3K
В работе мне нередко случается реализовывать долгие процессы, где не обойтись без прогресс-индикатора. Проблемы начались, когда процессы стали слишком сложными, но при этом хотелось иметь один непрерывный прогресс-индикатор для всего процесса. К примеру, процесс может состоять из вызовов функций Asub, Bsub и Csub, каждая из которых выполняется довольно долго (скажем, примерно 10%, 20% и 70% общего времени). Пусть Asub содержит два цикла, идущих подряд, Bsub несколько вложенных циклов, а Csub один цикл, но при этом в середине этого цикла вызывает Asub. Решая задачу в лоб, можно довести код до такого состояния, что треть всех строчек будет вычислять текущий процент и определять, пора ли его обновлять в UI, а функция Asub принимать дополнительные параметры, чтобы определить, какой диапазон процентов ей отображать (от 0 до 10, если вызвана из основного процесса или какой-то другой, если вызвана изнутри Csub). В результате код теряет читаемость, а поддерживать его становится сложнее. И нас ждут приятные минуты, когда мы захотим повторно использовать Bsub в другом месте, но уже не в середине, а в конце общего процесса, так что выводимые ей проценты от 10% до 30% будут не к месту. Я пришёл к выводу, что с этим надо что-то делать.

Я поставил следующие требования. Добавление в существующий код отображения прогресса не должно:
  1. Менять прототипы существующих функций и методов;
  2. Добавлять новых переменных внутри функций;
  3. Содержать сколь-нибудь нетривиальные вычисления текущего прогресса (скажем, 100*$i/$n уже считается нетривиальным);
  4. Мучать таймер или отсчитывать итерации с целью понять, надо ли обновить прогресс-индикатор или не тратить время на эту дорогостоящую операцию.
Мы не будем говорить про отображение прогресс-индикатора: это может быть виджет или контрол в любимой оконной системе, передача на веб-фронтэнд через любимые WebSockets или просто вывод строчки «12%» в STDOUT. Пусть у нас есть рендерер — функция вывода, которая принимает текущий прогресс в процентах и опционально текстовое сообщение, описывающее процесс или его стадию.

Разделение процесса на подпроцессы


Простой пример может выглядеть так:
init_progress;
# делаем половину процесса
do_first_half;
update_progress 50;
# делаем вторую половину
do_last_half;
update_progress 100;
Теперь предположим, что каждая половина — это вызов долгой функции, которая может выдавать свою информацию о прогрессе. При этом она не знает, в каком контексте она была вызвана и какой диапазон общего прогресс-индикатора отведён на её выполнение. Естественная реализация была бы примерно такой:
sub do_first_half() {
# делаем кусок
update_progress 33;
# делаем кусок
update_progress 66;
# делаем кусок
update_progress 100;
}
То есть мы сообщаем информацию о своём прогрессе, а уже кто-нибудь пусть отобразит её на нужный диапазон (в нашем случае 0-50%). Здесь мне пришла в голову аналогия со стеком матриц OpenGL, где любые аффинные преобразования трёхмерных координат описываются матрицей 4×4 и последовательность преобразований помещается в стек, а когда дело доходит до задания вершин конкретного объекта, мы указываем конкретные числа без всяких вычислений. OpenGL сам преобразует координаты, умножая на конкретную матрицу. Здесь у нас по сути тоже координаты на прогресс-индикаторе, только одномерные. Аффинное преобразование описывается двумя числами: перенос и масштабирование. Мы будем складывать преобразования в стек, а функция update_progress выполнит необходимое преобразование и передаст рендереру уже преобразованные координаты:
# Стек содержит пары [масштабирование, перенос]
my @stack = ([1, 0]);
sub update_progress($) {
  my $percent = shift;
  $percent = $stack[-1][ 0 ]*$percent + $stack[-1][1];
  renderer($percent);
}
Теперь добавим функции push_progress и pop_progress. Для удобства использования будем передавать в push_progress не масштабирование и перенос, а диапазон, на который надо отобразить последующие проценты. Разумеется, если какое-то преобразование уже действует, то параметры push_progress также надо преобразовать:
sub push_progress($$) {
  # Начало и конец диапазона
  my ($s,$e) = @_;
  # Преобразуем в соответствии с активным преобразованием
  ($s,$e) = map {$stack[-1][ 0 ]*$_ + $stack[-1][1]} ($s,$e);
  # И помещаем в стек
  push @stack, [($e-$s)/100, $s];
}

sub pop_progress() {
  pop @stack;
}
Теперь осталось только обернуть вызовы функций do_first_half и do_last_half в скобки push_progress/pop_progress:
push_progress 0,50;
  do_first_half;
pop_progress;
push_progress 50,100;
  do_last_half;
pop_progress;
Уже неплохо. К сожалению, придётся следить, чтобы каждому push_progress соответствовал парный pop_progress. Однако мы можем фрагмент кода между push_progress и pop_progress обернуть в блок и передать в функцию sub_progress примерно такого вида:
sub sub_progress(&$$) {
  my ($code, $s, $e) = @_;
  push_progress $s, $e;
  my @retval = &{$code}();
  update_progress 100;
  pop_progress;
  return @retval;
}
Основной код тогда упростится:
sub_progress {do_first_half} 0,50;
sub_progress {do_last_half} 50,100;
Заметьте, что перед pop_progress я вызвал update_progress(100) на всякий случай, если блок забыл это сделать. Теперь становится ясно, что параметр $s не нужен: вместо него можно использовать последнее выведенное значение прогресс-индикатора.

Циклы


Теперь посмотрим, что можно сделать с циклами. Предположим, что все итерации цикла выполняются примерно одинаковое время и количество итераций известно. Это не сработает с циклами вроде for($i=1; $i<=1024; $i*=2), однако сработает с любыми циклами типа foreach (кстати, приведённый цикл легко преобразуется в foreach: for(map {2**$_} 0..10)). Наш for_progress будет выполнять такую цепочку действий для каждой итерации: поместить в стек диапазон [$i/$n*100, ($i+1)/$n*100], где $i — номер итерации, а $n — количество элементов списка, загрузить текущий элемент в $_, выполнить блок кода, вызвать update_progress(100), извлечь из стека последний элемент. Тогда в существующих циклах достаточно заменить for на for_progress, перетащить список в конец (как в map) и присвоить вашей переменной $_, если вы использовали другую переменную. Замечу, что next и last продолжат работать (хотя с варнингом), так как внутри for_progress обычный for. Простейший тест выглядит так:
init_progress;
for_progress {sleep(1)} 1..10;
Так как update_progress вызывается в конце блока автоматом, в цикле его можно вообще не вызывать. Однако если каждая итерация длинная, вы можете его использовать, указывая проценты выполнения текущей итерации. Разумеется, работают вложенные циклы, использование sub_progress внутри for_progress и наоборот. Вот простенький пример:
sub A {
  for_progress {
    sleep(1);
  } 1..4;
}

sub B {
  sleep(1);
  update_progress 10;
  sub_progress {A} 50;
  sleep(1);
  update_progress 60;
  sleep(2);
  update_progress 80;
  sleep(2);
}

init_progress;
sub_progress {A} 25;
sub_progress {A} 50;
sub_progress {B} 100;
Современное программирование трудно представить без слов map и reduce. Для них также написаны обёртки map_progress и reduce_progress:
init_progress;
print "\nSum of cubes from 1 to 1000000 = ".
    reduce_progress {$a + $b*$b*$b} 1..1000000;
Тут, конечно, встаёт вопрос производительности: итерация слишком коротка, и вызов обновления прогресс-индикатора каждый раз замедлит процесс на порядки. update_progress учитывает это и вызывает рендерер не каждый раз, а только когда посчитает нужным: если проценты достигли 100, достаточно сильно изменились или прошло достаточно времени с последнего обновления (всё настраивается параметрами init_progress). Кроме того сделаны дополнительные оптимизации, в результате чего у меня пример с reduce_progress выполняется «всего» в 4.5 раза медленнее, чем с List::Util::reduce. Для очень коротких итераций применять осторожно.

Где взять


Первую версию модуля Progress::Stack я положил в CPAN. Пока заявку на namespace не утвердили, но пакет можно скачать с сайта CPAN'а. Помимо описанных здесь возможностей там есть ещё кое-что, включая объектный интерфейс (хотя он не особо нужен) и функцию file_progress для обработки текстового файла по аналогии с while(<FH>) {}. В документации есть подробное описание и примеры.

Замечания и пожелания приветствуются :-)
Tags:
Hubs:
Total votes 38: ↑28 and ↓10+18
Comments10

Articles