Discover distributed actors — an extension of Swift's actor model that simplifies development of distributed systems. We'll explore how distributed actor isolation and location transparency can help you avoid the accidental complexity of networking, serialization, and other transport concerns when working with distributed apps and systems.
To get the most out of this session, watch “Protect mutable state with Swift actors” from WWDC21.
♪ instrumental hip hop music ♪ ♪ Hello! My name is Konrad and I'm an engineer on the Swift team. Welcome to "Meet distributed actors in Swift." In this session, we'll learn how you can take your Swift concurrency-based apps beyond a single process. Swift actors were designed to protect you from low-level data races in the same process. They do this by compile time enforced actor isolation checks. Distributed actors refine the same conceptual actor model and extend it to multiple processes, such as multiple devices or servers in a cluster. So just in case you are not yet familiar with Swift actors, we recommend you first watch the "Protect mutable state with Swift actors" session from WWDC 2021. The app we'll work on during this session is a tic-tac-toe-style game I've been developing recently: Tic Tac Fish! The fun idea here is that you can select a team you're playing for, which then corresponds to emojis that will be used to mark your moves as you play the game. Then, as you mark your moves on the field, emojis from your team will be placed on the field until one of the players wins. Right now, I have only implemented an offline mode, where I can play against a bot opponent, but I'd like to introduce a few multiplayer modes to take my app to the next level. I'm already using actors in this app to manage concurrency and model players involved in the game. Let's see what it takes to migrate those player actors to different processes, and how distributed actors can help me do this. Before we jump to the code, let us take a step back and visualize why actors are so well-suited for building concurrent and distributed applications. Throughout WWDC sessions, you may hear us use the term "sea of concurrency" when talking about actors. This is because it's a great mental model to think about them. Each actor is its own island in the sea of concurrency, and instead of accessing each other's islands directly, they exchange messages between them. In Swift, sending those messages across islands is implemented as asynchronous method calls and async/await. This, in combination with actor state isolation, allows the compiler to guarantee that once an actor-based program compiles, it is free from low-level data races. Now let us now take this same conceptual model and apply it to our game, reimagined as a distributed system. We can think of each device, node in a cluster, or process of an operating system as if it were an independent sea of concurrency, here marked as the smaller dark rectangles. Within those, we were able to synchronize information rather easily since they were still sharing the same memory space. And while the same concept of message passing works perfectly well for concurrency as well as distribution, there are a few more restrictions distribution needs to put into place for it all to work. This is where distributed actors come into the picture. By using distributed actors, we're able to establish a channel between two processes and send messages between them. In other words, if Swift actors were islands in the sea of concurrency, distributed actors are the same in the vast sea of distributed systems. From a programming model, not much has really changed -- actors still isolate their state and still can only communicate using asynchronous messages. We could even have more distributed actors in the same process, and for all intents and purposes, they are as useful as local actors, with the difference that they're also ready to participate in remote interactions whenever necessary. This ability to be potentially remote without having to change how we interact with such distributed actor is called "location transparency." This means that regardless where a distributed actor is located, we can interact with it the same way. This is not only fantastic for testing, as we execute the same logic in local actors, but also enables us to transparently move our actors to wherever they should be located, without having to change their implementation. OK, I think we're ready to look at some code and convert our first actor to a distributed actor. First, let's have a quick look at the general game UI, and how it interacts with my player actors. The view is a pretty standard SwiftUI code, and I have a few text and button elements representing the game field. As a user clicks a GameCell, we ask the player actor to generate a move and update the view models that power the UI. Thanks to Swift concurrency, all those updates are thread-safe and well-behaved. Currently, the actor representing user input is implemented as an offline player. Let's have a look at it next. This actor encapsulates some state that allows it to generate game moves. Specifically, it needs to track how many moves it already made and what team it is playing for. Because each team has a number of emojis to chose from for each move, use the number of moves made to select the emoji character ID. I also need to update the model once a move has been created. The model is a MainActor isolated class, so mutations of it are thread-safe. I do need to use "await" as I make the userMadeMove call, though. Lastly, the offline player also declares a method that is going to be called whenever the opponent has made a move. The only thing we need to do here is update the view model, which will make the game field active again so the human player can select their move, and the cycle continues until the game ends. Our bot player is also represented using an actor. Its implementation is actually quite a bit simpler than the offline player since it does not have to worry about updating the view model; it just keeps track of the GameState and generates game moves. Since the bot player is a bit simpler, I think it's a good one to start our conversion to distributed actors. OK, I think we're ready to look at some code and convert our first actor to a distributed actor. The first step towards our distributed Tic Tac Fish game will be to convert the BotPlayer type to a distributed actor, while still only using it locally. Let's open Xcode and see how we can do that. In order to declare a distributed actor, I'll need to import the new distributed module, which we introduced in Swift 5.7. This module contains all the necessary types I'm going to need to declare and use distributed actors. I can now add the distributed keyword in front of the BotPlayer actor declaration, like this. This will cause the actor to automatically conform to the DistributedActor protocol, and enable a number of additional compile time checks. Let's see what kind of errors the compiler will ask us to fix now. The compiler helpfully tells us that the BotPlayer does not declare an ActorSystem it can be used with. As distributed actors always belong to some distributed actor system, which handles all the serialization and networking necessary to perform remote calls; we need to declare what type of actor system this actor is intended to be used with. Since, for now, my only goal is to have the bot player pass all the distributed isolation checks, without actually running it on a remote host, I can use the LocalTestingDistributedActor System that comes with the Distributed module. I can tell the compiler about the actor system we're going to use by either declaring a module-wide DefaultDistributedActorSystem typealias, or an ActorSystem typealias in the body of the specific actor. The latter bit is a bit more specific, so let's go with that.
The next error is about the "id" property, that I had previously implemented manually in order to conform to the Identifiable protocol that both my player actors need to conform to. The error now says that the ID property cannot be defined explicitly as it conflicts with a distributed actor synthesized property. IDs are a crucial piece of distributed actors. They are used to uniquely identify an actor in the entire distributed actor system that it is part of. They are assigned by the distributed actor system as the actor is initialized, and later managed by that system. As such, we cannot declare or assign the ID property manually -- the actor system will be doing this for us. In other words, I can simply leave it to the actor system and remove my manually declared ID property. The last error we need to deal with here is the distributed actor's initializer. The compiler says that the actorSystem property has not been initialized before use. This is another compiler synthesized property that is part of every distributed actor. Not only do we need to declare the type of actor system we want to use, but we also need to initialize the synthesized actorSystem property with some concrete actor system. Generally, the right thing to do here is to accept an actor system in the initializer, and pass it through to the property. This way, I could pass in a different actor system implementation in my tests to facilitate easy unit testing. We'll also have to pass an instance whenever we create a new bot player, so let's do this now.
Awesome! We're done with all the declaration side errors. But there's still some call-site errors we need to address though. It seems that only distributed methods can be called on potentially remote distributed actors. This is similar to annotating only some actors in your system as distributed actors. Not every method on a distributed actor is necessarily designed to be called remotely. They can have small helper functions, or functions which assume the caller has already been authenticated. This is why Swift asks you to be explicit about the distributed API surface, you want to expose to remote callers. Thankfully, this is also easily fixed by just adding the distributed keyword to those functions. As both makeMove and opponentMoved methods are intended to be called remotely, let me add the distributed keyword to them to both of them.
OK! With that, there's only one last thing we need to take care of. As distributed method calls can cross network boundaries, we need to ensure that all of their parameters and return values conform to the serialization requirement of the actor system. In our case, the actor system is using Codable, Swift's native serialization mechanism. Specifically, the compiler tells us that, "Result type GameMove does not conform to the serialization requirement Codable." Let me have a quick look at the GameMove type. Luckily, it seems that it's a clean little data type that I can easily make Codable by just adding the necessary conformance. The Swift compiler will synthesize the necessary Codable implementation for me. And with that, we're done! I can check the game runs as expected.
OK, a point for team fish! And although the bot player still executing on the same local device, we have already paved the way for the exciting next step. In this step, we'll actually reap the benefits of the bot player's newly gained location transparency powers. I have already prepared a WebSocket-based sample actor system that we can use for this. By making use of this actor system, we'll be able to move our bot player to a server-side Swift application, and resolve a remote reference to it from our mobile game. As far as the actor is concerned, we only need to change the declared ActorSystem from the LocalTesting DistributedActor System to the SampleWebSocketActorSystem that I prepared for the sample app. The rest of the actor code remains the same. Next, let us resolve a remote bot player reference, rather than creating one locally. It is worth keeping in mind that the terms "local" and "remote" are a matter of perspective when it comes to distributed actors. For every remote reference, there is some corresponding local instance on some other node in the distributed actor system. Creating a local instance of a distributed actor is performed much the same way as any other Swift object: by calling its initializer. Obtaining a remote reference to a distributed actor, however, follows a slightly different pattern. Instead of creating an actor, we will attempt to resolve an actor ID using a concrete actor system. The static resolve method allows us to ask an actor system to attempt to give us an existing actor instance for an actor with that ID or, return a remote reference to an actor identified by it. Actor systems should not perform actual remote lookups when resolving identifiers because as you can see, the resolve method is not asynchronous, and therefore should return quickly and not perform any networking or otherwise blocking operations. If an identity looks valid, and seems to be pointing at a valid remote location, systems shall assume that such actor exists and return a remote reference to it. Keep in mind that at the time of resolving an ID, the actual instance on the remote system may not even exist yet! For example, here we're making up a random identifier for an opponent bot player that should be dedicated to playing a game with us. This bot does not exist yet, but it will be created on the server-side system as the first message designated to this ID is received. Now moving on to a server-side Swift application. Thanks to the sample WebSocket actor system I prepared, implementing that will be a breeze. First, we create the WebSocket actor system in server mode, which makes it bind and listen to the port rather than connect to it. And we have the app wait until the system is terminated. Next, we'll somehow need to handle the pattern of creating actors on demand as we receive messages addressed to IDs that are not yet assigned any actor instances. Generally, the actor system will receive incoming messages, attempt to resolve their recipient IDs in order locate a local distributed actor instance, and then execute a remote call on the located actor. As we just discussed though, our bot player IDs are literally made up, so the system can't possibly know about them and even less so create the right type of actor by itself. Thankfully, our sample actor system implementation has just the right pattern prepared for us: on-demand actor creation. Please note here that this is only a pattern, and not something built in or provided by the distributed actor module. It is, however, a great example of how flexible and powerful actor system implementations can be. A system can offer various patterns and make complex tasks simple to deal with. Using this pattern, the actor system attempts to resolve a local actor for all incoming IDs as usual. However, when it fails to find an existing actor, it attempts to resolveCreateOnDemand. Since we are in control of both our client code making up the IDs and the piece of server code, we can help the actor system out by creating the necessary actors on demand. Since the bot identifiers we have been making up on the client are using some recognizable naming scheme -- like adding tags to the ActorIdentity or just using some recognizable names -- we can detect those IDs and create a new bot opponent for every message that does not have one active yet. We'll only create a new bot player for the first message designated to it, as subsequent remote calls will simply resolve the existing instance. And that's all there is to it! Our server implementation is complete and we can now play a game with our remote bot player. We can run the server from the command line using Swift run, or using Xcode and selecting the server scheme and clicking Run as usual. As we're done making our first move, we ask the bot player to do the same by calling makeMove on the remote player reference we have created. This triggers a resolve in the server-side system. It can't find an existing bot for this ID, so it attempts and succeeds, creating a bot on demand. The bot receives a makeMove call, and replies with the GameMove it generated. That was pretty great already! While we did have to do some up-front work to convert our bot player to a distributed actor, actually moving it to the remote system was pretty straightforward. And we didn't have to deal with any networking or serialization implementation details at all! All the heavy lifting was done for us by the distributed actor system. And while there aren't many hardened feature-complete implementations available just yet, this ease of going distributed is something we're striving for with this feature. Next, let's see how we can build a true multiplayer experience for our game. Our previous example used distributed actors in a client/server scenario, which you may be familiar with already from other apps you worked on. However, distributed actors can also be used in peer-to-peer systems, where there isn't a dedicated server component at all. This matches another idea I had for our game. Sometimes when traveling, you end up in these locations that don't really have great internet, but the local Wi-Fi works great. I'd like to still be able to challenge and play with my friends -- which are connected to the same network -- as I end up in such a situation. I went ahead and implemented another actor system implementation, this time using local networking features offered by Network framework. While we don't dive into the implementation of that actor system in this talk, you can watch "Advances in Networking, Part 2" from WWDC 2019 to learn how you would implement such custom protocol. It is also worth pointing out that access to local network can expose very privacy-sensitive information, so please take care to use it respectfully. Since this time we'll be dealing with already existing distributed actors on other devices, we can no longer just make up the IDs like we did in the previous example. We have to discover the specific actor on the other device we'd like to play a game with. This problem isn't unique to distributed actors, and is generally solved using service discovery mechanisms. However, in the domain of distributed actors, there is a common pattern and style of API actor systems are expected to offer that allows you to stick to strongly-typed APIs throughout all your code. We call it the receptionist pattern, because similar to a hotel, actors need to check in with it in order to become known and available for others to meet. Every actor system has its own receptionist and they can use whatever means most suitable for the underlying transport mechanisms to implement actor discovery. Sometimes this may rely on existing service discovery APIs, and only layer a type-safe API on top of them, or it may implement a gossip-based mechanism, or something else entirely. This, however, is an implementation detail from the perspective of the user of the actor system; all we need to care about is checking in our actor to make it discoverable and look up actors by some tag or type when we need to discover them. Let's have a look at a simple receptionist I have implemented for our SampleLocalNetworkActorSystem. It allows an actor to check in, which enables all receptionists in the distributed actor system to discover it. We can then get a listing of all actors of a specific type and tag as they become available in that system. Let's use this receptionist to discover a concrete opponent actor we'd like to play a game with. Previously, our GameView directly created -- or resolved -- an opponent in its view initializer. We can no longer do this, as we need to asynchronously wait for an opponent to appear on the network. To do this, let me introduce a matchmaking view that will show a "Looking for opponent..." message while we're trying to discover one. As this view appears, we'll kick off the matchmaking. The matchmaking will be done in a new unstructured task in which we'll ask the local actor system's receptionist for a listing of all actors tagged using the opposing team's tag. So if we're playing for team fish, we'll be looking for players from the team rodents, and vice versa. Next, we'll use an async for loop to await incoming opponent actors. As the system discovers a nearby device with an opponent we could play with, this task loop will be resumed. Let's assume the opponent is always ready to play a game and immediately store it in our model and start a game with them. We use a helper function to decide who should make the first move, and finally, tell the opponent that we want to start a game with them. Be sure to return here, in order to break out of the async for loop, as we only need one opponent to be done with our matchmaking task. For this gameplay mode, we do have to change our OfflinePlayer implementation a little. Let's call it LocalNetworkPlayer, and it'll be using the SampleLocalNetworkActorSystem. What's most interesting about it is that the makeMove method of the the actor representing a human player may now be invoked remotely! But making the move is actually the responsibility of a human player. In order to solve this challenge, we introduce a humanSelectedField asynchronous function to our view model. It is powered by a @Published value that is triggered when the human user clicks on one of the fields. As the human player clicks a field, our makeMove function resumes, and we complete the remote call by returning the performed GameMove to the remote caller. And again, that's all there is to it! We had to change the actor implementation a little to handle our true multiplayer game mode, but nothing really changed in the overall design of the system. And most importantly, nothing in our game logic changes was really specific to the fact we'll be using local networking. We discover an opponent and play a game with them by invoking distributed methods on player actors. To demo this game mode, I'll need an opponent to play with. Let's ask my fluffy assistant Caplin the Capybara. I heard he's pretty good at it! OK, he's pretty smart.
He is pretty good at it. Let me try here. Oh, he got me! This time you win, little critter, but we'll play another session. Thanks for your help, Caplin! Last but not least, let me give you an idea of what we can achieve by combining different actor systems. For example, we can use the WebSocket system to register device-hosted actor player actors in a server-side lobby system that will pair them up and act as a proxy for distributed calls between them. We might implement a GameLobby actor, with which device-hosted player actors are able to register themselves. As devices enter the play online mode, they would discover the GameLobby using a receptionist, and call join on it. The GameLobby keeps track of available players and starts a game session when a pair of players has been identified. A game session would act as the driver of the game, polling moves and marking them in the server-stored representation of the game. As the game completes, we can collect results and report back to the lobby. More interestingly though, we can scale this design horizontally. We can of course create more game session actors to serve more games concurrently on a single server, but thanks to distributed actors, we could even create a game session on other nodes in order to load balance the number of concurrent games across a cluster. That is, if only we had a cluster actor system. And, in fact, we do! We open-sourced a feature-rich Cluster Actor system library for you to use in such scenarios. It's implemented using SwiftNIO, and specialized for server-side data-center clustering. It applies advanced techniques for failure detection, and comes with it's own implementation of a cluster-wide receptionist. We encourage you to have a look at it, as it is both an advanced reference implementation of an actor system, and because of its powerful server-side applications. Let's recap what we learned during this session. First, we learned about distributed actors and how we provide additional compiler-assisted actor isolation and serialization checking. We learned how they enable location transparency, and how we can make use of it to free our actors from the necessity of being located in the same process as their caller. We also saw a few actor system implementations in action to get you inspired about what you could build using distributed actors. Distributed actors are only as powerful as the actor systems they are used with. So for your reference, here is a list of actor systems we saw during this session. The local testing system, which ships by default with Swift, and two sample actor systems: a client/server style WebSocket-based one and a local networking-based system. These systems are rather incomplete, and served more as an inspiration for what you might build using distributed actors. You can view them in the sample code app associated with this session. And last but not least, an open source fully featured server-side clustering implementation. Available as a beta package now, and it will be matured alongside Swift 5.7. To learn more about distributed actors, you can refer to the following resources: the sample code associated with this session, which includes all the steps of our Tic Tac Fish game so you can deep dive into the code yourself. The Swift evolution proposals associated with the distributed actors language feature, which explain the mechanisms powering them in great detail. You can also reach out on the Swift forums, where you can find a distributed actors category dedicated to actor system developers and users alike. Thanks for listening, and I'm looking forward to seeing what you'll use distributed actors for in your apps! ♪
Looking for something specific? Enter a topic above and jump straight to the good stuff.
An error occurred when submitting your query. Please check your Internet connection and try again.