13.
Resource Management
Written by Florent Pillet
In previous chapters, you discovered that rather than duplicate your efforts, you sometimes want to share resources like network requests, image processing and file decoding. Anything resource-intensive that you can avoid repeating multiple times is worth looking into. In other words, you should share the outcome of a single resource – the values a publisher’s work produces – between multiple subscribers rather than duplicate that outcome.
Combine offers two operators for you to manage resources: The share() operator and the multicast(_:) operator.
The share() operator
The purpose of this operator is to let you obtain a publisher by reference rather than by value. Publishers are usually structs: When you pass a publisher to a function or store it in several properties, Swift copies it several times. When you subscribe to each of the copies, the publisher can only do one thing: Start the work it’s designed to do and deliver the values.
The share() operator returns an instance of the Publishers.Share class. Often, publishers are implemented as structs, but in share()s case, as mentioned before, the operator obtains a reference to the Share publisher instead of using value semantics, which allows it to share the underlying publisher.
This new publisher “shares” the upstream publisher. It will subscribe to the upstream publisher once, with the first incoming subscriber. It will then relay the values it receives from the upstream publisher to this subscriber and to all those that subscribe after it.
Note: New subscribers will only receive values the upstream publisher emits after they subscribe. There’s no buffering or replay involved. If a subscriber subscribes to a shared publisher after the upstream publisher has completed, that new subscriber only receives the completion event.
To put this concept into practice, imagine you’re performing a network request, like you learned how to do in Chapter 9, “Networking.” You want multiple subscribers to receive the result without requesting multiple times. Your code would look something like this:
let shared = URLSession.shared
.dataTaskPublisher(for: URL(string: "https://www.raywenderlich.com")!)
.map(\.data)
.print("shared")
.share()
print("subscribing first")
let subscription1 = shared.sink(
receiveCompletion: { _ in },
receiveValue: { print("subscription1 received: '\($0)'") }
)
print("subscribing second")
let subscription2 = shared.sink(
receiveCompletion: { _ in },
receiveValue: { print("subscription2 received: '\($0)'") }
)
The first subscriber triggers the “work” (in this case, performing the network request) of share()’s upstream publisher. The second subscriber will simply “connect” to it and receive values at the same time as the first.
Running this code in a playground, you’d see an output similar to:
subscribing first
shared: receive subscription: (DataTaskPublisher)
shared: request unlimited
subscribing second
shared: receive value: (303425 bytes)
subscription1 received: '303425 bytes'
subscription2 received: '303425 bytes'
shared: receive finished
Using the print(_:to:) operator‘s output, you can see that:
- The first subscription triggers a subscription to the
DataTaskPublisher. - The second subscription doesn’t change anything: The publisher keeps running. No second request goes out.
- When the request completes, the publisher emits the resulting data to both subscribers then completes.
To verify that the request is only sent once, you could comment out the share() line and the output would look similar to this:
subscribing first
shared: receive subscription: (DataTaskPublisher)
shared: request unlimited
subscribing second
shared: receive subscription: (DataTaskPublisher)
shared: request unlimited
shared: receive value: (303425 bytes)
subscription1 received: '303425 bytes'
shared: receive finished
shared: receive value: (303425 bytes)
subscription2 received: '303425 bytes'
shared: receive finished
You can clearly see that when the DataTaskPublisher is not shared, it receives two subscriptions! And in this case, the request runs twice, once for each subscription.
But there’s a problem: What if the second subscriber comes after the shared request has completed? You could simulate this case by delaying the second subscription.
Don’t forget to uncomment share() if you’re following along in a playground. Then, replace the subscription2 code with the following:
var subscription2: AnyCancellable? = nil
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
print("subscribing second")
subscription2 = shared.sink(
receiveCompletion: { print("subscription2 completion \($0)") },
receiveValue: { print("subscription2 received: '\($0)'") }
)
}
Running this, you’d see that subscription2 receives nothing if the delay is longer than the time it takes for the request to complete:
subscribing first
shared: receive subscription: (DataTaskPublisher)
shared: request unlimited
subscribing second
shared: receive value: (303425 bytes)
subscription1 received: '303425 bytes'
shared: receive finished
subscribing second
subscription2 completion finished
By the time subscription2 is created, the request has already completed and the resulting data has been emitted. How can you make sure both subscriptions receive the request result?
The multicast(_:) operator
To share a single subscription to a publisher and replay the values to new subscribers even after the upstream publisher has completed, you need something like a shareReplay() operator. Unfortunately, this operator is not part of Combine. However, you’ll learn how to create one in Chapter 18, “Custom Publishers & Handling Backpressure.”
In Chapter 9, “Networking,” you used multicast(_:). This operator builds on share() and uses a Subject of your choice to publish values to subscribers. The unique characteristic of multicast(_:) is that the publisher it returns is a ConnectablePublisher. What this means is it won’t subscribe to the upstream publisher until you call its connect() method. This leaves you ample time to set up all the subscribers you need before letting it connect to the upstream publisher and start the work.
To adjust the previous example to use multicast(_:) you could write:
// 1
let subject = PassthroughSubject<Data, URLError>()
// 2
let multicasted = URLSession.shared
.dataTaskPublisher(for: URL(string: "https://www.raywenderlich.com")!)
.map(\.data)
.print("multicast")
.multicast(subject: subject)
// 3
let subscription1 = multicasted
.sink(
receiveCompletion: { _ in },
receiveValue: { print("subscription1 received: '\($0)'") }
)
let subscription2 = multicasted
.sink(
receiveCompletion: { _ in },
receiveValue: { print("subscription2 received: '\($0)'") }
)
// 4
let cancellable = multicasted.connect()
Here’s what this code does:
- Prepares a subject, which relays the values and completion event the upstream publisher emits.
- Prepares the multicasted publisher, using the above
subject. - Subscribes to the shared — i.e., multicasted — publisher, like earlier in this chapter.
- Instructs the publisher to connect to the upstream publisher.
This effectively starts the work, but only after you’ve had time to set up all your subscriptions. This way, you make sure no subscriber will miss the downloaded data.
The resulting output, if you run this in a playground, would be:
multicast: receive subscription: (DataTaskPublisher)
multicast: request unlimited
multicast: receive value: (303425 bytes)
subscription1 received: '303425 bytes'
subscription2 received: '303425 bytes'
multicast: receive finished
Note: A multicast publisher, like all
ConnectablePublishers, also provides anautoconnect()method, which makes it work likeshare(): The first time you subscribe to it, it connects to the upstream publisher and starts the work immediately. This is useful in scenarios where the upstream publisher emits a single value and you can use aCurrentValueSubjectto share it with subscribers.
Sharing subscription work, in particular for resource-heavy processes such as networking, is a must for most modern apps. Not keeping an eye on this could result not only in memory issues, but also possibly bombarding your server with a ton of unnecessary network requests.
Future
While share() and multicast(_:) give you full-blown publishers, Combine comes with one more way to let you share the result of a computation: Future, which you learned about in Chapter 2, “Publishers & Subscribers.”
You create a Future by handing it a closure which receives a Promise argument. You further fulfill the promise whenever you have a result available, either successful or failed. Look at an example to refresh your memory:
// 1
func performSomeWork() throws -> Int {
print("Performing some work and returning a result")
return 5
}
// 2
let future = Future<Int, Error> { fulfill in
do {
let result = try performSomeWork()
// 3
fulfill(.success(result))
} catch {
// 4
fulfill(.failure(error))
}
}
print("Subscribing to future...")
// 5
let subscription1 = future
.sink(
receiveCompletion: { _ in print("subscription1 completed") },
receiveValue: { print("subscription1 received: '\($0)'") }
)
// 6
let subscription2 = future
.sink(
receiveCompletion: { _ in print("subscription2 completed") },
receiveValue: { print("subscription2 received: '\($0)'") }
)
This code:
- Provides a function simulating work (possibly asynchronous) performed by the
Future. - Creates a new
Future. Note that the work starts immediately without waiting for subscribers. - In case the work succeeds, it fulfills the
Promisewith the result. - If the work fails, it passes the error to the
Promise. - Subscribes once to show that we receive the result.
- Subscribes a second time to show that we receive the result too without performing the work twice.
What’s interesting from a resource perspective is that:
-
Futureis a class, not a struct. - Upon creation, it immediately invokes your closure to start computing the result and fulfill the promise as soon as possible.
- It stores the result of the fulfilled
Promiseand delivers it to current and future subscribers.
In practice, it means that Future is a convenient way to immediately start performing some work (without waiting for subscriptions) while performing work only once and delivering the result to any amount of subscribers. But it performs work and returns a single result, not a stream of results, so the use cases are narrower than full-blown publishers.
It‘s a good candidate to use for when you need to share the single result a network request produces!
Note: Even if you never subscribe to a
Future, creating it will call your closure and perform the work. You cannot rely onDeferredto defer closure execution until a subscriber comes in, becauseDeferredis a struct and would cause a newFutureto be created every time there is a new subscriber!
Key points
- Sharing subscription work is critical when dealing with resource-heavy processes, such as networking.
- Use
share()when you simply need to share a publisher with multiple subscribers. - Use
multicast(_:)when you need fine control over when the upstream publisher starts to work and how values propagate to subscribers. - Use
Futureto share the single result of a computation to multiple subscribers.
Where to go from here?
Congratulations for finishing the last theoretical mini-chapter for this section!
You‘ll wrap up this section by working on a hands-on project, where you’ll build a API client to interact with the Hacker News API. Time to move along!