Has Swift's concurrency model gone too far?

I never ran into that issue in 15 years of iOS and Mac development -- maybe we were working on different types of software, but I posit that it's not a universal issue…

4 Likes

Yet you can easily can be done in variety of languages.

Using GCD, running something like

for i in 0..<10_000 {
    DispatchQueue(label: "#\(i)").async {
        Thread.sleep(forTimeInterval: 1.0)
        print(Thread.current)
    }
}

will result in hundreds of threads, leading to actual performance issues. And its really easy to write something like this using GCD – just loading some async data in a list. I'm pretty sure almost anyone did make such a mistake in the beginning. You can create similar examples in many other languages.

In contrast, with Swift Concurrency this code

await withDiscardingTaskGroup { group in
    for i in 0..<10_000 {
        group.addTask {
            await Task.sleep(for: .seconds(1))
            print(i)
        }
    }
}

will never result in such thing, it's just impossible.

So while you maintain policy of always making a progress (which I started really appreciate after diving into this model), there is no problem of taking all threads in pool.

3 Likes

No need to apologise I probably wasn't being very clear :). Actually I think I owe you the apology because at one point I thought you were saying the opposite, that in green thread system you couldn't have the async/await style at all. So sorry about that.

Anyway to try to clear up my point: The fundamental difference I see between green threads as used in say Erlang, and a Task in swift, is that there is no problem blocking and waiting on the former. It is possible that you might have a problem with a synchronous system call for sure, but I'm not certain how much of a problem that would really be in practice. I'm not an expert at all on Erlang so also might be quite wrong. It was my understanding that even if you were going to be holding up a scheduler thread there are tools in place to allow you to yield frequently so this is possible.

From an even further back perspective on what I hope is my point: concurrency is hard and the Swift manifesto outlines some important points on it that I agree with: swift should be giving programmers tools to solve their problems, to make properties of their code clear, and these kind of things should be maintainable — and part of that is to be composable. That doesn't feel like it has been acheived yet to me. And not being able to easily turn a task into something that can return something simply is part of the problem.

To take an example, say you are writing a class and it has to be a class — likely it's a @Observable for use with SwiftUI or something. This class wants to do some concurrency and get a result back. The only way to do that with the task is to pass the class reference asynchrounly so the task can write back to it, because you can't block on a result. That means the class has to be Sendable. But it's a class, with mutable state that we are writing back to. So that now has to go behind a lock. And locks are in many ways right back where we started — hard to use, easy to get wrong, easy to deadlock with (even if the OS threads themselves aren't deadlocked), and not composable.

I don't see any other way for that to work — if I'm wrong I would dearly like to know it!

So my vision for a Swift that actually solved this problem would need a way out of this kind of thing. A green thread model where blocking is possible is one thing I thought of. I appreciate there are technical issues that make this hard, but it hasn't stopped other languages. I do see the point made about this suiting some language design, eg dynamic langauges, better. Erlang has it's virtual machine. Haskell uses green threads as I mentioned but obviously has a very different architecture.

Having blocking threads would still not solve issues like helping devs with race conditions etc. This is where I would also love to see the ability to mark critical sections directly. I am not enough of an expert to know how doable that is along with provable safe memory safety, but it seems worth considering. So there would be locks, but they would not have to be user managed.

One way of going about things in this sort of fashion I know about is Software Transactional Memory. I realise that it can't be as performant in general as direct locked based code, but I also think that Swift doesn't need to be as fast as C.

Of course this is all very pie in the sky — this is not the direction that was chosen for Swift and I could see eg STM being a hard sell. We feel very between a rock and a hard place now in Swift alas.

If you do the same thing in Erlang it doesn't matter because the threads are so lightweight. So you may have thousands of threads but there is no performance hit, so it doesn't matter!

I'm not saying the system couldn't be made to spawn lots of threads. I'm saying it was never something I did by accident, and therefore nothing I needed to worry about in practice. Leading to my conclusion that the times when Swift Concurrency gets in my way are not a worthwhile tradeoff, since I didn't suffer from the thing it is protecting me from.

2 Likes

Haven't tried this in Erlang (as far as I know they are a bit different in nature on how it is modelled and it is quite unique), but Go – which is another great example of green threads – suffers from the exact same issue.

Why do you think Swift's Task, which in fact as close to green thread as possible from what I know (and understand), isn't lightweight?

1 Like

I don't — I think Task is lightweight. I also think it suffers from the problem of it being hard to escape from the async world back into the synchronous one, an issue I have heard many developers struggle with. So I was looking for a system that would have both advantages.

In fact, there is another way! You apply isolation with @MainActor. Globally-isolated types become Sendable. Now, that's appropriate for a reference type that holds UI state. For other cases, another technique may make more sense - actors being one.

4 Likes

Sounds like the Task should return a result and not touch the class instance at all.

2 Likes

There are many cases where you can't do this. One is if you need something to conform to a protocol that doesn't include the isolation. Sure you can nonisolate bits to achieve that sometimes, but often you can't. Equality comes up with this a lot. You want to be @MainActor but you also want to be Equatable based on vars that the isolation would need to cover. You are stuck.

There is also a lot of developer confusion about if you mark something @MainActor then all it's work is going to be done on the main thread and slow the app down. It can be hard to second guess what operations inherit the isolation.

Also this works mostly only for the MainActor itself because you can hope to already have a context for it. If you use a different actor you get back into the problem of suddenly everything related to that whole code path having to become async because you can never block.

These issues mostly come up in legacy codebases for sure, and we will slowly iron our way out over time. With new code you can design with async in mind and avoid it most of the time. But I still think that using actors correctly is going to continue to be extremely hard for developers to get right and have good instincts for. I guess that brings us back to education and docs :)

3 Likes

That would require you do already be in an async function right?

Challenged yes, but not stuck!

This is well-known class of problem called a "Protocol Conformance Isolation Mismatch". In the case of a MainActor type, my personal favorite solution is a preconcurrency conformance.

@MainActor
class MyClass {
	var value = 1
}

extension MyClass: @preconcurrency Equatable {
	static func == (lhs: MyClass, rhs: MyClass) -> Bool {
		lhs.value == rhs.value
	}
}

It looks weird, because Equatable is not actually "preconcurrency", but it expresses the semantics perfectly. It is only meaningful to compare these types when also MainActor-isolated. The ability to constrain a conformance to a specific isolation domain is so handy that this is going come to the language as an "isolated conformance". It's part of the vision document, and one I'm pretty excited about personally!

Now, for actors, the situation gets more complex. There are valid cases for conforming an actor to a protocol with synchronous requirements, but they are limited. This can also be an indication that you have reached for an actor when a non-Sendable type is more appropriate. But those are super hard to use in combination with concurrency. Thankfully, that will also be getting way easier, via isolation inheritance changes.

6 Likes

Ah yes of course that's an interesting solution with @preconcurrency.

I had also had the idea for isolated conformance when I first ran across this and have been really glad you are pushing it :). I remember looking up to see if someone had had the same thought and finding that thread. In the meantime I have clearly managed to forget that using @preconcurrency lets you do that in the code now. Do we know when isolated conformance is likely to actually make it into the language?

On the other hand the isolation inheritance change scares me somewhat, but maybe I'm not up to date enough on the discourse around it as it was a while ago I was thinking and reading about it.

Would you say that there are no situations where your best option is to use a lock to make something Sendable?

My example was probably not the best. And I admit I have always been able to find a way of getting things done, though it has been very painful in large codebases. Because all of these changes have massive knock-on effects. And I think that this is all very complicated and hard for developers in general, especially those earlier in their learning journey. The statement in the manifesto was

There should be a structured "right" way to achieve most tasks.

I originally read that to mean the right way should be as clear and obvious as possible, but maybe that was me putting too much of my own spin on it! But that is why I've constantly been wondering if there is a different way this could have gone that was not so hard to use. Where the right way to do something actually is usually obvious.

When does this actually come up? Are you using @MainActor-isolated Cocoa APIs from the implementation of == or something?

Couldn't this cause runtime crashes if == is ever called generically off the main actor?

@MainActor
final class Counter: @preconcurrency Equatable {
  var count = 0
  static func == (lhs: Counter, rhs: Counter) -> Bool {
    lhs.count == rhs.count
  }
}

@Test func boom() async {
  let counter: some Equatable = Counter()
  func someFunc(_ eq: some Equatable) -> Bool { eq == eq }
  someFunc(counter)  💥
}
1 Like

Yes it will always crash. But these types are MainActor-isolated!

If you actually need to do comparisons from arbitrary isolation, this is not the appropriate solution. One option that can work well here is to decompose your state down into a value type which is owned by the reference type. Then, you can do comparisons on that value type instead. I like this in general, but it can require significant change to an existing system…

2 Likes

Saw this reasoning, ditto for the thread killing.
However, couldn't this be ~easily addressed by allowing some extra time (before suspending / killing) until all mutexes are unlocked? Won't handle the degenerate cases of apps that hold mutexes for long time or lock/unlock a mutex in a tight loop, etc, but for the majority of cases should be fine, no?

Wouldn't work for spinlocks, semaphores, groups, read locks, or any other non-ownership-carrying locks. But yeah, it's a neat idea.

1 Like

Because of the nature of isolation, in a large app one often has to end up making a lot of things @MainActor you might not think need to be taken on their own.

In the course of converting a large code base this issue with protocols been one of the hardest things to fix. Even if you control the protocol, and change it to main actor; well that just changed a load of other conforming class to main actor, and now you have issues in call sites that use those, and those conform to a protocol, etc etc.

3 Likes

So do we have a proposal actually in the works for isolated conformance where this crash wouldn't be possible? Where eg if your equality conformance is on an actor, outside of it you can await to call ==?

That's what I thought of, and thought your thread was suggesting, but am not sure of the status. From a brief rescan of the thread it isn't clear if the issues with casting are surmountable.

Sorry if I'm being slow here!

1 Like