This article proposes a "nursery", which is just a wrapped sync.WaitGroup/pthread_join/futures::future::join_all/a reactor that waits for all tasks to terminate/etc.
It then uses an exception-like model for error propagation to "solve" error handling (which is fairly easy to handle with a channel).
The construct is a decently usable, already applied tool to handle a set of problems, but the article takes the issue way out of proportions and overhypes the proprosed solution. The "with" example for benefits to not having a "go" statement seem rather bogus, especially seeing that such RAII constructs do not exist in Go (no destructors, remember?).
Trying to claim that "go" is as terrible as the original "goto" is ignorance of the original problems. Bad use of goto can be a nightmare to track (as the author tried to illustrate), but goroutines do not jump around, they branch from the main goroutine, following normal control flow from there. They are easy to follow, and the language is designed so that you can throw around with them and forget them without them causing you problems.
Also, this article is comparing a list of concurrency constructs and one parallism construct (pthread_create—threading.Thread doesn't count as parallism due to GIL) to callbacks, something which have nothing to do with concurrency at all. Very odd.
I thought the article's comparison of go routines to goto was fair, in the context made: when calling a function in a language that allows go-routine like things (including pthread_create), one can't know if background tasks will be spawned. This is similar in the free-form time of unrestricted goto that one could not know if control flow really would return to the point lexically after the function call. His proposed abstraction does allow one to look after a nursery block and know whether or not background tasks are still running.
Knowing whether a background task has spawned is very different from not being able to follow the control flow (goto potentially jumping to an entirely different function body).
Now, while Go is designed mostly for you to not care about goroutines, there are some corner cases where one must know if a resource is used by anything, such as the chase of when you wish to close a file handle.
However, I'd argue that this is not related to go's concurrency model and the presence of background tasks at all. It's related to object lifetimes. This should be made clear at API surfaces.
A language solution to this would be Rust's lifetimes, not a new concurrency model.
Nurseries and lifetimes are orthogonal concepts. In fact, Rust already has a "nursery", except it's generally called a scoped threadpool. They use lifetimes to ensure the threads can reference stuff on the stack without copying it. So a "nursery" is basically just a scoped threadpool with a 'static lifetime.
The description of the nursery is you can pass it around wherever you like. More generally, this concept seems to have been designed in a language without lifetimes. You obviously can have a scoped threadpool without a 'static lifetime, but if you want to pass it around to arbitrary locations then you do.
I think the whole point of the nursery's design is that you can create and destroy them during the life of your program, and that destroying them forms a barrier where the owner of the nursery waits for its children to finish. Having a nursery with a static lifetime in Rust would therefore be pointless, as it would live until the end of the program.
I think you misunderstand. I'm talking about writing something like `ScopedThreadpool<'static>`, which means any external data referenced by the threadpool must have a static lifetime (or rather, it means the ScopedThreadpool cannot reference anything on the stack, because that would prevent you from e.g. returning it to your caller or passing it to another thread). The ScopedThreadpool itself can be created and destroyed whenever.
I would definitely consider the "nursery" to be a newly created device (threadpool/reactor/...), where the resources used by the functions run in this "nursery" only has a lifetime requirement equal to the nursery.
It actually does not solve the problem of lifetimes. It only solves a very isolated instance of it.
For example, if we take the problem of an os.File, some library might store the reference, and use it unexpectedly in a later function call after you called Close. This presents the exact same issue as a goroutine holding it.
(I honestly have no other examples of this issue than files/connections being closed prematurely for Go.)
I've never encountered a situation in Go where I'm passing file handles around like this.
In practice you'd probably have one goroutine manage the resource and its lifespan then other code would communicate with that goroutine using channels.
You might pass an os.File or a network connection as an io.Reader or io.Writer to something that might keep it. The GC actually has an extension to sorta have destructors to help with leaking fd's from forgotten os.File's (runtime.SetFinalizer).
However, having coded Go for quite a few years by now, I haven't found it to be an issue at all.
This is very true. The same way one doesn't know if a background was started is the same way you can't know if a file was written to or some global state was changed. I thought async/await solves most of his examples
You realise that makes you sound exactly like an old Fortran programmer refusing to give up goto? A for loop is just a wrapped goto! ;-)
I think the author proposes a useful way to structure multi-threading. The comparison to goto isn't perfect and he needs to play fast and loose with some terms to keep the analogy working but he makes a good point.
I'm not convinced yet that the nursery pattern should be the only allowed way to start a thread but saying "you should use it unless you have a good reason not to" is a good provocation to get the discussion going.
> a "nursery", which is just a wrapped sync.WaitGroup/pthread_join/futures::future::join_all/a reactor that waits for all tasks to terminate/etc.
The fact that the 'nursery' can and has been implemented with more 'primitive' constructs is actually a point of similarity with goto/structured programming not a point of difference. The GP didn't seem to spot this, despite making it their first complaint, so it was good that the post you're responding to did.
The post you're responding to is not calling the GP a dummy because of a disagreement, it's pointing out the apparent criticism the GP levelled was exactly the same criticism levelled against structured programming, and ultimately goto lost.
As someone whose work includes hardware driver programming, I'd rather not give up goto. It would make a lot of error handling extremely cumbersome, and much less readable. :(
However, I find "go" and "goto" to not intersect at all. I've written this many times in other comments on this thread, so I'd rather not type it out again, but the TL;DR: is that "goto" can make understanding a function when read difficult, while "go" is clear when read. No function is understood if never read, and spawning "background tasks" is a core part of asynchronous programming (and thus not an unusual side-effect).
The "nursery pattern" is a decent construct that I have used quite often whenever I felt a need, but it doesn't appear to really solve any issues mentioned in the post. I also elaborated on this quite a few times already, so TL;DR: the only real problem of goroutines is things like references to potentially closed objects, but any method may end up storing an internal reference used at a later call, making the issue not related to concurrency.
I believe our industry’s several decades of demonstrated inability to write concurrent code correctly disagrees with you.
Languages like go (and Rust, to a greater degree) have improved the situation. But there are still a ton of sharp edges that existing approaches still have, and—often—they’re ones that aren’t readily apparent until after a project starts growing and begins unearthing issues that are rare, only happen on faster hardware, or require higher levels of concurrency than we’re previously used.
> I believe our industry’s several decades of demonstrated inability to write concurrent code correctly disagrees with you.
But mostly so for the reasons nurseries try to solve (and actually manage to somehow solve some)? I'm not convinced.
Lifetime of threads is usually easy compared to races/deadlock/etc. on the resources they use.
Not to say we shall not use nurseries where applicable. But is it the new fundamental structure concurrency should be based on? Debatable.
My wild guess is that the Rust approach of concurrency (even if it is not exactly on the same subject, but we are trying to find the fundamental way of structuring things) will have more impact.
> I believe our industry’s several decades of demonstrated inability to write concurrent code correctly disagrees with you.
Not at all. Concurrent/parallel code isn't particularly difficult to write—there's just a unique class of problems related to it that might occur, but that does not mean it is hard.
Rather, the industry has through several decades demonstrated an inability to write bug free code in general. I wouldn't blame concurrency/parallelism for this.
I guess we can debate what "hard" means, but I think "adds a lot more problems that are much harder to reason about and avoid" is exactly the definition of hard. If it's not, I'm honestly not sure what is. Torn reads, deadlocks, concurrent modification, the effects on optimizers, the overhead of scheduling and lock management. Each of these are deep, complicated issues you only have to deal with when writing concurrent (well, parallel) code.
But yeah I mean, if you mean it's pretty easy to call pthread_create then OK. But if you at all care about your program working, then parallel code is much, much harder to write.
All the problems you present go into the category of sharing state. This is the unique problem presented by concurrent programming, which is no harder than all the other problems in programming. This does not mean that concurrent programming is hard, but that just like all other programming, it has some sharp edges.
The issues related to sharing state can be easily avoided by, for example, using a CSP-style paradigm (channels in Go). In Go, this only leaves behind a deadlock, which are automatically panic, making it incredibly easy to debug.
Overhead of scheduling and effects on optimizers are not related to concurrency/parallelism. The scheduler is affected by many things even under single-threaded execution which is far beyond the scope of normal application developers, and optimizers are largely unaffected by concurrent/parallel programming (although shared structures must internally present memory barriers). The optimizer is also beyond the scope of normal application development.
If you are doing development that requires precise control over optimizations and scheduling (like I do), then all bets are off.
However, with "normal" (i.e. not-processing-8x100Gb/s streams) programming, concurrent programming is a breeze unless you intentionally shoot yourself in the foot.
> ...which is no harder than all the other problems in programming.
Well but for almost all of those problems you have something to help you. Yes manual memory management is hard, thanks GC/lifetimes. Yes error handling is hard, thanks exceptions (kind of). The point of OP is that practically all of the things we've built to help us with parallel/concurrent programming are still very low level, and your program still has to deal with the fallout in a way that it doesn't with GC or exceptions or other helpful abstractions.
> The issues related to sharing state can be easily avoided by, for example, using a CSP-style paradigm (channels in Go). In Go, this only leaves behind a deadlock, which are automatically panic, making it incredibly easy to debug.
There are only a few languages/platforms where CSP is feasible, and very few (none?) of them achieve the performance of CSP in Go because they don't/can't use segmented stacks. And even when using CSP you still need synchronization primitives. Parallel Go code uses mutexes everywhere. CSP in Go helps with _implementing_ parallel programs, but the Go team provides a lot of extra tooling to help _debug_ parallel programs that's well outside the definition of CSP --> the deadlock panic you cite is a good example actually.
> Overhead of scheduling and effects on optimizers are not related to concurrency/parallelism.
I disagree; mostly my evidence is "just google for volatile in C".
~~~
In general I still think parallel program is in a separate class of problems, but even if we stipulate it's just as hard as other problems, I still think we have much better tools for dealing with the other problems. I think we're getting there -- and CSP is part of that -- but it's definitely not the case that we've settled on a solution for 99% of problems.
> ... and your program still has to deal with the fallout in a way that it doesn't with GC or exceptions or other helpful abstractions.
[citation needed]. What in the world is this "fallout"?
You can of course chose not to use CSP or some other abstraction, but that would be like chosing to do manual memory management (which you can also do in Go through cgo if you'd like), and the "fallout" is identical: You are on your own. CSP is of course not magic, but neither is a GC or lifetimes. You must always know the tools you are working with, as they have their own issues that must be dealt with.
If we look at the concerns in the article, Rust's lifetime actually remove those issues entirely, much in line with its "fearless concurrency" motto. Go's CSP is opt-in-although-aggressively-recommend, and is by no means a low-level construct.
A compare-and-swap is a "low-level" construct. You can of course pull in atomic primitives if you feel like doing so, but then you are choosing to go low-level.
Also, exceptions are a terrible, terrible thing. They make error handling much, much worse.
> There are only a few languages/platforms where CSP is feasible, and very few (none?) of them achieve the performance of CSP in Go because they don't/can't use segmented stacks
I do not see how this claim makes sense. Segmented stacks are an unnecessary green thread implementation detail that can both harm and benefit performance (if you use FFI, they harm performance a lot), and is neither necessary for green threads, nor related to CSP at all.
CSP works just fine in other languages. You can fully execute a CSP paradigm in C, just like you can avoid it altogether in Go and Rust.
Although, while you can avoid CSP in Rust, you still can't create the issues presented in the articles due to lifetimes. You would have to quite explicitly and intentionally get your hands dirty with unsafe code in order to shoot yourself in the foot here.
> I disagree; mostly my evidence is "just google for volatile in C".
"volatile" has nothing to do with anything here. Not only that, it is an entirely useless construct, as it does not provide reordering guarantees (that is, while subsequent reads see a previous write, the reads and writes may be reordered so that the reads now happen before the write). Proper constructs use memory barriers.
However, neither of these constructs have bad effects on optimization, unless you are aiming for code that executes fast without working at all. Also, memory barriers are low-level primitives. Unless you are designing synchronization primitives, you shouldn't touch them.
"volatile" and memory barriers have no effect on process scheduling at all. It does, however, necessarily affect the CPU instruction pipeline, but once you get your hands this dirty, you're way out of the scope of high-level programming safety net. Down here, we work with assembly.
That would work, but I'm not sure I find it that pretty. Plus, it assumes all "ops" have compatible error output (which is unfortunately rarely the case).
What I instead do is usually:
int some_func() {
int err = OK;
if (some_op() != SOME_OK_CRITERIA) {
err = SOME_OP_FAILED;
goto error;
}
....
error:
// do necessary cleanup
return err;
}
I'm also mostly responsible for Linux and BSD kernel mode driver, as well as our user-mode driver (most of our driver exist as a shared user-mode component)—I wipe the Windows kernel mode driver off on someone else. :)
I have no allergy to goto at all, as long as its not used in absurd fashions (like how it's used in a duffs device). If a goto seems easier/prettier, I use a goto.
> The "with" example for benefits to not having a "go" statement seem rather bogus, especially seeing that such RAII constructs do not exist in Go (no destructors, remember?).
You've greatly underestimated how general this problem is.
First, Python's `with` statement has nothing to do with destructors. From the PEPM for `with`:
with VAR = EXPR:
BLOCK
which roughly translates into this:
VAR = EXPR
VAR.__enter__()
try:
BLOCK
finally:
VAR.__exit__()
`exit` is a method, not a destructor. As a result, Go could easily gain a `with`-like construct.
Moreso, the issue applies even if there's nothing like `with` in the language. If I'm reading a function definition, and that definition uses the "acquire, try, finally, release" pattern that is the desugaring of `with`, then it sure would be nice to know that nothing from BLOCK is running after the `finally` statement has run. `go` breaks that assumption.
I mentioned RAII because the text around "with" mentions its use with RAII.
Go does not have exceptions (panic is not meant as "normal" flow control), and therefore has no use for a "with" construct. "defer" is used for a somewhat similar purpose. Go does not have destructors, and therefore has no possible implementation of RAII.
However, none of this applies to goroutines. A goroutine only gives errors if the author decides that such is necessary. If so, it will likely be through an error channel. There is no unexpected code paths through such readout, rendering "with" and RAII useless.
So again, for a post that was very focused on complaining directly about the "go" keyword, I was expecting something applicable to Go.
Agree with everything. I think the misunderstanding here is that goroutines are not classical threads (which would share many problems with `goto`) - they exist somewhere between coroutines and actors (because of channels+select). Actors are an established and mature solution to many headaches surrounding concurrency and parallelism.
Goroutines share more in common with method calls (which are a form of branching) and even with single-threaded scenarios, methods can do surprising things - especially if you have shared global state.
Additionally, if you control how data is shared (such as message passing - channels) you shouldn't have to be concerned about what the other thread is doing - so long as it reacts to messages that you send to it.
I don't think that's a misunderstanding at all. The argument is all about control flow and programmer understanding. The actual underlying mechanisms aren't what he is arguing against, instead he dislikes the potential for programmer confusion when code is being executed that wasn't expected. That is 100% possible with goroutines.
Method calls aren't really branching. The code path is still totally linear. You could basically copy paste the code in the method definition in place of its call and get the same outcome (not literally but you know what I mean). For goroutines this is not the case.
I think your points about state ownership are right on, but that's kind of the author's whole point. Right now, there are a lot of things you have to be very conscious of to write good code that executes cleanly using goroutines/threads/etc. That is very much the same as writing good code with gotos. It's 100%, unequivocally possible to write good code using gotos (every control flow structure dijkstra proposed can be represented with them), it just requires a lot of added thought, and the potential for mistakes is much higher.
The author is not proposing any functionality that doesn't already exist, just a new control pattern to reduce the chance of creating problems.
It really isn't. The big difference is that "goto" can lead to code you read being grossly misunderstood due to complicated flows, potentially even absurd things like jumping to a different function. (And as you mention, good code can be written with goto's—it can make particularly error handlers much easier to read in C.)
This is not the case at all with "go", which is extremely clear as to what it does and how the flow will go.
However, as a caller, you do not know the flow of the function you call unless you read it. As with any type of asynchronous programming, a function might have scheduled something for later execution: It might be a future, a promise, a timeout/interval, network receive callbacks, or a goroutine.
That is, strictly speaking, none of your concern as long as the function lives up to its contract. If not, all bets are off regardless.
> ... it just requires a lot of added thought, and the potential for mistakes is much higher.
I get your point, but I must disagree that asynchronous programming (which is what goroutines is merely an implementation of) increase the complexity of writing good code, nor increase the likelyhood of state ownership mismanagement in any measurable fashion.
> The author is not proposing any functionality that doesn't already exist, just a new control pattern to reduce the chance of creating problems.
I don't really find that the author suggests anything new at all. Rather, the author takes an existing blob of code and claims a new benefit from it.
EDIT: erroneous "in which case" replaced with "if not", as initially intended.
> I must disagree that asynchronous programming increase the complexity of writing good code
Could you say more about why? It seems to me that purely synchronous code is much easier to reason about than async code, in that a) less is happening at once, and b) things happen linearly. That seems less complex almost by definition.
I'd certainly believe that things like goroutines are better than the threading that came before, with way less cognitive load. But I can't see how it's less complex than linear code.
> But I can't see how it's less complex than linear code.
I am being misinterpreted here (in a peculiar way that has happened before on HN...). I wrote that it does not increase the complexity "in any measurable fashion".
It seems that you interpreted this as "concurrency is less complex than non-concurrency", when in reality I am saying "concurrency is a tiny bit more complex than non-concurrency" (extra emphasis on "tiny").
Now, back to the topic:
Asynchronous programming is strictly speaking the simple idea that code will be run later when some conditions are met. For example, running a callback when a network response is received from the "fetch" API in modern JS, with all I/O handled behind the scenes by an event engine of sorts.
I do not believe that this concept provides any measurable increase in cognitive load. You only concern yourself with these devices where they are used, and they are extremely simple to wrap your head around.
In another model, you may have full parallelism and shared memory access, in which case you need to be more careful to only use thread safe structures. Even then, unless you do something stupid™, everything is fine, and the cognitive load is increased globally but only mildly so with a model similar to goroutines and channels.
Finally, you may have to design and use synchronized/atomic data structures, in which case things increase in difficulty. While this does increase cognitive load in a small area of the code, I does not increase the cognitive load over the entire application. However, just like not all application need to design a cryptographic protocol, not all applications need to design concurrent data structures even if it uses concurrency. Thus, one should additionally note that this localized overhead is not a universal overhead of all concurrent programming.
BTW, I do not find that goroutines present less cognitive load than pthreads ("threading") and a CSP library. They simply present syntactic sugar for a better developer experience. They also give an M:N threading implementation unlike pthreads, but that's an implementation detail.
Having a magic, behind-the-scenes event engine with a queue containing an unknown number of items executing in unpredictable order seems definitionally way more complex than not having any of those things and running the code in strict linear order. Performance is way better, sure, but at a cost of complexity and cognitive load.
Sure, the difference is tiny if everything works smoothly. But if everything worked smoothly, we wouldn't have jobs.
This is a bogus argument: "abstractions are complexities and lack control".
This argument can be equally applied to any other abstraction or convenience functionality, such as garbage collectors (unpredictable destructor execution, resource consumption and performance), OS schedulers (unknown number of items executing fighting for time quanta, unpredictable performance, unpredictable latency), high-level languages (unpredictable code generation, lack of ability to express proper intention related to processor abilities), etc.
However, the problem here is that your expectation of the abstraction is wrong, not that they introduce complexities. Furthermore, all abstractions give up control to the abstractions so that you do not have to concern yourself with it. You cannot both have full control over functionality and not concern yourself with the functionality.
An even engine is a scheduler like the OS scheduler. It is designed to take care of scheduling, making it not a concern for you, hiding the implementation (effectively making it "unpredictable"). If you want control, you have to give up the abstraction and go straight on the iron, just like you would have if you want precise memory behavior.
However, considering this a "complexity" would be wrong. There is nothing complex about asynchronous programming's lack of predictable execution order. The contract is that the code will execute when the event arrives, nothing else. This is by no means a complexity. In many engines, the execution of your code isn't even concurrent, only having I/O run in the background.
(I am intentionally ignoring buggy event engines, just like we ignore buggy GC's, kernels, compilers, CPU's, etc.)
> I think the misunderstanding here is that goroutines are not classical threads (which would share many problems with `goto`) - they exist somewhere between coroutines and actors (because of channels+select).
I don't think that comparison makes sense. Channels and select are not a property of goroutines themselves, nor is using those the only way to communicate with other goroutines. The actual functionality of a goroutine is very similar to a thread, even if the broader language/conventions push it towards a coroutine/actor.
> Goroutines share more in common with method calls (which are a form of branching) and even with single-threaded scenarios, methods can do surprising things - especially if you have shared global state.
But they can't run in parallel, or data race. Goroutines, like classical threads, can have data races.
> Additionally, if you control how data is shared (such as message passing - channels) you shouldn't have to be concerned about what the other thread is doing - so long as it reacts to messages that you send to it.
This is also true of threads. Message passing is a layer on top of, and agnostic to, some concurrency mechanism. Go might make it slightly syntactically simpler than languages that use true OS threads (much simpler than C's pthread_spawn, but not particularly different to Rust's thread::spawn(some_closure)), but that's a syntax layer.
Callbacks have plenty to do with concurrency: first you compile everything into CPS (where callbacks are continuations) and then a trivial event loop nets you cooperative multitasking. When you don't have language support for this but you need concurrency, you just write something resembling CPS by hand and call it "callback hell".
> (pthread_create—threading.Thread doesn't count as parallism due to GIL)
Eh? Why would you have a GIL? Maybe in Go, but so what, just don't use such a language/run-time.
I felt that such "aggressive" countering was justified considering the equivalently grand claims of the post ("EXTREMELY_POPULAR_CONSTRUCT considered harmful").
Go's error paradigm is through return values that can be ignored, so an error channel would mimic "non-async" error handling in this case: Checking an error is is always up to the developer.
The discussion about error paradigms (exceptions vs. globals vs. error values, forced checking vs. free choice, etc.) is quite a big one, and arguably an entirely different topic.
It really is a whole wrapped up group of existing constructs, but that doesn't mean there isn't merit in it. The author mentions how this was central to Dijkstra's proposal for structured programming: "And now that Dijkstra understood the problem, he was able to solve it. Here's his revolutionary proposal: we should stop thinking of if/loops/function calls as shorthands for goto, but rather as fundamental primitives in their own rights – and we should remove goto entirely from our languages."
> The "with" example for benefits to not having a "go" statement seem rather bogus, especially seeing that such RAII constructs do not exist in Go (no destructors, remember?).
I don't really understand what you mean by this. You would certainly need a different mechanism in Go than in Python, but I fail to see how that is a criticism of the argument he is making. He has built one implementation in one language, clearly it would look different in other languages.
> Trying to claim that "go" is as terrible as the original "goto" is ignorance of the original problems. Bad use of goto can be a nightmare to track (as the author tried to illustrate), but goroutines do not jump around, they branch from the main goroutine, following normal control flow from there. They are easy to follow, and the language is designed so that you can throw around with them and forget them without them causing you problems.
Why exactly is branching better? With "go" statements, you can easily end up with goroutines that you aren't even aware of floating around doing things that you aren't aware of. His example of calls in external libraries is probably the best illustration. Currently, you could call what you think is a simple function from a library, and end up with a whole host of goroutines you didn't expect floating around, doing things and using resources. Articles about writing good libraries [0] have to make points about how you need to take care of this stuff.
Gotos create problems when you end up surprised about what code is being executed, making tracing execution paths difficult. Branching creates a different set of problems: most crucially that you can have code being executed and not even realize it's happening. The issues are different, but his analogy is really spot on IMO. Both of these things can be solved with code reading and debugging, but the author's whole point is that with an improved abstraction you no longer have to worry about that sort of thing.
> Also, this article is comparing a list of concurrency constructs and one parallism construct (pthread_create—threading.Thread doesn't count as parallism due to GIL) to callbacks, something which have nothing to do with concurrency at all. Very odd.
These are all related concepts. They are about doing different things "at the same time," just with different definitions of what the phrase means. The problems the author are describing are considerably worse in a scenario where actual processes are being spawned, but the nursery pattern is relevant in all of them. It's about control flow, not capabilities. Even using threading with a GIL can create execution paths that you aren't really aware of until you actually look through the code or debug.
Of course not. It also isn't original, or a "silver bullet" that I find relevant to general concurrent programming.
> I don't really understand what you mean by this.
I was trying to keep my rant a bit short, but my point is that the only practical example for a post that puts a lot of effort into complaining about a core Go construct (it's the title) does not even remotely apply to Go.
This is partly due to Go being shaped around this very core construct.
> Why exactly is branching better?
"goto" can (sometimes, as there are many valid usecases) be problematic as it can make control flow very obscure when read. "go" is extremely clear to read, and only has the concern that you don't know if a given function has created goroutines. It is of course usually described by documentation, or implicit from functionality that such thing will occur, and unlike goto, the code is extremely clear about what is going on if you read it.
Furthermore, whether a function call has created goroutines is by itself not a concern. What can be a concern would be if some types of resources that must be closed (i.e. a file) is referenced after you close it. That is not related to concurrency, but lifetimes.
By lifetimes, I do not necessarily mean Rust-style compiler-enforced lifetimes (which I like), but simply API contracts. A library may store a reference to something you pass, and may use this reference in later calls, potentially after you invalidated the reference by closing a file descriptor.
A joined execution does not even remotely solve this problem, as it is not related to concurrency. It solves a different problem, related to just managing concurrent execution.
> These are all related concepts. They are about doing different things "at the same time, ...
They are not at all. Callbacks are often used together with certain types of concurrency constructs, such as an event-loop that can call callbacks upon various events. In JS, for example, the concurrency construct is a single global event-loop that calls tasks/microtasks.
Callbacks themselves, however, are not a concurrency construct. Thus, comparing them to concurrency constructs is very weird.
(A SAX parser is an example of a non-concurrent use of callbacks.)
> Even using threading with a GIL can create execution paths that you aren't really aware of until you actually look through the code or debug.
It does not create execution paths that are not easily visible (you know nothing about a program until you look through the code), but yes, threading.Thread can lead to some unanticipated behavior.
Note that I was excluding this as a parallelism construct, not a concurrency construct. The behavior will be some form of cooperative multi-tasking.
Ah I get what you mean about the use of go as an example.
Also, I think when the author refers to callbacks they are referring to callbacks in the JS sense, that is to say callbacks in the context of an event loop type environment. The author certainly wouldn't care about other types of callbacks, since they do not create the split execution paths that he is describing.
The distinction you are trying to draw between concurrency and parallelism, while totally valid, isn't particularly relevant here. The problems the author is describing are almost entirely to do with developers writing and understanding code, and they generally apply to both parallel and concurrent systems. The user experience of writing parallel and concurrent code can often feel extremely similar, so the tools the author is describing will tend to apply in both situations.
Because the author engages directly with the core point Dijkstra made, and proposes a new abstraction which parallels with that core point. The author does so in a clear and thoughtful way which cannot be dismissed out-of-hand.
And yet misses the point entirely, presenting non-original work that only acts as a band-aid on a symptom, rather than dealing with the cause (which is not related to concurrency constructs at all).
The (simplified, and as I understand it) gist of concurrency in CSP is that the program is expressed as a series of parallel (PAR) and sequential (SEQ) operations.
Everything in a PAR block will run in parallel and all their outputs will be collected and fed as the input to the next SEQ. Everything in a SEQ will run sequentially as a pipeline until the next PAR. Every PAR must follow a SEQ and vice versa, as two PARS or SEQS next to each other will simply coalesce.
eg.
PAR
longCall1
longCall2
longCall3
SEQ
reduceAllThreeResults
doSomethingWithTheReducedResult
PAR
nextParallelOp1
nextParallelOp2
This misses the point of the article. It's short-sighted to say that he's reinvented CSP given that the entire concurrency model in Go is based around CSP, and the author is already aware of it. The article is more related to RAII, scope, lifetimes, etc. than any model of concurrency.
Hmmm...as far as I understood the FA, the point is exactly that 'go' is unlike PAR in important respects.
'go' is more like a goto, the new goroutine is spawned, without any scoping. PAR opens a scope where all the contained routines are executed in parallel, but all of these must have terminated before the statement after the PAR is executed.
This is realised in a real language as occam. I've never understood why so few languages have a par construct, it's much more natural than asynchronous launching.
The neat thing is that PAR and SEQ do not compose mere operations; they compose other CSP processes. This gives CSP a very regular, self-similar structure.
(The "basic" processes are just the send/receive of a value over a channel. By the way, Go channels are a direct lift from CSP!)
Speaking of CSP, more broadly process algebras (a.k.a. process calculus) generally have such a "parallel composition" operator.
Also, CSP genuine inter-process communication primitive is a _multi-way_ rendezvous which can synchronise an arbitrary number of process, possibly more than two. This is called "interactions" in chapter 2 of Tony Hoare CSP book [1], and channels are built on top of these interactions in chapter 4.
This multi-way synchronization is also present e.g. in the LOTOS specification language, which is an ISO standard. The CADP [2] verification toolbox offers various tools like model-checker to verify LOTOS programs, and also to generate executables. For those who know how special/weird the LOTOS syntax is, the CADP folks also develops the LNT language which looks much more like Ada/Pascal.
The FAQ even mentions Occam, mentioned by another commenter:
"Occam and Erlang are two well known languages that stem from CSP. Go's concurrency primitives derive from a different part of the family tree whose main contribution is the powerful notion of channels as first class objects."
Yes, the communications primitives line up with event synchronizations and the select with the external choice operator, but Go doesn't implement the process operators parallel, seq and interleaved. This is what the guy is getting at, without realizing it. The slightly grandiose noise in the writing kind of reminds me of Wolfram actually (ouch).
It's amazing how many people managed to skim through the post, and hammer on their own preconceptions and facile counter-arguments, for things that are all addressed in the argument.
And that's for a very well written post, that tries to address all common issues.
And yet, people manage to get it wrong, or write facile responses like "re-implementing the fork/join".
Not to mention missing the whole nuance of what the author is talking about, which is not about novelty of a feature, but about what it allows us (and even more so, what it constraints us).
It's like as if people being shown for loops and structured programming in the 60s responded with "this proposal just reinvents gotos". Or worse, that "this is more restrictive that gotos".
Yes, the author knows about the Erlang's model. He writes about it in the post, and about how you can use his proposal to do something similar.
Yes, the author knows about Rust's model. In fact Graydon Hoare, the creator of Rust (now working at Apple on Swift), has read the post's initial draft and gave his comments to the author.
This addresses the wrong problem. The real issue is control over data shared between threads, not control flow.
C/POSIX type threads have no language support for indicating what data is shared and which locks protect which data. That's a common cause of trouble. The big question in shared memory concurrency is "who locks what". Most of the bugs in concurrent programs come from ambiguities over that question.
Early attempts to deal with this at the language level included Modula's "monitors", the "rendezvous" in Ada, and Java "synchronized" classes. These all bound the data and its lock together. Rust's locking system does this, and is probably the most successful one so far. (Yes, the functional crowd has their own approaches.)
Go talked a lot about controlling shared memory use. The trouble with goroutines, as Go programmers found out the hard way, was that the "share by communicating, not by sharing" line was bogus. Even the original examples had shared data. But the language didn't provide much support for controlling that sharing.
Python is basically at the C level of sharing control over data, except that the Global Interpreter Lock keeps the low-level data structures from breaking. This prevents Python programs from doing much with multi-core CPUs. Since this is just another thread library for Python, it has the same limitations.
Real concurrency in Python with disjoint data, and without launching a heavy-weight subprocess, would be a big win. But this isn't it.
Almost all attempts at CSP-style programming in the end resorted to sharing data to get a little bit better performance. I wonder whether we shouldn't have used a bit of speedup that Moore's law gave us to cover that cost and be done with all the shared state headaches.
Except shared data doesn't give you a little bit better performance, it gives you massively better performance. Or, in some cases, it's the only way to get usable performance at all.
Now what you could do is break objects down into annotated types. Consider immutable vs. mutable in combination with thread-unsafe, thread-compatible, and thread-safe. Immutable data that's not thread-unsafe you can share freely across threads, all is well. L2/L3 caches are happy. Mutable that's thread-safe can similarly be shared at will. Then you can force that thread-compatible objects be wrapped & accessed only from a Mutex or transfered between threads as part of a move operation.
Rust gives you the tools to do all of this, and indeed does some of it, but as part of the steep learning curve of the ownership model.
I proposed something like that for Python in 2010.[1] Immutable objects could be shared. Mutable objects had to either be unshared, or a subclass of an object that enforced synchronization.
Python's Little Tin God didn't like it. Mostly because I proposed to freeze the code of the program once the second thread started. That takes away much of the dynamism he insists on.
Might be worth looking at again. The separation of data into immutable, unshared, or synchronized is mainstream now.
Python seems like an odd place to try and shove this into. Both due to its heavy object mutability & dynamic nature in combination with the GIL making heavy threading of python code a waste of everyone's time anyway.
There aren't many languages with the concept of ownership or moving at all, and trying to retro-fit that is probably going to be not a good experience for anyone involved.
Rust is largely there, in yet another thing it does well, but if you don't want that something like C++ would be probably a better place to try it. There you at least have move & ownership as a language concept already.
I think threads is a big enough of a problem that it can be divided into multiple sub problems where each individual problem deserves a solution. Yes, the resource sharing problem is harder than the part that this solves, but does that really matter?
But isn't it the case that while nurseries may not solve shared data handling, it certainly doesn't make it harder to reason about. If you get a fix rule that when the program has passed a certain point (the nursery) then the threads are done and the shared data related to the task will not be shared anymore.
This is usually why I end up using https://godoc.org/golang.org/x/sync/errgroup instead of straight go statements, as it addresses some of the cancellation and error propogation issues. When I think of my use of naked go statements, it's usually for periodic tasks; having something similarly structured for them would be a clear win to me (though potentially the impact is less significant, as it's less painful to write a well formed periodic task using the go statement and context).
Ah, neat. It's relatively new—I've always rolled my own implementation whenever I needed it (it's super simple, which is why this library is 67 lines of implementation, half being comments), but why roll your own when it's part of the (experimental) standard library?
ErrGroup is nice, but it was created before contexts existed, and doesn't have support for cancellation. I have a bounded worker pool executor that handles cancellation that I'm currently extracting from a private project; shout out if interested.
The errgroup I linked has a single constructor `WithContext(ctx.Context) (*Group, context.Context)`. I think you might be thinking of the stdlib's sync.ErrGroup :)
Thank you! I had no idea about this package! I rolled my own similar thing, but I'd much rather be using something semi-blessed. Looks like I have some refactoring to do, but that's not always a bad thing.
I thought the title was kinda clickbaity, but it turned out to be a great article. Also the comparison to goto really effectively conveyed the point he was trying to make. I have two questions though:
* Does anything else like this currently exist (other than the Trio library he mentions), which shows that it's a superior paradigm in practice?
* What are the cons to this approach? Why not do it?
I don't think its a superior paradigm, just a different one.
I only see his nursery as being useful when you really want your async tasks to complete before the function in which they were dispatched returns. That's far from covering every use case of concurrency!
A lot of the value of concurrency is in background operations. These simply can't be tied to the duration of a function call on the dispatch thread. Doing so will literally kill any advantages of concurrency in the first place, might as well just block directly. (This is especially true of apps modelled as an update loop, you definitely don't want to block that loop.)
I can think of very few places where I'd actually want a nursery, and even in those cases I'd rather use the promises or fork&join already available.
> I only see his nursery as being useful when you really want your async tasks to complete before the function in which they were dispatched returns. That's far from covering every use case of concurrency!
His section "There is an escape" answers this criticism: "The nursery object also gives us an escape hatch. What if you really do need to write a function that spawns a background task, where the background task outlives the function itself? Easy: pass the function a nursery object."
He goes on to explain a bit more, but one of his points is what really ties this abstraction back into the goto discussion: "Since nursery objects have to be passed around explicitly, you can immediately identify which functions violate normal flow control by looking at their call sites, so local reasoning is still possible."
> A lot of the value of concurrency is in background operations. These simply can't be tied to the duration of a function call on the dispatch thread.
I think they can: applications with background threads would have an outer-level nursery at whatever "main" is for them, and that nursery would be passed into whatever function needs to spawn a background thread.
Then what is the difference between the nursery escape and joining a thread or extracting the value of a promise? Both of these will bubble any error back into the dispatch thread.
Seems like we're just layering indirections (I wouldn't call that an abstraction) for little added value over the existing constructs.
The difference is that you can look at function, see it takes no nurseries as parameters, and know it does not spawn any background tasks. And if it does take nurseries, then you know it's likely that it does.
When I call a function, I have no way of knowing if there are any unhandled promises within it. The difference is the restriction: with nurseries, background tasks can only outlive function calls if the calling function allows them to.
Needed is a strong word, but what this provides are guarantees when reading the code. Much like you know that control flow will come back to the function you're reading after calling another function. We didn't need to know that control flow will resume in the calling function, but it turned out to be very useful.
What guarantees? Nothing prevents a function receiving a nursery from not using it. Unless the language can enforce it you don't really gain anything valuable over returning a Promise, except more complex code that doesn't compose.
> Much like you know that control flow will come back to the function you're reading after calling another function.
What about continuations? Exceptions? Aborts? setjmp()? I can think of many cases where control doesn't return to the caller that are perfectly valid.
Basically, either the code is so simple theres obviously no bugs, or the code is so complex theres no obvious bugs. I feel a nursery is closer to the later than the former.
In some existing languages, yes. But it’s easy to imagine future languages (including future versions of current languages) that disallow creating nurseries outside of a function scope.
You might want to consider that this exact thread of questioning does not deviate at all from the similar line that was used to defend `goto` against structured programming. I’m not saying that you’re wrong or that this is necessarily the same thing, but the author addresses these kinds of questions through analogy to the `goto` debate in the article. It’s worth thinking about their similarity.
TL;DR, this doesn’t add expressive power (in fact, it removes it). The author argues that the power removed is power that is unnecessary, dangerous, and inhibits the ability to infer higher-level invariants about your programs that can improve understandability while potentially enabling even better abstractions. Even more briefly: restrictions make your code better.
I agree, but I still wouldn't call a nursery an abstraction; its an indirection, and a mutable one at that. I also disagree with the goto analogy; concurrency and control flow are two distinct things, they have much more differences than similarities.
Implementing a promise as a monad will yield all the same benefits while also keeping the ability to compose and be immutable; and then you have an abstraction and the result is simple.
I agree restrictions make the code better, this just isn't one of these cases to me.
A process has much higher overhead (both in time and space), more complex communications and is not always possible in the first place. It also adds complexity to the build pipeline. These quickly adds up to being not worth it over a task or thread.
You wouldn't spawn a process for the rendering/input/physics/network/job/loader threads of a game engine for one. You can't spawn processes from a web app either.
Threads are actually pretty darn simple when you have either immutable data or uniqueness constraints. Deterministic parallelism is also very powerful while preventing all sorts of nasty bugs.
> A process has much higher overhead (both in time and space), more complex communications and is not always possible in the first place.
This can change if OSes start optimizing for the proposed convention.
> It also adds complexity to the build pipeline.
At least in C/C++ it doesn't. But again, this is also easily solvable if we want to.
> You wouldn't spawn a process for the rendering/input/physics/network/job/loader threads of a game engine for one.
Why not? In my mind, if you need 2-way communication between 2 threads, they should be siblings. If they need one way communication, there should be a parent-child structure. If they need no/minimal communication, they are better off as processes. I don't know which categories each of the threads you named fall into. I realize that this approach will require extensive redesign of existing software, very much like the elimination of goto required.
> You can't spawn processes from a web app either.
No reason for it to stay that way.
> Threads are actually pretty darn simple when you have either immutable data or uniqueness constraints. Deterministic parallelism is also very powerful while preventing all sorts of nasty bugs.
I won't pretend to know all those words :P I just think the article's proposal has some merit and we should consider it.
>
> This can change if OSes start optimizing for the proposed convention.
What conventions? Its not realistic to assume the world will change to fit your views of software :p
> At least in C/C++ it doesn't.
Sure does; it adds more build targets, gets you to maintain shared code across executables, and plan deployment for multiple executables instead of one. Thats all before even coding the support for that.
> Why not?
Because these depend on shared memory and ownership transfer for performance; you'll drastically drop performance just for the sake of isolation.
> No reason for it to stay that way.
I will literally stop using the web if pages can spawn processes :)
> I won't pretend to know all those words :P
Hehe, basically immutability makes it so nobody can mutate data, thus making it safe to be shared across threads. Uniqueness will transfer ownership such that only one thread has references to a mutable piece of memory at any time. Deterministic parallelism means its impossible to have race conditions or deadlocks.
> I just think the article's proposal has some merit and we should consider it.
Agreed, I'm still having a hard time seeing it however :p
threads were actually invented for these sorts of use cases, as "lightweight processes".
If OSs change again, sure, the way we program will change again.
At the moment, using processes is too resource intensive, that's exactly why we have threads in the first place. And I don't understand what benefit you are suggesting using processes over threads would have.
I use concurrency in the form of an error group combined with an injected context and a way of propagating the cancel via that context to an exec if I we're going that route. It's a best of both worlds solution IMO.
>I only see his nursery as being useful when you really want your async tasks to complete before the function in which they were dispatched returns. That's far from covering every use case of concurrency!
Perhaps, but only having a complex solution that handles 100% of use cases is less desirable than having a simple one that handles 80% of the most common use cases PLUS the ability to go deeper and use the complex method (if, and only if, it's necessary...)
Agreed, I'd rather have a few simple constructs, especially if they compose.
Still, there's value in unification and generality, at least when the result is simple, even if that covers only 80% of cases.
For example you generally run a lot more composable tasks than actual threads and syncs. You get a general, and powerful abstraction for the general case yet threads are still there for more exotic needs.
> * Does anything else like this currently exist (other than the Trio library he mentions), which shows that it's a superior paradigm in practice?
Erlang's supervisor behaviours are basically that. They add some more stuff (the actual supervision) but fundamentally every process in a supervision tree will necessarily manage and outlive all its children processes.
Sure, but Erlang solves this problem indirectly. There's no risk of concurrent processes unexpectedly changing state (the real problem Trio addresses, or at least reduces the blast radius of) because there is no shared state.
> Sure, but Erlang solves this problem indirectly.
It solves the issue quite directly.
> There's no risk of concurrent processes unexpectedly changing state (the real problem Trio addresses, or at least reduces the blast radius of) because there is no shared state.
That doesn't seem to have any relevance to Trio's purpose, a low-level spawn has this exact same guarantee and yet is pointed to as a problematic primitive right in the introduction. The essay says literally nothing about unexpected state changes. In fact the word "state" appears nowhere in the essay. The essay is about control flows and their structuring (or lack thereof).
The entire reason why trio is proposed as useful is because of bad control flow leading to unpredictable state.
That's what was discussed in the example about file handles. The problem isn't bad concurrent control flow itself, the problem is that bad concurrent control flow enables unpredictable (or difficult to predict) changes in the apparent expected state all over your program. That's the same situation as with 'goto': spaghetti jumps are bad largely because they make it hard to think about "what is true when the flow of execution gets here here" at a given point in the program.
Erlang's concurrency is probably the most similar (greenthreading, channels, messages) and best out there. Errors are handled nicely via supervisor; you can produce a long running fault tolerant system.
Haskell's async[1] solution is very nice too, with the exception restarting processes and fault tolerance isn't really there yet. On the other hand, in Haskell there's stuff like STM, which makes atomic updates to shared memory easy.
At the lower level side of things Rust's model is very nice too. You can statically verify that you don't have certain classes of bugs.
Same. I kinda eye-rolled at the title, but got hooked by the article.
I'd be interested in implementing this in Go and throwing a web server implementation at it to see if it makes more sense. Though maybe that's too simple a use-case for it.
It'd be interesting to see how to implement the error propagation using channels, without having any control over the goroutine passed in. If the goroutine panics, how to capture that and pass it back to the nursery?
> As a result, every mainstream concurrency framework I know of simply gives up. If an error occurs in a background task, and you don't handle it manually, then the runtime just... drops it on the floor and crosses its fingers that it wasn't too important.
You ought to look into Erlang and Elixir on the BEAM vm/runtime. It's arguably the best example of this kind of concurrency (greenthreading, async) done properly with regards to error handling.
I don't write Elixir or Erlang, but I believe this process is managed by the supervisor. You can select various behaviours for when a process crashes or errors out[1]. For instance, you can have a process simply restart after it crashes. Combined with a fail-fast mentality, this produces remarkably fault tolerant and long lived applications.
Supervision trees (or sync.WaitGroup in Go) are good tools for achieving the same end result. But it isn't as semantically protected as the article states. In Trio, the property is given by the programs scope.
However, if process creation/termination follows the scoping rules of the program, I have a hunch you run into situations where certain things are not only hard, but outright impossible to express.
Now, the author gambles that this is a good thing and we will eventually find good structural solutions to all the problems. I, on the other hand, is a bit more pessimistic because it has been tried before and found to be lacking.
Not sure what you mean by semantically protected? OTP supervisor trees enforce the constraints that are specified in the supervisor’s start function. It’s semantics are defined by the OTP library and function scopes. As you mention there are times to break out of the strict (supervisor) pattern if needed/wanted and start processes manually. Otherwise the default behaviors provide a nice set of limited behaviors that have proved useful across a broad spectrum of situations.
Really what TFA’s discussing seems much more akin to OTP than raw Erlang. Or more specifically a subset of it. Occasionally I wish there were a few more OTP supervisor behaviors but nothing that’s a show stopper. Not familiar with Go’s WaitGroup but I haven’t seen it used much in code I’ve read.
This is very very long to explain a very simple pair of ideas:
1) You should be able to declare scoped blocks that mandate execution of all tasks started in that block ends when the scope ends.
2) This is fundamentally superior to all other forms of concurrency.
I get it; this is basically what async/await gets you, but conceptually you can spawn parallel tasks inside an awaited block, and know, absolutely that all of those tasks are resolved when the await resolves.
(this is distinct from a normal awaited block which will execute tasks inside it sequentially, awaiting each one in turn).
...seems like an interesting (and novel) idea to me, but I flat our reject (2) as ridiculous.
Parallel programming is hard, but the approach from rust, to give you formal verification, instead of arbitrarily throwing away useful tools seems much more realistic to me.
The article makes a strong case. Even if rust allows formal verification, not all programmers use it, and if you use their library, you don't know if it's been verified.
Take it back to the goto analogy: can you formally verify goto? Yes. Does that mean it's good to include as a language primitive? No.
You are basically taking an entire argument, ignoring its merits, and saying "but you can do it another way". You are exactly right, but you haven't rebutted the fact that structured concurrency is philosophically superior.
I love rust and the community; they will get to the truth of this argument eventually. But I suspect the truth is that "njs was right", and I hope the it's sooner rather than later.
It's not only that Rust allows formal verification, it's that it does it by default, writing unsafe Rust code for no serious reason is fundamentally frowned upon, and even then there is a serious effort to bring some tooling to bring more confidence even on "unsafe" Rust code.
> But I suspect the truth is that "njs was right", and I hope the it's sooner rather than later.
I'm not sure. The problem can and has been solved without the manually "pass the nursery object around" ""escape hatch"" for the general case of threads (because no: having the execution of spawning functions delayed by the lifetime of thread is arguably reasonable is some cases, but certainly not the general case of what threads are useful and used for)
It is still useful for tons of existing languages as a pattern anyway, but only if the use cases are suitable.
But the author is focused on a narrow use case of threads (and a narrow subset of the problems they introduce), and present their solution as a general truth and new fundamental control structure of computing, independent of already existing, in production, and arguably better solutions; and independent of analyzing the new problems their silver bullet introduces.
“Formal verification” for Rust code -- does that actually exist yet, or do people just hope/assume that the language will be proven consistent and that awesome theorem-checking tools will emerge eventually?
I agree that Rust has a great model that seems to lead to very solid code, but “formal verification” is a high bar to clear.
Some people are actively working on formal verification for Rust. The RustBelt project is the first that jumps to mind. A paper and some discussion here: https://news.ycombinator.com/item?id=16302530
Well, you are right, it is not really "formal verification".
But I don't know how to call it, and it is way more checked on some aspects (well, obviously, we are not talking about checking each program against a spec...) that the competition. And by that, I mean that the competition is actually not even trying...
I found it still an interesting read, but I agree with you. The most important problem is:
> Then our guarantee is lost: the operations that look like they're inside the with block might actually keep running after the with block ends, and then crash because the file gets closed while they're still using it. And again, you can't tell from local inspection; to know if this is happening you have to go read the source code to all the functions called inside the ... code.
But actually you can "tell", or even better have a type system good enough to prevent such mistakes entirely, and the author even knows a bit about Rust, yet fails to state that at least that item is a solved problem there (by using a different and arguably more general approach).
Now I don't know enough to decide whether something is missing on the panicking background thread front, but if it does that seems very solvable.
I don't buy that spawning threads (or even moral equivalents) and multiprogrammation is in a situation similar to unstructured use of goto anyway. You have tons of problems applicable to one and not the other. And of course resource management is hard to get right with threads. But we have at least a production example of a language that get it right on some points by leveraging more general ideas (and the other difficult points are mostly not addressed by the nursery idea, anyway).
Pure fork/join concurrency isn't a novel idea. I was doing some work on it in grad school. Without blocking channels, fork/join has some really nice properties, such as guaranteed deadlock freedom.
However, I agree with you that there's no one-size-fits-all approach to concurrency. Sometimes you want long-lived tasks that communicate—the actor model, in other words—in which case fork/join doesn't buy you much. I do think that fork/join is frequently what you want, though.
>this is distinct from a normal awaited block which will execute tasks inside it sequentially, awaiting each one in turn
I believe that python's `async for` construct (which may be stolen from c#, but I'm not sure) does this, and another user mentioned Promise.all which in javascript I believe allows the child promises to resolve concurrently.
In both cases, these are for concurrency, not parallelism.
The point of the construct is not explicitly waiting for child tasks/threads/whatever, but that as a formal restriction, you cannot leave the scoped block with unresolved tasks.
I don't believe either of the constructs you've mentioned do this, and I'm not aware of any that do.
The trivial counter example would be a deeply nested `setTimeout(..., 5000)` inside the javascript code.
It doesn't matter if you've called Promise.all or not.
Also, regarding parallelism: Obviously async/await are for that; this is basically pitching an equivalent construct for parallel processing.
I think this is novel, frankly, and the alternatives being thrown around by people are by people who didn't read the article.
>The trivial counter example would be a deeply nested `setTimeout(..., 5000)` inside the javascript code.
Well of course, setTimeout isn't async/await based, its callback based. If instead you only had this api, you could make that formal assertion:
await promiseSetTimeout(time).then(...);
And in fact you can write promiseSetTimeout today!
So basically, async/await provides this as long as you only use async/await. In JS you can't formally assert this because there are functions that subvert the normal control flow, but if you outlaw those, you absolutely can.
The proposed construct does not require that: I'm not saying its better or worse; I'm just pointing out that it's different.
Obviously if you choose to avoid branching code in your functions, they won't branch; what's novel here is that the closing scope (ie. in this case, the collapse of the python with block) triggers a collection of all the ambient tasks started in that context.
I'm sure you could implement something similar in javascript, but it is not the same as Promise.all.
This is kind of a silly question. Its always possible to subvert a safe construct system if you try hard enough. You can write unsafe blocks in rust. You can pass in a callback to a nursery, as is described in the article.
>The proposed construct does not require that
Well, kind of. The article actually explicitly states that
>Here's a simpler primitive that would also satisfy our flow control diagram above. It takes a list of thunks, and runs them all concurrently:
which is equivalent to promise.all, satisfies the invariant nurseries do, except in very specific circumstances (unbounded while loop-y constructs). And you just simply can't use promise.all in that situation.
I don't agree that the proposed idea is fundamentally new and amazing; but I think it is novel, and there may be some value in being able to semantically bind tasks to execution points, specifically when the tasks are spawned in naive (or uncontrolled, eg. library) code, and have not explicitly opted in to the scheme.
Does this particular implementation do that perfectly? No, probably not.
...but I think the idea of it has some merit.
There's more value here than just waiting for a series of deferred tasks that you have explicitly created, and explicitly opted into a specific concurrency workflow with.
Note specifically these parts of the proposal:
> we declare that a parent task cannot start any child tasks unless it first creates a place for the children to live
^-- This is not satisfied by your method, at all.
> But the problem with this is that you have to know up front the complete list of tasks you're going to run, which isn't always true.
Literally the next sentence after the one you quoted.
> What if you really do need to write a function that spawns a background task, where the background task outlives the function itself? Easy: pass the function a nursery object. There's no rule that only the code directly inside the
Notice how you can avoid the 'setTimeout(() => { .. })` in nested code issue by explicitly passing in a context that has its own scope.
...
There's a lot of stuff in here that is actually quite thoughtful.
You don't have to dig through it, finding every little nitpick you can to call it out on; just think about the idea being proposed.
It's certainly not 'just promises'; if you think it is, then... I don't know what to say. You're wrong. :P
(requires python 3.6, I'm using threading.timer in place of setTimeout, but they're the same construct). There's a great talk by david beazly about how threads and asyncio don't play nicely, except when they do.
Note that you could imagine that this timer is created in an awaited function or a supposedly synchronous child function.
If the nursery did what you think it did, which is to wait until everything async within the block completes, you'd see this print first, then print finished. It doesn't though, finished is printed first. The reason is that you need to opt in to the trio constructs by using promises/futures/coroutines. Using threaded callback based things give you the same problems with trio as they do in other async/parallel constructs.
If you're a good citizen and opt in to the safety guarantees trio provides, it can keep things clean for you, yes. But you do need to explicitly opt into the scheme by only using code that you know is promise/future/coroutine based. Threads are a doozy.
But the same thing is pretty much true with async/await promises/coroutines in js or python. If you require that you only use promises (or you tightly wrap all of your threads/callbacks in promises and then use that), you can get the safety of nurseries in most of the situations (I noted there were some exceptions before!), but with just the async/await syntax, no need for the extra async context manager.
I agree that trio is cool. I've used it before. But you're ascribing to it and this construct magical powers that they cannot and do not have.
In this implementation, or perhaps, in python at all...
Does that make it completely valueless?
I would venture to suggest that maybe there's a big wide world of languages that actually support controlling how threads are spawned, where it might not be.
I agree that it's worth considering other ways of handling concurrency. But we should do so by staying within the realm of reality.
Your understanding of nurseries, conceptually, does not match their capabilities. It's not about any language or implementation, it's that what you think they can do isn't possible. It violates the halting problem.
You can't statically infer whether or not a function will have async side effects without opting into some scheme that describes those effects. If you do that, nurseries provide some guarantees. But those same guarantees are provided by just using async/await, which tracks explicitly which functions are async and which are not.
Nurseries do potentially provide some advantages when dealing with tasks that you want to outlive their scope, which async/await doesn't handle well, and when dealing with an unbounded number of async calls (maybe, I think an `async for` construct handles it too).
But otherwise, most of the advantages you seem to think nurseries provide aren't. And not just by this implementation, but by any implementation. They are provably not providable by any implementation that isn't equivalent to marking your async functions as async.
If you can control how threads spawn, like C# thread contexts (for example the MVC context forces async resumes on the original thread rather than an arbitrary one), this is entirely plausible without an 'opt in' model.
In general, it is not possible to know if an arbitrary thread will halt. That's the halting problem. You can restrict yourself and make sure that all threads 'return' on completion (promises, futures), or do various other things. But in an unrestricted system, it is provably impossible to know whether or not an arbitrary thread will halt.
The issue is waiting for threads at runtime, at fixed boundary points, not statically determining if they halt or not.
The halting problem is irrelevant to this discussion.
Well.. whatever. You can provably determine whatever irrelevant point you want I guess.
If a naively spawned task or routine can be artificially restricted at a boundary point, that's enough for me.
You could without question implement this by binding child tasks to the threadlocal context on spawn and wait all on a dispose block in c#; certainly if you explicitly went out of your way to swap to a different sync context it would break but so what?
You can always unsafe your way into a hole in any language.
>If a naively spawned task or routine can be artificially restricted at a boundary point, that's enough for me.
Ok, and you have three options to do this:
1. Block
2. Opt into some system that allows you to defer blocking until later
3. Know when the callback will finish
1 isn't async, 2 is an opt in system, and 3 has a prerequisite of the halting problem.
That's it. You can restrict yourself to not using primitives like `Thread`, and instead only use futures or async/await style things, and that's a valid solution. Hell, your language could not expose a Thread primitive at all. But its solution #2. You cannot just take a look at arbitrary code and pick a point or points where thread execution is restrained.
If you can, it's because the language you're using prevents you from doing certain things. (for example, opting in to "all threads must be promises, and all promises must be awaited)
+1. This is structured programming, applied to concurrent setting. The argument for structured programming was made and won in the '60s [0]. It is amazing that we keep making the same mistakes over and over again.
> The unbridled use of the go to statement has an immediate consequence that it becomes terribly hard to find a meaningful set of coordinates in which to describe the process progress. Usually, people take into account as well the values of some well chosen variables, but this is out of the question because it is relative to the progress that the meaning of these values is to be understood! With the go to statement one can, of course, still describe the progress uniquely by a counter counting the number of actions performed since program start (viz. a kind of normalized clock). The difficulty is that such a coordinate, although unique, is utterly unhelpful. In such a coordinate system it becomes an extremely complicated affair to define all those points of progress where, say, n equals the number of persons in the room minus one!
> The argument for structured programming was made and won in the '60s
Most code out there is fine using break and continue in loops, and early returns are pretty popular. All of these are unstructured programming (by the 60s definition). I don't think it's all that clear that it has won.
"In the end, modern languages are a bit less strict about this than Dijkstra's original formulation. They'll let you break out of multiple nested structures at once using constructs like break, continue, or return. But fundamentally, they're all designed around Dijkstra's idea; even these constructs that push the boundaries do so only in strictly limited ways. In particular, functions – which are the fundamental tool for wrapping up control flow inside a black box – are considered inviolate. You can't break out of one function and into another, and a return can take you out of the current function, but no further. Whatever control flow shenanigans a function gets up to internally, other functions don't have to care.
This even extends to goto itself. You'll find a few languages that still have something they call goto, like C, C#, Golang, ... but they've added heavy restrictions. At the very least, they won't let you jump out of one function body and into another. Unless you're working in assembly, the classic, unrestricted goto is gone. Dijkstra won."
I tend to agree with the author. Linux kernel code, for example, uses goto for error handling, particularly when implementing system calls. But the author is correct: the gotos can't jump out of the function. Readers can still infer control flow from function call sequences. I quite enjoyed the post, I think it's worth giving the author's claims serious consideration.
Putting aside pedantic purity arguments, structured programming has come to dominate high-level-language design and the way we think of programming. Programs like the FLOW-MATIC example from the article are simply not being written any more in any HLL. Few (if any) concepts have achieved greater ubiquity.
I LOVE Go's concurrency model, but this article has won me over (pending some experimentation anyway).
If you just skimmed, this is actually worth a careful read. The parallels between "go" and "goto" are explained very clearly, and you get some awesome Dijkstra quotes to boot!
The article had a whole section on how any challenges to existing paradigms and tools will be met with fierce opposition. The comments here are an excellent illustration!
Article persuasive prima facie and arguments plausible. I've had to reinvent a structured method of managing threads a number of times, unfortunately.
Author is a PhD student, which bodes well for not reinventing wheels dumbly. Therefore, I look forward to the lit review of other concurrency & parallelism work through the last 40 years, which this writeup notably lacks (author mentions his stack of papers to review).
Your ideas are intriguing to me and I wish to subscribe to your newsletter.
In what way? Nurseries sound like they're exactly the same thing as a Rust scoped threadpool with a 'static lifetime. Not some sort of super modernization like you're suggesting.
My project is currently struggling with how to migrate to Python async. The biggest challenge is the place where async and sync interface.
Just the other day, my colleague was wondering out loud about the possibility of using a context manager to constrain the scope of async. This is it. This is exactly what we were looking for.
This is a really useful property to have and reason about.
Instead of several independent coroutines with arbitrarily overlapping lifetimes, we can now think of all coroutines as organized in a single hierarchy with properly nested lifetimes.
The function call stack becomes a call tree - each branch is a concurrent execution.
I have yet to see any of these problems in core.async (Clojure's version of goroutines). core.async has enabled fantastic programming abstractions like async pipelines using transducers and "socket select" type programming. Perhaps functional programming is the solution to solve concurrency issues.
Clojure has HUGE advantages over Go in the first place, that helps immensely in making concurrency sane and safe. Most of the goodness in Clojure is emergent of its design, very few languages have that property.
The simple fact that Clojure can implement goroutines as a library shows how flexible the language is. Then there's immutability and a strong focus on simplicity among others.
So, my initial thought when reading was "yeah, it's async/await". But it's subtly different than that though.
You're free to spawn parallel tasks in async/await -- you just use Promise.all or Future.sequence, or whatever your language provides to compose them into a larger awaitable.
Nurseries seem to go a step beyond this by reifying the scheduling scope as the eponymous nursery object. This means that you have a new choice when the continuation of your async task happens: a nursery pass in from some ancestor of the call tree. My gut says that this offers similar power as problematic fire-and-forget async tasks, but takes away the ability to truly forget about them.
My guess is that, in practice, you end up with some root level nursery in your call stack to account for this. But account for it you must! And while the overwhelming sentiment in these comments is pretty dismissive, I'd caution against downplaying the significance of this. It's basically like checked exceptions or monadic error handling.
I also think about how this maps to task or IO monad models of concurrency. It seems like there's an inversion of control. Rather than returning the reification of a task to be scheduled later, the task takes the reification of a runtime, upon which to schedule itself. I'm not sure what the ramifications of this are. Maybe it would help with the virality of async return values [1], but at the cost of the virality of nursery arguments.
Lastly, one thing this article nails is the power of being able to reason about continuation of control flow. Whether or not nurseries have merit as a novel construct, this article still has a lot of educational use by making this argument very clearly. Even if the author is wrong about nurseries being "the best", it sets a compelling standard that all control mechanisms--async or not--should have to explain themselves against.
I do have a couple questions:
- In the real world, would library APIs begin to get clogged with the need for a nursery on which to run an async task? Think async logging or analytics libraries.
- Would usages similar to long-running tasks that receive async messages be compatible? I'm thinking of usages of the actor model or channel model that implement dynamic work queues.
- Does this increase the hazard presented by non-halting async tasks?
Didn't expect to say this, but the article is completely right! This is obviously the right way to write concurrent programs. Kudos for writing this.
One question though. The first part of the article says that "onclick" handlers should be replaced with nurseries as well. But I don't see how. Can someone explain?
That's an interesting tool, but merely among other. Not the next big things to structure thread usage. There are loads of problems if we wanted to generalize that solution as fundamental, and a quick review of the state of the art (of which we can have a quick preview by taking a look at these very comment threads on HN) show a wide spectrum of alternatives, sometimes for similar and sometimes for somewhat different use cases.
I see fundamental issues with it: in some cases the checking model proposed by Rust is better; also - and this is related -, your don't always fix things reliably by mindlessly extending lifetimes or delaying things until termination of others, in the same way that mindlessly switching a resource usage to a shared_ptr in C++ if you had a lifetime issue can't be done in the general case, because you could very well only be trading a bug for another. Checking capabilities are more useful and general than constructive limitations, especially when we have load of counter examples on use cases.
So without hesitation: yes, this is more structured than having no structure on the point considered, but that is not at all a sufficient criteria to make that the kind of panacea the author seems to think it is. I would have been way more positive in seeing that presented as a comparison with the other existing solutions, similar or not, and without that little escape hatch story that makes me thing the author has found a hammer and now everything looks like a nail to them.
I suspect that if you were to try to do this in real code, something like an onclick handler would have to be run in a nursery scoped at the page level, by the code running the page. An onclick handler on its own accord can't run itself in the correct nursery. It's like the network handling case there, where the nursery's lifetime is either unbounded, or tied to the lifetime of the OS process, depending on how you want to look at it. Functions don't always return within a program, or at least, don't always return excepting maybe a last cleanup as the program terminates.
"Maybe a subtle difference here is that a page doesn't get to block on anything before it gets closed."
In real browsers, the UI act of closing a tab and the completion of cleaning up all of its resources are already clearly separated. I can see that when I terminate a browser with a lot of tabs and the main window has entirely closed while the browser runs at 100% CPU for quite a few more seconds. Plus a browser can probably hard-kill running Javascript code with some reasonable effectiveness. (Not sure. Asynchronous exceptions are very, very hard.)
So I don't think that's a disqualifier.
"So how is creating a nursery that matches the lifetime and visibility of the page different from not using a nursery at all?"
Well, as I've said in one of the reddit conversations, bear in mind the entire purpose here is not to enable something that was previously impossible, but to constrain us from using primitives in their most powerful form. The big thing is that if you had a nursery-based system, and the page had a nursery associated with it, and the page's render routine returned, you'd know that all the page threads must necessarily be terminated. You can't know that with the same confidence now, because the programming style does not permit that level of confidence by construction.
Browsers are a bit of a pathological case on a lot of levels, though, and probably not a great mental example, because you can assume a high degree of competence, concern, and skill in the people programming the browser, and they do things like use static analysis all the time and even in the "worst" cases, develop entire new programming languages to write browsers in. So you are probably justified in saying "But jerf, I'm pretty confident the browsers are already cleaning up their stuff without this stuff." The real question is, how does this look for Joe Programmer and his ability to work in the domain of multithreaded programming, which is well known and widely acknowledged to be very difficult, by constraining what mistakes he can make?
One of the other angles you can look at this with is, if I have two pieces of correct code and I compose them together, are they still correct? The nursery system says that with my nursery, I can call other code that uses them, and the composition attained over that function call is also correct and does not leak resources. We do not get that guarantee with some other primitives. We do with others. There's a lot of experimentation still going on in this field. I'm not saying this approach is guaranteed to be correct, but it's one of the more plausible claims to being a primitive as basic as "if" is relative to "goto". Compare with Software Transactional Memory, which as nice as it may be, is wildly more complicated than "if" no matter how you slice it.
As callback have their uses as event handlers in single-threaded event-driven programs, I would guess that he is referring specifically to mechanisms that use callbacks in a concurrent setting - perhaps the code calling the handler is supposed to set up the nursery?
Lately, I am of the idea that the real problem with how we do concurrency is that we have yet to figure out a way to do it without first-class procedures. When we spawn a thread, even in a low language such as C, we use something to the effect of:
The trouble with this approach to concurrency is twofold:
(0) It forces a hierarchical structure where one continuation of the branching point is deemed the “parent” and the others are deemed the “children”. In particular, if the forking procedure was called by another, only the “parent” continuation may return to the caller. This is unnatural and unnecessarily limiting. Even if you have valid reasons to guarantee that only one continuation will yield control back to the caller (e.g., to enforce linear usage of the caller's resources), the responsibility to yield back to the caller is in itself as a resource like any other, whose usage can be “negotiated” between the continuations.
(1) It brings the complication of first-class procedures when it is often not needed. From a low-level, operational point of view, all you need is the ability to jump to two (or more) places at once, i.e., a multigoto. There is no reason to require each continuation to have a separate lexical scope, which, in my example above, one has to work around by passing “perhaps some local data” to `launch_Thread`. There is also no reason to make “children” continuations first-class objects. If you need to pass around the procedure used to launch a thread between very remote parts of your program, chances are your program's design is completely broken anyway. These things distract the programmer from the central problem in concurrent programming, namely, how to coordinate resource usage by continuations.
The main culprit to me in Golang seems to be channels, not goroutines. If your workflow essentially is defined by a mesh of channels and goroutines, it's hard to reason or understand.
I have no direct practical knowledge of Golang, but working on a large application that used BlockingQueue for concurrent communication and one which extensively used services buses for communication - both were hard to understand and reason about flow.
After some years with Scala Futures I'd say they work well and reason well. They can be seen as normal function calls returning Future instead of another 'container'.
They reflect the black box mentioned in the article, with one way in and one way out (e.g. when a method returns Future[_]).
The point about error handling: We use Option,Seq.empty on read error handling, Validation on create/write and Either on side effects (like sending mail).
(yes, they are still leaky abstractions e.g. when debugging, but work fine most of the time)
I think the article's point is that with Future's you can still pretty easily invoke a Future-returning function and forget to return its value, ending up with what you might call an orphan continuation.
The problem is much like the author said -- it's easy to have errors disappear into the ether in a way that is much less likely in synchronous logic. Also, if those side-effects matter, it's easy to make faulty assumptions about time ordering.
The most obvious situation to me is in the way asynchrony exists in front-end programming and how this affects testability. If you can't actually know when a process (like an animation) ends, you can't accurately test.
In general, my experience has been that reification of abstract things often presents benefits in the long run. Reification of functions admits a whole host of techniques. Reification of classes facilitates metaprogramming. Reification of in-flight processes as promises helps with being able to compose and abstract over them. Nurseries seem like reficiation of an finite execution context.
I'm not following, how could an error with Future[Either[A,B]] disappear compared to synchronous logic of Either[A,B]? Our code base is the same for sync and async logic and error handling.
One thing that doesn't work is Anders Hejlsbergs method of letting unchecked exceptions bubble up, but exceptions haven't been a good idea for business code anyway.
I generally agree with his premise that it sucks having to figure out if a function is concurrent or not; i.e., does it return a value or a Promise/Future. I'm not sure if his solution solves that particular issue though, unless it's handled automatically in his "nursery.start_soon" function.
Worst title ever. I never complain about this stuff, but can someone please change it? You think it’s going to be some analysis about Go, but instead it’s someone pushing their library.
I was underwhelmed by reading this (maybe due to a clickbaity title and IMO sketchy extension of badness from goto to the go statement).
That said, the article has good technical content. It proposed a new concurrency library with interesting properties. Concurrency comes with additional cost. The library proposes a paradigm to minimize certain costs and should provide punchy examples of how things can be done simply and efficiently with it.
But instead it is picking shallow fights with the go statement (does the author know about the "sync" package and WaitGroup)? Overall I found the advocacy section WAY too long. Use most of that real estate to show goodness of your library, not on trying to punch holes in the competitors. My 2c.
I’m wondering if some promise/future systems already provide a similar guarantee. The main useful property of the nursery system is that a function’s signature indicates whether that function leaves a background task running. If you can guarantee that promises/futures are dropped if they go out of scope, then you get a similar guarantee. Similarly, if you have a system where promises don’t actual run their background parts unless someone is waiting for them, then there are no leaky background tasks.
I'm really growing fond of the async/await abstraction. How do I get this in more C-like languages like Go, Rust, or C++? (I have a bunch of C++ code that I want to call via C ABIs.)
I'm intrigued by libdill but it's mysterious enough that I'm scared to include it in my project -- I don't want to risk getting sidetracked by having to debug my concurrency primitives.
In Go you can use waitGroups to ensure that the goroutines are completed, similar to join. You’ll have to pass a cancel chan or context if you’d like to cancel/timeout.
With that approach, you increase the number of threads being used for a single "block" by how deep your "wait" stuff goes.
In C# and Python, an async context shares a single thread loop that all joins jump back to. Simply starting a thread and waiting on it isn't suitable in most scenarios, namely web and UI dev.
In Go it doesn't really matter how many "threads" you have because Go has its lightweight threading model (aka coroutines) - creating a new "thread" is very cheap.
The reason C#, Python (and Rust), have that model is because they don't have coroutines and starting another thread is very expensive.
Also, this isn't just a matter of keeping thread resources down, it's supporting scenarios like developing web and UI software.
For example, if I have "OnButtonClick" and it does async work, I'd like to also call other functions that may also touch UI components. WPF has support for binding async actions to UI events, and it eliminates the need to ever wonder what thread you are on.
In short, you can't "join" a thread on the UI thread. Proper async/await with a task scheduler is invaluable.
Also, C# has thread pools. Most people don't create a raw thread using "new Thread()". They would normally use "Task.Factory" or "Task.Run". If it is a long running thread, you can specify to Task.Factory that is it long running and it will remove your thread from the thread pool and put another one in it's place.
The go runtime has been shown to handle millions of goroutines, and there are performant and scalable programs, like Cloudflare's RRDNS that run tens of thousands of goroutines (https://blog.cloudflare.com/quick-and-dirty-annotations-for-...). There's plenty of web software using this model written in Go.
I haven't done UI programming in Go - but in your example, I'd imagine you could simply have an event launch a goroutine - you could do whatever you want in that routine without blocking the main thread and simply pass a message back to the main thread if needed. Managing the number of goroutines isn't something you have to think about in go.
`Task.Factory`, and `Task.Run` still create threads, OS threads, which are different and much, much heavier than goroutines. You'd run out of memory trying to create 1000 OS threads, where as 1000 goroutines are a walk in the park (not just for Go, but for most runtimes using M:N threading).
async/await does have it's advantages over coroutine style concurrency, but I wouldn't count thread count as one of them.
>But now you are in the "goto" mess that this article describes.
This is where I don't really agree with the article. I agree with you that async/await may be better for UI programming - Go, and Erlang, which has a similar runtime to Go, aren't used to make frontends.
The article presents this example of 1 mainthread spawning 3 shortlived parallel tasks, waiting for them to complete, before they continue. This he argues, creates spaghetti code, where the concerns of a single routine are split among 3 different functions. This is the spawn/join model, and while it exists in Go, this isn't how concurrent go programs are normally written.
Goroutines are more commonly used like actors - in that a single goroutinThe concept of "branching off or onto" the main thread doesn't really exist. Instead my main thread runs something like an event loop, and in your example, the OnButtonClick handles the event synchronously, in its own goroutine, and will send a message back to the main thread's event loop on whatever state needs to change. This is the idea behind the CSP/Actor model which has been proven to scale for years - Erlang/OTP is a major proponent of it and is over 30 years old, and proven to scale (ex. Whatsapp managed 900M uses with only 50 engineers). If this model was as bad as goto, I don't think Erlang would have the reputation for building concurrent & parallel software it has today. This Actor model also isnt something that I can easily grok into the Trio library - and I'm hesitant to call something like Trio superior when the Actor model has years of experience.
Each model has their own pros and cons. A major plus of the Go model vs async/await is that I don't have to think about writing "asynchronous" code. I can write my code any way I like, and the Goruntime can easily make it asynchronous (partly because the Go language has already done the hard parts - everything, like sockets and files are async by default). This isn't true of the major async/await languages - in Javascript, everything that might be blocking has to either use callabacks or the async keyword. In Python, if I use a library that doesn't use my async library or is synchronous I lose. And Rust, it's already starting to rear its head as if I'm writing a library using Tokio, and I want to include a library using Rayon, I'm going to have problems. You could see why the designers, who thought they were building a systems language, would be wary of exposing an async runtime. Async runtimes "infect" everything around it.
However, a major plus async/await model is that since everything is async, its very easy for very small functions to be completely non-blocking. In Go if you had set of serial functions, they would always execute serially. For example if a request came into a web server, it would get its own goroutine. Then that goroutine would read from redis and then mysql. Most gophers would write the code such that the read from mysql would only happen only after the read from redis. You could put both reads in a goroutine - but async/await is much more efficient here, requires much less lines of code, and wont require a mutex. In async/await the two database reads will almost always execute parallely, without the developer having to do anything.
Each model has its own strengths and weaknesses which is why it's hard to consider one strictly better for another.
I found this pretty fascinating and I'm looking forward to hearing the theoreticians chew it over.
A question I had was with the API that's been chosen. The `nursery` is chosen as the reified object, and a function `start_soon` is exposed on it. Perhaps in other parts of the library there are other methods exposed on `nursery`? If not, in some languages it seems like the `start_soon` method itself would make more sense as the thing to expose. In use, it might do like this:
...
nusery {
(go) in
go { this_runs_concurrently_in_the_nursery }
go { this_also_runs_concurrently_in_nursery }
// make a regular function call passing it the nursery's `go`
some_func(go)
}
...
And elsewhere:
...
func some_func(go) {
do_something()
go {
nursery {
// This nursery is within the outer one.
(go2) in
go2 { do_stuff }
go2 { do_more_stuff }
}
}
}
...
> whenever you call a function, it might or might not spawn some background task. The function seemed to return, but is it still running in the background? There's no way to know without reading all its source code, transitively. When will it finish? Hard to say.
This reminds of me "colored functions" (red vs blue) where it becomes imperative to know if a function you are calling returns a value or a Future/Promise.
Some languages allow annotating a function to indicate as such so the IDE can help. His particular solution he presents actually doesn't address this question: Is your function sync or async? You still have to know when calling a function if it's async and needs to be in a nursery or not.
Should a programming language abstract away whether a function is async or not? async/await is a step forward (C#/JS) but it still requires knowing if the child function is async or not.
I found it an interesting idea and writeup. It'd be good to see what could be done in a language that implemented this - concurrency only allowed in the context of nurseries. On the downside, though, I think there's a lot of concurrency patterns that could only be implemented by creating a nursery near the top level of the application and passing it around all over the place, thus getting you pretty much right back where you started.
Want a web app that sets up some long-running thing to run in the background while the request returns quickly? Well then you're going to need a nursery above the level of the request which is still available to every request. I don't see what that gives you above conventional threading. Oh, and you'd also need to implement your own runner in a thread to have a task failure not bring down the whole application.
I don't like the term "nursery" (maybe "highway" or "complex" or... something else) but this seems to be a good design change, unless I'm missing something
I believe it's used because it keeps track of "children", or child functions spawned by the current function. Without knowing that background however, it's not immediately obviously to someone that hasn't heard the term what it means.
As others have mentioned, reusing the "await" keyword could cover a lot of these nursery scenarios.
Yeah, I thought the article was interesting but the choice of "nursery" still has me scratching my head. I can't see where it comes from or why that word was chosen.
His note on resource cleanup doesn't really make sense -- or, rather, it only makes sense if you're using python's solutions for it. What about something like, say, c++ where you have a smart pointer that keeps track of the number of references to it, to store your file pointer? Then when you pass that to your concurrent function, it won't get cleaned up, until that other thread finishes, then that reference to it will be lost and it'll clean itself up.
Coming from an occam/transputer background (in the 1980s) I felt goroutines were too low level. Luckily its trivial to add a PAR-like construct via sync.WaitGroup (e.g. github.com/atrn/par). That said Go's channels are far easier to work with - buffering and multi-producer/-consumers being very common needs which, in occam, you implement yourself. The lack of guards in Go's ALT (select) is a shame and the nil channel hack is just that, a hack.
When you add in queues for passing things back and forth between the async functions, I think this boils down to an Actor model. The nursery would be equivalent to supervisors in Erlang.
go statement is just another form of explicit process creation, fork/join pattern. What the author suggested is just similar to cobegin/coend -- implicit process creation.
cobegin/coend are limited to properly nested graphs, however fork/join can express arbitrary functional parallelism (any process flow graph) [1]
Yes, for graceful error handling it needs to form some sort of process tree or ATC(Asynchronous transfer of control), which is implemented in Erlang/OTP and ada programming.
How is if different that just using goto to go to either the true or false clause? It isn't about what you can do, it's about what you can't do if you use this mechanism (this restricts where you can call spawn and join) and the guarantees you can then build on top of that.
Given the "escape hatch", there is no difference to passing a thread pool object around (or referencing it otherwise), that would for ex join on destruction.
And this is a valid approach. But pretending that this should be the only one and that this is in a way similar to unstructured goto vs structured programming? I'm not buying it. Because there will be long lived global nurseries floating around in big enough codebases, effectively eliminating all the guarantees they are supposed to provide for the affected threads. I mean; I'm not sure they can even guarantee the advantages they are supposed to provide (in the sense of providing new easy to check properties, with actual tools existing capable of checking them).
Don't get me wrong. I find the approach interesting, and will happily use it where applicable, but just the comparison to goto does not really makes sense, nor does the fiction that threads are best modeled by always being contained into managing function calls (hmf, except when they are not...). The "escape hatch" is so big that it just plain devalues the solution compared to not having it (or having it only in vastly more constrained ways) and then obviously not pretending this is what should replace traditional spawning (and even more) everywhere.
build what on top of it? If i want a blocking thread i would just call a function.. something something goto something isn't a valid argument that applies to everything. The other way to propagate errors up is chaining promises.
You might want to read further. He didn't claim that callbacks are not harmful. In fact he suggested that they suffer from the same problems and he is using "go statements" to encompass all of the forms of concurrency handling.
A point I believe the author has missed: Goroutines aren't simply forked functions, but are abstracted as independent, autonomous processes. Now, that doesn't mean 'go' is a sufficient tool to reason about goroutines, but this may also not be as useful a solution as he touts it to be.
I don't think that's really relevant to his point. His argument is about control flow, rather than capabilities. The library he has built is designed to provide any functionality that didn't previously exist, but rather to make code that accomplishes the same tasks as before easier to reason about and somewhat safer to write.
An implementation of this pattern could handle the spawning of functions however it wants and the language supports (as independent processes, threads, with an event loop, etc.).
I think this has a lot of merit. And the analogy to goto is very apt and deep. I suspect that 20 years later all our concurrency interfaces may well look like this.
However, the momentum of today's programming community may be too great to surmount. When goto was criticized, there were fewer people to convince to give up on it. Now, there are orders of magnitude more devs. And all of them are comfortable in the current way of doing things.
I know people already said it, but causality, resource cleanup and error handling are all solved with Actor model in a more general, more flexible and more reliable way than nurseries.
If you think reasoning about concurrency is hard, try testing and modelling it, especially for something distributed. This is where naive ideas about concurrency should start to fail and a need in solid foundation arise.
This article proposes a "nursery", which is just a wrapped sync.WaitGroup/pthread_join/futures::future::join_all/a reactor that waits for all tasks to terminate/etc.
It then uses an exception-like model for error propagation to "solve" error handling (which is fairly easy to handle with a channel).
The construct is a decently usable, already applied tool to handle a set of problems, but the article takes the issue way out of proportions and overhypes the proprosed solution. The "with" example for benefits to not having a "go" statement seem rather bogus, especially seeing that such RAII constructs do not exist in Go (no destructors, remember?).
Trying to claim that "go" is as terrible as the original "goto" is ignorance of the original problems. Bad use of goto can be a nightmare to track (as the author tried to illustrate), but goroutines do not jump around, they branch from the main goroutine, following normal control flow from there. They are easy to follow, and the language is designed so that you can throw around with them and forget them without them causing you problems.
Also, this article is comparing a list of concurrency constructs and one parallism construct (pthread_create—threading.Thread doesn't count as parallism due to GIL) to callbacks, something which have nothing to do with concurrency at all. Very odd.