Thanks for your reply, Rokhini! Sorry it's been so long—I went on vacation and am only now getting back to my stack of discussions…
(emphasis mine)
OK, I don't mean to pick nits here, but I am still trying to nail down the truth. From your language above, it seems like it's not just a matter of which primitives you use, but how you use them. However, I also understand that a semaphore does not convey task dependency information, and that in a thread pool with task-stealing, avoiding deadlock can depend on using dependency information to ensure the right task is stolen. So which is it? If I implement a (correct) lock with a semaphore and use it according to the pattern that you recommend for locks, can I deadlock?
While such an internal implementation of a lock allows for a thread that is not the one which called lock() to call unlock()
Can we please pretend I never brought that up? I was wrong about the rules for locks and I'm not actually interested in the case of passing a lock across threads. I haven't been focused on it for several messages now, except to say that it could theoretically make sense, if it were allowed (but it isn't!)
The main actor is tied to the main dispatch queue. The main queue is tied to the main thread but that tie can be broken if your application calls dispatch_main() .
Sorry, do you mean DispatchQueue.dispatchMain? Sadly, I am not well versed in GCD and can't figure out what that documentation says it's doing. I don't know what "park" or "wait" mean in this context. There's nothing that says this function must be called on the main thread, so “parks the main thread” (presumably?) doesn't mean “blocks the current thread.” But you generally can't force a thread that's not the current thread to stop or pause, so it's hard to know what that means… When documentation says a call “waits for” something, that generally does mean the current thread is blocked. So I'm gonna guess that dispatchMain is callable only from the main thread (despite that not being documented), and causes that thread to block until some other thread submits new blocks to the main queue.
dispatch_main() does a bit of bookkeeping and exits the main thread
When you say this call "exits the main thread," do you mean the main thread actually exits, or do you just mean that it blocks?
at which point, the main queue is no longer thread bound to a main thread. It will be serviced by a thread on demand from the dispatch's worker thread pool when there is work on the main queue.
So you could try to make the case that you have some freedom to hold locks across await if your code executes on the @MainActor but I think that is fragile… Relying on auxiliary knowledge like this to use locks in async code on the MainActor, is not how I'd recommend someone write code with async.
Agreed.
OK, do all the special rules for interop with Swift async have to do with thread-hopping, or are there others?
It's about thread-hopping and also about using primitives that assume a minimum number of threads. A semaphore assumes at least 2 threads being vended to you - the thread which will wait and another one which will signal.
A lock doesn't have this requirement - it is perfectly possible, albeit redundant - to use a lock for code that is entirely single threaded. This ties back into thinking about the guarantee of forward progress as being able to finish the workload on a single thread if that's what the runtime decides it can vend to you.
This is great.
- Is this true even if the task that will unblock the primitive is itself going to block?
How is that possible? You have a thread running a task, if the task is using a primitive that causes it to block, you are now blocking the thread as well. How can you guarantee that the task will unblock itself if the thread that is executing it, is blocked?
Maybe I'm missing something, but this seems straightforward. Let me try to reconstruct the context. You wrote:
Blocking a thread on a primitive can be safe if you can guarantee that the task which will unblock that primitive and your thread, has already run or is concurrently running.
and I asked
Is this true even if the task that will unblock the primitive is itself going to block?
It's possible this way:
- Thread T0 is running a task S0.
- S0 launches thread T1 which runs task S1
- S0 blocks on a result from S1
- S1 launches thread T2 which runs task S2
- S1 blocks waiting on a result from S2
Here, S1 is the task that will eventually unblock the primitive blocking S1. It blocks in the last step but is guaranteed to be unblocked when S2 completes.
- When you say "has already run" I suppose you mean that the async function that will unblock the primitive has started, is suspended, and is guaranteed to unblock the primitive before exiting?
I meant that it has already unblocked the primitive and so your thread doesn't have to block on the primitive at all when it is trying to acquire it.
If the Task that will unblock the primitive is suspended and hasn't yet unblocked the primitive, once the Task becomes runnable, there is no guarantee that you will get an additional thread to execute that task - the cooperative pool may be at its limit and it may not give you another thread.
Got it! So “is concurrently running” means “is running on a different thread,” which is usually pretty hard to guarantee.
The main thing I'd advise, is to be able to make sure your workload can complete with a single thread using the environment variable. If you are able to run to completion reliably in that environment, you are safe and will be able to handle multiple threads running your workload.
So that's what the environment variable does? Very revealing.
Thanks, this is super-helpful!