Notes on structured concurrency, or: Go statement considered harmful
vorpus.org
vorpus.org
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.
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.
Rust is renowned for being a total headache with borrowing and lifetimes. Nurseries might be a simpler solution for this.
As you say, a corner case. But a common one...
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.)
In practice you'd probably have one goroutine manage the resource and its lifespan then other code would communicate with that goroutine using channels.
However, having coded Go for quite a few years by now, I haven't found it to be an issue at all.
Unless they are documented informally or encoded formally.
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.
"I disagree; I don't think x is like goto."
"Now that we've agreed that x is like goto, not wanting to give up x makes you like a big dinosauric dummy~"
corresponds fairly closely with
> 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.
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.
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.
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.
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.
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.
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.
[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.
NTSTATUS err = S_OK;
if(!FAILED(err)) err = Op1();
if(!FAILED(err)) err = Op2();
...
if(FAILED(err)) /*cleanup*/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.
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.
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.
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.
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.
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.
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 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 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.
> (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.
What.
...and: [whatever] is just a [whatever]
...comes across as a bit of an asshole?Why not just make your point instead of investing a lot in making sure everyone knows that you're smart and the other person is dumb?
Maybe Knuth was just as rude to Dijkstra, who knows?
I felt that such "aggressive" countering was justified considering the equivalently grand claims of the post ("EXTREMELY_POPULAR_CONSTRUCT considered harmful").
If none of your code ever actually waits on the error channel when you spawn a goroutine, what happens?
Is there any circumstance where that behavior is preferable to guaranteeing something must wait on the error channel?
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.
> 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.
EDIT: too much "however".
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.
http://www.usingcsp.com/cspbook.pdf
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
etc.'select' is ALT
'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.
?
No, it isn't, because Go doesn't do the italicized part:
"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."
(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!)
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.
[1] http://usingcsp.com/cspbook.pdf [2] http://cadp.inria.fr/
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."
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.
http://kimundi.github.io/scoped-threadpool-rs/scoped_threadp...
EDIT: He compares to Rust in a comment on the Reddit thread:
https://www.reddit.com/r/programming/comments/8es8x3/notes_o...
As an aside, HN users tend to sneer at Reddit, but this is another case where the discussion on Reddit is better than the one here.
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.
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.
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.
[1] http://animats.com/papers/languages/pythonconcurrency.html
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.
Can you elaborate on this?
* 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 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.
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.
Seems like we're just layering indirections (I wouldn't call that an abstraction) for little added value over the existing constructs.
> 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.
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.
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.
If your background operation has those characteristics, maybe it is better to spawn a new process for it. Why a 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.
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. >
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
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.
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...)
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.
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.
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).
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.
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.
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?
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.
I wonder how Trio handles error propagation.
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.
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.
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.
> 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.
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.
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...
> 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).
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.
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.
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.
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.
Well, what if you don't?
That's the point.
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
I think you're overestimating nurseries here. They still don't handle the case you described of a deeply nested setTimeout:
import asyncio
import trio
import threading
async def my_tricky_function():
threading.Timer(5, lambda: print('first')).start()
await trio.sleep(.5)
async def a_function():
async with trio.open_nursery() as n:
n.start_soon(my_tricky_function)
print("finished")
trio.run(a_function)
(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.
sigh~
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.
Maybe its worth considering.
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.
This is 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 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.
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)
> 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!
[0] http://www.u.arizona.edu/~rubinson/copyright_violations/Go_T...
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.
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!
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.
ed: author has phd, not is a student.
Except that's exactly what the author did. They just reinvented scoped threadpools.
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.
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.
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?
[1] http://journal.stuffwithstuff.com/2015/02/01/what-color-is-y...
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?
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.
So how is creating a nursery that matches the lifetime and visibility of the page different from not using a nursery at all?
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.
launch_thread(function, perhaps, some, initial, data);
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.
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)
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.
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.
You could achieve something similar in JavaScript with Promise.all() and await:
await Promise.all([
asyncFunc1(),
asyncFunc2(),
asyncFunc3()
])
Of course, that's not language-level and the point seemed to focus more on eliminating traditional branching than just adding another way to do it. const promises = files.map(readFileAsync); // "nursery"
const fileContents = await Promise.all(promises); // "with"
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.It is well organized, written and specific about it's claims.
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 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.
Very excited!
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.
I wish Go had something like this as well.
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.
Fair enough.
> I haven't done UI programming in Go - but in your example, I'd imagine you could simply have an event launch a goroutine...
But now you are in the "goto" mess that this article describes.
Async/await allows me to to write "goto safe" async code synchronously, for UIs.
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.
That's Tokio in Rust.
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 }
}
}
}
...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.
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.
Yes, the same thing is true of goto and other control flow patterns. Patterns change to match the tools available.
Let me try with something else.
Imagine you have try/catch/finally BUT NOT AS CONTROL FLOW CONSTRUCS but "just api calls".
So, you language need to be used like:
foo()
exceptions.try{
bar()
this.catch{
}
}
It means, you need to remember to ALWAYS REMEBER to "close" the start of the call.Imagine how bad this could be. If only "try/catch" was as with "IF/ELSE/ENDIF" so you not do something stupid like:
foo()
exceptions.catch{
what?()
}
bar()As others have mentioned, reusing the "await" keyword could cover a lot of these nursery scenarios.
https://wiki.rice.edu/confluence/display/HABANERO/Habanero-J...
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.
[1] http://www.ics.uci.edu/~dillenco/compsci143a/notes/ch02.pdf
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.
That is not what this is though.
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.).
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.
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.