You ask:
So why does Message1 not appear right after step 1 as coded?
Because you are blocking the main thread with your repeat-while loop. Your code is a perfect demonstration of why you should never block the main thread, as the UI cannot be updated until you free up the main thread. Any system events will be prevented, too. If you block the main thread for long enough, you even risk having your app unceremoniously killed by the watchdog process (designated with the termination code 0x8badf00d, pronounced “ate bad food”).
Your code says:
// The intention of the next lines is to hold the
// processing of the main thread until the async task is
// completed.
That is the problem. That is why your UI has frozen. This is to be categorically avoided.
FYI, this behavior is not unique to Swift Concurrency. This issue would manifest itself if you had attempted to block the main thread waiting for something running slowly on a GCD background queue, too.
BTW, I noticed that testAsync is calling sleep. But, as Apple says in Swift concurrency: Behind the scenes:
Recall that with Swift, the language allows us to uphold a runtime contract that threads will always be able to make forward progress. It is based on this contract that we have built a cooperative thread pool to be the default executor for Swift. As you adopt Swift concurrency, it is important to ensure that you continue to maintain this contract in your code as well so that the cooperative thread pool can function optimally.
Thus you should not call sleep inside testAsync. You can use Task.sleep(nanoseconds:), though:
func testAsync() async throws {
print("in testAsync step 2")
try await Task.sleep(nanoseconds: NSEC_PER_SEC * 2)
print("in testAsync step 3")
testAsyncDone = true
}
It looks like a traditional “sleep” API, but as the docs say, this “does not block the underlying thread” (emphasis added).
Also, note that this code is not thread-safe. You are accessing testasyncDone from multiple threads without any synchronization. Turn on the Thread Sanitizer (TSAN), and it will report:

You can synchronize this yourself (locks or GCD are traditional mechanisms), or with the new Swift Concurrency system, we would use an actor. See WWDC 2021 video, Protect mutable state with Swift actors.
So, I suspect that this is going to trigger the response, “well, if I can’t block the main thread, then what should I do?”
Let us consider a bunch of alternatives.
If you really need two tasks running in parallel and coordinate this with some state variable, one method counting until the other changes the state of that variable, you could first create an actor to capture this state:
actor TestAsyncState {
private var _isDone = false
func finish() {
_isDone = true
}
func isDone() -> Bool {
_isDone
}
}
Then you could check this actor state:
var testAsyncState = TestAsyncState()
@IBAction func didTapButton(_ sender: UIButton) {
print("MyButton Pressed step 1")
Task.detached { [self] in
await MainActor.run { message1.text = "In Button action - start" }
try await self.testAsync()
print("MyButton Pressed step 4")
await testAsyncState.finish()
}
Task.detached { [self] in
// The intention of the next lines is to keep ticking
// until the state actor isDone or we reach 100 iterations
var count = 0
repeat {
count += 1
try await Task.sleep(nanoseconds: NSEC_PER_SEC / 10)
print(count)
} while await !testAsyncState.isDone() && count < 100
await MainActor.run { message2.text = "In Button action - finished" }
}
}
Or, alternatively, you could bypass this actor state variable entirely, and just cancel the counting task when the other finishes:
@IBAction func didTapButton(_ sender: UIButton) {
print("MyButton Pressed step 1")
let tickingTask = Task.detached { [self] in
// The intention of the next lines is to keep ticking
// until this is canceled or we reach 100 iterations
do {
var count = 0
repeat {
count += 1
try await Task.sleep(nanoseconds: NSEC_PER_SEC / 10)
print(count)
} while !Task.isCancelled && count < 100
await MainActor.run { message2.text = "In Button action - finished" }
} catch {
await MainActor.run { message2.text = "In Button action - canceled" }
}
}
Task.detached { [self] in
await MainActor.run { message1.text = "In Button action - start" }
try await self.testAsync()
print("MyButton Pressed step 4")
tickingTask.cancel()
}
}
Or, if you just wanted to do something on the main thread when the async method is done, just put it after the method that you are awaiting:
@IBAction func didTapButton(_ sender: UIButton) {
print("MyButton Pressed step 1")
Task.detached { [self] in
await MainActor.run { message1.text = "In Button action - start" }
try await self.testAsync()
print("MyButton Pressed step 4")
// put whatever you want on the main actor here, e.g.
await MainActor.run { message2.text = "In Button action - finished" }
}
}
Or, if you wanted a ticking timer on the main thread and you want to cancel it when the async task is done:
@IBAction func didTapButton(_ sender: UIButton) {
print("MyButton Pressed step 1")
var count = 0
let timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
count += 1
print(count)
}
Task.detached { [self] in
await MainActor.run { message1.text = "In Button action - start" }
try await self.testAsync()
print("MyButton Pressed step 4")
// put whatever you want on the main actor here, e.g.
await MainActor.run {
timer.invalidate()
message2.text = "In Button action - finished"
}
}
}
There are lots of ways to skin the cat. But, the key is that none of these block the main thread (or any thread, for that matter), but we can initiate whatever needs to happen on the main thread at the end of the async task.