Wes Matlock

Mastering Actors and Concurrency - Best Practices

Introduction


Mastering Actors and Concurrency - Best Practices

Introduction

Concurrency is a core concept in modern programming, allowing multiple tasks to run simultaneously and efficiently, which is crucial for creating responsive and high-performance applications. In Swift, managing concurrency safely and effectively became much more robust with the introduction of Actors in Swift 5.5.

Actors are designed to provide a safer, more structured way to handle state in a concurrent environment, significantly reducing the risks of data races and ensuring the integrity of shared mutable state.

I’m writing this blog post because I have frequently encountered questions about concurrency and actors in recent interviews. Mastering these concepts is essential not just for building high-quality applications but also for standing out in technical interviews.

This post will provide a deep dive into actors, concurrency best practices, and practical examples that you can apply to your Swift projects.

Understanding Actors in Swift

Actors are a reference type introduced in Swift 5.5 that provides a simple and safe way to manage mutable state in concurrent code. An actor protects its mutable state by ensuring that only one task can access that state at a time. This makes it easier to reason about the state and avoids common concurrency pitfalls like data races, where two or more threads access shared data simultaneously and cause unpredictable behavior.

How Actors Work

  • Actors encapsulate state and provide methods to manipulate that state. They allow only one task to execute within them at any given time, ensuring mutual exclusion.
  • Unlike classes, actors manage concurrency automatically. When you call a method on an actor from a concurrent context, the method is executed in a thread-safe manner.
  • Internally, actors use a lightweight concurrency model based on cooperative multitasking. This means actors can suspend and resume tasks, providing better performance and responsiveness without blocking threads.

The Problems Actors Solve

  • Data Races: Actors prevent data races by isolating state. Only one task at a time can access an actor’s mutable state.
  • Deadlocks: Actors avoid deadlocks by not blocking threads. Instead, they use asynchronous functions to allow other tasks to proceed while waiting.
  • Complex Synchronization: Actors eliminate the need for explicit locks and semaphores, reducing code complexity and potential errors.

Key Concepts of Actors

1. Actor Isolation

  • Actor isolation is the core feature of actors. It ensures that mutable state managed by the actor is not directly accessible from outside. Instead, the state is modified or read through asynchronous methods, preserving thread safety.

Example:

actor BankAccount {  
    private var balance: Double = 0.0  
  
    func deposit(amount: Double) {  
        balance += amount  
    }  
  
    func getBalance() -> Double {  
        return balance  
    }  
}

Here, the balance property is isolated within the BankAccount actor. The only way to interact with it is through the deposit and getBalance methods.

2. Reentrancy

  • Actors in Swift can be reentrant, meaning they can start processing a new message while waiting for an asynchronous operation to complete. This can improve performance but also introduces potential pitfalls if not handled correctly.
  • To avoid reentrancy issues, avoid writing actor methods that depend on each other’s states when called concurrently.

3. Non-blocking Behavior

  • Instead of blocking, actors suspend tasks until the required resources are available. This cooperative multitasking allows for more efficient execution, particularly on devices with limited resources.

Example:

actor FileDownloader {  
    private var downloads: [String: Data] = [:]  
  
    func downloadFile(from url: String) async throws -> Data {  
        if let data = downloads[url] {  
            return data  
        }  
  
        let fileData = try await fetchData(from: url)  
        downloads[url] = fileData  
        return fileData  
    }  
  
    private func fetchData(from url: String) async throws -> Data {  
        // Simulate network delay  
        try await Task.sleep(nanoseconds: 1_000_000_000)  
        return Data(url.utf8)  
    }  
}

Best Practices for Using Actors

1. Minimize Shared State

  • Reduce the shared mutable state between actors to avoid bottlenecks and contention. Instead, prefer to keep state local to each actor or pass immutable copies of data when possible.
  • Example: Use immutable structures or pass-by-value parameters when interacting between actors.

2. Design Actor Hierarchies Carefully

  • Avoid nesting actor calls or creating dependencies between actors that could lead to deadlocks or starvation. A clear, well-defined actor hierarchy minimizes these risks.

Example:

actor ParentActor {  
    let child = ChildActor()  
    func performTask() async {  
        await child.childTask()  
    }  
}  
  
actor ChildActor {  
    func childTask() {  
        // Perform child task  
    }  
}

3. Avoid Reentrant Actor Methods

  • Reentrant methods can lead to unexpected behavior if an actor’s state changes between suspensions. Design actor methods to be independent and avoid relying on internal state that can change.
  • Example: Avoid writing methods that wait on await statements and then mutate the state assuming nothing has changed.

4. Use Nonisolated Methods for Read-Only Access

  • If an actor method only reads state without modifying it, mark it as non-isolated to allow concurrent reads without blocking.

Example:

actor Logger {  
    private var logs: [String] = []  
  
    func log(message: String) {  
        logs.append(message)  
    }  
  
    nonisolated func readLogs() -> [String] {  
        return logs  
    }  
}

5. Use MainActor for UI Updates

  • In SwiftUI applications, UI updates should occur on the main thread. Use the @MainActor attribute to ensure actor methods that interact with UI run on the main thread.

Example:

@MainActor  
class ViewModel: ObservableObject {  
    @Published var text: String = "Initial text"  
  
    func updateText(newText: String) {  
        self.text = newText  
    }  
}

Sample Application: Concurrency with Actors in a SwiftUI App

For this example, we’ll build a simple Task Manager application using SwiftUI and Actors. This app will allow users to manage tasks by adding new tasks, marking them as completed, and deleting them. The state management of tasks will be handled by an actor to ensure thread safety and proper concurrency handling.

Features:

  1. Add a new task.
  2. Mark a task as completed or uncompleted.
  3. Delete a task.
  4. The UI updates automatically in response to changes managed by the actor.

Setting Up the Xcode Project

Before we dive into the code, let’s set up a new Xcode project:

  1. Open Xcode and select “Create a new Xcode project”.
  2. Choose App under the iOS tab and click Next.
  3. Enter a Product Name (e.g., “TaskManager”), set the Interface to SwiftUI, and the Language to Swift.
  4. Click Next, choose a location to save the project, and click Create.

Once the project is created, we can start building our Task Manager app by creating the necessary SwiftUI views and using Actors to manage the app’s state.

1. Define the Actor for Task Management

• We use an actor to manage tasks to ensure that all operations (add, delete, toggle complete) are thread-safe.

import Foundation  
  
actor TaskManager {  
  private var tasks: [TaskItem] = []  
  
  struct TaskItem: Identifiable {  
    let id: UUID  
    var title: String  
    var isCompleted: Bool  
  
    init(title: String) {  
      self.id = UUID()  
      self.title = title  
      self.isCompleted = false  
    }  
  }  
  
  func addTask(title: String) {  
    let newTask = TaskItem(title: title)  
    tasks.append(newTask)  
  }  
  
  func toggleTaskCompletion(id: UUID) {  
    if let index = tasks.firstIndex(where: { $0.id == id }) {  
      tasks[index].isCompleted.toggle()  
    }  
  }  
  
  func deleteTask(id: UUID) {  
    tasks.removeAll { $0.id == id }  
  }  
  
  func getTasks() -> [TaskItem] {  
    return tasks  
  }  
}

2. Create the TaskViewModel Class

The TaskViewModel class is an ObservableObject that interacts with the TaskManager actor to fetch, add, update, and delete tasks. It uses the @Published property wrapper to notify the SwiftUI views when there are changes.

import SwiftUI  
  
@MainActor  
class TaskViewModel: ObservableObject {  
  @Published var tasks: [TaskManager.TaskItem] = []  
  private let taskManager = TaskManager()  
  
  func fetchTasks() async {  
    tasks = await taskManager.getTasks()  
  }  
  
  func addTask(title: String) async {  
    await taskManager.addTask(title: title)  
    await fetchTasks()  
  }  
  
  func toggleTaskCompletion(id: UUID) async {  
    await taskManager.toggleTaskCompletion(id: id)  
    await fetchTasks()  
  }  
  
  func deleteTask(id: UUID) async {  
    await taskManager.deleteTask(id: id)  
    await fetchTasks()  
  }  
}

3. Design the SwiftUI Views

Now, let’s build the SwiftUI views that provide the UI for the Task Manager app. We’ll have a main list view to display tasks and a form to add new tasks.

import SwiftUI  
  
struct TaskListView: View {  
  @StateObject private var viewModel = TaskViewModel()  
  @State private var newTaskTitle: String = ""  
  
  var body: some View {  
    NavigationView {  
      VStack {  
        List {  
          ForEach(viewModel.tasks) { task in  
            HStack {  
              Text(task.title)  
                .strikethrough(task.isCompleted, color: .black)  
              Spacer()  
              Button(action: {  
                Task {  
                  await viewModel.toggleTaskCompletion(id: task.id)  
                }  
              }) {  
                Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")  
                  .foregroundColor(task.isCompleted ? .green : .gray)  
              }  
            }  
          }  
          .onDelete(perform: { indexSet in  
            Task {  
              for index in indexSet {  
                await viewModel.deleteTask(id: viewModel.tasks[index].id)  
              }  
            }  
          })  
        }  
  
        // Add New Task Section  
        HStack {  
          TextField("New Task", text: $newTaskTitle)  
            .textFieldStyle(RoundedBorderTextFieldStyle())  
          Button(action: {  
            Task {  
              await viewModel.addTask(title: newTaskTitle)  
              newTaskTitle = ""  
            }  
          }) {  
            Image(systemName: "plus.circle.fill")  
              .foregroundColor(.blue)  
          }  
          .disabled(newTaskTitle.isEmpty)  
        }  
        .padding()  
      }  
      .navigationTitle("Task Manager")  
      .toolbar {  
        EditButton()  
      }  
      .task {  
        await viewModel.fetchTasks()  
      }  
    }  
  }  
}  
#Preview {  
  TaskListView()  
}

4. Implementing SwiftUI View Logic

The TaskListView uses a List to display tasks and provides buttons for marking tasks as complete or deleting them. It also includes a text field and a button to add new tasks. The @StateObject is used to keep the TaskViewModel in sync with the UI.

  • Adding a New Task: Uses a TextField for user input and a button to trigger the addition of a new task. The addTask method in the TaskViewModel is called asynchronously.
  • Marking Tasks as Completed/Uncompleted: A button next to each task item toggles its completion state. This action is performed asynchronously using the toggleTaskCompletion method.
  • Deleting Tasks: The list supports swipe-to-delete functionality, where the deleteTask method is called asynchronously to remove tasks.

Conclusion

By understanding and applying the principles of Actors in Swift, you can build robust, high-performance applications that handle concurrency safely and predictably. This sample application demonstrates these best practices in action, making it a valuable reference for developers looking to master concurrency in Swift.

If you want to learn more about native mobile development, you can check out the other articles I have written here: https://medium.com/@wesleymatlock

🚀 Happy coding! 🚀

By Wesley Matlock on September 17, 2024.

Canonical link

Exported from Medium on May 10, 2025.

Written on September 17, 2024