Combine Future broken?

Why does this code print hello world:

        let _ = Future<String, Never> { promise in
            promise(.success("world"))
        }.sink { print("hello \($0)") }

but this code prints nothing?

        let _ = Future<String, Never> { promise in
            DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
                promise(.success("world"))
            }
        }.sink { print("hello \($0)") }

Is Future broken right now? I'm using Xcode 11 Beta 7 (11M392r) on macOS Catalina (19A546d).

Future is behaving correctly.

In your first example, everything happens synchronously, so there is no opportunity for Combine to cancel the subscription before the Future delivers its value to the Sink.

In your second example, you're not saving the AnyCancellable returned by the sink modifier, so Combine cancels your subscription before your async closure runs.


Gory details follow...

First, consider initializing a Future like this:

    let future = Future<String, Never> { promise in
        promise(.success("world"))
    }

This calls promise synchronously, before Future.init returns. So by the time the Future gets assigned to the future variable, it is in a completed/success state. We can prove it in the debugger (with a breakpoint after future is initialized):

(lldb) expr -- dump(future)
▿ Combine.Future<Swift.String, Swift.Never> #0
(Future<String, Never>) $R0 = 0x0000600003880910 {}
  ▿ lock: 0x00007ff03d4008b0
    - pointerValue: 140669796485296
  ▿ downstreams: Combine.SubscriberList
    - items: 0 elements
    - tickets: 0 elements
    - nextTicket: 0
  ▿ result: Optional(Swift.Result<Swift.String, Swift.Never>.success("world"))
    ▿ some: Swift.Result<Swift.String, Swift.Never>.success
      - success: "world"

Note that future has a private result property containing the successful result.

Next, apply the sink modifier to future with a breakpoint on the print:

    _ = future.sink {
        print("hello \($0)")  // put a breakpoint here
    }

When you run it and stop at the breakpoint, you'll see a stack trace like this:

#0  closure #2 in AppDelegate.application(_:didFinishLaunchingWithOptions:) at /Users/rmayoff/TestProjects/combineTest/combineTest/AppDelegate.swift:18
#1  thunk for @escaping @callee_guaranteed (@guaranteed String) -> () ()
#2  Subscribers.Sink.receive(_:) ()
#3  protocol witness for Subscriber.receive(_:) in conformance Subscribers.Sink<A, B> ()
#4  AnySubscriberBox.receive(_:) ()
#5  Future.Conduit.request(_:) ()
#6  protocol witness for Subscription.request(_:) in conformance Future<A, B>.Conduit ()
#7  Subscribers.Sink.receive(subscription:) ()
#8  protocol witness for Subscriber.receive(subscription:) in conformance Subscribers.Sink<A, B> ()
#9  Future.receive<A>(subscriber:) ()
#10 protocol witness for Publisher.receive<A>(subscriber:) in conformance Future<A, B> ()
#11 Publisher.subscribe<A>(_:) ()
#12 Publisher<>.sink(receiveValue:) ()
#13 AppDelegate.application(_:didFinishLaunchingWithOptions:) at /Users/rmayoff/TestProjects/combineTest/combineTest/AppDelegate.swift:17

The interesting thing to note here is that the sink modifier call is still on the stack (at frame #12). The sink modifier creates a Sink and subscribes it to future (frame #9), which results in future creating a subscription (of type Future.Conduit) that it passes to the Sink (frame #7), which the Sink then makes a demand on (frame #5), which gets passed to future. Since future is already complete, it can and does respond to the demand immediately by delivering its value. I believe the call into future with the demand has been eliminated by tail-call-optimization, else it would be at frame #3.

So, in your first example, everything happens synchronously. There is no chance for the subscription to be cancelled before the Future has delivered its value to the Sink.

Now consider initializing a Future like this:

    let future = Future<String, Never> { promise in
        DispatchQueue.main.async {
            promise(.success("world"))
        }
    }

Here, assuming this runs on the main thread, we can't possibly call the promise until some time after Future.init returns. So future is initialized to an incomplete promise. We can check this in the debugger:

(lldb) expr -- dump(future)
▿ Combine.Future<Swift.String, Swift.Never> #0
  ▿ lock: 0x00007fe88f501b70
    - pointerValue: 140636813532016
  ▿ downstreams: Combine.SubscriberList
    - items: 0 elements
    - tickets: 0 elements
    - nextTicket: 0
  - result: nil
(Future<String, Never>) $R0 = 0x0000600001f37f70 {}

Here we can see that future's result property is nil because it hasn't been completed yet.

Next, apply the sink modifier to this incomplete Future:

    _ = future.sink {
        print("hello \($0)")
    }

The print statement never runs. Why not? Well, this is all still running on the main thread. So the closure that completes future (by calling promise) still can't run yet. But the sink modifier returns an AnyCancellable which we're not storing persistently. This means Swift destroys the AnyCancellable by the time the enclosing scope is exited, which still happens on the main thread. So the AnyCancellable is destroyed before the promise-calling closure can run. When an AnyCancellable is destroyed, it calls its cancel method, which in this case cancels the subscription. (Other types that conform to Cancellable do not necessarily call cancel on destruction, but AnyCancellable always does.)

Eventually, control of the main thread returns to the run loop, which executes any blocks added to the main queue, including the closure that calls promise. But by that time, the subscription has been cancelled, so our print statement never runs.

How can we fix this? We have to store the AnyCancellable persistently to keep the subscription alive. Usually we'd use an instance property, like this:

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    let future = Future<String, Never> { promise in
        DispatchQueue.main.async { [weak self] in
            promise(.success("world"))
            self?.scrip = nil
        }
    }
    scrip = future.sink {
        print("hello \($0)")
    }
    return true
}

var scrip: AnyCancellable?
9 Likes

Thanks @mayoff for such a detailed response! I am still trying to unpack all of your post.
Here's the way I was actually using Future:

    func doStuff() {
        let future1 = Future<String, Never> { promise in
            DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
                promise(.success("hello"))
            }
        }
        
        let future2 = Future<String, Never> { promise in
            DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
                promise(.success("world"))
            }
        }
        
        let publisherZipped = Publishers.Zip(future1, future2)
            .sink { print("\($0.0) \($0.1)") }
    }

Based on your response, I'm guessing the same principles are at play even when passing both Futures into Publishers.Zip? Although probably with this code, I'd only need to publisherZipped to prevent cancellation?

EDIT: I tried keeping publisherZipped around and everything works as expected :raised_hands:

@mayoff thanks for the detailed response, but I strongly believe something's broken with this feature or at least a breaking change was introduced later on. The example @sjmueller is posted is shown in almost every post I've read about this topic.

For example, in the "Basic Usage" section of this post: Asynchronous Programming with Futures and Promises in Swift with Combine Framework or this other course Episode #80: The Combine Framework and Effects: Part 1(search in the page for "let aFutureInt = Future" and you'll see the example)

I'm obviously missing something but believe that's supposed to work (what you propose feels kinda hacky)

It is entirely possible that the behavior has changed since the earliest versions, but it's not “supposed to work”. However:

  • A debug build may extend the lifetime of objects, including AnyCancellable objects, so that subscriptions last longer than they do in release builds.

  • A Swift playground keeps objects alive longer than a normal program environment does.

Both of these effects mean that it's easy to write tutorial code that works in the tutorial context but fails in production.

1 Like