10.
Actors in a Distributed System
Written by Marin Todorov
In the previous chapters, you learned how to run concurrent tasks in parallel on multiple CPU cores. Furthermore, you learned how to use actor types to make concurrency safe. In this last chapter of the book, you’ll cover the advanced topic of distributed actors: actors that run locally as well as in other processes — or even on different machines altogether.
There are multiple reasons you’d want to make use of distributed actors, for example:
- To run code in a child process on the same machine. This way, if a fatal error crashes the child process, your main process will continue working and can even start a new copy of the child.
- To run a process on a remote machine, like a database server. This way, you don’t need to use REST or GraphQL to send and receive data. You simply call methods directly on objects running on the server.
- Finally, you can use a cluster of devices to perform many tasks as an ensemble.
The distributed actors model has been around for some time, and libraries offer actors and distributed actors for many languages. Therefore, this chapter includes only a minimal amount of theory that covers the model in general.
Understanding the State of Distributed Actors in Swift
Distributed actors were part of Swift’s larger set of proposals for modern concurrency. When Swift 5.5 introduced async/await initially, it did have partial experimental support for the distributed language feature, but not all implementations were complete. As the new concurrency features of async/await, tasks, groups and actors improved over a number of minor releases, the distributed actors did not, holistically speaking, land.
At the time of this writing, the latest Swift version is 5.8, and distributed actors have been around for more than a year, but the support for real-world usage still feels like a work-in-progress in some ways:
- The feature is still described as experimental.
- The documentation is somewhat unclear; it contains typos and still looks like a draft version.
- Generally, the guidelines are that actor systems are challenging to build, and developers shouldn’t build them. However, there are yet to be any officially released systems by Apple for developers to use.
- The examples provided officially use a combination of
async/await, classes, locks and notifications instead of leveraging modern concurrency in Swift.
Given all of the above, since it covers an experimental Swift feature, this chapter is also experimental. But it’s fun!
In this chapter, you’ll work on a project that includes an almost completed distributed actor system. You’ll make a few changes to each key part: the network service, the actor system itself, the distributed actor, and the app that puts it all together.
In doing this, you’ll understand how the network layer and the system work, which should give you a better understanding of how to use distributed actors if one day you have access to an official actor system or if you decide to build one yourself.
Work through the chapter with the understanding that the project follows the basics of Apple’s examples and is, therefore, just a sample system for learning purposes.
Great work on making your way through this rather lengthy disclaimer! Now, it’s time to get to it…
Evolving Local to Distributed
You’re already familiar with actors; they isolate state by putting an automatic barrier between the type’s synchronized internal state and when accessing the synchronized scope from “outside”. That means calls from other actors are considered outside access and automatically made asynchronous:
That means this process may take an arbitrary amount of time. In fact, in Chapter 6, “Testing Asynchronous Code”, you had to implement a custom way to time out asynchronous calls that took longer than expected.
For local actors, the compiler transparently serializes calls that access the actor’s state:
But, the compiler can sometimes inject the same logic into the isolation layer. For example, distributed actors take advantage of the isolation layer’s asynchronous nature and allow you to add a transport service there. A transport can relay calls to actor methods to another process, to another machine on the network or to a JSON API available on a web server.
Distributed actors introduce the notion of location transparency, which allows you to work with both local and distributed actors in much the same way. Switching between the two requires minimal code changes.
You can choose any kind of transport because the distributed actor language feature is transport agnostic! The same actor could theoretically partake into a Bluetooth, REST, or websocket-powered actor system.
The transport layer sits between your local copy of the distributed actor and the one running elsewhere:
The diagram above shows an example of a local database actor that transparently forwards database queries to a server on the web. Your UI layer, MyView, uses the actor asynchronously, so it makes no difference if the actor is local or distributed.
To make writing code as straightforward as possible, distributed actors have the following constraints:
- They allow no direct access to data from outside the actor. The model requires async method calls, allowing the transport layer to make a request and wait for a response.
- Distributed actors are uniquely identifiable via a free-form ID property.
- The method parameters and the method return values are automatically serialized for transport by the actor system and therefore need to conform to
Codable. - All properties and method calls are throwing at the point of use to allow for transport failures. In other words, all distributed methods, even those which don’t throw, should be called with
try.
Note: At the point of writing this, there are still wrinkles to iron out in the compiler. For example, forgetting to write a
trywhen calling a non-throwing distributed method may simply hang the build.
Getting Started With SkyNet
In this chapter, you’ll work more on the project from Chapter 7, “Concurrent Code With TaskGroup”.
You won’t start where you left the project at the end of Chapter 7, so make sure to open the starter project provided for this chapter.
You’ll improve the app by adding an actor system that connects to local network devices over Bonjour. This will let you perform more concurrent scans using system resources across the network.
Note: Bonjour is an Apple-developed network service that allows you to connect to devices on your network with no configuration: https://en.wikipedia.org/wiki/Bonjour_(software)
The ultimate goal for this guided exercise is to scale the app’s computing power by connecting more devices to a meshed network. Thus, the name of this chapter’s project is SkyNet.
Connecting to Devices via Bonjour
At the end of Chapter 7, “Concurrent Code With TaskGroup”, you completed the Sky project. The user can start a scan by tapping Engage systems, and the app will concurrently iterate over sectors in satellite imagery and scan them.
Open the starter project for this chapter and meet the SkyNet project.
SkyNet will use Bonjour to discover other iOS devices running the app and talk to them. For example, Bonjour lets you automatically find all the printers, scanners and other devices on your Wi-Fi.
As even basic networking requires a lot of boilerplate, the starter project already includes the bare bones of a functioning transport service over Bonjour.
Open BonjourService.swift from the BonjourTransport folder and peek inside.
BonjourService creates a Bonjour network session, starts an advertiser service that tells other devices about the current system and starts a browser that finds other systems on the network:
In effect, the included code automatically connects all the SkyNet devices to one other.
You’ll work on BonjourService a little later to get a sense of what’s happening in that type.
It’s time to get coding! In this chapter, you’ll work on all of the architectural layers to get a sense of them.
By the time you’re done, SkyNet will be able to take over other devices and run a theoretically unlimited amount of concurrent tasks.
Creating a Distributed Actor
Firstly, you’ll explore the distributed keyword. You prefix an actor, property or method with distributed to indicate that these might be invoked remotely.
Create a new Swift file called ScanActor.swift in the Tasks folder and add an empty distributed actor inside:
import Foundation
import Distributed
distributed actor ScanActor {
typealias ActorSystem = BonjourActorSystem
}
First, you import the Distributed framework. Technically speaking, distributed actors are part of Swift itself and shouldn’t require importing any frameworks. It is, however, tightly coupled with some of the types and protocols found in the Distributed framework, so you often need to import it too.
Another novelty in the code above is the distributed keyword preceding actor. Adding that new keyword turns an actor into a distributed actor; it imposes the compile-time constraints we covered earlier, adds some magic properties and methods and adds distributed behavior at runtime.
Finally, you set the distributed actor’s system type to BonjourActorSystem. This is the actor system you’ll use in this project, and you’ll work on it in the next chapter section.
For now, add some code to the empty ScanActor type; insert:
private let nameValue: String
init(name: String, actorSystem: ActorSystem) {
self.nameValue = name
self.actorSystem = actorSystem
}
You add a name to the actor and set it upon initialization. This name will be the uniquely identifying ID for that actor.
You also initialize the actorSystem property, which is automagically added to the type behind the scenes for you.
Next, you’ll add some remotely accessible states. Insert the following properties in ScanActor:
distributed var name: String {
nameValue
}
private var countValue = 0
distributed var count: Int {
countValue
}
name and count are distributed properties, meaning you can access them remotely. The compiler imposes the restriction of computing these properties. This is why both of them proxy a private property in the code above.
count is the number of tasks the actor has currently committed to executing. You’ll track this count so you can balance how much load each of the devices in the network takes and prevent some devices from overcommitting while others remain idle.
Finally, add these two methods to balance and run tasks:
distributed func commit() {
countValue += 1
}
distributed func run(_ task: ScanTask) async throws -> Data {
defer {
countValue -= 1
}
return try await task.run()
}
Similarly to the rest of the type’s interface, these two methods are prefixed with distributed, making them available for remote execution. When a remote system calls commit(), count increases and “reserves” some of the actor’s capacity.
run(_) takes a task parameter and executes it, much like the local app model currently runs tasks.
Note: If you follow a similar pattern in a production system, keep in mind that due to network failure, you might not balance the calls to
commit()andrun(_), so you’ll need more checks to verify the actor isn’t committed but idling.
Your distributed actor is now complete. You learned how to expose state and remotely available methods. Before moving on to working on the actor system itself, you’ll add some final touches to the Bonjour service.
Tracking Devices on the Local Network
The Bonjour service plays two key roles in network discovery. On one side, it “advertises” the current device on the network; on another, it listens for announcements from other devices. This way, effectively, each device tracks all other devices on the network:
Open BonjourService.swift and have a look inside — whoa, there’s plenty of code in there already… Most of the methods already in place are callbacks allowing Bonjour to inform you about devices getting on and off the network, errors, and so on.
To get a sense of the service, you’ll alter some of the methods called when a device connects or disconnects from the local network.
Scroll down to session(_:peer:didChange:) and append this code at the bottom of the method:
if [.connected, .notConnected].contains(state) {
actorSystem?.connectivityChangedFor(
deviceName: peerID.displayName,
to: state == .connected
)
}
This method callback receives a device name (peerID) and its status, which could be notConnected, connecting, or connected.
If a device has disconnected or successfully connected, you’d like to notify the system so it can add or remove it from the list of available actors.
connectivityChangedFor(deviceName:to:) is a custom method on the BonjourActorSystem actor system, which is currently empty. You will implement it in a moment.
Before that, find the method called browser(_:lostPeer:) and add the following code to it:
actorSystem?.connectivityChangedFor(
deviceName: peerID.displayName,
to: false
)
This is the method that the bonjour “browser” calls if a device disappears from the network. In this case, you call the same method as before, but you set the connected status directly to false this time.
As you can see, the Bonjour service doesn’t “understand” much about what your app is doing, it’s mostly a system of callbacks that let you react to changes on the network.
Next, you’ll work on the actor system itself.
Managing Actors in a Distributed System
An actor system may take on many tasks; using or managing a data transport such as Bluetooth, encode and decode invocations across the wire, manage a list of available remote actors, receive remote requests and many others.
Generally speaking, a system should be able to at least send requests and receive responses to allow you to access remote actor properties or methods:
Essentially, the actor system’s main purpose is to automate all the steps you would usually implement manually when adding networking to your app. It tracks connected actors that your local device can reach out to, and when they need to send and receive messages, it encodes all the data for transporting and decode it at the receiving side.
If you have a well-designed system, you’ll ideally never bother making a network call manually again.
In this chapter, you will only delve into some of the details of implementing a system; you’ll only scratch the surface by completing a couple of missing features in the starter code. Feel free to read through the rest and learn further from Apple’s Distributed framework documentation.
Firstly, your system needs to track the local actor for the current device, so it knows when to retry tasks that fail to execute remotely.
Open BonjourActorSystem.swift and add this new property in BonjourActorSystem:
var localActor: ScanActor!
Then, append the code to initialize the local actor at the end of init(localName: String):
self.localActor = ScanActor(
name: localName,
actorSystem: self
)
withActors { $0[localActor.id] = localActor }
The first line of code creates a ScanActor instance with the local device name as an ID. Then, you use the withActors(_) function that gives you thread-safe access to the system’s actors dictionary and adds the local one.
Note:
withActors(_)uses a lock to guarantee safe access to the list of actors, similar to Apple’s examples.
Now that you have the local actor safe and sound, you can move on to track the rest of the actors in the system. Scroll down to connectivityChangedFor(deviceName:to:) and add the options to add and remove actors.
First, append:
if connected {
if let remoteActor = try? ScanActor
.resolve(id: name, using: self) {
withActors { $0[remoteActor.id] = remoteActor }
}
}
resolve(id:using:) is another method automagically added to your actors by the compiler without you having to adopt a protocol or implement yourself. It’s a factory method that, given an identifier and an actor system, creates an instance of your actor type.
When the device has connected, and you have resolved the remote actor, you add it to the list of system actors.
Now, add at the bottom of the method:
else {
withActors { $0.removeValue(forKey: name) }
NotificationCenter.default.post(
name: .disconnected, object: name
)
}
Besides removing the remote actor for the given ID, you also send a notification. The actor system listens for .disconnect notifications to cancel network requests if the receiver device has just disconnected.
Now it’s time to finally run the app. First, build and run the project in a simulator of your choice.
There’s a good chance that the first thing you’ll notice is a macOS system alert that asks permission for Sky.app to talk to other devices over the local network:
If you see this dialog, click Allow to give SkyNet access to the network; that will take you to the app’s main screen:
Note: If you’re running on a device, you might see the alert on your device instead.
You don’t see much difference from how the app looked at the end of Chapter 7, “Concurrent Code With TaskGroup”, do you?
Of course not, at this point, SkyNet is only running on a single device. This is not SkyNet, it’s just the Sky project. Tap the Engage systems button; you’ll see that the app works just as it did before.
Luckily, Xcode allows you to start multiple iOS simulators simultaneously! While you’re running the project, select a different simulator from the device list next to the scheme selector:
Once you start the app on a second or a third simulator, Xcode will stop the app on the previously running simulator. You’ll need to manually restart SkyNet on the simulator(s) so you can have a few copies of the app working together.
Note: If you have an older Mac, it might not be happy running multiple simulators simultaneously, and it might not be able to devote multiple cores to multiple simulators. In that case, you’ll need to run at least one copy of the app on a device to see the best results.
As soon as you launch the project on your additional device, a connectivity icon will appear in the top-right corner:
If you tap the icon, you’ll see a list of all the connected devices.
Note that the connectivity framework is quite verbose. The output console fills up quickly with messages along the lines of:
[MCNearbyDiscoveryPeerConnection] Read failed.
[MCNearbyDiscoveryPeerConnection] Stream error occurred: Code=54 "Connection reset by peer"
Connectivity: iPhone SE (3rd generation) true
[GCKSession] Failed to send a DTLS packet with 117 bytes; sendmsg error: No route to host (65).
[GCKSession] Something is terribly wrong; no clist for remoteID [1104778395] channelID [-1].
...
For the most part, you can ignore these messages. They make looking for your own logs a little difficult, but the connectivity framework usually quiets down after a few moments.
Before wrapping up this section, you’ll add one more method to find the first available actor whenever you need to execute a task remotely.
Since the system keeps a list of all actors and each actor keeps track of the tasks it’s currently committed to running, it should be simple enough to loop over the list and find the first actor with some availability.
Add the new method to BonjourActorSystem:
func firstAvailableActor() async throws
-> ScanActor {
while true {
}
fatalError("Will never execute")
}
This method will return an actor or throw and, therefore, never reach that one last line, throwing a fatal error. You do need it to satisfy the compiler’s desire that all code paths correctly wrap up the method execution.
The while loop ensures you’ll keep trying to find an actor until there is an available one.
Next, insert the following code inside the while to loop over the system’s actors:
for nextID in withActors(\.keys) {
}
try await Task.sleep(for: .milliseconds(100))
This code takes the identifiers of all actors and, in the for body, will probe each in turn to find the first that has free capacity.
Just in case you loop over all actors and none have the capacity, you wait for a moment, about 100 milliseconds, before restarting the loop.
Finally, insert inside the code to inspect each actor inside the for loop:
guard let nextActor = try? ScanActor
.resolve(id: nextID, using: self),
await nextActor.count < 4 else {
continue
}
do {
try await nextActor.commit()
return nextActor
} catch { }
As you did previously, you try to resolve the actor, and if that succeeds, you verify that the actor hasn’t already committed to executing four or more tasks.
Ultimately, if you find a match, you call commit() on the actor and return it as a result. This “reserves” some of the actor’s capacity, as per your code in ScanActor.commit(), and you’re ready to send a scan task to the returned actor.
Don’t get spooked by the empty catch in the code block. Since commit() itself is not throwing, the only way for that call to throw is if the network fails. In that case, you just swallow the error and let the for loop continue so you can try the next actor.
With these last changes, all of the chess pieces are set on the board, and with a few swift moves, you need to swap the old code with the new code to get everything working.
Using a System Instead of a Single Actor
In this section, you’ll leave behind the service and the actor system and move on to updating the app model.
Open ScanModel.swift and scroll down to the worker(number:) method.
You likely remember from Chapter 7, “Concurrent Code With TaskGroup” that worker(number:) takes the input for a scan task, creates it and runs it.
In this section, you’ll adjust the method to forward the task to an actor instead of always running it locally.
Firstly, add a second parameter to the method called actor like so:
func worker(number: Int, actor: ScanActor) async
-> Result<Data, Error> {
Then replace the line result = try .success(await task.run()) with:
result = try .success(await actor.run(task))
Nice! This is all you need to do to move the work from your local system to execute across devices over the local network potentially. You do, however, face an error right now, and there’s no time like the present to take care of it.
Since runAllTasks() will work quite differently than before, go ahead and remove all the code inside that method. Next, you’ll re-add some of the code and sprinkle in some new goodness as well.
First of all, re-add the basic group code:
started = Date()
try await withThrowingTaskGroup(
of: Result<Data, Error>.self
) { [unowned self] group in
}
You’ll insert code inside the group closure in the rest of this section.
You’ll add as many tasks to the group as the total amount is set to. However, and this is important, since firstAvailableActor() suspends until it finds a free actor, you’ll never create more tasks than your system can currently handle.
Insert this code inside the group closure from above:
for number in 0 ..< total {
let actor = try await
actorSystem.firstAvailableActor()
group.addTask {
return await self.worker(
number: number,
actor: actor
)
}
}
For each planned task, you find the first available actor. While the local system has capacity, that’s always the local actor. Then you add a concurrent task that executes the work on the given actor.
Next, just as before, you’ll loop over the completed tasks and print the results. Append, still in the group body:
for try await result in group {
switch result {
case .success(let result):
print("Completed: \(result)")
case .failure(let error):
print("Failed: \(error.localizedDescription)")
}
}
And finally, once you have processed all the results, you should reset the stats. Append to the group closure directly after the last code inserted:
await MainActor.run {
completed = 0
countPerSecond = 0
scheduled = 0
counted = 0
}
print("Done.")
Build and run the project on two or more simulators and tap Engage Systems in one of the running apps while having the app running in all of them. This should start the system and leverage the actors in each device. Ultimately, you should see reduced duration when the work completes:
A single device needed just over 20 seconds to complete the work but running SkyNet in two simulators gets the work done in about half the time!
Now, imagine how powerful your iPhone could become if it could spawn an actor system using all the idle CPUs on your home WIFI — your vacuum robot, smart watches, all the phones, your routers and printers! You just have to check which of those devices run Swift and get busy coding…
Updating the UI to Showcase Collaborative Work
While it’s pretty impressive to make simulators join SkyNet and work together, presentation is important, too. Right now, collaborating on the search for alien life seems a little…unspectacular.
Before moving on to the last few heavy-duty tasks in this chapter, you’ll include a little animation onscreen when devices connect and start a joint scan session. The starter project already includes the animation, so you just need to set a flag to true when performing joint work.
Open ScanModel.swift and add a didSet handler to the scheduled property, so it looks like this:
@MainActor @Published var scheduled = 0 {
didSet {
Task {
isCollaborating = scheduled > 0
&& actorSystem.actorCount > 1
}
}
}
The starter project UI code will pick up isCollaborating’s value change and then play an animation onscreen while the property is set to true.
Build and run on all the iOS Simulators you’re currently testing on. Then, tap Engage systems on one of the devices and enjoy the cool logo animation.
Your UI has really come alive! A connection indicator shows when devices connect, and a cool animation tells the user when the app is doing heavy work across nodes in SkyNet.
Now it’s time to make things really neat. Adding a bit of code to make those other remote devices display an animation when they run errands for the actor system initiating the work.
To reassure yourself that it is actually working on remote devices, you’ll also add some logging messages.
Open ScanActor.swift and append to commit():
NotificationCenter.default.post(
name: .localTaskUpdate,
object: nil,
userInfo: [Notification.taskStatusKey: "Committed"]
)
The .localTaskUpdate notification tells the model that something has changed relating to local tasks, and the UI should be updated. The user info dictionary of the notification contains a status message which’ll be displayed at the bottom of the screen.
The other method where you change the status of running tasks is run(_:), so move over to that method.
At the beginning of run(_:), create an empty dictionary for the notification:
var info: [String: Any] = [:]
Now, add the following code to the end of the defer block:
NotificationCenter.default.post(
name: .localTaskUpdate,
object: nil,
userInfo: info
)
This will post the notification once the task is complete.
Then, populate the dictionary based on the results of the task. Replace the return statement with the following code:
do {
let data = try await task.run()
info[Notification.taskStatusKey] = "Task \(task.input) Completed"
return data
} catch {
info[Notification.taskStatusKey] = "Task \(task.input) Failed"
throw error
}
This code will add the correct message to the user info dictionary.
Finally, you need to listen for the .localTaskUpdate notification and update the view accordingly.
Open ScanModel.swift and append a second task to the body of systemConnectivityHandler():
Task {
for await notification in NotificationCenter.default
.notifications(named: .localTaskUpdate) {
let status = notification.taskStatus
let runningTasksCount = try await actorSystem.localActor.count
Task { @MainActor in
if scheduled == 0 {
isCollaborating = runningTasksCount > 0
}
localTasksCompleted.append(status)
}
}
}
In this code, you asynchronously loop over any .localTaskUpdate notifications. On each update, you check if there aren’t locally scheduled tasks, but the actor is running tasks — when this condition is met, it means the actor is running tasks sent over the network. You also add the status message to the log.
Note: At the time of writing, Xcode 14.2 incorrectly shows a warning that you’re using
tryto access a non-throwing property in that code. Following the warning message and removing thetrywould hang the compiler.
Like you did previously, make sure to build and run the project on two or more simulators. Then start all the copies of SkyNet and tap Engage Systems in one of them. You’ll see the devices that receive a task to run their logo animation and confirmation of which device is running which task will appear in the logs.
Retrying Failed Tasks
While it might seem like you’re finished with this chapter, there’s one final task to take care of.
You’ve probably noticed that thanks to the code you added in Chapter 7, “Concurrent Code With TaskGroup”, you skip over any failed tasks and never return to them.
The code in ScanTask.swift calls UnreliableAPI.action(failingEvery:) to fail every tenth task so you can verify your error-handling skills. You catch the error when the local system fails and print a log message. When one of the remote systems fails to run the task, your request simply times out.
To wrap up SkyNet, you’ll add new logic to retry failed tasks. After all, you don’t want to miss any signs of alien life because one of the scans failed on the first try, do you?
Open ScanModel.swift and scroll to runAllTasks(). Here, you’ll run your concurrent task group and expect each task to return a Result<String, Error>. Result helps you gracefully handle errors. You’ll use the Result.failure case to print the error message to the output.
To retry failed tasks, you won’t need the error; however, you will need the task itself. To handle that, you’ll add your own custom error type. Add the following anywhere inside ScanModel:
struct ScanTaskError: Error {
let underlyingError: Error
let task: ScanTask
}
This is especially useful for remotely executed tasks, which can fail for many reasons: shaky connections, timeouts, etc.
Back in runAllTasks(), replace Result<Data, Error>.self with Result<Data, ScanTaskError>.self. This causes a few compile errors.
Scroll to worker(number:actor:) and change its return type from Result<Data, Error> to:
Result<Data, ScanTaskError>
Then, towards the middle of the method body, replace the line that defines the result variable with:
let result: Result<Data, ScanTaskError>
Finally, in that same method, replace result = .failure(error) with:
result = .failure(.init(
underlyingError: error,
task: task
))
That takes care of the updates in worker(...).
Next, scroll to your current error handling code in runAllTasks():
case .failure(let error):
print("Failed: \(error.localizedDescription)")
Here, you’ll get the failed task and schedule it to execute on the local system once again. Replace the current case with:
case .failure(let error):
group.addTask(priority: .high) {
print("Re-run task: \(error.task.input).")
print("Failed with: \(error.underlyingError)")
return await self.worker(
number: error.task.input,
actor: self.actorSystem.localActor
)
}
This addition will ensure that, if one task fails, whether remote or local, you add a new task to the group and retry the scan on the local system.
From experience, the best way to handle retrying tasks involves keeping track of how many times you’ve tried a task. It’s annoying when a task always fails, but you keep retrying it indefinitely.
Luckily, SkyNet will ultimately complete all the tasks that failed initially so that the current retrying logic will suffice.
Build and run. Look at the output console:
Completed: 11
Completed: 9 by Marin's iPod
Re-run task: 16. Failed with: UnreliableAPI.action(failingEvery:) failed. <---
Completed: 12
Completed: 13 by Ted's iPhone
Completed: 14 by Ted's iPhone
Completed: 17
Completed: 15
Completed: 18
Completed: 16
Re-run task: 19. Failed with: UnreliableAPI.action(failingEvery:) failed. <---
Completed: 19
Done.
You see, some of the remote tasks failed, timed out, and the app ran them locally once again to complete the full scan.
You also see that the app happily reports that it worked through the full batch of tasks:
With that last addition, your work here is truly done!
Congratulations on completing this final book project. There was a lot to take care of: an actor system, networking service, distributed actors, new model logic and plenty more!
Key Points
- Systems of distributed actors communicate over a transport layer that can use many different underlying services: local network, Bonjour, REST service, web socket and more.
- Thanks to location transparency, regardless of whether the actor method calls are relayed to another process or a different machine, you use a simple
awaitcall at the point of use. - In a system of distributed actors, each needs a unique address to relay requests reliably to the target peer and the responses delivered back to the original actor.
- Using distributed actors can fail for a myriad of reasons, so asynchronous error handling plays an even more significant role in such apps.
- Last but not least, a distributed app uses the same APIs as a local app:
async/await, task groups and actors. The actor model allows for encapsulating the transport layer and keeping its implementation hidden from the API consumers.
Where to Go From Here?
Completing this book is no small feat!
You started in Chapter 1, “Why Modern Swift Concurrency?” by writing some of your first async/await code and some pesky asynchronous tasks. Not long after that, you were already juggling tasks, continuations and asynchronous sequences, each furthering your understanding of the new concurrency model.
In the book’s second half, you moved forward with more advanced topics like testing, dynamic concurrency and — wait for it — actors. These ensure you’re as concurrent as possible while avoiding some of the usual multithreading problems like data races and crashes.
By now, modern Swift concurrency should hold no secrets for you. If you have thoughts, questions or ideas you’d like to share with this book’s readers, be sure to let us know in the book forums.
I want to leave you with this old proverb, which the Spider-Man comic books popularized. I think it’s fitting for the last page of the book, given your newly acquired, vast knowledge of concurrent programming:
“With great power comes great responsibility.”