Wait until Task is finished without await

I want to wait until some task is done without having to make the function async. Is that possible?

var data: Data {
    return task!.value.data
}

In this instance, I can't easily make data async because it's part of a protocol.

I understand the design of async/await is to guarantee progress. Would having some way to yield until a task is done (that isn't async) not guarantee progress?

It is by design not easy to block until a task finishes; that can be a dangerous thing to do. This sounds like an XY problem: why do you want to do this?

2 Likes

I have a protocol which has var data: Data on it. This is for abstracting the underlying representation of the data which is ultimately saved in my documents.

Because of the requirements of SwiftUI's FileDocument, I simply can't make it async AFAIK (see fileWrapper(configuration:) | Apple Developer Documentation)

Does that help?

Unfortunately, I’m not familiar with how fileWrapper tends to be used. However, when you have an inherently asynchronous thing that you can’t await, I’ve found that the solution often involves making the property some kind of Observable<T?> instead of T. This might be, for example, a @Published var data: Data? = nil on an ObservableObject.

I don't think I can make that work in this case. If the Data were on an observable reference by the FileDocument, then what would I do if that Data is nil? Also there's the question of undo/redo. I suppose ReferenceFileDocument might improve things, but then I have to do my own undo/redo.

To the original question though: why isn't there some yield-until-task-is-finished function? I didn't mean block above. After all, if the caller is yielding, then progress can be made on other things.

There is, that's exactly what await means.

Would you be able to extract the actual work that the task is doing as a synchronous operation? Unless it's doing something like networking, you should be able to do that. If not you can create a thread under your control (via threading APIs or a serial dispatch queue you create) and block that one thread. That way, you will not affect the Swift concurrency thread pool.

I also see that the documentation that you linked to for fileWrapper mentions it is called on a background thread, you can probably just use that thread (seems like it is meant for you to spend time on it generating the data).

1 Like

I'm still not quite sure what you mean by yielding. Fundamentally, synchronous code has no concept of suspending, giving up its thread to other ... things ... and then resuming. If it waits, it sits on its thread and blocks anything else from using it.

You could spawn a detached task to call you back, though:

Task.detached {
  let data = await task!.value.data

  // Callback on whatever random BG thread we're on:
  callback(data)

  // Or (more useful), callback on the main thread:
  await MainActor.run { callback(data) }
}
2 Likes

I can tell RunLoop to go do a few tasks. That's yielding. Didn't need async/await for it. What am I missing?

I think I tried that in the past... will give it a go again to refresh my memory.

I meant outside of an async function (thought that was obvious, sorry). I'm not understanding why it isn't possible. Not that I disagree, just don't understand.

Some operations aren’t safe to be suspended or to switch threads — see swift-evolution/0340-swift-noasync.md at main · apple/swift-evolution · GitHub for some examples.

I created a new document based app project (default Xcode template) just to be sure and added a sleep(10) at the beginning of fileWrapper. Not only does it hang the UI, but it also doesn't even update the titlebar to show the new document name (on macOS)!

Time to file some bugs.

I don’t think you’re encountering a bug. sleep is from the c standard library and it is a synchronous, blocking operation. It tells the OS to not run your program (in that thread) for X amount of time. When you call sleep on the main thread, that thread cannot perform any work for the time you specify. To be clear, sleep does not mean that the current function/task suspends (which is what happens with async/await); instead, the entire thread goes to sleep. So, since the UI performs computations and updates on the main thread, telling the OS to put the main thread to sleep is expected to freeze the UI.

Now, coming back to your example, I think you might need to share some more code for folks to understand exactly what you’re trying to do. Unfortunately, other concurrency patterns (like sleep or RunLoop) do not always directly translate to swift concurrency patterns. For example, you can call await Task.sleep instead of c’s sleep, but you need to make that call in an async context (e.g. an async function). Also, if data is not marked async, you can’t just block that thread to perform a long-running task; that would be very inefficient. If you share a more detailed code example, the community might be able to point to an appropriate Swift concurrency pattern.

5 Likes

But, the documentation for fileWrapper (linked upthread) says:

SwiftUI calls this method on a background thread. Don’t make user interface changes from that thread.

Ok that's odd. I quickly tested this in a project of mine where I use FileDocument, added a sleep(10) in the same method, it did not block the GUI. You might want to pause the program in the debugger while the GUI is blocked and try to see what the main thread is doing.

Seems like I can reliably reproduce the UI hang by attempting another save while the first one is in progress. Steps are:

  1. Run the test app (SwiftUI document based app with sleep(10) at the beginning of fileWrapper)
  2. Create a new document
  3. cmd-s and do the dialog
  4. observe: document title still says untitled
  5. cmd-s again
  6. observe: beachball, UI hung

This undesirable behavior doesn't happen if the sleep(10) is removed.

I think the point of having this on a background thread is just to not hang the UI if the save takes, say half a second. Unfortunately the long-running operation for me is the voxelization of a fairly high resolution triangle mesh, which takes on the order of 10 seconds on my machine.

When hung, the UI thread is waiting on a semaphore:

I am not sure you will be able to sort that out using SwiftUI, which has a very limited API. I'm pretty sure you can achieve whatever you need by subclassing NSDocument in an AppKit app.

As you requested, here's a more complete example which shows the issue which motivated my questions. You can simply paste it into the SwiftUI document based app template if you'd like to run it and experiment. (In my actual app, I do use async/await for loading, so the slowness on loading in this example I don't need to fix. just saving)

SlowSaveApp.swift:


import SwiftUI
import UniformTypeIdentifiers

extension UTType {
    static var exampleText: UTType {
        UTType(importedAs: "com.example.plain-text")
    }
}

// Note that we use a class because the actual app uses an
// immutable data model with structural sharing between
// versions.
class SlowDataModel {

    private let text: String

    var data: String {
        // This simulates a slow conversion between
        // representations.
        sleep(10)
        return text
    }

    init(text: String) {
        // Could start the conversion here, but
        // how could one do that using async/await?
        self.text = text
    }
}

struct SlowSaveDocument: FileDocument {
    var model: SlowDataModel

    init(text: String = "Hello, world!") {
        self.model = SlowDataModel(text: text)
    }

    static var readableContentTypes: [UTType] { [.exampleText] }

    init(configuration: ReadConfiguration) throws {
        guard let data = configuration.file.regularFileContents,
              let string = String(data: data, encoding: .utf8)
        else {
            throw CocoaError(.fileReadCorruptFile)
        }
        model = SlowDataModel(text: string)
    }
    
    func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
        let data = model.data.data(using: .utf8)!
        return .init(regularFileWithContents: data)
    }
}

ContentView.swift:


import SwiftUI

struct ContentView: View {
    @Binding var document: SlowSaveDocument

    @State var text: String = ""

    var body: some View {
        TextEditor(text: $text)
            .onAppear {
                // Slowness here is easier to fix with async/await.
                // In my actual app I show a spinner
                text = document.model.data
            }
            .onChange(of: text) { newValue in
                document.model = SlowDataModel(text: newValue)
            }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(document: .constant(SlowSaveDocument()))
    }
}