Git

Vala: память и замыкания

Niti, Atomic Booster, плагины Tuner и менеджер входа Seni написаны на Vala. Это нативный язык для GTK: он компилируется в код C, без сборщика мусора и без слоя биндингов. Цена нативности в том, что за временем жизни объектов нужно следить самому. Эта страница о самой частой причине утечек, замыканиях. Остальные страницы раздела: GTK4 и сигналы, D-Bus и плагины, история найденных багов, диагностика и чек-лист.

Важно. Материал собран по реальным ошибкам панели и приложений проекта и по разборам в других проектах (Geary, Dino, GNOME To Do). В конце каждого примера указано, как исправить.

Как Vala управляет памятью#

Объекты GObject учитываются по счётчику ссылок. Vala сама вставляет g_object_ref и g_object_unref. Объект живёт, пока на него есть хотя бы одна сильная ссылка. Из этого следуют три правила:

  • сильная ссылка это обычная переменная или поле;
  • unowned и weak не увеличивают счётчик и не гарантируют, что объект ещё жив;
  • цикл ссылок (A держит B, B держит A) не освобождается никогда, счётчик не дойдёт до нуля.

Как устроено замыкание#

Когда лямбда захватывает локальную переменную или this, компилятор создаёт на куче структуру с данными замыкания. Пример:

public class Controller : GLib.Object { private int counter = 0; public void setup_timer () { int step = 5; GLib.Timeout.add_seconds (1, () => { this.counter += step; return GLib.Source.CONTINUE; }); } }

В сгенерированном C эта структура хранит self с вызовом g_object_ref, то есть сильную ссылку на this. Пока замыкание живо, объект Controller тоже живёт.

Важная деталь: valac захватывает this даже тогда, когда тело лямбды его не использует (известная проблема компилятора, GNOME/vala#957). Если лямбда лежит в поле того же объекта, получается готовый цикл. Об этом прямо пишут разработчики Vala: лямбды в методах класса по умолчанию держат this сильной ссылкой.

Подписка на сигнал долгоживущего объекта#

Самый частый паттерн утечки. Виджет подписывается на сигнал синглтона и захватывает this:

// Плохо public class SidebarItem : Gtk.ListBoxRow { private ApplicationSettings settings; public SidebarItem (ApplicationSettings settings) { this.settings = settings; this.settings.theme_changed.connect (() => { this.update_theme (); }); } private void update_theme () { } }

Что происходит: ApplicationSettings живёт всё время работы программы и хранит список подписчиков. Подписка держит замыкание, замыкание держит SidebarItem. Когда виджет убирают из списка, его счётчик остаётся выше нуля, и он вместе со всем своим поддеревом остаётся в памяти навсегда.

Исправление: слабая ссылка в замыкании, идентификатор подписки в поле и отписка в dispose:

public class SidebarItem : Gtk.ListBoxRow { private ApplicationSettings settings; private ulong handler_id; public SidebarItem (ApplicationSettings settings) { this.settings = settings; weak SidebarItem self = this; this.handler_id = this.settings.theme_changed.connect (() => { if (self != null) self.update_theme (); }); } public override void dispose () { if (this.handler_id != 0 && this.settings != null) { this.settings.disconnect (this.handler_id); this.handler_id = 0; } base.dispose (); } private void update_theme () { } }

Осторожно. В методах экземпляра локальный weak self = this в компиляторе valac 0.56 не всегда даёт настоящую слабую ссылку. В проекте на этот шаблон есть правило линтера weak-self-instance. Самый надёжный вариант: вынести подписку в статический метод и передавать weak-параметры явно. Так была исправлена реальная утечка в панели уведомлений, см. историю багов.

Правило для подписок: всегда сохранять ulong handler_id и вызывать disconnect в dispose. Отписку и разрыв циклов делают именно в dispose, а не в деструкторе: деструктор не сработает, пока цикл жив.

Таймеры держат объект вечно#

GLib.Timeout и GLib.Idle с возвратом Source.CONTINUE живут, пока их не снимут. Источник хранится в главном цикле, а не в виджете:

// Плохо public class StatusWidget : Gtk.Box { public StatusWidget () { GLib.Timeout.add (1000, () => { this.refresh_status (); return GLib.Source.CONTINUE; }); } private void refresh_status () { } }

Виджет убрали из окна, но таймер продолжает работать и держит StatusWidget. Исправление: сохранить идентификатор, снять в dispose и не работать с мёртвым объектом:

public class StatusWidget : Gtk.Box { private uint timer_id = 0; public StatusWidget () { weak StatusWidget self = this; this.timer_id = GLib.Timeout.add (1000, () => { if (self == null) return GLib.Source.REMOVE; self.refresh_status (); return GLib.Source.CONTINUE; }); } public override void dispose () { if (this.timer_id != 0) { GLib.Source.remove (this.timer_id); this.timer_id = 0; } base.dispose (); } private void refresh_status () { } }

Все лямбды блока делят один контекст#

valac создаёт одну структуру данных на блок кода. Несколько лямбд в одной области видимости делят её, и любая из них держит всё, что захватила соседняя:

// Плохо: буфер на 50 МБ живёт целый час, хотя таймер им не пользуется public void process_data () { var huge_buffer = new LargeMemoryBuffer (1024 * 1024 * 50); var short_lived = new TemporaryObject (); short_lived.done.connect (() => { print ("Buffer size: %zu\n", huge_buffer.size); }); GLib.Timeout.add_seconds (3600, () => { print ("Still running...\n"); return GLib.Source.CONTINUE; }); }

Исправление: разделить области фигурными скобками. Тогда данные буфера освободятся на выходе из блока:

public void process_data () { { var huge_buffer = new LargeMemoryBuffer (1024 * 1024 * 50); var short_lived = new TemporaryObject (); short_lived.done.connect (() => { print ("Buffer size: %zu\n", huge_buffer.size); }); } // данные замыкания для huge_buffer освобождаются здесь GLib.Timeout.add_seconds (3600, () => { print ("Still running...\n"); return GLib.Source.CONTINUE; }); }

В реальной панели из-за этого свойства утекало целое дерево карточек уведомлений. Подробности в истории багов.

Обратная ошибка: объект исчез сразу#

Локальная переменная без поля-владельца уничтожается на выходе из функции. Подписка на её сигнал молча никогда не сработает:

// Плохо public void setup_network_monitor () { var monitor = new NetworkMonitor (); // счётчик = 1 monitor.status_changed.connect ((online) => { print ("Network: %b\n", online); }); // конец функции: счётчик = 0, объект уничтожен, сигнал не придёт } // Исправление: хранить в поле класса-владельца public class ApplicationService : GLib.Object { private NetworkMonitor monitor; public void setup_network_monitor () { this.monitor = new NetworkMonitor (); this.monitor.status_changed.connect ((online) => { print ("Network: %b\n", online); }); } }

Так в панели были сломаны компоненты быстрого меню: класс-обёртка создавался локальной переменной в конструкторе, а его подписки на сервис питания пропадали почти сразу. Обёртка и сам Gtk.Widget это два разных объекта с отдельным счётчиком. То, что виджет жив в дереве GTK, не значит, что жива Vala-обёртка вокруг него.

Правило: любой класс, который создан ради подписок на внешние сервисы или ради контроллеров жестов, ссылающихся на его поля, должен быть полем класса-владельца, а не локальной переменной.

Делегаты: owned или unowned#

public delegate void WorkerCallback (); public void execute_immediately (unowned WorkerCallback cb) { cb (); } public void schedule_task (owned WorkerCallback cb);

Развилка в обе стороны:

  • если функция сохраняет делегат в поле как owned, возможен цикл (так утекали менеджеры таймеров в Geary: исправление было в переводе поля на unowned);
  • если параметр без owned, а вложенная лямбда его захватывает и вызывает позже, возможен вызов уже освобождённой памяти и падение. Именно так падал таскбар панели при клике по окну: параметр-делегат без owned в вспомогательной функции строки окна.

Сверяйте владение в описаниях сторонних C-библиотек (.vapi): unowned там, где библиотека держит колбэк, превратится в утечку или в падение.

Асинхронные методы#

Тело async-метода компилятор превращает в структуру состояния, которая хранит все локальные переменные между yield. Всё, на что они ссылаются, живёт минимум до конца асинхронной цепочки. Это нужно учитывать, когда метод ждёт долгий ответ: закрытый пользователем объект остаётся живым, пока ответ не придёт. Как с этим работать, смотрите на странице про D-Bus.