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.