Experiments in Uniform Memory Management by Erik van Bilsen
August 9, 2017 Erik van Bilsen
August 9, 2017 Erik van Bilsen
[SHOWTOGROUPS=4,20]
We experiment with algorithms to make memory management in Delphi more consistent between desktop and mobile platforms. This eases debugging and finding memory leaks. We look into using object interfaces and semi-automatic memory management based on ownership. Along the way, we touch on using linked lists and Delphi’s messaging framework.
или Зарегистрируйся repository on GitHub. You will find it in the Для просмотра ссылки Войди или Зарегистрируйся directory.
A Tale of Two Memory Management Models
With the advent of mobile compilers, Delphi introduced a new memory management model based on Automatic Reference Counting (ARC). This model promised an easier way to manage memory by not having keeping track of allocations and by (mostly) eliminating the danger of “dangling pointers”. No more need for create - try - use - finally - free blocks everywhere. This is being reduced to create - use and Delphi will take care of the rest.
Unfortunately, this promise was never fulfilled, for the following reasons:
For larger objects, this overhead is small. But if you use a lot of small objects, then the overhead becomes noticeable and it is not uncommon that 10-15% of application time is spend on just managing object lifetime. And having a lot of small objects is not uncommon. For example, loading an XML document into a DOM can easily result in the creation of many thousands of small objects. Also, with the “inheritance-vs-composition” scale tipping more and more in favor of composition, you end up with more (smaller) objects. Even creating a simple FireMonkey TLabel control on iOS results in the creation of 23 other objects (in Delphi 10.2). Adding it to a form using the default style creates another 108 objects (some of which only temporary). And in this process, the atomic TObject.__ObjAddRef method gets called over 1400 times, and __ObjRelease over 1300 times.
I know this is the price you pay for ARC, but unfortunately, you pay it on those platforms (with low-powered CPUs) that can least afford it…
Towards Uniform Memory Management
Ideally, we would like a memory model that works the same on all platforms. That way, you only have to target a single model and you only have to test your code on a single platform (as far debugging memory-related issues is concerned). If you have experience with debugging memory growth and reference cycles on mobile platforms, then you would probably appreciate that.
So you basically have 3 approaches:
или Зарегистрируйся). This method has big drawbacks though:
Enable ARC on all platforms
Well, there is no way to enable ARC on all platforms. At least, not until support for it is added to the Delphi compilers (which, as I mentioned, is a research area on the Для просмотра ссылки Войдиили Зарегистрируйся).
But you can easily enable ARC on a per-class basis: just create an object interface with the public API of the class and implement this interface. Nick Hodges would say that Для просмотра ссылки Войдиили Зарегистрируйся since it decouples your API from the actual implementation. And as an added benefit, you get (mostly) automatic memory management (as long as your class derives from TInterfacedObject or you implement the reference counting yourself). If you follow this model, then memory management on ARC and non-ARC platforms will be virtually the same, greatly simplifying the management of object lifetimes. Also, you can debug memory related issues (such as reference cycles) on Windows now, which is much easier than doing that on a mobile device.
[/SHOWTOGROUPS]
We experiment with algorithms to make memory management in Delphi more consistent between desktop and mobile platforms. This eases debugging and finding memory leaks. We look into using object interfaces and semi-automatic memory management based on ownership. Along the way, we touch on using linked lists and Delphi’s messaging framework.
This post is accompanied by a sample application in our Для просмотра ссылки ВойдиWarning: this post is more opinion than fact. Memory management is a controversial topic for many people and there are a lot of different opinions about how Delphi’s memory manager should work. Please feel free to disagree with me…
A Tale of Two Memory Management Models
With the advent of mobile compilers, Delphi introduced a new memory management model based on Automatic Reference Counting (ARC). This model promised an easier way to manage memory by not having keeping track of allocations and by (mostly) eliminating the danger of “dangling pointers”. No more need for create - try - use - finally - free blocks everywhere. This is being reduced to create - use and Delphi will take care of the rest.
Unfortunately, this promise was never fulfilled, for the following reasons:
- When developing cross-platform apps that also need to run on Windows and/or macOS, you still need to use the “old” create - try - use - finally - free pattern of freeing your objects when you are done with them. So there is no gain in reduced and simplified code.
- Even if you are developing for mobile platforms (or Linux) only, you will probably still to do the majority of testing and debugging on Windows, since it is so much faster and easier. So again, you need to use the “old” pattern.
- Confusingly, Free behaves in opposite ways on ARC and non-ARC platforms. On ARC platforms, it just sets the reference to nil (which may or may not actually free the object), while on non-ARC platforms it actually frees the object (but does not set the reference to nil). If you need some form of deterministic finalization, then you need to use DisposeOf instead of Free.
- Automatic Reference Counting still requires manual tweaking to avoid memory leaks. It is very easy to (inadvertently) create a reference cycle (aka circular reference) between two or more objects. These cycles will prevent these objects from being freed, resulting in a memory leak. To avoid this, you need to make some references [weak] (or [unsafe]). It is not always obvious if there is a reference cycle in you code, especially if the chain of references is long. Furthermore, it is very difficult to detect a reference cycle at run-time. You cannot use the ReportMemoryLeaksOnShutdown feature, since there is no real concept of “shutdown” with mobile apps. There is a CheckForCycles API, but this only works on a single instance and doesn’t report all live reference cycles in the app.
In addition to this, ARC adds quite a bit of CPU overhead. Every time you assign an object, Delphi automatically increases and/or decreases the reference count of 1 or 2 objects. This results in one or more atomic operations that lock the CPU for a fraction of time. Furthermore, to manage [weak] references, the RTL keeps track of all of these in multiple dictionaries. When an object is destroyed, these references are checked and set to nil.The first item in the list above may be addressed by a future Delphi version. The current Delphi Для просмотра ссылки Войдиили Зарегистрируйся mentions the possibility of ARC being implemented on all platforms (under “Research Areas”). That would be great since it means we would finally have a consistent model. It would be even better if we could also optionally disable ARC on all platforms…
For larger objects, this overhead is small. But if you use a lot of small objects, then the overhead becomes noticeable and it is not uncommon that 10-15% of application time is spend on just managing object lifetime. And having a lot of small objects is not uncommon. For example, loading an XML document into a DOM can easily result in the creation of many thousands of small objects. Also, with the “inheritance-vs-composition” scale tipping more and more in favor of composition, you end up with more (smaller) objects. Even creating a simple FireMonkey TLabel control on iOS results in the creation of 23 other objects (in Delphi 10.2). Adding it to a form using the default style creates another 108 objects (some of which only temporary). And in this process, the atomic TObject.__ObjAddRef method gets called over 1400 times, and __ObjRelease over 1300 times.
I know this is the price you pay for ARC, but unfortunately, you pay it on those platforms (with low-powered CPUs) that can least afford it…
Towards Uniform Memory Management
Ideally, we would like a memory model that works the same on all platforms. That way, you only have to target a single model and you only have to test your code on a single platform (as far debugging memory-related issues is concerned). If you have experience with debugging memory growth and reference cycles on mobile platforms, then you would probably appreciate that.
So you basically have 3 approaches:
- Disable ARC on all platforms.
- Enable ARC on all platforms.
- Find some middle ground.
- You cannot use Free to free an object, since Free just sets the reference to nil on ARC platforms. When ARC is disabled, this will not result in the release of this reference, and thus the instance will never be freed.
- Also, you cannot use DisposeOf, since this only calls the destructor, but does not free up the memory for the object.
- So you need to come up with a different way to destroy your objects. Adding yet another approach adds to the confusion and doesn’t make things easier.
- But more importantly, you have to make sure that all variables containing instances of destroyed objects are set to nil. With ARC disabled, freeing an object leaves a dangling pointer. If you don’t set all references to this destroyed object to nil (including references in arrays or collections), then Delphi will try to call __ObjRelease on the dangling pointer at some point, most likely resulting in an Access Violation. That is a big burden and definitely doesn’t make things easier.
Enable ARC on all platforms
Well, there is no way to enable ARC on all platforms. At least, not until support for it is added to the Delphi compilers (which, as I mentioned, is a research area on the Для просмотра ссылки Войди
But you can easily enable ARC on a per-class basis: just create an object interface with the public API of the class and implement this interface. Nick Hodges would say that Для просмотра ссылки Войди
But object interfaces are a bit heavy on the performance side. Since an interface is basically just a Virtual Method Table, this means that every method call is a virtual method call, using one additional level of indirection (and a VMT table lookup, which isn’t very cache friendly). In addition, if your class has simple properties that just access a field, then those would have to be implemented using getter and/or setter methods in the object interface. This means that accessing the property isn’t a simple matter of accessing a field anymore. Instead, it calls a method now (actually, a virtual method to make things worse), which adds quite a bit of overhead. This may be a bit nitpicky, and not an issue for your run-of-the-mill business logic classes. But it can become an issue in high-performance applications such as animated user interfaces or games (or in general when you don’t want to drain the battery).Talking about reference cycles: when using object interfaces, you can also create a reference cycle between two interfaces, preventing both underlying objects from being destroyed. This used to be a challenge until Delphi 10.1 Berlin arrived. Since that version, you can also use [weak] and [unsafe] references for object interfaces on Windows and macOS. So you don’t need “hacks” anymore to manually break cycles, or to store interfaces in plain old pointers to avoid cycles.
[/SHOWTOGROUPS]