Introduction
libui is a GUI library that supports the three major operating systems: Windows, macOS, and Linux (currently, the successor project is libui-ng). Internally, it contains three different libraries that call native APIs, unified under a single ui.h
header file to provide similar UI functionality across all operating systems. It can also be easily used from other languages through FFI (Foreign Function Interface). While development has slowed somewhat recently, there are few similar libraries available, and libui continues to maintain its unique value.
libui Bindings
I have been creating Ruby bindings and Crystal bindings for libui. Through this process, I have come to realize how difficult it is to combine libui with garbage collection.
The Problem of Disappearing Controls and Callback Functions
Creating Ruby or Crystal bindings for libui is not particularly difficult. The work of checking function signatures and writing matching low-level bindings can be done mechanically.
However, when you call these low-level APIs to create simple applications, the following problems occur with a certain probability:
- Controls disappear and memory access violations occur
- Callbacks disappear and memory access violations occur
Both Ruby and Crystal are languages that use garbage collection (GC), so memory that is determined to be unused gets reclaimed. As a result, pointers and callback functions that should be used in the future by the GUI main loop are mistakenly freed by the GC.
In GC languages, the timing of memory deallocation is controlled indirectly through references.
In Ruby, callback functions are unconditionally stored in a dedicated array. This effectively creates a memory leak (old callbacks remain in the array even after new ones are added), but since callback functions are usually finite in number in GUI applications, this is not a practical problem.
Crystal uses a more complex management approach. Each callback function is tied to the instance of its related control. For example, a callback function that fires when a button is pressed is owned by that button. Additionally, the nested relationships of controls themselves are reproduced as an ownership tree. For example, a Window contains a Box, and the Box holds a Label and Button.
By using this ownership tree, we can significantly reduce the problem of incorrect collection by the GC.
By the way, why does Crystal's GC collect pointers even though controls may be referenced later in the main loop? I don't have a clear understanding of this point, but it's possible that memory tracking becomes difficult when closures are boxed.
libui's Memory Management Rules
libui is a C library designed for users to manage memory themselves. However, in practice, it introduces a mechanism where "when a parent control is freed, the memory of child controls is also freed." The controls that can be parent controls are Window, Box, Grid, Group, Tab, and Form.
When you destroy
these, child controls are freed first, then the parent itself is freed. Therefore, in actual operation, you often free child controls collectively by destroying the Window.
The problem is that on the Crystal side, we cannot detect such deallocation within native libraries. NULL checks might help us guess immediately after memory deallocation (libui sets pointers to NULL before deallocation), but this is unreliable.
Window deallocation can happen automatically. When the [x] button in the Window's title bar is clicked, a callback function is triggered by uiWindowOnClosing
, and if the return value is true, the Window's destroy is automatically triggered.
In contrast, uiOnShouldQuit
triggered from the Quit option in the menu bar represents application termination, so it does not automatically trigger destroy for the window. The user must destroy the Window themselves and call uiQuit.
libui's Memory Leak Detection Mechanism
libui has a built-in mechanism for detecting memory leaks. This is a very useful feature, but it often doesn't work well with GC languages. This is because in GC, the timing of memory deallocation is indefinite, and we cannot guarantee that all memory has been freed at the time of checking. Therefore, implementations that hook into GC's finalize
to perform deallocation should be avoided.
Table Deallocation Procedure
Table is based on Model-View architecture, with TableModel and Table separated. A TableModel can only be freed after all Tables using that model have been destroyed. Therefore, the deallocation procedure is as follows:
- Remove the Table from its parent control
- Explicitly destroy the Table
- Finally destroy the TableModel
Area Deallocation Procedure
Unlike Table, Area can be handled by simply destroying the control.
MultilineEntry Deallocation Procedure
While detailed investigation of the cause is still in progress, on macOS there appear to be cases where problems occur unless you remove it from the parent control and destroy it individually, similar to Table.
Summary
When using libui (libui-ng), there are many important considerations regarding memory management, especially deallocation.
In languages that use garbage collection like Crystal and Ruby, you normally don't need to worry about memory. Even with C language bindings, manual memory management often becomes unnecessary by using deallocation callback functions like finalize
.
However, I learned that with libraries like GUI libraries that have interactive operations where timing and synchronization are important, there are cases where you cannot rely too much on GC and must manually free memory at appropriate times.
In such cases, Ruby and Crystal often provide APIs that use blocks based on RAII (Resource Acquisition Is Initialization) concepts. This can handle more than half of the cases.
There seem to be cases that are difficult to handle with this alone, but I am still learning and experimenting through trial and error.
Thank you for reading. This article was translated from Japanese to English by Claude Sonnet4.
Top comments (0)