Comparative Asynchronous Programming
Ash Furrow at Playgrounds Conference, 2017
With that in mind, let's get started. We've got a lot to talk about. First, we're going to be talking about asynchronous programming, what it is, why it's hard and why there is no best approach. Second, we're going to be looking at the async approaches that Swift ships with either in the language or the frameworks. Third, we're going to take a look at the supported abstractions that you can build on top of Swift. Fourth, we're going to take a look at some of the things that you can't build with Swift. Let's get started. Asynchronous programming, difficult and subjective. Before we talk about what asynchronous programming is, I think we should contrast it with synchronous programming. This is what you might call normal programming.
Normal programming is the kind of programming you probably learned how to do first. It's the kind of programming where function execution starts at the top and moves its way towards the bottom. The program waits for each line of code to finish executing before moving onto the next one. It synchronizes on those lines of code and that's why it's synchronous programming. As Swift developers, most of the code we write is synchronous or normal. Let's take a look at an example. Here we have a function call and it's synchronous because from the call site's perspective, things happen in one step. The function starts, it executes and it finishes atomically.
Now if all that function is doing is adding two numbers together and those two numbers are already loaded into RAM, it's going to be really fast. If it has to load those numbers from RAM first, then it's going to be a lot slower. If it has to load them from the disk or heaven forbid the network, it's going to be even slower than that. At a certain point, we're sort of stuck. The CPU is waiting for other things to finish before it can continue doing work. That's a shame because CPU time is really valuable and we don't want to waste it and that's where asynchronous programming comes in. It mitigates the wasted CPU time by letting the program continue to do other work while it waits for something to finish.
Asynchronous programming in contrast to synchronous programming is when you execute code out of order that it's written in. Your program can start executing something asynchronously, continue on to the next line of code while it waits for the asynchronous thing to finish. You can resume the asynchronous code later on due to some external event, so if a network request completes, a user interaction occurs, that sort of thing. Things can get executed in parallel to either across threads or processors. That's beyond the scope of this talk. The point is that things can get really complicated really quickly.
If any part of your application is blocking, then your entire application is blocked. That's no good. Non-blocking code is great because it leads to better performance, but it does have its drawbacks. Think of Swift. If instead of having a return value, every function returned void, but had a completion handler. How terrible would that be, right? It might be great, right? We don't know because we haven't tried it. Let's keep exploring to see what we would find. We've got two refile functions. On the left, it's synchronous. From the call site's perspective, it happens immediately. From the right hand side we have an asynchronous call that takes completion block. This is nice because it's performance, it's better code, but it doesn't have a return value.
We can't return from our function that uses this code. Effectively, the asynchronous nature of this code propagates up the call stack and that can be kind of frustrating from a programmer's perspective. Swift doesn't have this kind of opinionated nature of Node.js, which I think is kind of a shame because it leads programmers to avoid thinking about these trade-offs and that's a shame. Let's embrace the trade-offs, right? To write modern software, you're going to have to write asynchronous codes sometimes. You've got to think about which abstraction you want to use because if you don't think about it, you're probably not going to pick the right one by chance. To that end, let's take a look at some of the things that ship with Swift that you write asynchronous code.
Swift's built-in abstractions for async programming are a little lacking and that's because, I hate to say it, most of them come from Objective-C. Again Swift is not an opinionated language, so it doesn't really have strong opinions and I think that's a shame because I mean strong opinions are I think great, even if you disagree with them. Let's explore. Built-in async approaches. We're only going to be talking about iOS, macOS, tvOS and watchOS. I'm really sorry, Chris Bailey, but we're not going to be talking about Swift on Linux today. It's outside the scope of this talk. Let's get started with Grand Central Dispatch. This is really helpful for managing threading more than it's helpful for managing asynchronous programming only.
It's probably best not to use GCD directly, but rather use it to build higher level abstractions that are easier to think about. Those abstractions can include something called NSOperation and NSOperationQueue. These are built on top of GCD and allow you to link bits of computation together, so you can form complex dependency graphs that say, "Well, this operation depends on this other one being completed first. When it's done, then I can execute." If you want an example of how powerful this can really be, check out DRB Operation Tree, which is a library we use at Artsy and we use it to in queue thousands or hundreds of thousands of operations onto operation queues for downloading massive amounts of data over an API.
All right. We should also probably mention POSIX Threads. These are way too low level to do in Swift, but you can do it, but don't. Target action. This is something that a lot of iOS developers are probably familiar with. Basically the abstraction says, "Let's give an instance of a class and a function on that instance that's executed some later point in time." Usually this is due to user interaction. Anytime you have a UI button that gets tapped or a slider value that gets changed or jester recognizer that gets fired, that's probably within using the target action pattern. They're pretty great, but they don't really scale well. Let's take a look at another abstraction that's built-in to the Swift Language.
Callbacks, which are sometimes called completion handlers. These are short anonymous functions that get executed at a later point in time. You've probably used these for getting notified when a network request completes or maybe a UI view animation is finished and they're quite handy. I want to take an in-depth look at these. Here we've got log in with credentials and we got a completion handler. At some later point in time this code is going to get executed with the results parameter and we can handle that either as a success or a failure. Pretty cool. The problem is that without paying attention, we can end up in callback hell. It's very difficult to put a callback within another callback. Actually that's not true.
It's very easy to put a callback in a callback and that's the problem because you end up with callbacks within callbacks within callbacks. You get messy stack traces that are really hard to debug and that's no good. As an example, here we see, "Okay. We've got to get credentials from the users and we have them. Then we log in. Then we get the results and then we handle it." The code is moving further and further away from the left hand side of the screen. This is called the triangle of doom. Things get even worse because we're not handling any errors. Let's add error handling code. Oh boy. Let's consider the fact that we're handling errors in two different places. For every callback we add, we've got to add more error handling code.
We're not even using the idiomatic Swift error handling. We're not using throws or catch. What a shame. Now when you use a callback, you've got to be really cognizant about what information you're giving the callback, what parameters do you want to use. Let's take a look at a few examples. Now the first one we've got an optional value and that's probably the most straightforward. You either have a value or you don't. If you have a value, then things went well. If it's nil, then something went wrong. What went wrong? We've got no idea. Let's add an error. Now we've got an optional value and an optional error. This is an improvement in some ways, but we've introduced some ambiguity. For example, what happens if both of the credentials and the error are nil?
What happens if both of them are non nil? What do we do? We don't know. In that case, a result type is probably the best to use. For some reason and I don't know why, Swift it doesn't ship with a result type built-in. They're quite easy to build. There are a ton of libraries out there to use them, but let's build it on stage. Here we go. This is results. It's a generic enum. It takes success case and an error case. Success has an associated value that's the generic type and the error case has an associated error value. Because we can have a non-optional result, our result has to be exactly one of these two cases. It can't be both. It can't be neither. We've removed ambiguity from our code. Made it easier to read.
If you're going to use callbacks to write asynchronous code in Swift, I really encourage you to use a result type as a parameter. Not only is it clean and remove ambiguity from your code, but result types are monads. You can have this really cool gateway to higher level abstractions and cool things you can do with that. Unfortunately monads are outsides the scope of my talk. Callback hell, sure, but also callback heaven. If all your app is doing is getting some photos from the user's photo library and doing something with them really simply, callbacks might be the way to go. If you find yourself nesting callbacks with another callbacks, definitely look for a better abstraction. What could those better abstractions look like?
We can build abstractions on top of the Swift standard library and language. The first one I want to talk about are promises. Sometimes these are called futures. There's a distinction between these two, but it's outside the scope of my talk. A future is a class that represents a future value representing a success or a future error representing a failure. You write your code based on how you want to handle either of those two cases and then later on when one of them happens, the appropriate code gets executed. There are lots of libraries to do this in Swift. The one I want to show you is called bright futures. Here's the code. We have get credentials from users. We're going to flatMap that into a different future called a log in with credentials.
Then on success and on failure, we do the appropriate things. There are a few improvements to this code over the completion callbacks that we had and has to do inside each other. The first one is that we can flatMap and chain things really easily. We can transform one future into a different type of future. We avoid callbacks from callbacks. The second improvement is that all of our error handling is done in one spot. If either the first future fails or the second future fails, then the same error handling is going to get called. Awesome. Now I couldn't do a talk on asynchronous programming without talking about functional reactive programming.
Greg is going to give a really great talk after me going into a lot more detail, but as sort of a preview of that, FRP encapsulates a stream of events that you can observe. Those events are next value events, completion events and error events. Values and values, completion events stop the stream and error events stop the stream with an associated error. Streams either complete or error. Never both and sometimes neither. These streams are called observables. They're really cool. If you want to check out more FRP, take a look at RxSwift or ReactiveSwift, which was formerly called ReactiveCocoa. What does the code look like? Well, here it is. We've got Grand Central Users. We flatMap that into logging of credentials and we handle our success and error appropriately at the end.
You may be thinking, this looks really familiar. That looks similar to the bright futures example. That's because our FRP example was structured in such a way that we only ever receive one value. Functional reactive programming is at its best when you're dealing with a stream of values that are sent over time. Let's take a look at a better illustrative example. Here we've got a jester recognizer that emits events whenever the jester is moved on the screen. We map those events into the location in our view and then bind those locations to be the center point of a circle view. In five lines of code, we've written asynchronous code to handle a jester recognizer and make sure it follows the user's finger wherever they move it on the screen, which is pretty cool.
FRP will be my recommendation for a fully featured asynchronous approach. The abstractions are great and super powerful, but they take some getting used to. There is a learning curve and FRP might be too complex for your needs or for a large team that needs to get ramped up. If that's the case, stick to using promises. The actor model. This is probably the most far out approach we're going to discuss today. Actor programming treats actors as primitives for concurrent computation. We use a library at Artsy called ACA. It's written for Scala and Java. It's super cool. The actor model looks like this. Don't look at it too long. It's complicated.
The actor model provides a number of constraints and conditions on how you write your code that make things complicated, but really robust. There are different implementations, but generally speaking actors receive messages in an unspecified order and have to respond to those messages in some way. Actors can create child actors in order to do some work for them, but there are no callbacks. All the communication between actors is handled through message passing. Actors can maintain local state. They can respond to the same message in two different ways. Actors maintain a hierarchy. You can have really fault tolerant systems. If one of your child actors gets knocked over in production, you can just boot up a new one.
It lets you write code that looks normal or synchronous, but it's actually asynchronous under the hood. It's powerful and expressive and it's not possible to do in Swift for reasons that we're going to talk about shortly. Here's some hypothetical Swift syntax to show you want async/await could look like. We've got a function called log in. It has a normal return value. No completion handler. It's been marked as async with the async keyword. Then we have let credentials equal await, get credentials from users. What that's going to do is pause the function execution until get credentials from user returns a value synchronously and then our log in function is going to pick up executing from where it left off.
Really cool. It's going to do the same thing while we await for the log in with credentials to return. Then when we get its return value, we'll return it to the user. Pretty cool. The really cool thing is that async/await could be used with error throwing in Swift, so we could write idiomatic try catch code in Swift and would just work, which should be really cool. Async/await is the way that I wish sync had done asynchronous programming from the start. It's probably the ideal, but it's too late to standardized on now. That's a shame. Additionally, there are some asynchronous needs that aren't well met by async/await. For example, it's hard for me to picture how a jester recognizer handler could be done well in async/await.
Jester recognizers work really well with target action and even FRP. We can't do async/await. What else can't we do? Well, there are these things called coroutines, also called generator functions. There's a slight distinction. These are the strangest things I've come across. They're strange because they challenge some of the assumptions that I use to make about how functions work. For example, I thought functions can only complete once. Functions can only return a value once. Function execution always starts from the top and works its way down. These are like basic assumptions that programmers make that are not true with generator functions. Coroutines and generator functions aren't typically used directly, but they are required to make async/await work.
It's always a good idea to expose yourself to new ideas even if they don't pertain directly to your day-to-day job. If you see something cool in another language or you see something from another community, I really encourage you to take it to the Swift Evolution people. The compiler engineers there can provide you with workarounds maybe or can take your feedback into account when we as a community decide where we want to take Swift as a language. I'm really looking forward to seeing how Swift continues to evolve and I'm really looking forward to your questions later on. Thank you very much.
If you enjoyed this talk, you can find more info: