The new structured concurrency system in Swift brings excitement into Swift development.  Structured concurrency promises safer and easier to understand concurrency models to the language.  As with anything new, structured concurrency also brings some new challenges.  In this series of articles, we examine some of the issues developers will encounter when adopting structured concurrency.

The problem with unstructured concurrency in Swift

Swift's structured currency is a new way to handle asynchronous calls within a Swift-based application. Depending on the platform, Swift already had access to threads, Grand Central Dispatch (GCD), and Combine. What structured concurrency brings to Swift, as emphasized by Apple, is improved local reasoning about asynchronous calls. Local reasoning means that thread behavior and safety should be easily understood by looking at local code. With good local reasoning, properties and functions have well-defined behavior at call points with few or no unexpected side effects. The code itself is more of a linear, top-to-bottom flow. Global reasoning, on the other hand, means you would have to review code outside your current local context to understand thread behavior and safety. "Is the property I am changing thread-safe? Does this function being called have any asynchronous code under the hood?" With local reasoning, the answers to those questions are in front of you.

Structured concurrency in Swift is defined by a hierarchy of tasks. All structured concurrency occurs within a root task. Functions marked with an async keyword, for example, must be called from within a task context. However, standard async/await calls do not create a task on their own. Within structured concurrency, two methods to create structured tasks exist in Swift: async let and task groups. Tasks in these cases inherit the properties of their parent task.

While Swift's structured concurrency implementation is powerful, Swift also brings unstructured concurrency in those cases where structured concurrency is unavailable or undesired. Unstructured concurrency occurs in cases where tasks are either run without a parent task or run by splitting off the task. This type of concurrency in Swift is riskier to use because the developer, rather than the language and compiler, must manage the correctness and lifecycle of a task.  Furthermore, unstructured concurrency can break local reasoning.

Tasks in Swift are represented by the Task structure. The structure defines the priority of the task and allows the run time to decide in which thread to execute the task. Unless otherwise specified, subtasks will inherit characteristics from the parent task. Tasks are also cancellable, allowing opportunities to interrupt long-running operations.

Consider the following class:

class MyClass {
	func doTheAsyncThing() async {
		print("I've done the async thing")
	}
}

To call doTheAsyncThing(), the caller must already exist within a task. Indeed, at some point within the call stack, at least one task must exist.  This task could be created by an actor or manually created.  Manual creation of a Task can be simple:

func doTheThing() {
	Task {
		let myObject = MyClass()
		await myObject.doTheAsyncThing()
	}
}

Unlike doTheAsyncThing(), doTheThing() does not support structured concurrency because it does not have the "async" keyword. The function cannot suspend and wait for asynchronous calls to complete so it must fully run to completion. However, it can support unstructured concurrency using a Task. The contents of the Task is considered structured concurrency because those functions are called within a Task. However, notice there is no "await" associated with the Task itself. Therefore the function will not wait for the Task to complete. The Task is providing a window into structured concurrency, but the Task itself is unstructured.

An advantage of structured concurrency is the ability to wait for asynchronous code to complete.  However, in unstructured concurrency, waiting for completion is more complex.  It is possible to observe the completion of a Task. In structured concurrency, a Task can be captured and awaited:

class MyClass {
	func doTheAsyncThing() async {
		let task = Task {
			print("working hard!")
		}
		_ = await task.result
	}
}

In unstructured concurrency, a Task can be captured, but not awaited within the same function:

class AnotherClass {
	var tasks: [Task<(), Never>] = []

	func doTheThing() {
		tasks.append(Task {
			print("working hard!")
		})
	}
}

Notice that the task returns a Result type. However, to access the result, the task must be awaited, which requires a task hierarchy.

While necessary, tasks also present a few problems. Consider the call site for the doTheThing() function:

anotherClass.doTheThing()

There is nothing wrong with the above call. It works! However, remember the goal of local reasoning? Nothing in that call site suggests that the function might be asynchronous. The only way to know whether that function is asynchronous and thread-safe is to look at the implementation of doTheThing(). This could lead to unexpected problems.

For example, if you are converting an app to structured concurrency and you have the following function:

func neededFunction() {
	// do stuff here
	someClass.doSomethingImportant()
	// finish
}

If you convert someClass into an actor, you will now have to update this function:

func neededFunction() async {
	// do stuff here
	await someActor.doSomethingImportant()
	// finish
}

Now, all callers of this function must now adopt "async" as well:

func someCallingFunction() async {
	await myClass.neededFunction()
}

However, it is likely that at some point, a root Task object must be set:

func cannotBeAsyncFunction() {
	Task {
		await myClass.neededFunction()
	}
}

So, the functionality of cannotBeAsyncFunction() has changed from a fully synchronous function to an asynchronous function without any change to the function signature. This breaks local reasoning. While this can be okay or even required in many contexts, using a Task in this situation can lead to unexpected behaviors.

For example, the cannotBeAsyncFunction() could be called from an class init function:

class MyOtherClass {
	init(aClass: AClass) {
		aClass.cannotBeAsyncFunction()
	}
}

In the above, the init process calls our cannotBeAsyncFunction(). The call appears synchronous, but it is not. If an unstructured asynchronous function is called in an init function, the object might not be fully configured by the end of the init because of the hidden task. The owner of the object might then assume the object is ready and begins making calls, which could lead to a range of problems including race conditions. Once problems begin to appear, however, finding these problem will be difficult because local reasoning is broken.

It is important to observe, however, that init functions can also adopt "async". In that case, awaitable calls will not present the same issues as unstructured tasks because the init function will not return until the tasks are complete.

Strategies

It is clear that unstructured tasks, while necessary, are not ideal and their use should be limited. Developing strategies and design patterns to limit unstructured concurrency and mitigate possible side effects is important for consistency and understandability. The following represents possible approaches to handling unstructured concurrency.

1. Actors

Actors (standard or global) creates the root task for you and force the use of async where required.  Furthermore, global actors provide a tool to group objects on the same actor: that is, all work is done sequentially across member types and provides some of the benefits of actor types. Since all the types are on the same actor, they do not have to use concurrency to communicate. This approach is particularly useful for UI-related classes grouped under the provided global actor @MainActor. For example, a common area where types must conform to non-structured concurrency protocols and subclasses is in UI code, such as MVC, MVVM, MVCP related subclasses, and delegates.

2. Adopt "async"

If you need asynchronous calls, the best solution is to simply mark a function as "async". This provides all of the benefits of structured concurrency. However, this would require the ability to change a function's signature which may be limited by protocol conformance. Since such a structured concurrency approach ultimately requires a parent task to function, adopting "async" might push the problem of unstructured concurrency elsewhere. However, careful placement of the Task may provide benefits including understandability and testability for not only the local code but the application as a whole.

3. Returning Task

Since tasks in Swift are types, functions can return tasks. If it's possible to modify the function signature, yet the function still cannot use structured concurrency, then creating and returning a Task in a function might be a good choice. For example:

@discardableResult
func doTheThing() -> Task<Void, Never> {
	Task {
		print("Done")
	}
}

This function both creates and returns a Task. Since Task is in the function signature, the function is clearly asynchronous. The @discardableResult allows the caller to ignore this return value if unneeded. However, if the caller is from a structured context, the Task can be awaited:

// unstructured context
myClass.doTheThing()


// structured context
let task = myClass.doTheThing()
await task.result

4. Completion Handlers

If structured concurrency is inappropriate for a particular function, using completion handlers might be an alternative to returning a Task. In general, structured concurrency provides a way to get rid of completion handlers in favor of simpler "async" calls. On the other hand, an appropriate step in adopting structured concurrency is leaving an existing completion handler-based function in place which would then call a new structured concurrency version of the function.

func unstructuredFunction(completion: (() -> Void)?) {
	Task {
		await stucturedFunction()
		completion?()
	}
}

While calling the structuredFunction() directly would be preferred, this approach clearly states that the function is asynchronous. If the completion handler is used, then the caller can define activity in reaction to the work in the Task. However, using completion handlers this way moves where the unstructured concurrency begins. 

5. Task Capture

Since a Task is a type, it can be referenced and stored. When a Task is created, the operation is launched, but the result of the task is not necessarily awaited. Indeed, awaiting a task requires a stuctured concurrency context. However, capturing tasks can provide avenues to cancel the tasks and obtain the results. For example:

@objc class MyTableViewDelegate: NSObject, UITableViewDelegate {
	var selectedRowTask: Task<Void, Never>?

	func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
		selectedRowTask?.cancel()
		selectedRowTask = Task {
			// do some work
		}
	}
}

In the above example, we capture the task that's spun up by the didSelectRowAt delegate method. The task is captured in a property when it is created in the delegate method. Holding onto the task allows us to cancel the task if it's not complete by the time the user selects another row. Keep in mind that the delegate method cannot access the result of the Task. To access the result, the Task must be awaited, but the delegate method is not "async" and therefore cannot await the result. Captured tasks can be a powerful way to track and cancel tasks in both unstructured and structured concurrency contexts.

6. Just a Task

If the above approaches are not appropriate, it is fine to use a Task as in the doTheThing() example above. Indeed, conforming to protocols that are not async will likely require this approach. If a chance exists that the Task may need cancelling or the result of a Task is required, then the Task capture approach is more appropriate.

Unit testing

Unit testing with structured concurrency is relatively easy to write and understand. With asynchronous calls awaitable, no special infrastructure is required to wait for the asynchronous code to complete. Unstructured concurrency, on the other hand, takes more effort.

Take, for example, the following class:

class MyClass {
	var value: Int = 0

	func myFunction() {
		Task {
			await myAsyncFunction()
		}
	}

	func myAsyncFunction() async {
		value += 1
	}
}

A unit test for myFunction() might look like this:

func test_myFunction_givenCalled_thenValueUpdated() {
	let expectedResult: Int = 1
	testObject.myFunction()
	XCTAssertEqual(testObject.value, expectedResult)
}

This test would likely pass... most of the time. The contents of myAsyncFunction() take little time to execute and typically finishes before the assert. If we increase the duration of the function:

func myAsyncFunction() async {
	sleep(1)
	value += 1
}

The test will now fail consistently. The task in myFunction() will not complete until sometime after the function returns. The longer the function takes, the more likely the assert will be called before the task is fully complete. This type of problem is nothing new; it is common problem in asynchronous programming.

Using the returning Task stratagy, our function becomes:

func myFunction() -> Task<Void, Never> {
	Task {
		await myAsyncFunction()
	}
}

// Test function
func test_myFunction_givenCalled_thenValueUpdated() async {
	let expectedResult: Int = 1
	let task = testObject.myFunction()
	await task.value
	XCTAssertEqual(testObject.value, expectedResult)
}

With the above implementation, we can now use the function in a structured context within the test that makes waiting for the result simple.

A similar implementation could be used with a completion handler:

func myFunction(completion: (() -> Void)?){
	Task {
		await myAsyncFunction()
		completion?()
	}
}

// Test function
func test_myFunction_thenValueUpdated() async {
	let expectedResult: Int = 1
	let expectation = XCTestExpectation(description: "Wait for task")
	testObject.myFunction {
		expectation.fulfill()
	}
	wait(for: [expectation], timeout: 5)
	XCTAssertEqual(testObject.value, expectedResult)
}

The expectation-based test is currently commonly used with Dispatches is iOS and macOS. However, this approach is the most complex solution thus far.

If the function signature is set by a third-party, such as an external delegate or library protocol, then reliably waiting for  task completion is difficult. However, a model for handling this situation has existed within the Swift community. GDC is swift has many of the same difficulties when it comes to testing. However, creating custom Dispatch launchers with dependency injection provides a great way to control flow (for example, see this article). Creating a task launcher is possible:

protocol TaskLaunchable {
	func task<T>(priority: TaskPriority?, operation: @escaping @Sendable () async -> T)
}


class TaskLauncher: TaskLaunchable {

	func task<T>(priority: TaskPriority?, operation: @escaping @Sendable () async -> T) {
		Task(priority: priority, operation: operation)
	}
}

This task launcher simply creates a new task with the given operation and doesn't have a return value (return values will greatly complicate the protocol). The mock would look like:

class MockTaskLauncher: TaskLaunchable {
	struct CapturedTask {
	let priority: TaskPriority?
	let operation: (@Sendable () async -> Any)
	}

	var capturedTasks: [CapturedTask] = []

	func task<T>(priority: TaskPriority?, operation: @escaping @Sendable () async -> T) {
		capturedTasks.append(CapturedTask(priority: priority, operation: operation))
	}

	func completeTasks() async {
		await withTaskGroup(of: Any.self) { taskGroup in
			capturedTasks.map { taskGroup.addTask(priority: $0.priority, operation: $0.operation) }
		}
	}
}

The mock is designed to capture the tasks, store them, and then launch them later in completeTasks(). This approach gives a test the ability to control the tasks and await them.

class TestClass: XCTestCase {
	var testObject: MyClass!
	var mockLauncher = MockTaskLauncher()

	override func setUp() async throws {
		testObject = MyClass(taskLauncher: mockLauncher)
	}


	func test_myFunction_thenValueUpdated() async {
		let expectedResult: Int = 1
		testObject.myFunction()
		await mockLauncher.completeTasks()
		XCTAssertEqual(testObject.value, expectedResult)
	}
}

If you were paying close attention to the example class, you might have noticed that despite the unit testing here, MyClass is susceptible to race conditions. The value property was public, and myFunction() and the property could be called from different threads. If the mutable state is required, perhaps the best solution in the above example is to use an actor instead of a class. 

Lessons learned

Unstructured concurrency is a necessary part of asynchronous code in Swift.  The main purpose of the new unstructured concurrency is to provide windows in the structured concurrency, local reasoning, and safer code.  Yet like all forms of multithreading, it comes with responsibility to ensure code correctness, thread-safety, and code clarity to ensure a project is successful.  With a little care, unstructured concurrency should make apps better.

Technologies