Async/await status?

Again, async/await without the guarantee that you end up in the original synchronization context is an anti-feature, i.e. a feature that makes the language worse instead of better. If that gets shipped in Swift then it will be a disaster. It's a foot-gun mislabeled as bubble gum.

This is no minor detail. It's a fundamental part of what makes async/await better. If all it did was make the code look simpler while making most simple-looking code also wrong then it would lead to more bugs, not fewer. The goal should be to make bugs more obvious, not less obvious. And trust me, if await returns to some arbitrary queue after being invoked on the main queue then it absolutely will lead to bugs, many of which will be subtle and unpredictable.

You also can't just change this behavior in the future. Changing this behavior will break code that depends on the original behavior. You would end up needing two different kinds of async/await, and the simple-looking one would be forever stuck with the behavior you almost never want.

6 Likes

in general case you can't return to the original queue/thread (except for the special case when you started on the main queue/thread).

One possible way is to declare a value as async result async let/var result, the same as func(...) async -> T; and await async values to concurrently invoke tasks let r=await (r1, r2)

lazy await semantic

async let a = await taskA()
async let b: ...
if condition {
  b = await taskB1()
} else {
  b = await taskB2()
}
let c = await (a,b) // invoke taskA and taskB1/taskB2 concurrently 

This is why I try hard to say "synchronization context" instead of "queue". Synchronization context is a term used in .NET, and there may be a better or more generic one but I don't know what else to call it. The concept is basically "the context that the caller expects to return to", and that is not necessarily tied to a particular thread or a particular queue.

In .NET the synchronization context is a general interface for scheduling work, and the typical contexts are basically "the UI thread" or "the thread pool" (i.e., some arbitrary non-UI thread). You can also make your own custom context and set it as the current context, which you would do if you had a particular way of scheduling work.

I think this concept could be mapped to Swift with similar basic options being "dispatch async to the UI thread" or "dispatch async to some concurrent queue that can be serviced by a thread pool", and there could be some ability to set the current context so you can influence the decision.

The cases that usually matter are the special ones like the UI thread, though, so that's the one that absolutely must work correctly. By default if you start on the main queue you should return to the main queue after an await. You may not be in the same full stack of queues you started on, but you will end up back on the main queue.

2 Likes

i see you point.

    // we are on the main thread here
1.	let image1 = await getImage1() // returns to main thread
2.	let image2 = someLongSyncCall(image1)
3.	let image3 = await cleanupImage(image2) // returns to main thread
4.	imageView.image = image3

at line 4 you want to be on the main thread, sure thing. though on lines 2 and 3 you probably don't...

when you mistakenly modify UI from a secondary thread in practice you resolve this relatively quickly as the debugger prompts you about this ("don't modify UI from background thread" or smth along those lines). my fear is that performing those steps like 2 unnecessarily on the main thread are much more harder to detect and avoid.

the alternative is something like this with explicit switch:

    // we are on the main thread here
1.	let image1 = await getImage1()
2.	let image2 = someLongSyncCall(image1)
3.	let image3 = await cleanupImage(image2)
    4.     await returnToMainTheadExplicitly() // explicit switch
5.	imageView.image = image3

or maybe somehow differentiate your intention via different flavours of await.

    // we are on the main thread here
1.	let image1 = await getImage1()
2.	let image2 = someLongSyncCall(image1)
3.	let image3 = awaitOnMainThread cleanupImage(image2) // switch
4.	imageView.image = image3

I prefer the Kotlin model, where you can switch context wherever you need.

  // we are on the main thread here
  imageView.image = withContext(BackgroundThread) {
        // we are on a background thread
	let image1 = await getImage1()
	let image2 = someLongSyncCall(image1)
	return await cleanupImage(image2)
  }

It makes clear where you are. One rule when designing Android coroutine API, is that an async function must never assume in what context it is when it is executed and always specify it explicitly (using withContext).

This also make a clear distinction between coroutine (which does not imply multithreading) and multithreading. You can perfectly write non-blocking async code where everything is executed in the main context, and you can then add multithreading by switching to background context where needed.

Assuming that async call are executed on an other context is flaw IMHO. getImage1() may perfectly be run on the main thread with non-blocking primitive (callback based IO calls converted into coroutine).

And as adamkemp, I really think that coroutine code must not change context implicitly.

One interesting approach for concurrency within actors is the structured synchronous programming paradigm.

I created an embedded DSL with functionBuilders to demonstrate its usage:https://github.com/frameworklabs/Pappe

Not done yet but upcoming is the integration of async replies from other actors into the calling actor.

If we’re gonna allow the syntax as you’ve just written, we’d need to tweak the rule surrounding let (or even need @once closure). The closest I can think of with the current rule is something like this

let (a, b) = withNursery {
  //Using Builder here
  nursery.spawn { taskA() }
  nursery.spawn { taskB() }
}

which is not bad, though it might get unwieldy with more items.

If we’re going to use coroutine model, which I believe we are (though @John_McCall did say it could still be premature to discuss that) , we’d at least need to mark a location (inside the callee) where it can yield. Otherwise, it’d just be the same thing as await immediately, differed by the order of execution because there’s still one control point at anytime.

Also, the current proposal said that you need to mark async functions with await, which I do agree with.

It’s already been discussed, started somewhere around here: Async/await status? - #119 by adamkemp.

Maybe. I’m not asking for a concurrency out-of-the-box, you’d still need to interact w/ dispatch queue, thread, or whathaveyou. I’m asking whether it’s powerful enough to facilitate an easy syntax on the caller site (even if it could cost some headache to the callee).

I just didn’t want us getting side-tracked on specific stack-allocation schemes; coroutine vs. native stack is (like it or not) a key design element and is fine to discuss.

3 Likes

am i understanding this fragment correctly that at point (2) i am still on the main queue, so inside processImage i shall not do something silly like a lengthy math operation synchronously?

@IBAction func buttonDidClick(sender:AnyObject) {
  // 1
  beginAsync {
    // 2
    let image = await processImage()
    imageView.image = image
  }
  // 3
}

Yes. beginAsync, as defined in the original blog post, does not change which thread/queue you're on. It basically just provides a scope that supports await within a scope that doesn't. Note that not every implementation of async/await would require this, and I've been pushing for another direction that would not require this.

Is there a distinction between the withContext idea and a hypothetical awaitable q.async { ... } function? They both seem like they could just be library features built on top of async/await as a convenience. Am I missing something deeper?

so what do i do in practice if i want to perform this long awaited operation on a non main thread? something like this?

@IBAction func buttonDidClick(sender:AnyObject) {
     beginAsync {
         var image: Image
         beginBackground {
            image = await processImage()
         }
         imageView.image = image
     }
}

i hope i am mistaken, as it is not so different to what i can do today:

@IBAction func buttonDidClick(sender:AnyObject) {
     downloadImage { image in
         DispatchQueue.main.async {
             imageView.image = image
         }
    }
}
1 Like

tl;dr - search the original proposal for "syncCoroutine"

The proposal talks about having a suspendAsync() function (as a complement to beginAsync) which gives you a callback which you can call to resume the async context at a time and place of your choosing, such as on another thread or queue.

I called this out in a previous comment, and I think it's pretty cool. The proposal then shows an extension method DispatchQueue.syncCoroutine() (and an async variant) which uses suspendAsync() to let you switch dispatch queues in the middle of an async function.

But as I noted before, this gives you two different meanings of "async" which can be confusing, and may account for some of the confusion in these discussions about the async/await concept. There is DispatchQueue.async() which executes the given block on another queue (and often therefore another thread), but the async of "async/await" means that execution of the function can be suspended and resumed in a way that is totally orthogonal to threads and queues.

1 Like

this? not dramatically different, but ok:

@IBAction func buttonDidClick(sender:AnyObject) {
	beginAsync {
		await backgroundQueue.syncCoroutine()
		image = await processImage()
		await DispatchQueue.main.syncCoroutine()
		imageView.image = image
	}
}

indeed

I'm personally not a fan of trying to use await to switch contexts deliberately. I think it makes it too difficult to keep track of which code runs in which context. Yes, it helps reduce the nested scopes, but in this case those nested scopes carry some important meaning. They show you visually where the boundary is between different contexts.

This is the same reason I think it's so important that await by default should return you to the original context. It's too difficult to reason about asynchronous code if two consecutive lines of code in the same scope may run in different contexts. Trying to be clever about this to reduce the number of curly braces is just going to make your code harder to understand and increase the likelihood of bugs.

If the nested scopes are too visually noisy then I would suggest extracting the nested scope into a function, which is the same kind of thing you would do in any situation where a function is getting too complex.

1 Like

ok, how async/await will change this code, or is it not a good candidate for async/await?

@IBAction func buttonDidClick(sender:AnyObject) {
	processImageInBackground { image in
		DispatchQueue.main.async {
			self.imageView.image = image
		}
	}
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
	processImageInBackground { image in
		DispatchQueue.main.async {
			if still good to do it on this cell {
				self.imageView.image = image
			}
		}
	}
    return cell
}

I think we want to achieve some thing if this level

func processImage() async -> Image {
  await DispatchQueue.global().asyncContext {
    return image
  }
}

func buttonClick() {
  DispatchQueue.main.noWaitContext {
    //fetch in the background, return to main
    let image = await processImage()
    self.image = image
  }
}

func tableView() -> Cell {
  let cell = ...
  DispatchQueue.main.waitContext {
    let image = await processImage()
    self.image = image
  }
  return cell
}

Though I don’t see (yet) how this can be accomplished with current design.

It could be something like this:

@IBAction func buttonDidClick(sender:AnyObject) async {
    self.imageView.image = await processImageInBackground()
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
    loadImage(forCell: cell)
    return cell
}

func loadImage(forCell cell: UITableView) async {
    let image = await processImageInBackground()
    if shouldStillUpdateImage(forCell cell) {
        cell.imageView.image = image
    }
}

Note that this doesn't follow the same rules from Chris Lattner's original proposal in that I'm calling an async function and not awaiting it. If that is a requirement it would look more like this:

@IBAction func buttonDidClick(sender:AnyObject) {
    beginAsync {
        self.imageView.image = await processImageInBackground()
    }
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
    loadImage(forCell: cell)
    return cell
}

func loadImage(forCell cell: UITableView) {
    beginAsync {
        let image = await processImageInBackground()
        if shouldStillUpdateImage(forCell cell) {
            cell.imageView.image = image
        }
    }
}

And processImageInBackground could look something like this:

func processImageInBackground() async -> Image {
    return await myImageProcessingQueue.async {
        return generateImage()
    }

I still feel like a lot of these cases feel clunky without tying async/await to some future type, which makes it much easier to do things like convert existing callback-based code into awaitable code or to control the behavior. But I don't want to derail the discussion again so I'll just leave it there.

we know that one of the benefits of await/async is to reduce callback hell indentation, which will also not happen if you split callback-hell based code into functions. let me combine your version into a single fragment:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
	let cell = tableView.dequeue(...)
	beginAsync {
		let image = await myImageProcessingQueue.async {
		    generateImage()
		}        
		if shouldStillUpdateImage(forCell: cell) {
			cell.imageView.image = image
		}
	}
	return cell
}

is it much better than below "no await/async" version?

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
	let cell = tableView.dequeue(...)
	onQueue(myImageProcessingQueue) {
		let image = generateImage()
		onMainQueue {
			if shouldStillUpdateImage(forCell: cell) {
				cell.imageView.image = image
			}
		}
	}
	return cell
}