Understanding ARC’s Effect On Your App’s Performance

In the previous article, we talked a bit about how different types have different performance characteristics due to how they are managed in our main memory. Today, we are going to take a look at a feature that most of us barely think about, but that has had a profound effect on how we produce code. We’re also gonna ask questions about what effect it has on our apps performance.

What is ARC?

ARC, or Automatic Reference Counting, is used for memory management purposes. It is a feature of the Clang compiler and is used by both Objective-C and Swift. Its main purpose is to make sure that objects that are no longer strongly referenced by anything in your program are safely deallocated and their memory blocks returned.

ARC is not the same as garbage collection. In Java, for example, the garbage collector is a program that is constantly running behind the scenes. It may run at any point in time and may also require resources that you would have wanted to use in your own application. ARC, on the other hand, depends on reference counting. This requires programmers to know a bit think more about how they implement their programs, or we could create retain cycles that ARC is not capable of handling.

Allocation of Objects

When allocating a class object in Swift (only class objects are reference counted, Value Types are off the hook!), a call is made to allocate the appropriate amount of memory on the heap. What you won’t see, is that Swift actually allocates more space than you need to store your object (2 more words, if anyone is interested in the numbers). The extra space is used to keep a pointer to the type information and to keep a reference counter. This reference counter is what makes ARC so powerful, but it is also a feature that will make things a little bit trickier for programmers who are looking for maximum performance.

Keeping Track of References

When your object is allocated and assigned to a variable, the reference counter is incremented to 1. This indicates to the system that your object is currently in use and should remain allocated. The counter is incremented every time your share the reference to another variable, and decremented when those variables are destroyed. When the counter goes to 0, the object can be safely deallocated.

The above seems simple simple enough, so why do I bring it up?
Though incrementing and decrementing variables are usually fast and inexpensive operations — in the case of ARC, they are not.

The reason that ARC’s operations are much slower than the regular arithmetic operations are the following:

  1. There are a few layers of indirection that needs to be dealt with before Swift can actually update the counter.
  2. References can be shared across multiple threads. This means that Swift has to have mechanisms to deal with increments and decrements happening simultaneously since two threads can’t manipulate the same variable at the same time.

As you can see, this is not trivial and it will take a little bit of time to make sure that all goes according to plan. Let’s get to some code and see how this will add up!

class MyClass {
var a: Int
init(a: Int) {
self.a = a
}
}
var A = MyClass(a: 1)
var B = A
// Use A
// Use B

This is a very stripped down example of a class, just for simplicity. We’re allocating an object of type MyClass and assigning it to A, and then re-assign it to B. Let’s take a look at what the Swift compiler and ARC does behind the scenes for us:

var A = MyClass(a: 1)
var B = A
retain(B)// Use A
release(A)
// Use B
release(B)

We got three more method calls, just from that simple program. Also, remember that the retain(_:) and release(_:) calls actually take a little bit of time due to the reasons we discussed above.

Let’s have a look at a slightly more realistic example and see that things can escalate very quickly:

struct Label {
var text: String
var font: UIFont
init(text: String, font: UIFont) {
self.text = text
self.font = font
}
}
var label1 = Label(text: "Hello", font: .systemFont(ofSize: 20))
var label2 = label1
// Use label1
// Use label2

This is a pretty straight forward label struct, but it holds references to objects that are reference counted. Let’s see what happens to it when we let ARC do its thing.

var label1 = Label(text: "Hello", font: .systemFont(ofSize: 20))
var label2 = label1

retain(label2.text._storage)
retain(label2.font)

// Use label1
release(label1.text._storage)
release(label1.font)

// Use label2
release(label2.text._storage)
release(label2.font)

Oh God! What happened?
Due to the fact that our struct held references to other objects on the heap, those references had to be retained when the struct was copied. So this struct created a total of 6 additional calls to non-trivial methods, just from being copied to another variable.

ARC calls its methods a lot more frequently than one might think of. Lets use our first example class in a method call of our own:

func doSomething(to a: MyClass) {
retain(a)
// Do something
release(a)
}
var A = MyClass(a: 1)
doSomething(to: A)

As you can see, the local variable in our doSomething(to:) method also caused the overhead cost of reference counting. Imagine if we passed in our Label struct, and the method was called frequently in our program?

Final thoughts

As you can see, ARC does a whole bunch of work in the background that we are not aware of. This is a good thing, because our memory would get flooded with ghost objects if it didn’t. However, this is something that we need to think about when we’re designing our applications, especially if we are designing for performance.

That’s it for this week. Feel free to comment if you have questions, and follow to get notifications about future articles.

Data Scientist | Software Engineer | Author

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store