Почему ваши программы «стареют»?

Рассказывает Никита Салников-Тарновски, работник Plumbr


Недавно я натолкнулся на такой термин, как «старение ПО». Изначально я подумал, что это всего лишь какое-то очередное ничего не значащее определение (а убедился я в этом после прочтения статьи на Википедии), но когда я поглубже вник в эту концепцию, то она показалась мне весьма здравой. Так что я подумал, что стоит поделиться своими мыслями и знаниями по этой теме с вами.

Начнем с того, что Википедия говорит о «старении ПО»:

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

Это определение чертовски скучно. Но, думаю, все вы сталкивались со следующей ситуацией: вы только что поставили Windows и довольны ее производительностью. Постепенно ее работа становится все более медленной, и чтобы решить проблему, необходима перезагрузка. Проходит год, и даже перезагрузка системы уже не помогает, и приходится переустанавливать ОС заново.

Перезагрузка/переустановка Windows — отличный пример, знакомый многим. Дэвид Парнас однажды сказал:

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

Парнас также упоминал о том, что legacy ПО более уязвимо к старению. Вне зависимости от своего объема, ваш код, скорее всего, подвержен проблеме старения ПО, вызванного разными причинами:

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

Так как список выглядит немного сухо, я попробую проиллюстрировать его с помощью Java.

Утечки памяти

На данный момент работа с утечками памяти — главный источник заработка нашей компании. Мы работаем с тысячами приложений, и 50% из них содержат по меньшей мере одну утечку. Приведенный ниже пример иллюстрирует такой случай.

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

public class Calc {
  Map cache = new HashMap();

  public int square(int i) {
     int result = i * i;
     cache.put(i, result);
     return result;
  }

  public static void main(String[] args) throws Exception {
     Calc calc = new Calc();
     while (true)
        System.out.println("Enter a number between 1 and 100");
        int i = readUserInput();
        System.out.println("Answer " + calc.square(i));
     }
  }
}

Блокирующее поведение

Все мы были в ситуации, когда программа отлично работала годами, а потом что-то происходило, и потоки начинали простаивать и страдать от нехватки ресурсов.

Следующий пример служит отличной иллюстрацией. Нижеприведенный код может работать отлично месяцами до тех пор, пока кто-то не запустит одновременно операции transfer(a, b) и transfer(b, a). В таком случае возникнет взаимная блокировка.

class Account {
  double balance;
  int id;

  void withdraw(double amount){
    balance -= amount;
  }

  void deposit(double amount){
    balance += amount;
  }

  void transfer(Account from, Account to, double amount){
       sync(from);
       sync(to);
          from.withdraw(amount);
          to.deposit(amount);
       release(to);
       release(from);
   }
}

Незакрытые файлы

Уверен, что многие из вас встречали такой код, проклиная коллегу, который забывает закрывать ресурсы после загрузки из них всего нужного. Такой код может работать месяцами, но в конце концов неизбежно выдаст java.io.IOException: Too many open files, что, опять же, является отличным примером причин старения ПО.

Properties p = new Properties();
try {
  p.load(new FileInputStream(“my.properties”));
} catch (Exception ex) {}
finally {
  // Не хочу закрывать поток, не буду закрывать поток
}

Раздувание памяти/файла подкачки

Современные ОС зачастую перемещают долго неиспользуемые участки операционной памяти во вторичное хранилище, и вы можете натолкнуться на проблемы, когда физической памяти уже будет не хватать, и система начнет перемещать ее неиспользуемые куски в файл подкачки или swap-раздел, освобождая ОЗУ для более нужных операций. Ситуация становится еще хуже при использовании сборщика мусора — при полной сборке мусора сборщик должен обойти каждый доступный объект в графе объектов, чтобы найти мусор. В процессе работы он затронет каждую страницу в куче приложения, тем самым вызывая постоянный обмен страницами между оперативной памятью и файлом подкачки.

К счастью, на современных JVM последствия такой работы незначительны по нескольким причинам:

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

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

Согласны ли вы с тем, что ПО имеет тенденцию стареть так же, как и люди, учитывая рассмотренные выше примеры? Лично я думаю, что да.

Перевод статьи «Why is your software aging?»