The most common way to think about actors is similar to structs and classes. They are a type with properties or methods defined.

actor FileSystemAccess {
    func readFromFile(urlString: String) {
        FileManager.default.contents(atPath: urlString)
    }
}

This is fine. But this can lead to issues as the codebase grows and logic changes. This article will show a case where an actor with no properties or methods defined (an Empty Global Actor) is a valid option. This can help us remove Massive Actors from our code.

Game of Thrones meme saying Massive Actors are Coming

Background

In my work transitioning apps to Swift Concurrency I’ve wanted to use functionality similar to MainActor, but with a custom actor.

@CustomActor
class SomeClass {}

or

class SomeIsolatedType {
   @CustomActor
   func doWorkOnOtherActor()
}

This has led me to create Global Actors with no custom functionality. This isn’t how most of us are thinking about actors, but it allows us to do some powerful things.

  1. Avoid dumping too much logic into an Actor. The removes the threat of Massive Actors. And leaves us more options as the codebase evolves.
  2. Separate the logic in our code from how it is run. This is a powerful technique I’ve used for years to allow code I work with to scale.

Let’s talk about an example of where we could use an empty Global Actor.

Use Case - Restrict Access to File System

File access is a classic example of code that is not thread safe. The runtime will gladly modify the same file from multiple threads. This can lead hard to reproduce bugs. Actors in Swift Concurrency provide a way to enforce safe file system access at compile time.

Possible Approach 1 - add methods to the FileSystemActor

One approach for solving this with Swift Concurrency is to add the functionality to an actor. This matches how most of us think about creating actors. And how Apple shows actors being used in their documentation.

actor FileSystemAccess {
    func readFromFile(urlString: String) {
        FileManager.default.contents(atPath: urlString)
    }
}

This will work ok for basic functionality. But has the downside of forcing all file system functionality in to exist in the same type. Even methods that are unrelated and should be broken out into new types.

actor FileSystemAccess {
    func readFromFile(urlString: String) {
        FileManager.default.contents(atPath: urlString)
    }

    func unrelatedFileWrite() {
        // ... 
    }

    func thisShouldntBeInTheSameType() {
        // ... 
    }
}

Downside - Massive Actors

This can lead us to the Massive Actor problem.

Possible Approach 2 - bring in the extensions

A tempting alternative is to break up large types by using extensions

extension FileSystemAccess {
    func unrelatedFileWrite() {
        // ...
    }

    func thisShouldntBeInTheSameType() {
        // ...
    }
}

This doesn’t solve anything. It may split up the functionality into new source files. But the types are still bigger than needed. And the approach has the same downsides as the Massive Actor. Devs do this hoping smaller files make code more readable. But usually it makes things more confusing because you have to look to a new file to understand the behavior the type is responsible for.

Neither of these are good solutions for complex functionality. But may work ok for simple use cases.

Solution - use an Global Actor

This allows you to annotate classes, properties and methods with a custom actor.

This requires a Global Actor to be defined.

@globalActor actor FileSystemActor {
    static let shared = FileSystemActor()
}

This allows us to annotate our classes and structs with a custom actor

@FileSystemActor
class SomeClass {
    let fileSystem = FileSystemAccess()

    func readFromFile(urlString: String) async {
        await fileSystem.readFromFile(urlString: urlString)
    }
}

This also works for properties

class SomeClass {
   @FileSystemActor
    let fileSystem = FileSystemAccess()
}

and methods

class SomeClass {
   @FileSystemActor
   func accessSomething() {

   }
}

For our use case, the actor doesn’t need any functionality defined at all. It’s an Empty Global Actor.

Benefits of using Empty Global Actors

  1. It separates the logic from how it is run. A win!

  2. They allow us to isolate functions in legacy code. We know you didn’t mean to have file access in your ViewController, but now you have a workaround until you can make a more proper fix.

Aspect oriented programming?

Astute observers have been noting that property wrappers in Swift allow us to do things described in Aspect Oriented Programming. Splitting out functionality into a specific Actor is a valid way to keep different concerns separate in our code.

In my work I commonly push back on any solutions that introduce more global variables into existence. But this provides enough benefits to justify it.

What do you think?

Would this work for code you’re working with? What are some other use cases for Empty Global Actors?