Incremental migration to Structured Concurrency

This is a great talk! But I'm finding this point confusing. They say:

Using a lock in synchronous code is safe when used for data synchronization around a tight, well-known critical section. This is because the thread holding the lock is always able to make forward progress towards releasing the lock. As such, while the primitive may block a thread for a short period of time under contention, it does not violate the runtime contract of forward progress. It is worth noting that unlike Swift concurrency primitives,

Now, I don't understand how this upholds the guarantee at all. Sure, the thread that succeeds in acquiring the lock makes forward progress, but any other thread contending for it will block, and thus not make forward progress. So what guarantee am I actually being asked to uphold? That at least one thread makes forward progress? I don't think that's quite it, because I can easily deadlock two out of my quad-core system's four threads using a pair of mutexes locked in different orders, and surely that would be bad.

In fact, later in this talk, there's:

Such a code pattern means that a thread can block indefinitely against the semaphore until another thread is able to unblock it. This violates the runtime contract of forward progress for threads.

…which is not different, in any way I can tell, from the critical section scenario, unless there's some way to measure whether something is blocking “indefinitely.” AFAICT if I'm going to uphold a guarantee, it needs to be well-defined, and this one doesn't quite seem to be. Is there a more rigorous formulation akin to what's found here?

Yes, blocking typically involves a thread context switch which is expensive. But it's all relative - here when I said "bad", I meant relative to unsafe behaviour which can lead to unrecoverable deadlocks. It is definitely not efficient to block or context switch excessively which I've talked about in the Swift Concurrency: Behind the Scenes WWDC talk.

There is more nuance here than a blanket statement of saying "blocking is bad".

4 Likes

It is a well defined guarantee that you are being asked to uphold and I hope it becomes clearer if you consider the distinction between locks and semaphores.

Threads that are blocked on a lock, are blocked on another thread that is already holding the lock and in the critical region. It is most likely on another CPU executing the critical region. As a result, the blocked threads will become unblocked shortly after when the lock is released. When you have N threads all blocked on a single lock, they will make progress serially through the critical region protected by the lock. So yes, for a short period of time while blocked on a lock, each of those N-1 threads are not making forward progress. But in a larger eventual timescale - say on the order of hundreds of milliseconds - each of those N-1 threads will eventually get the lock, and then finish its work and make forward progress.

I don't think that's quite it, because I can easily deadlock two out of my quad-core system's four threads using a pair of mutexes locked in different orders, and surely that would be bad.

Yes and that would be bad anywhere, regardless of whether you run on Swift concurrency's cooperative pool or not, because you don't have well-defined lock ordering.

Semaphores on the other hand are different from locks because you never know who is going to unblock a thread blocked on a semaphore. It could be another thread that is running on core and making progress and who will signal the semaphore. Or it could be work that has yet to even start executing - i.e future work. It is that ambiguity which makes it impossible to hold a semaphore safely in the cooperative thread pool. You can easily have NCPU tasks all blocked on a semaphore, expecting an NCPU+1th task to get a thread and run and then signal the semaphore and unblock the NCPU tasks.

AFAICT if I'm going to uphold a guarantee, it needs to be well-defined, and this one doesn't quite seem to be. Is there a more rigorous formulation akin to what's found here?

Another way to think about the guarantee you are being asked to hold is the following: Your Swift Concurrency workload should be able to complete even if the thread pool decides to give you just a single thread.

Using a lock in such a workload would be just fine, the same thread will take the lock and drop it each time. Using a semaphore however, won't be safe. The single thread may run a task that is expecting some condition to be met and blocks on the semaphore until it is. Your workload is now deadlocked unless you are given another thread. It cannot make forward progress here on its own.

Now, if your code was instead awaiting on such a condition, that thread would switch away from the task that cannot make progress, to execute another task which can. Eventually, your workload would complete. The LIBDISPATCH_COOPERATIVE_POOL_STRICT=1 environment variable helps you uphold your runtime guarantee by doing exactly this - it restricts your thread pool to size 1.

11 Likes

Thanks for your answer (and for the talk), Rokhini!

I think that depends on the fairness of the scheduler and the priorities of those threads? I guess we're probably only concerned about blocking the threads in the cooperative threadpool here, where fairness and priority is known.

OK… assuming a thread never causes threads to block without guaranteeing that it will allow them to run again in a finite amount of time, is that enough? Or is this really about having thread dependency information (which is mentioned several times in the talk)?

Heh, this sounds really straightforward, but as I think about it, I'm not sure I know how to use that to draw conclusions about what blocking code I'm allowed to write. I've tried about a dozen ways to rephrase it, and they're all failing me.

Maybe it's just this:

  • if an async task T1 (or any sync code called by T1) causes another async task T2 (or any sync code called by T2) to block, it must allow T2 to run again before T1's next await, and before T1 returns.

Even if I got that right, I'm still not sure what the rules are for a thread that's not in the cooperative pool that causes an async task to block.

Thanks again for taking the time.

1 Like

Yes true, we can easily construct a system whereby all of your threads are preempted for long periods of time due to a constant stream of higher priority work elsewhere - maybe even in a different process. But the point I'm trying to make is that, if the threads are given the CPU time, those threads will make progress and complete.

OK… assuming a thread never causes threads to block without guaranteeing that it will allow them to run again in a finite amount of time, is that enough?

I'm not sure how you can really promise that in code that you write, without knowledge of how the thread pool would schedule your tasks. It's pretty fragile to rely upon it especially if changes are made in scheduling behaviour or even in the number of threads that the thread pool would give you. For instance, you can imagine that the cooperative thread pool may want flexibility to dynamically size up or down depending on what other workload is running on the system so as not to overcommit more threads than we have cores.

Maybe it's just this:

  • if an async task T1 (or any sync code called by T1) causes another async task T2 (or any sync code called by T2) to block, it must allow T2 to run again before T1's next await , and before T1 returns.

Even if I got that right, I'm still not sure what the rules are for a thread that's not in the cooperative pool that causes an async task to block.

I'll be honest, I'm not sure I fully grokked that formalization.

What do you mean by "another thread causing an async task to block"? That can only happen if your async task and this other thread are using some primitive to synchronize between them. So really the code you can write really comes down to what kinds of primitives are safe to use in this world and whether they express the dependencies clearly (as mentioned in the talk):

Is it an await-ing on another task or on an actor whereby the dependency on what will unblock the task is clearly expressed? Or a lock where the thread dependency on the lock owner is known? Or is the primitive being used here something like a semaphore or a condition variable where it is not know who or what thread or work will signal it?

1 Like

These two methods are such thin wrappers over Task that I don't understand why they exist at all. Why not just use Task directly instead of these methods?

You're correct, at the end they seem almost pointless s the real action was here:

Thanks again for all your comments. To update you all on what I actually did: I created a semaphore based analogue of CheckedContinuation, then replicated withCheckedThrowingContinuation. This allowed me to remove the structured concurrency completely while allowing me to switch to it in later refactoring.

@available(*, deprecated, message: "use withCheckedThrowingContinuation")
public func withThrowingSemaphore<T>(queue: DispatchQueue = DispatchQueue.global(),
                                       _ body: @escaping (SemaphoreContinuation<T>) -> Void) throws -> T {
    let semaphore = SemaphoreContinuation<T>()
    queue.async {
        body(semaphore)
    }
    return try semaphore.wait()
}

@available(*, deprecated, message: "use withCheckedThrowingContinuation")
public func withThrowingSemaphore(queue: DispatchQueue = DispatchQueue.global(),
                                    _ body: @escaping (SemaphoreContinuation<Void>) -> Void) throws {
    let semaphore = SemaphoreContinuation<Void>()
    queue.async {
        body(semaphore)
    }
    return try semaphore.wait()
}

// Semaphore based analogue of CheckedContinuation
public class SemaphoreContinuation<T> {
    let semaphore = DispatchSemaphore(value: 0)
    private var _result: Result<T,Error>!

    public func resume(returning x: T) {
        _result = .success(x)
        semaphore.signal()
    }
    
    public func resume(throwing x: Error) {
        _result = .failure(x)
        semaphore.signal()
    }
}

extension SemaphoreContinuation where T == Void {
    func resume() {
        resume(returning: ())
    }
}

fileprivate extension SemaphoreContinuation {
    
    func wait() throws -> T {
        semaphore.wait()
        return try _result.get()
    }
}

The only issue I left unsolved was the @escaping body which wasn't, due to the the semaphore, actually escaping.

This is something I've been wondering about as well. I think we're ultimately going to need new locking primitives which operate on the Task level rather than the thread level, and which communicate task dependencies to executors.

I think it makes sense. This is the kind of information that the OS scheduler needs in order to efficiently schedule threads on physical execution units, so if we bring that in to the Swift runtime as executors managing tasks on a thread pool, they will need the same kind of metadata about the work that they manage as the OS scheduler currently has -- including which chunks of work are currently being blocked by which specific other chunks of work.

I also worry that we have already defined Executor, SerialExecutor, and UnownedSerialExecutor as public protocols in the standard library. Can those be extended to incorporate locking primitives/task dependency metadata without breaking ABI? :man_shrugging:

Sorry, let me try to do better.

What do you mean by "another thread causing an async task to block"? That can only happen if your async task and this other thread are using some primitive to synchronize between them.

Yes, exactly. I didn't want to phrase it in terms of some responsibility for or guarantee from the async task that gets blocked, because after all, it is blocked and can't do anything at all. I didn't want to phrase it in terms of some property of the overall system being built (like “it would work if there was only one thread”) because those kinds of properties can be devilishly hard to reason about in concurrent systems.

So really the code you can write really comes down to what kinds of primitives are safe to use in this world and whether they express the dependencies clearly (as mentioned in the talk):

Sure, I watched the whole talk and gratefully took in every word! But it's hard to imagine that's the whole story, since although you say some primitives can be used safely, you also advise caution in their use. I'm trying to figure out what the exact cautions are. Surely it comes down to what you do with the primitives? The simplest example I can think of that is likely problematic: a thread could acquire a lock and never release it. While I can imagine cases of plain multithreading where that is sometimes acceptable (if inefficient), it seems likely to be unacceptable for a fixed-sized cooperative thread pool. Another example: one thread could lock a mutex, then pass the lock to another thread (by move) to be unlocked. While that's not an error in a plain multithreaded system, I'm guessing it undermines the dependency information that the system assumes to be represented by a lock. Lastly, since a lock can be implemented in terms of a binary semaphore, it doesn't seem to be an intrinsic property of the semaphore that makes it problematic.

Another question: is failure to express dependency information potentially manifested in deadlock, or only in temporary priority inversion? The latter might be an acceptable risk for some applications, while the former is almost never acceptable.

Thanks,
Dave

Surely it comes down to what you do with the primitives? The simplest example I can think of that is likely problematic: a thread could acquire a lock and never release it. While I can imagine cases of plain multithreading where that is sometimes acceptable (if inefficient), it seems likely to be unacceptable for a fixed-sized cooperative thread pool.

That would be something that is risky in any thread pool. Thread pools are not infinite - most of them have some kind of limit in the end. It might be some really high number like 1024, they may keep going until the kernel tells you there is no more memory to create more threads. So if you have a thread which never releases a lock and all other work items eventually need that lock, you will eventually still run into a deadlock of some kind because those work items can't make progress.

The cooperative pool simply brings the thread pool limit to be closer to NCPUs.

Another example: one thread could lock a mutex, then pass the lock to another thread (by move) to be unlocked. While that's not an error in a plain multithreaded system, I'm guessing it undermines the dependency information that the system assumes to be represented by a lock.

pthread mutexes very unfortunately allow this but if you do this, the behaviour is actually undefined - regardless of whether it is used in async code or not. What is the critical region if you have a thread that locks and another thread that unlocks it? It's extremely fragile and unclear what kind of synchronization you are expecting here and what your protected region is.

That is why os_unfair_locks actually explicitly enforce this by crashing your process if the thread that unlocks the lock is not the same one which took the lock.

Edit: I just noticed the very important point you made here - 'by move'. The locks that exist today don't support this, and as mentioned, os_unfair_locks explicit enforce thread locality. If we have the ability to do this in the future, we'd have to rethink our lock APIs. The caution was made with what we currently provide and support today.

Lastly, since a lock can be implemented in terms of a binary semaphore, it doesn't seem to be an intrinsic property of the semaphore that makes it problematic.

Yes you're right but the only difference here, is that you are using a single bit which is flipped on and off instead of having say, a pthread_t worth of information to have additional bookkeeping on who the locking thread is. This does mean that the primitive will allow you to unlock a mutex on a different thread than the one which locked it but then you are falling into the same problem as earlier mentioned, of having undefined behaviour.

But it's hard to imagine that's the whole story, since although you say some primitives can be used safely, you also advise caution in their use. I'm trying to figure out what the exact cautions are

Regarding locks, the caution is as follows: Locks intrinsically rely on thread locality - you need to unlock the lock on the same thread which took the lock. You can't hold a lock across an await because there is no guarantee that the same thread will pick up the continuation.

Using thread local storage is another example of something that is not safe in Swift concurrency since you don't know which threads will pick up your task and the various partial tasks as it suspends and resumes.

The cases where you need caution with some primitives are really more about correctness in your state machine as opposed to "you are likely going to get into an unrecoverable state". Now if you use these primitives unsafely - like never releasing a lock - that is bad behaviour anywhere, regardless of whether that happens in async code or not.

See also SE-0340: Unavailable From Async Attribute which will provide annotations that API providers can use to warn against using such unsafe primitives in async code, and provide safer alternatives.

Another question: is failure to express dependency information potentially manifested in deadlock, or only in temporary priority inversion? The latter might be an acceptable risk for some applications, while the former is almost never acceptable.

Both.

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. This is something you cannot guarantee with a semaphore but you can with a lock because you will only block on the lock when you know someone else who will unblock you is already running code in their critical region.

The likelihood of deadlock when using a primitive without clear dependency information is higher in a thread pool with a small limit, compared to one with a much higher limit. In a thread pool with a higher limit, the thread pool will keep giving you threads until one of those threads runs your semaphore-signaling task runs, or the thread pool limit is hit. Most thread pools have a high enough limit that you will likely get away with not hitting the limit and subsequent deadlock. But this comes at the cost of thread explosion, inefficiencies and lots of contention.

With Swift concurrency, because we have the asynchronous waiting semantics of await, the choice made was to take advantage of that to build a more efficient cooperative thread pool instead.

I also encourage you to think about the risk of priority inversion in say, a constrained device like the Apple Watch where you could easily see a "small" priority inversion result in multi-second user visible hangs.

4 Likes

We do have this dependency information today for task level dependencies and we don't need new locking primitives for that - you simply await on a task. Structured concurrency already provides this dependency information.

Today, the scheduler doesn't really perform any kind of DAG analysis to determine which piece of work to schedule first vs which to schedule next. That would be probably a bit too inefficient to factor into scheduling decisions.

That being said, the information is available if we wanted to use it in some form in the future.

2 Likes

Dumb question, but am I safe using DispatchSemaphore or should I switch to NSLock? I tried to work it but can decipher it from documentation.

Semaphores aren't safe. NSLock can be used safely but require caution to only use in synchronous code and not across an await.

3 Likes

Hmm, we do also have unstructured Tasks, so I don't think we can entirely depend on structured concurrency. That said, if we consider the ultimate goal of having everything isolated to some actor/global actor, then it would be true. It would mean every access to every piece of mutable state is dispatched by some serial executor, so we can simply say that every task in the queue depends on all tasks before it.

Every access to mutable state would need to await and hop to an appropriate executor rather than block/wait for a lock.

It's interesting. I'm not entirely sure what I think about it, especially in terms of performance. Hopeful but a little sceptical, perhaps.

I know all this, thanks. I'm still trying to get a complete explanation of what specific rules I must follow to use the “use with caution” primitives safely in the presence of Swift async code. These primitives always needed to be used with caution (i.e. according to their documentation) but presumably there are special rules for correct interoperation with Swift async. These details are important if I'm going to use Swift async with an existing body of code that uses threading primitives.

Another example: one thread could lock a mutex, then pass the lock to another thread (by move) to be unlocked.

pthread mutexes very unfortunately allow this but if you do this, the behaviour is actually undefined -

As I reckon things, if the behavior is undefined, they don't allow it. The compiler doesn't enforce the rules, I get that, but the rules are spelled out and, at least with respect to this rule, once you know whether an async task can thread-hop, you know everything needed in order to ensure that it's followed.

I had thought lock-passing among threads was allowed by C++ but now I see §32.5.3.3.2 ¶3 forbids it.

Lastly, since a lock can be implemented in terms of a binary semaphore, it doesn't seem to be an intrinsic property of the semaphore that makes it problematic.

Yes you're right but the only difference here, is that you are using a single bit which is flipped on and off instead of having say, a pthread_t worth of information to have additional bookkeeping on who the locking thread is.

My point here is that you said something like “it's just a matter of which primitives you use,” and also something like “locks can interoperate correctly with Swift async (if used with caution) but semaphores can't.” If I can implement something with the exact semantics of a lock using a binary semaphore, and that lock can be used correctly, then the semaphore in that lock must be interoperating correctly too. I must have misunderstood one of your statements.

Regarding locks, the caution is as follows: Locks intrinsically rely on thread locality - you need to unlock the lock on the same thread which took the lock. You can't hold a lock across an await because there is no guarantee that the same thread will pick up the continuation.

OK, this is good, thanks. Presumably this doesn't apply to code known to be in the @MainActor, though, since it is locked to a single thread?

Using thread local storage is another example of something that is not safe in Swift concurrency since you don't know which threads will pick up your task and the various partial tasks as it suspends and resumes.

OK, do all the special rules for interop with Swift async have to do with thread-hopping, or are there others?

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.

This sounds like it's going to be useful information, if I can a few more questions answered (sorry):

  • Is this true even if the task that will unblock the primitive is itself going to block?
  • 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?
  • Aside from this rule, and the caveats about thread-hopping, are there any others?

Thanks again!

2 Likes

You're right but the dependency of a task on an unstructured task is also known at the point of awaiting it. The distinction is that you know the dependency up front with structured concurrency and there is clear scoping. The dependency on an unstructured task is only known at the point you await it. But it is known to the runtime.

1 Like

I was referring to the lock APIs we have today - pthread_mutexes, os_unfair_locks, NSLock, etc - and in general when I said "using a lock", I meant a lock implementation that is typically used by clients in the following manner in synchronous code:

Thread 1:
lock()
<critical section>
unlock()

while with a semaphore, I was referring to the likes of DispatchSemaphore or DispatchGroup which typically are used in client code in the following manner:

Thread 1:
<do some work>
if (!condition) 
   semaphore.wait() 

Thread 2:
<do other work to satisfy condition>
condition = true; 
semaphore.signal()

A lock can be implemented with a semaphore but that's the internal implementation of the lock and not of interest to clients who are using the lock in async code. While such an internal implementation of a lock allows for a thread that is not the one which called lock() to call unlock(), I consider that to be (a) undefined behavior (b) not the 99.9% use cases of how people use locks or mutexes when using it as clients of these APIs.

OK, this is good, thanks. Presumably this doesn't apply to code known to be in the @MainActor , though, since it is locked to a single thread?

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(). dispatch_main() does a bit of bookkeeping and exits the main thread 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 and requires additional knowledge about whether or not the application has called dispatch_main(). 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.

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.

  • 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?

  • 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.

This is a very fragile guarantee to be able to uphold as a developer because you are now relying on the scheduling order between tasks, and that can change.

  • Aside from this rule, and the caveats about thread-hopping, are there any others?

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.

The Swift concurrency runtime reserves the right to make different scheduling decisions, including optimizing the size of the thread pool based on global information on what is happening in the system. Therefore relying on specific scheduling order between tasks and threads is discouraged.

11 Likes

// replied on wrong thread :wink:

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!

1 Like