Has Swift's concurrency model gone too far?

Given the current design, it seems like the runtime folks definitely have strong feelings about this, so I just went with it.

The manifesto cites Erlang as an inspiration several times. Erlang, to the best of my knowledge, uses green threads. This gives you most if not all of the performance improvements over having to use OS threads, without any of the problems the limited thread pool introduces. It also allows for performance tuning by the scheduler itself — like supplying backpressure on actors submitting to slow threads.

The limited thread pool is the source of a lot of the difficulties IMO.

2 Likes

I don't think green threads can ever be more efficient than OS threads, however, they use them typically in dynamically-typed languages (like Erlang) where otherwise guaranteeing atomicity/sendability of variables would become hell.

Swift with its automatically sendable basic types is in a great position to be the most efficient concurrent language, but going back to the original thesis of this thread, it has become too complex while trying to become one.

I think this is on the table now, but we'll need to see how this can be done.

1 Like

I think there's a fourth big one: a lot of Swift devs just don't see any value in concurrency checking errors, at all. Not at first, anyway.

It's quite common to see threads here about some Swift Concurrency warning/error with a message along the lines of "I ran into this warning/error. How do I get the compiler to shut up? The code is fine". I'd say that about half the time, once someone chimes in and explains in detail what the compiler is pointing out, the OP of that thread then understands the issue and wants to fix it.

Concurrency is hard. Most people would need to invest quite some time to get a correct understanding of the problems at hand. I'd say it's impossible to get developers to put that effort into something they see no value in. So a lot of people (understandably) take the first shortcut that hides the error, unaware of the implications. And the problem deepens, because they now get even more seemingly superfluous or cryptic errors. An example of this would be those complex nonsendable types with different properties in different isolation contexts that a lot of people end up with.

I agree that the vision document is a move in the right direction, because it's going to simplify a lot of the easy cases. But still, there's going to be some weird, hard to understand errors coming from concurrency checking. Because concurrency is a difficult topic.

I don't know what the solution to the problem is. It's really difficult to convey any of this in a 2-3 lines long compiler message. Sometimes the compiler points out some subtle issue whose explanation could be an entire chapter in a book.

I wonder if people would have a different mindset if there were some broad concurrency articles explaining common situations, common ways of getting it wrong and why it matters, and a few possible approaches, with the compiler linking to those articles when it identifies one of such situations.

There's already something similar to this with the compiler offering a handful of possible approaches to solve a particular issue, but it's hard to make an informed decision based on a list of one-line suggestions and no context. Perhaps being able to link to longer and more structured content would change the way people think about concurrency errors in Swift. Idk.

9 Likes

it would help, i would imagine, if the Swift compiler only printed warnings once per instance of the warning (not, as it currently is, multiplied by the number of threads building the project), and didn’t print warnings from upstream libraries the developer has no control over at all.

2 Likes

I want to make sure I understand this distinction. Green threads have to actually execute on real backing OS threads, and I thought this is usually done via a pool. Isn't this roughly equivalent to how the task model works here?

1 Like

Think of green threads as your languages own thread system implemented entirely in user space, on top of the OS threads. So you would maintain a pool of OS threads to run your green threads on, but you don't expose the OS thread concept to the code itself at all. Instead you present your own thread management. Of course different implementations of green threads do this differently — it's quite possible to have green threads and no actual parallelism, but that isn't what I'd be recommending for Swift.

So in this world Swift would have it's own scheduler and maintains its own concept of a thread. These threads can then be very lightweight as they do not have to deal with the same issues OS threads face. Creating and destroying them can be incredibly fast, for example. Context switching can also be made very efficient.

Now, because we control the scheduler we can do all kinds of fun things. Are we sending more messages to a particular thread than it is processing? Well then we can preemptively slow down the threads which are sending those messages, giving it time to catch up. (this is the back pressure example I was alluding to.)

We still have all the advantages of the limited OS thread pool — context switching is fast etc, we get additional advantages. and most importantly, we can have as many green threads as we want — at least up to a very high number! The parallelism is of course still controlled by the limited thread pool.

So we could easily block threads and not worry about it at all (apart from the main thread of course but that is another issue). Because the threads we would be blocking would be our green threads, not the underlying limited pool of OS threads.

(Btw Green threads have nothing to do with dynamic vs static typing. Haskell uses green threads, for example.)

1 Like

I think is exactly how Swift concurrency works though, isn't it? One potential difference I can think of is synchronous work on a task also ties up its current OS thread, in a way that it cannot be pre-empted by the runtime to reclaim the thread. Is that feature a requirement for a true green-thread system?

4 Likes

To my knowledge, green threads work best in VM-based languages, where a VM instruction is indivisible and memory integrity therefore is kind of automatic.

I brought up dynamic vs. static typing because supporting OS threads in a dynamically typed language is a lot more difficult and costly, it's why those languages usually opt for green threads. But they do it not because green threads make your code run faster, they do not. There's nothing that beats OS threads in terms of using your multicore system to the fullest.

Yes, this is how Swift Concurrency works, other than the bit about blocking, which is not true unless you delete all access to blocking syscalls from the language and wrap them in something else.

5 Likes

Green threads avoid the sync/async function distinction and instead switch the entire stack, so that any function can suspend at any time. This generally slows down all procedure calls by some amount, but that can be amortized in various ways. However those green threads must still be scheduled to run on a thread pool of OS threads, so the same questions around scheduling, backpressure, what to do if an OS thread is blocked on a synchronous system call, etc, must be solved in both models.

10 Likes

Yes I know — but do we really need that performance increase at the cost of all these issues? That's my point. They are still be considerably faster than using OS threads for all your tasks, and that is likely to be fast enough.

The main difference I would say is the thread abstraction. You can still get hold of the OS threads and that lets you break the whole thing by starving the thread pool by not making forward progress. A green thread implementation would hide this completely.

I guess the Swift concurrent executor (ie the default actor) is playing the part of the green thread scheduler. But it doesn't do everything a full scheduler can do, most notably it cannot pause a task midway but rather has to wait for the task to give up the thread — this only happens at suspension points eg await calls. Because of this, a function that isn't making forward progress can deadlock and starve the thread pool.

Yeah this basically, though it doesn't mean you couldn't have the sync/async distinction, it just changes how it works under the hood and allows for suspension at arbitrary points by the scheduler, and for blocking of the green thread which is the important bit I'm getting at!

This is true, but I think these are problems in the current Swift model too, no? Perhaps there are some advantages there, but my overall point is that I'm not sure the disadvantages of the current Swift system outweigh the advantages.

This is all fairly academic I guess, it seems pretty unlikely that this is going to change in Swift. And for that reason we will always have this gulf between sync and async contexts.

Forgive me if it's a stupid question, but wouldn't that ability make the re-entrancy issues even worse?

Typically a/the concern with having completely arbitrary suspension points is that you get suspended while holding the mutex inside of malloc, and then everything else grinds to a halt until you run again

3 Likes

No, if the green thread makes a blocking system call, it will still block the entire OS thread that the green thread was scheduled on.

3 Likes

(If this wasn't true, then when the syscall returned it would overwrite part of the calling thread's stack. You can see an example of that happening here: Debugging memory corruption: Who wrote ‘2’ into my stack?!)

4 Likes

Well the scheduler could make it that the task always gets scheduled on the same thread, be that a green thread or a real OS thread. I can't think of a reason of the top of my head that that wouldn't work anyway!

Yes, but how often is that actually a problem? Is that a system call that could somehow actually deadlock the thread that is suspended? I am not an expert here by any means, are these system calls common enough that this will hugely impact performance in places where blocking was used in order to go from async to sync?

Sorry if I'm missing something, not able to give this my full attention right this moment! :sweat_smile:

The point that @David_Smith and I are making here is that what Swift calls a Task is essentially the same thing as a green thread, so the same tradeoffs apply to both equally. The type system distinction between sync and async functions doesn't fundamentally change the implementation space. It seemed you were suggesting otherwise -- if I misunderstood, my apologies.

6 Likes

Having to be super careful or else end up on Thread 74,386 is the most annoying thing about GCD. I personally, don't want that back.

7 Likes