Skip to content

Comment on Concurrency in Swift: One possible approach

Comments

I'm surprised that async/await is the preferred idiom.

Having worked with async/await in Node.js a lot, it is of course a significantly better solution than plain promises, but it is also quite invasive; in my experience, most async code is invoked with "await". It's rare to actually need to handle it as a promise; the two main use cases where you want to handle the promise as a promise is either when doing something like a parallel map, or when you need to deal with old callback-style code where an explicit promise needs to be created because the resolve/reject functions must be invoked as a result of an event or callback.

Would it not be better to invert this -- which is the route Erlang and Go went -- and make it explicit when you're spawning something async where you don't want to deal with the result right away? In Go, you just use the "go" keyword to make something async. So the caller decides what's async, not the callee. If callers arbitrarily decide whether to be async or not, a single async call ends up infecting the whole call chain (which need to be marked "async" unless you're explicitly handling the promise/continuation without "await").

Both Erlang's and Go's concurrency model is thread-based. An "async" operation invokes a function on a separate call stack. The function doesn't need to know which call stack it's invoked on. The functions that that function calls don't need to know, either. Lua's coroutines are similar.

The problem with the above approach, however, is that you can't create hundreds of thousands of threads while also transparently supporting the traditional C ABI. C ABIs aren't designed to dynamically grow the stack, and so any thread that needs to invoke C (or Objective-C) code must always create threads with very large stacks (on the order of hundreds of several hundred KB or even megabytes) if they want to support legacy code.[1]

Languages that don't want to put the effort into growable stacks have no choice but to implement a solution that requires annotating the function definition, directly or indirectly.[2] The annotation tells the compiler to generate code that stores invocation state (temporaries, etc) on a dynamically allocated call frame (usually allocated by the caller) rather than pushing them onto the shared thread stack. This is true whether or not the function is a coroutine that can yield multiple values before finishing. Basically, without using a thread-based model, you can never put the caller in full control.

[1] Work on GCC Go necessitated adding a feature to GCC called split stacks. So GCC can actually compile C and (I think) C++ code that can dynamically extend their stacks. However, for it to work properly you have to compile everything with split stacks, including libc and all dependent libraries.

[2] Technically a compiler could emit two versions of a function, one that uses the thread stack for temporaries, and one that uses a dynamically allocated frame. That would put the caller in control. Some languages with very complex meta-programming capabilities (like various Lisps) can do this by making the await keyword a function which literally re-writes the callee into an async function that stores temporaries on a caller-provided buffer. So you can implement it without any compiler support. But it's still limited because the functions invoked by the async function would have to be rewritten recursively. The thread-based design is really the best approach, but it's a non-starter for many languages because of concerns about interoperability and legacy support. JavaScript rejected a thread-based model because existing implementations were too heavily dependent on the semantics of the traditional C stack, and they didn't want to throw away their existing investments.

The problem with the above approach, however, is that you can't create hundreds of thousands of threads while also transparently supporting the traditional C ABI. C ABIs aren't designed to dynamically grow the stack, and so any thread that needs to invoke C (or Objective-C) code must always create threads with very large stacks (on the order of hundreds of several hundred KB or even megabytes) if they want to support legacy code.

Where did this claim originate from? It gets tossed around all the time in greenthread discussions but it's completely false. When you create a real thread even though it has an 8MB stack or whatever it doesn't actually allocate 8MB. 8MB is not the allocation size, it's the growth limit. The stack grows dynamically allocating memory when necessary until it hits that limit. The C/C++/<insert any language here> ABI doesn't need to be compiled for this because it's just page faulting. Standard OS behavior for decades.

This is exactly what we do in Crystal: just mmap a new stack and let the OS deal with growing the stack through page faults. One thing I will say though is that you can't shrink the stack, which is a problem. You also may need to adjust vm.max_map_count to get more than 32k fibers.

One thing I will say though is that you can't shrink the stack

It'd be manual but you could madvise it to shrink. Unlikely to be worth the effort unless you have one code flow that has a particularly deep stack vs. the common case, though.

So it still consumes 8 MB of address space though? On a 32 bit system that would severely limit the number of threads you could allocate in each process wouldn't it? How do you allocate 8 MB of stack times a hundred thousand, in a 32 bit address space?

Yes, it breaks on 32bit but that's an acceptable tradeoff in most places because the vast majority of places where a modern language like swift would actually be used are 64bit.

The person I was replying to asked where the 'myth' came from. It came from 32 bit systems, where it isn't a myth. Even if it doesn't apply today (and administrating thousands of pages per thread still isn't free even if you have the address space, so I'm not sure it doesn't still apply really), that's where the 'myth' came from - that's the answer to their question.

What 32-bit systems even exist anymore? Much less 32-bit systems where you want to run thousands of threads in a single process?

The question was 'where did the myth come from' not 'does it still apply today'.

To your second point, your lisp analogy requires a macro or very sophisticated JIT. These are not viable in swift's primary runtime, not even a little bit. It's a non-starter.

Node.js [ ... ] most async code is invoked with "await"

I think this might be caused by a lack of complementary programming concepts, such as actors.

Having worked with C# and the Orleans actor framework I found that I'm now using more complex async constructs, such as await Task.WhenAll() and async Linq statements that actually make code run more efficient.

Adding async and actors together to the Swift language seems like a very good idea to me.

It’s the easiest to implement and could possibly make it into Swift 5. Sounds like he wants Actors just as much.

AboutSource Built by g1lg1l

Hackerly is an independent reader for Hacker News, built on the public HN API. Not affiliated with Y Combinator.