Possibility of synchronous Task body execution

It is possible there is no problem here, but I feel like I need some guidance to be sure. Here's the issue, very simplified:

nonisolated(nonsending) func timingSensitiveFunction() async {
  // point a
  Task {
    // point b
    potentiallyDeadlockingWork()
  }
}

It is vital that execution from point A to B never be synchronous. I know that it is possible Task will synchronously schedule its body on an actor, and that should be fine.

Are there any other situations/optimizations here that could result in moving from A to B synchronously? Or is this plain Task sufficient?

2 Likes

A plain Task should be sufficient. That's the rationale behind the introduction of Task.immediate; Task.init always schedules the closure to run at some (possibly very small) point in the future, but using Task.immediate above would go from point a to point b above without changing context; scheduling would only happen when a suspension occurs at some future await.

2 Likes

Thank you! That's about what I expected.

Would it be too strong for me to say this is a semantic guarantee?

I think you have to define what your expectations around "synchronous" here are. What happens under the hood is that Task {} is creating a job that's enqueued on the default executor. Now depending on what the executor and your runtime looks like a few different things can happen.

If the call to timingSensitiveFunction is not isolated to an actor, i.e. it runs on the default executor, and the default executor has more than one thread then the Task {} might run concurrently to the task calling timingSensitiveFunction which means in theory potentiallyDeadlockingWork can finish before your return out of timingSensitiveFunction.

However, it might also be that the default executor has only a single thread so the Task {}'s job will get enqueued and handled by the same logical thread. In theory, if there is only a single thread and nothing happens after timingSensitiveFunction the task with "point b" might run right after but not "synchronously".

It really depends on how this work could deadlock and what must be guaranteed for it to not deadlock.

3 Likes

Right, ok thank you for this. As always, I should have taken a little more time to better explain the actual situation.

There's some code out of my control here, but I'm pretty sure this is an accurate representation.

nonisolated(nonsending) func a() async {
  lock.withLock {
    Task {
      b()
    }
  }
}

func b() {
  lock.withLock {
  }
}
1 Like

Thanks for sharing. So the code is dangerous since calling out to any external code from within a lock can and often will result in deadlocks. We have seen this already when continuations were resumed from within a lock. Both continuation resumption and task creation are enqueuing a job on an executor. The deadlock with continuations was not caused by the job enqueue but due to the runtime holding a lock while calling task cancellation handlers.

So for your code the only thing that can cause a deadlock here is either:

  • An executor that holds a lock while running a job where the same lock is used while enqueueing a job. No executor should be doing this so this should be fine.
  • An executor that immediately calls job.runSynchronously when enqueued is called

The second one is a lot more likely and can easily happen. If you have an executor that does essentially this:

final class MyExecutor: TaskExecutor {
    func enqueue(_ job: consuming ExecutorJob) {
        job.runSynchronously(on: self.asUnownedTaskExecutor())
    }
}

While Task {} doesn't inherit executor preferences automatically if such an executor would be set as the default executor then your example would deadlock. In fact, the snippet below shows such a deadlock happening.

Deadlock
@main
struct Test {
    static func main() async throws {
        let executor = MyExecutor()
        await withTaskExecutorPreference(executor) {
            await a(executorPref: executor)
        }
    }
}

let lock = Mutex(1)

nonisolated(nonsending) func a(executorPref: TaskExecutor) async {
    _ = lock.withLock { _ in
        Task(executorPreference: executorPref) {
            b()
        }
    }
}

func b() {
    lock.withLock { _ in
        print("Never executing")
    }
}

final class MyExecutor: TaskExecutor {
    func enqueue(_ job: consuming ExecutorJob) {
        job.runSynchronously(on: self.asUnownedTaskExecutor())
    }
}

Nothing in the contract of Task.init or TaskExecutor is guaranteeing this AFAIK. An executor could run a job right when it is enqueued. I believe the difference between Task.init and Task.immediate is that the job is only created and enqueued at the first potential suspension point and the synchronous preamble of the task is run without creating a job at all.

4 Likes

There's no strong guarantee in the language or runtime that says the executors must not run a thing inline if isolation allows it to. You could imagine an executor having queues per threads, and realize "i have nothing in my queue! might as well just run this thing immediately". I'm not entirely sure if Dispatch technically is able to pull such tricks but wouldn't be too shocked if it did... I'll see if I can find out though.

4 Likes

Thank you all for going through this with me. It's bad news for my design, but very useful to have clarity on the risks here.

1 Like

what if we did the opposite and made the calling of the lock in side the Task {}

nonisolated(nonsending) func a() async {
  Task {
      b()
    }
}

func b() {
  lock.withLock {
  }
} 

func c() async  {
  // …. async work
  lock.withLock {
  }
}

will the above code make a problem if c() was called at the same time the Task {} in a() was excuted or they will be excuted on different threads ?

Following up here, since I promised I'd check -- none of our current executors, including dispatch, would execute the block immediately/synchronously; So while we do not enforce it in any way today, one can rely on the lack of running those blocks immediately.

Task.immediate (and friends) being a notable exception here, but that doesn't even get passed to an executor, unless a suspension or hop happens, so it's a separate semantic really, that is unique to that API.

6 Likes

I don’t think we’ve formalized it, because it’s somewhat difficult to formalize, but yes, generally I would consider enqueue implementations that run the job synchronously to be incorrect.

I suppose you could formalize this as saying that there must not be a happens-before relationship between any event of the job and the return from enqueue. Obviously events can happen chronologically before enqueue returns, but if you can actually establish a well-ordering, you’ve done something wrong.

1 Like