Immediately start receiving values from an AsyncSequence

Between AsyncAlgorithms and AsyncExtensions I've been able to replace most usage of Combine with Swift Concurrency.

But the one thing that keeps me coming back to Combine is when I need a value to be immediately available. Consider this example (substitute any publisher and action):

class Coordinator {
	@Published var name = "John"
	@Published var nameCharacterCount = 0
	
	var nameObserver: AnyCancellable?
	
	init() {
		nameObserver = $name
			.map { $0.count }
			.sink { [weak self] count in
				self?.nameCharacterCount = count
			}
	}
}

The sink callback will immediately be invoked and nameCharacterCount will have the most up to date value from the very beginning.

However if you were to implement this with swift concurrency:

class Coordinator {
	@Published var name = "John"
	@Published var nameCharacterCount = 0
	
	var nameObserver: Task<Void, Never>?
	
	init() {
		let sequence = $name.values
		nameObserver = Task { [weak self] in
			for await name in sequence {
				self?.nameCharacterCount = name.count
			}
		}
	}
	
	deinit {
		nameObserver?.cancel()
	}
}

There would be a slight initial pause because the Task closure would not fire until the next tick of the run loop.

From my understanding, if you are already in an async context and there is a value ready to return immediately from a sequence, there is no delay with the first iteration of the loop. But jumping to an async context always enforces a delay.

Would it be possible to add some kind of way to create a Task that fully inherited the current running context? Something like Task.attached, that would run immediately and only return from the initializer once it encountered an actual await pause?

6 Likes

I had similar issues and I actually found that it was a mistake in the design of my code. In my use case we have database accesses via rx/combine that were used in navigation paths in an app. Upon reflection this just happened to work by chance, rx/combine internals happened to run things synchronously even if there were rx/combine pipelines that seemed to be asynchronous. On the migration to async I actually opted to allow this explicitly behaviour by offering fetchModelSync versions of the methods.

In general this is not an issue with AsyncSequence itself, but just the fact that once you have a suspension point you can't assume things are synchronous anymore.

Maybe some of the new tools to work in MainActor help with some specific cases but in general seems like a no-go.

This does look like a mistake in the design, usually the Task would retain or own the object it is working on. And if the goal is to replace Combine usually the class and the @Published would be gone too. If this is for SwiftUI have you seen .task?