Await/Async, part deux

With SwiftCoroutine you can do next using CoFuture and coroutines:

func fetchContentsOfURL(_: URL) -> CoFuture<HTML> { ... }
func downloadImage(_: URL) -> CoFuture<Image> { ... }
func processImage(_: HTML) -> CoFuture<Result> { ... }
// not async.
func extractImageURLsFromHTML(_: HTML) -> [URL] { ... }

func getImageURLs(from url: URL) -> CoFuture<[URL]> {
    CoFuture {
        let html: HTML = try fetchContentsOfURL(url).await()
        return extractImageURLsFromHTML(html)
    }
}

func getImages(urlList: [URL]) {
    let urlsFutures = urlList.map(getImageURLs) //will start in parallel
    Coroutine.start {
        for urlFuture in urlsFutures {
            let urls: [URL] = try urlFuture.await()
            for imageURL in urls {
                let image: Image = try downloadImage(imageURL).await()
                let result: Result = try downloadImage(image).await()
                imageLibrary.addImage(result)
            }
        }
    }
}

Or without CoFuture:

func fetchContentsOfURL(_: URL) throws -> HTML { ... }

func downloadImage(_ url: URL) throws -> Image {
    let (data, _, error) = try Coroutine.await {
        URLSession.shared.dataTask(with: url, completionHandler: $0).resume()
    }
    if let error = error { throw error }
    if let image = data.map(Image.init) { return image }
    throw URLError(.cannotParseResponse)
}

func processImage(_: HTML) throws -> Result { ... }
// not async.
func extractImageURLsFromHTML(_: HTML) -> [URL] { ... }

func getImages(urlList: [URL]) {
    Coroutine.start {
        for url in urlList {
            let html = try fetchContentsOfURL(url)
            for imageURL in extractImageURLsFromHTML(html) {
                let image = try downloadImage(imageURL)
                let result = try processImage(image)
                imageLibrary.addImage(result)
            }
        }
    }
}
1 Like

I’m not convinced your example is an improvement over the pyramid of doom. Yes, the pyramid is deeply nested, but it is clear and easy to reason about what it does. Not so much the tangle of functional calls.

Regardless, I think it’s important to design features so they enable the use-cases we want to achieve.

For myself, I see the important use-case as being able to create a sequential pipeline of tasks, and execute many such pipelines in parallel.

In particular, for doing things in parallel I want an easy way to let the computer handle threading for me. The vast majority of the time I just want to say, “Here are a bunch of task pipelines I want completed in the background. Each pipeline must have its internal steps performed in the order I have specified. I do not care when the separate pipelines are executed relative to each other, nor how many threads they use. Please figure it out for me so they all run efficiently.”

Sure, sometimes I will want fine-grained control over threads and parallelism, and in those cases I can drop down to using lower-level primitives.

For the common case, I want to be able to say, “In this task pipeline, perform these steps in this order. Now begin processing that pipeline while I describe the next one.”

As I understand, await is a construct for taking a task pipeline, which would ordinarily be executed asynchronously in the background, and inserting it into another pipeline where it constitutes a single step and therefore must be completed before the next step begins.

And async is a way to specify a task pipeline.

6 Likes

I think the parallelism you're looking for with the getImages examples would be better served by Combine's API. It could look the following for instance (with the helper functions returning Publishers).

func getImages(urlList: [URL]) {
	let images = Publishers.Sequence(urlList)
		.flatMap(fetchContentsOfURL)
		.flatMap(extractImageURLsFromHTML)
		.flatMap(downloadImage)
		.flatMap(processImage)	
		.collect()
	
	imageLibrary.addImages(images)
	return images
}

await/async is more useful when you don't want parallelism, when you want to thread a value through a sequence of async steps.

edit: corrected the maps to flatMaps

1 Like

That is very much not the code I want to write.

I do not think in terms of flatMap.

I do not want a long snaking chain of functional calls.

I want to write imperative code, line by line, in the order it should be executed. I find it much easier to read, reason about, and understand that way.

6 Likes

Notice that you are asking for two things:

a. Create a sequential pipeline of tasks.

b. Execute pipelines in parallel.

You can't do (b) until you can do (a).

Currently we have no syntax for creating a sequential pipeline of tasks. At best, with completion handlers, we have syntax for creating two pipelines:

  a() {
    // #1
  }
  // #2

The point of the async/await proposal is to eliminate the unwanted pipeline:

 await a()
 // #1

It's not more complicated than that.

Once we've got that under our belt, we can start talking about parallelism, but that's a separate discussion.

2 Likes

I do think it's worth mentioning here that a monadic do notation would allow an imperative style syntax for both (a) and (b). I won't expand on it here as I think this thread is avoiding more discussion in that direction but I do think it's worth calling out the practical benefits of that approach (it's probably a discussion for another thread).

1 Like

I view it differently.

Normal code is entirely sequential. When I write a function, the statements in it are executed in order, one after another.

We don’t need special syntax and keywords to make things happen sequentially, because things already happen sequentially.

It is only after we have a concept of parallel execution, that it becomes relevant to say “Actually, these two things which ordinarily would be executed in parallel, I instead want to perform them sequentially, first one then the other.”

Thus there is a third part:

c. Take two pipelines that would normally be executed in parallel, and instead append one to the other so they form a new, longer pipeline.

Again, the syntax for creating one sequential pipeline of tasks is…a normal function with no annotation, that works in the usual way.

If we add in some mechanism for background execution, then completion handlers become a mechanism for creating a second pipeline.

And at that point, we sometimes will want the ability to use whole pipelines (aka. a function with a completion handler) as a single step in another pipeline (meaning the code that calls it must wait for it to complete before proceeding).

Nested callbacks are one way to achieve that. I believe await is intended to be another way to achieve that.

My understanding is that the problem being addressed is, “Nested callbacks create a pyramid of doom.”

Thus async is proposed as an alternative syntax for completion handlers, so that source code can be written imperatively, but behave as if it involved nested callbacks.

6 Likes

Your (c) is my (a):

The "tasks" I was referring to were (basically) functions with a completion handler. There's no difference here except in choice of terminology.

We're basically in agreement here, I think. The await operator serves the purpose of inserting a shorter piece of pipe into a longer pipeline — and nothing more.

Well, not entirely. Sure, a pyramid is harder to read, and harder to write (because of scope explosion), but there's a more troublesome source of confusion.

The confusion happens when developers don't understand that completion handlers (pyramids) create additional paths of execution. They don't realize they still have the original path of execution that the asynchronous function branched from.

Typically, they assume that the original path is blocked until completion handler executes. That leads to mysterious failures in their code.

If you want to see examples, you can find them in the Apple developer forums. Typically, they are of the form, "I did everything right (and I have the print statements to prove it), but the result of my server [or database or whatever] fetch is still nil. What am I doing wrong?"

…but my (c) is only possible after (b) is possible. In other words, in order to put two ordinarily-parallel pipelines in sequence to form a single longer pipeline, it first has to be possible to have them both work independently, in parallel.

Parallelism is a precondition before async / await become meaningful.

This entire conversation is predicated on a world of parallelism.

So the underlying questions ought to be, “What should parallel code look like when it is truly parallel, what should it look like when some parts need to become sequential, and how do we make the two work together?”

Today we have:

func asynchronous1(_ a: A, _ onB: (B) -> Void) { ... }
func asynchronous2(_ b: B _ onC: (C) -> Void) { ... }

If I have both A and B I can run the functions concurrently in plain Swift:

asynchronous1(a) { anotherB in ... }
asynchronous2(b) { c in ... }

What if I need to use anotherB in asynchronous2?

asynchronous1(a) { b in 
  asynchronous2(b) { c in ... }
}

What if I need to use that C in further statements?

asynchronous1(a) { b in 
  asynchronous2(b) { c in 
    /// all
    /// the
    /// code
  }
}

With async/await I can write:

func asynchronous1(_ a: A) async -> B { ... }
func asynchronous2(_ b: B) async -> C { ... }

let b = await asynchronous1(a)
let c = await asynchronous2(b)
/// all
/// the
/// code

(In my model I don't need to write await if the caller is itself an async function, but it doesn't matter here) The code is linear and follows a single path of execution.

What if I have async/await and I want to restore the concurrency? I don't know: async/await has no story for that; all the code called, in the context where it's called, is 100% serially executed.

To restore concurrency, we might have functions like:

func concurrent<A1, A2, B1, B2>(
  _ ab1: (A1) async -> B1,
  _ ab2: (A2) async -> B2
) -> (A1, B1) async -> (A2, B2) {
  ...
}

that itself should probably use a (compiler-magic powered) function like

func toCallback<A, B>(_ ab: (A) async -> B) -> (A, (B) -> Void) -> Void {
  ...
}

that turns an async function into a callback-based one (we could have the dual function too).

2 Likes

As a passing thought, I think that it may be desirable for an async function to require either async or await to be called:

func asynchronous1(_ a: A) async -> B { … }
func asynchronous2(_ b: B) async -> C { … }

// `await` 
// waits and tells the current execution context to work on `asynchronous1` instead
let b: B = await asynchronous1(a)

// `async` / Task<C> equivalent
// requests the current execution context to schedule `asynchronous2` and continue
let c: async C = async asynchronous2(b)

/// more code, `asynchronous2` may or may not be done

This means the calling syntax of an async function is dependent only on how wants the result, and independent of whether the caller is async, and ensures potential control flow is explicit.

Obviously both async and await would do different things based on whether they are in an async function or not, but that is implementation details, as is what counts as an execution context, unless the stdlib provides a protocol and functions to register and deregister execution contexts.

1 Like

I always think async/await can be easily rewritten by the compiler without adding runtime support:

@async func thingsTakeALongTime() -> Bool {
	Traditional asynchronous function {
		@asyncDone(true)
	}
}

@async func funcA() -> Bool {
	return await thingsTakeALongTime()
}
@async func funcB() {
	let isTrue = await funcA()
	if isTrue {
		do something
	} 
}

Rewrite:

func thingsTakeALongTime(_ _await_recallQueue: OperationQueue?, _ _await_result_callback: @escaping () -> Void) -> Void {
	Traditional asynchronous function {
		_await_recallQueue?.addOperation {
			_await_result_callback(true) 
		}
	}
}

func funcA(_ _await_recallQueue: OperationQueue?, _ _await_result_callback: @escaping (Bool) -> Void) -> Void {
	return thingsTakeALongTime(.current, { // assume .current will work in any thread
		_await_recallQueue?.addOperation {
			_await_result_callback(true) 
		}
	})
}

func funcB(_ _await_recallQueue: OperationQueue?, _ _await_result_callback: @escaping (Bool) -> Void) {
	funcA(.current, {
		let isTrue = $0
		if isTrue {
			do something
		} 
		_await_recallQueue?.addOperation {
			_await_result_callback(true) 
		}
	})
}

This means that once the compiler sees that "await" is set, it will put the code remaining in the function into the async function's _await_result_callback. And call the _await_result_callback in the _await_recallQueue after all work is finished

And I think await should behave like defer , which only works in the current scope.

@async func funcC(async: Bool) -> Bool {
	print(1)

	if async {
		print(2)
		let isTrue = await funcA()
		do something
		print(3)
		return isTrue
	}

	print(4)
	return false
}

Rewrite:

func funcC(async: Bool, _ _await_recallQueue: OperationQueue?, _ _await_result_callback: @escaping (Bool) -> Void) -> Void {
	print(1)

	if async {
		print(2)
		return funcA(.current, {
			let isTrue = $0
			do something
			print(3)
			_await_recallQueue?.addOperation {
				_await_result_callback(isTrue) 
			}
		})
	}

	print(4)
	return _await_result_callback(false)
}

This mean you can return a await result or call @asyncDone(), otherwise will call _await_result_callback directly at the end. Also, once you return something using await, you have to use @async,

func funcD() -> Int {
	return await funcA() ? 1 : 2
}

is wrong.

And

func funcE() {
	print(await funcA() ? 1 : 2)
}

is right.

why I use _await_recallQueue to handle the result? Because Apple platforms are using main thread to render UI, if you use DispatchSemaphore or other other means to suspend the thread to achievein await/async and use it in the main thread, it will completely stop all the entire app.

Also, here I use OperationQueue.current just because DispatchQueue can't get the currentQueue directly, but if I could, I would call _await_result_callback by using queue.sync { } rather than using async.

There is also some malpractice, since using too many await may cause the compiler to take too long to infer the correct result and rewrite it. But if it comes true, I could have used it immediately rather than switching to a supported iOS version for my project.

It's already possible to do both those things, using our current asynchronous pattern and completion handlers. What I think you're asking for is nicer syntax for both sequential and parallel execution, and I agree we want nicer syntax.

However, as I have been saying over and over, and over and over, the point of await/async is not to give us nicer syntax for the sequential case (though it will), but to eliminate the unwanted path of execution that our current asynchronous pattern leaves behind as detritus.

I'm not sure how I can make that any clearer.

Yes, that's true, the compiler could rewrite the function to provide a sequential path of execution through the completion handlers.

What it can't do (today) is eliminate the original path of execution that does not go through the completion handlers (that is, the return from the function that doesn't wait).

No one has proposed a way of eliminating that path, except via a coroutine as stated in the await/async proposal. That would be a big change to the compiler.

I guess I'm going to have add this to every reply now:

"The goal is to eliminate or wait on the original path of execution, when a second path of execution is created by an asynchronous function call, without blocking the thread that the call was made on."

1 Like

I do not understand what this means.

How can you “wait on the original path of execution” “without blocking the thread that the call was made on”?

What does that mean?

I have one original path of execution, running on some thread.

I initiate an operation that would normally happen asynchronously, but this time I explicitly want to wait for it.

…and somehow also not block the execution of my original thread?

What is my original thread doing then? It’s supposed to be waiting…but also not be blocked?

I have literally no idea what you are describing.

3 Likes

@QuinceyMorris makes an implicit distinction between the operating system thread and the program's path of execution (program thread?). Traditional blocking hogs an OS thread; async/await is less expensive for the machine by letting the runtime move an awaiting program thread off a running operating system thread.

2 Likes

…so I can never call an async function from the main thread of an interactive application, because if I did then the OS would suspend my UI thread until the async call completes?

That sounds like…the exact opposite of what I would want “async” to mean, as a keyword.

Or is that only if I use await? Can I call an async function without await, and have it actually spin off a new path of execution in the background while my main thread continues running normally?

• • •

I still do not understand what problem is being addressed here. It would help my understanding if someone could clearly explain the following:

  1. In words, not in code, what problem is being addressed. Without using the words async or await, because we are describing a problem with the current world which does not have those keywords.

  2. In existing code, what are some realistic examples of workflows that encounter that problem. What does current code look like, what is the best practice today, and how does it fall short.

  3. What exactly do each of the proposed keywords do.

  4. In proposed code, what do the workflows from (2) look like using the new keywords. How is that code better than the existing approach.

3 Likes

You can, and that's the point.

async-await is a feature of the language runtime that does some of the same things that operating system threads do. When reaching the point where your program awaits, the runtime saves its state and starts running something else that was started asynchronously, but it's less demanding on system resources than blocking a operating system thread. In the case of the UI thread, async-await won't block it.

Well I very much don’t understand what it’s intended to do then.

@QuinceyMorris has been adamant that the goal is to eliminate the 2nd path of execution so there is only one path.

I've been using async/await in C#, but after reading this thread I had to go back and re-read the C# docs to un-confuse myself.

Something that I think is important to keep in mind for the await/try parallel is that, as @ExFalsoQuodlibet mentioned, try takes a throwing function — which conceptually returns Result<T, Error> — and lets you call it as if it returned T, plus error handling.

Similarly, an async function returns its result wrapped in (in C# terms) a Task<T>, which you await in order to extract the eventual result. When an async function uses await, at that point it, in turn, returns its own Task to its caller.

When a non-async function calls an async function... it can get a bit awkward, and in fact some people recommend not doing it at all. In C#, you call one of the Task methods explicitly, depending on how you want the async execution to proceed. For example, there is ContinueWith() which takes a completion handler.

(disclaimer: discussing the sync/async boundary in C# is... pushing the boundaries of my expertise, but I think the above is reasonably accurate)

1 Like