Notes on structured concurrency, or: Go statement considered harmful
구조적 동시성에 대한 소고, 또는 Go 문의 해로움
Every concurrency API needs a way to run code concurrently. Here's some examples of what that looks like using different APIs:
모든 동시성 API는 코드를 동시에 실행할 방법을 필요로 하죠. 서로 다른 API 들이 어떻게 생겼는지 한 번 볼까요.
go myfunc(); // Golang
pthread_create(&thread_id, NULL, &myfunc); /* C with POSIX threads */
spawn(modulename, myfuncname, []) % Erlang
threading.Thread(target=myfunc).start() # Python with threads
asyncio.create_task(myfunc()) # Python with asyncio
go myfunc(); // Golang
pthread_create(&thread_id, NULL, &myfunc); /* C with POSIX threads */
spawn(modulename, myfuncname, []) % Erlang
threading.Thread(target=myfunc).start() # Python with threads
asyncio.create_task(myfunc()) # Python with asyncio
There are lots of variations in the notation and terminology, but the semantics are the same: these all arrange for myfunc
to start running concurrently to the rest of the program, and then return immediately so that the parent can do other things.
다양한 표기법과 서로 다른 용어가 있겠지만, 문법적으로는 모두 같습니다. 모두 myfunc
를 프로그램의 나머지 부분과 동시에 실행하려는 것이며, 즉시 돌아와 부모가 나머지 부분을 실행할 수 있도록 하는 것이죠.
Another option is to use callbacks:
다른 방법으로는 콜백을 사용하는 것도 있겠습니다.
QObject::connect(&emitter, SIGNAL(event()), // C++ with Qt
&receiver, SLOT(myfunc()))
g_signal_connect(emitter, "event", myfunc, NULL) /* C with GObject */
document.getElementById("myid").onclick = myfunc; // Javascript
promise.then(myfunc, errorhandler) // Javascript with Promises
deferred.addCallback(myfunc) # Python with Twisted
future.add_done_callback(myfunc) # Python with asyncio
QObject::connect(&emitter, SIGNAL(event()), // C++ with Qt
&receiver, SLOT(myfunc()))
g_signal_connect(emitter, "event", myfunc, NULL) /* C with GObject */
document.getElementById("myid").onclick = myfunc; // Javascript
promise.then(myfunc, errorhandler) // Javascript with Promises
deferred.addCallback(myfunc) # Python with Twisted
future.add_done_callback(myfunc) # Python with asyncio
Again, the notation varies, but these all accomplish the same thing: they arrange that from now on, if and when a certain event occurs, then myfunc
will run. Then once they've set that up, they immediately return so the caller can do other things. (Sometimes callbacks get dressed up with fancy helpers like promise combinators, or Twisted-style protocols/transports, but the core idea is the same.)
다시 한번, 표현만 다를 뿐 같은 일을 수행합니다. 지금부터 어떤 이벤트가 발생하면 myfunc
를 실행하라는 것입니다. 한 번 설정되고 나면 즉시 되돌아와 부른 쪽에서 다른 일을 할 수 있게 되죠. (콜백이 promise combinators, 또는 Twisted-style protocols/transports와 같이 그럴싸해 보이는 형태로도 제공됩니다만, 결국 근간은 같습니다.)
And... that's it. Take any real-world, general-purpose concurrency API, and you'll probably find that it falls into one or the other of those buckets (or sometimes both, like asyncio).
그리고... 그렇죠. 실제로 사용되는 일반적인 그 어떤 동시성 API를 가져다 놓더라도 아마 둘 중 한쪽에 속할 겁니다 (가끔 asyncio와 같이 양쪽에 속하는 경우도 있죠).
But my new library Trio is weird. It doesn't use either approach. Instead, if we want to run myfunc
and anotherfunc
concurrently, we write something like:
하지만 제가 만든 새로운 라이브러리인 Trio는 좀 다릅니다. 어느 쪽에도 해당하지 않죠. 대신, myfunc
와 anotherfunc
를 동시에 실행하고 싶다면, 아래와 같이 하면 됩니다.
async with trio.open_nursery() as nursery:
nursery.start_soon(myfunc)
nursery.start_soon(anotherfunc)
async with trio.open_nursery() as nursery:
nursery.start_soon(myfunc)
nursery.start_soon(anotherfunc)
When people first encounter this "nursery" construct, they tend to find it confusing. Why is there an indented block? What's this nursery
object, and why do I need one before I can spawn a task? Then they realize that it prevents them from using patterns they've gotten used to in other frameworks, and they get really annoyed. It feels quirky and idiosyncratic and too high-level to be a basic primitive. These are understandable reactions! But bear with me.
"nursery" 구조를 처음 본 사람이라면 이게 뭔가 싶을 겁니다. 웬 들여쓰기가 있나? nursery
객체는 또 뭐고, 태스크를 실행하기 위해 왜 이런 걸 하나? 싶으실 겁니다. 그리고 나서는 다른 프레임워크에서 익숙하게 썼던 패턴을 사용하지 못해 짜증이 나겠죠. 기본 요소라기엔 기이하고 특이하며 너무 고급(high-level)처럼 느껴질 겁니다. 뭐 예상되는 반응입니다! 하지만 좀 참아보세요.
In this post, I want to convince you that nurseries aren't quirky or idiosyncratic at all, but rather a new control flow primitive that's just as fundamental as for loops or function calls. And furthermore, the other approaches we saw above – thread spawning and callback registration – should be removed entirely and replaced with nurseries.
이 포스트를 통해, 저는 nursery가 이상하거나 특별하지 않으며, 반복문이나 함수 호출과 같이 근본적으로 새로운 흐름 제어 방식임을 알리고자 합니다. 그리고, 위에서 봤던 기존의 방법 – 쓰레드 복제나 콜백 등록 – 들은 nursery로 완전히 대체되어야 한다고 봅니다.
Sound unlikely? Something similar has actually happened before: the goto
statement was once the king of control flow. Now it's a punchline. A few languages still have something they call goto
, but it's different and far weaker than the original goto
. And most languages don't even have that. What happened? This was so long ago that most people aren't familiar with the story anymore, but it turns out to be surprisingly relevant. So we'll start by reminding ourselves what a goto
was, exactly, and then see what it can teach us about concurrency APIs.
이상하게 들리나요? 비슷한 일이 예전에도 있었습니다: 바로 goto
가 흐름 제어의 시작과 끝이던 시절이 있었지만, 이젠 다 흘러간 얘기가 된 것처럼요. 몇몇 언어들이 아직 goto
라 불리는 것을 가지고 있지만 예전에 goto
라 불리던 것과 비교하면 다르고 기능이 제한되어 있습니다. 게다가 대부분의 언어에는 아예 없고요. 무슨 일이 있었냐고요? 옛날 옛적 일이라 아는 사람이 별로 없는 이야기지만 놀랄 정도로 유사한 이야기임을 알게 되실 겁니다. 그럼 이제 goto
가 어떤 것이었는지 알아보는 걸로 시작해서 그 이야기가 왜 동시성 API에 대한 얘기로 이어지는지 알아봅시다.
What is a goto
statement anyway?
도대체 goto
문이 뭐길래?
Let's review some history: Early computers were programmed using assembly language, or other even more primitive mechanisms. This kinda sucked. So in the 1950s, people like John Backus at IBM and Grace Hopper at Remington Rand started to develop languages like FORTRAN and FLOW-MATIC (better known for its direct successor COBOL).
FLOW-MATIC was very ambitious for its time. You can think of it as Python's great-great-great-...-grandparent: the first language that was designed for humans first, and computers second. Here's some FLOW-MATIC code to give you a taste of what it looked like:
FLOW-MATIC은 당시로썬 상당히 비범했습니다. 컴퓨터보다는 인간 친화적으로 만들어진 첫 번째 프로그래밍 언어로 파이썬의 할머니의 할아버지의 할머니 정도로 여겨도 됩니다. FLOW-MATIC 코드 맛 좀 볼까요.
You'll notice that unlike modern languages, there's no if
blocks, loop blocks, or function calls here – in fact there's no block delimiters or indentation at all. It's just a flat list of statements. That's not because this program happens to be too short to use fancier control syntax – it's because block syntax wasn't invented yet!
현대적인 언어와는 다르게 if
도 없고 반복문이나 함수 호출도 없군요. 알고 보면 블록 구분자와 들여쓰기조차 없습니다. 연속된 구문의 목록일 뿐입니다. 이 프로그램이 단지 짧거나 그럴싸한 제어 문법이 없어서가 아니라, 이 시절엔 아예 블록이라는 게 발명되지도 않았기 때문이죠!
Instead, FLOW-MATIC had two options for flow control. Normally, it was sequential, just like you'd expect: start at the top and move downwards, one statement at a time. But if you execute a special statement like JUMP TO
, then it could directly transfer control somewhere else. For example, statement (13) jumps back to statement (2):
대신 FLOW-MATIC에는 두 가지 제어 방식이 있습니다. 보통은 예상한 대로 위에서 아래로 한 구문씩 순차적으로 실행됩니다. 하지만 JUMP TO
같은 특별한 구문을 만나면 다른 곳으로 옮겨탑니다. 예를 들어, 구문(13)은 구문(2)로 점프합니다.
Just like for our concurrency primitives at the beginning, there was some disagreement about what to call this "do a one-way jump" operation. Here it's JUMP TO
, but the name that stuck was goto
(like "go to", get it?), so that's what I'll use here.
제가 만든 동시성 기초 요소(역주: nursery)와 마찬가지로, 이 "단방향 점프"를 무엇으로 불러야 하는지 논란이 있었습니다. 여기서는 JUMP TO
라고 했지만, 그 이름은 goto
로 굳어지게 됩니다. ("go to" 같은 거죠) 여기서는 이렇게 부르겠습니다.
Here's the complete set of goto
jumps in this little program:
자, 이제 이 작은 프로그램의 완전한 goto
점프 구성을 봅시다.
If you think this looks confusing, you're not alone! This style of jump-based programming is something that FLOW-MATIC inherited pretty much directly from assembly language. It's powerful, and a good fit to how computer hardware actually works, but it's super confusing to work with directly. That tangle of arrows is why the term "spaghetti code" was invented. Clearly, we needed something better.
여러분에게만 이게 혼돈의 카오스로 보이는 건 아닙니다. 이런 식의 점프 기반 프로그래밍은 FLOW-MATIC이 어셈블리 언어로부터 직접적인 영향을 받은 것입니다. 강력하며, 컴퓨터 하드웨어가 동작하는 방식에 딱 맞지만, 직접적으로 사용하기에는 혼란스럽죠. 이 화살표 더미로부터 "스파게티 코드"라는 명칭도 나왔습니다. 더 나은 게 필요합니다.
But... what is it about goto
that causes all these problems? Why are some control structures OK, and some not? How do we pick the good ones? At the time, this was really unclear, and it's hard to fix a problem if you don't understand it.
흠... 이 모든 문제를 일으키는 goto
란 무엇일까요? 왜 어떤 제어문은 괜찮고 어떤 건 안될까요? 어떻게 좋은 걸 고르죠? 당시에는 이게 명확하지 않기도 해서, 이해하지 못한다면 문제 해결이 정말 어려울 겁니다.
What is a go
statement anyway?
go
문은?
But let's hit pause on the history for a moment – everyone knows goto
was bad. What does this have to do with concurrency? Well, consider Golang's famous go
statement, used to spawn a new "goroutine" (lightweight thread):
하지만 잠깐, 모두가 goto
가 나쁘다고 외치는 역사의 한순간에 멈춰볼까요? 이 얘기가 동시성과 관련이 있냐고요? 뭐, Golang의 유명한 go
문을 생각해봅시다. 새로운 "goroutine"(경량 쓰레드)을 만들어 보죠.
// Golang
go myfunc();
// Golang
go myfunc();
Can we draw a diagram of its control flow? Well, it's a little different from either of the ones we saw above, because control actually splits. We might draw it like:
이 흐름을 다이어그램으로 그려볼까요? 음, 위에서 봤던 것과 좀 다릅니다. 흐름이 갈라지니까요. 그림으로 그려보면요,
Here the colors are intended to indicate that both paths are taken. From the perspective of the parent goroutine (green line), control flows sequentially: it comes in the top, and then immediately comes out the bottom. Meanwhile, from the perspective of the child (lavender line), control comes in the top, and then jumps over to the body of myfunc
. Unlike a regular function call, this jump is one-way: when running myfunc
we switch to a whole new stack, and the runtime immediately forgets where we came from.
일부러 양쪽 선의 색상을 다르게 했습니다. 순차적으로 실행될 부모 goroutine(초록선)은 위에서 시작해서 즉시 아래로 진행합니다. 그동안 자식(라벤더색)은 위에서 시작해서 myfunc
본체로 점프합니다. 일반적인 함수 호출과 다르게, 이 점프는 단방향입니다. myfunc
의 실행은 완전히 새로운 스택에서 이뤄지고, 런타임은 이 실행이 어디에서 시작되었는지도 모릅니다.
But this doesn't just apply to Golang. This is the flow control diagram for all of the primitives we listed at the beginning of this post:
이건 Golang에만 해당하는 것은 아닙니다. 이 흐름은 글의 시작에 열거했던 모든 기초 요소에 적용됩니다.
- Threading libraries usually provide some sort of handle object that lets you
join
the thread later – but this is an independent operation that the language doesn't know anything about. The actual thread spawning primitive has the control flow shown above. - Registering a callback is semantically equivalent to starting a background thread that (a) blocks until some event occurs, and then (b) runs the callback. (Though obviously the implementation is different.) So in terms of high-level control flow, registering a callback is essentially a
go
statement. - Futures and promises are the same too: when you call a function and it returns a promise, that means it's scheduled the work to happen in the background, and then given you a handle object to join the work later (if you want). In terms of control flow semantics, this is just like spawning a thread. Then you register callbacks on the promise, so see the previous bullet point.
- 쓰레딩 라이브러리는 일반적으로 나중에 쓰레드를 결합(
join
) 할 수 있는 객체를 제공합니다. 하지만 언어 차원에서는 알 수 없는 독립적인 작업입니다. 실제 쓰레드 복제 요소는 위와 같은 제어 흐름을 가집니다. - 콜백을 등록하는 것도 문법적으로는 다음과 같은 백그라운드 쓰레드를 시작하는 것과 같습니다. (a) 어떤 일이 발생할 때까지 멈춰있다가, (2) 콜백을 호출합니다. (구현은 완전히 다르게 되겠지만요.) 상위 수준의 흐름 제어로 보자면, 콜백 등록도
go
문과 동일합니다. - Future와 promise도 마찬가지입니다. promise를 돌려주는 함수를 호출하는 것은 백그라운드로 일어날 일을 예약한다는 것과 같습니다. 그리고 나중에 결합(join)할 객체를 – 원한다면 – 돌려줍니다. 흐름 제어 측면에서 보면, 쓰레드를 생성하는 방식과 같죠. 그리고 promise에 콜백을 등록하는 것이니 두 번 말할 것도 없습니다.
This same exact pattern shows up in many, many forms: the key similarity is that in all these cases, control flow splits, with one side doing a one-way jump and the other side returning to the caller. Once you know what to look for, you'll start seeing it all over the place – it's a fun game! 1
이 같은 패턴이 다양한 형태로 나타납니다. 이 다양한 형태의 핵심은 제어 흐름이 갈라지며, 한쪽은 단방향으로 점프하고 다른 한쪽은 호출했던 쪽으로 돌아간다는 것입니다. 뭘 봐야할 지 알게 되면, 같은 것을 여러 곳에서 찾아볼 수 있을겁니다 – 정말 즐거운 일이죠! 1
Annoyingly, though, there is no standard name for this category of control flow constructs. So just like "goto
statement" became the umbrella term for all the different goto
-like constructs, I'm going to use "go
statement" as a umbrella term for these. Why go
? One reason is that Golang gives us a particularly pure example of the form. And the other is... well, you've probably guessed where I'm going with all this. Look at these two diagrams. Notice any similarities?
그런데, 이러한 흐름 제어를 부르는 공통된 이름이 없습니다. "goto
문"이 다른 goto
같은 구문을 통칭하는 이름이 된 것과 같이, 저도 이런 형태를 모두 "go
문"이라고 부르려고 합니다. 하필 왜 "go" 라고 묻는다면... Golang에 이러한 형태에 대한 명백한 예제가 있달까요. 어쨌든 이제 다들 제가 이걸 가지고 뭘 하려는지 알 것 같은데요. 이 두 다이어그램을 보세요. 비슷하지 않나요?
That's right: go statements are a form of goto statement.
맞아요. go 문은 goto 문과 형태가 같습니다.
Concurrent programs are notoriously difficult to write and reason about. So are goto
-based programs. Is it possible that this might be for some of the same reasons? In modern languages, the problems caused by goto
are largely solved. If we study how they fixed goto
, will it teach us how to make more usable concurrency APIs? Let's find out.
동시성 프로그래밍은 작성하고 동작을 추론하기가 어려운 것으로 악명이 높죠. 마치 goto
-기반 프로그램이 그러했던 것처럼요. 같은 이유로 그런 것은 아닐까요? 현대 언어들에서는 goto
문제가 상당수 해결되었죠. 우리가 goto
를 해결한 것과 마찬가지로 이를 통해 사용하기 쉬운 동시성 API를 만들어낼 수 있을까요? 한 번 알아봅시다.
What happened to goto
?
goto
에 무슨 일이 있었던 거야?
So what is it about goto
that makes it cause so many problems? In the late 1960s, Edsger W. Dijkstra wrote a pair of now-famous papers that helped make this much clearer: Go to statement considered harmful, and Notes on structured programming (PDF).
당최 goto
가 뭐길래 이렇게 많은 문제를 낳았을까요? 1960년대 후반에 에츠허르 데이크스트라는 Go to의 해로움과 구조적 프로그래밍에 대한 소고 (PDF)와 같이 이 문제를 명확하게 설명하는, 근래에 매우 유명해진 글을 남겼습니다.
goto
: the destroyer of abstraction
goto
: 추상화의 파괴자
In these papers, Dijkstra was worried about the problem of how you write non-trivial software and get it correct. I can't give them due justice here; there's all kinds of fascinating insights. For example, you may have heard this quote:
이 문서들에서 데이크스트라는 비순차적 소프트웨어를 작성하고 잘 동작하게 만드는 문제에 대해 우려했습니다. 이러한 통찰에 대해 감히 제가 여기서 평가할 수는 없습니다. 예를 들자면, 이런 얘기를 들어보셨을 겁니다.
Yep, that's from Notes on structured programming. But his major concern was abstraction. He wanted to write programs that are too big to hold in your head all at once. To do this, you need to treat parts of the program like a black box – like when you see a Python program do:
이건 구조적 프로그래밍에 대한 소고에서 발췌한 것입니다. 하지만 그는 주로 추상화 에 대해 신경을 썼습니다. 그는 머릿속에 다 담을 수 없을 정도로 거대한 프로그램을 만들고 싶어 했습니다. 이를 위해 프로그램의 각 부분을 블랙박스처럼 다룰 필요가 있죠. 파이썬 프로그램을 예로 들어보겠습니다.
print("Hello world!")
then you don't need to know all the details of how print
is implemented (string formatting, buffering, cross-platform differences, ...). You just need to know that it will somehow print the text you give it, and then you can spend your energy thinking about whether that's what you want to have happen at this point in your code. Dijkstra wanted languages to support this kind of abstraction.
By this point, block syntax had been invented, and languages like ALGOL had accumulated ~5 distinct types of control structure: they still had sequential flow and goto
:
Same picture of sequential flow and goto flow as before.
And had also acquired variants on if/else, loops, and function calls:
You can implement these higher-level constructs using goto
, and early on, that's how people thought of them: as a convenient shorthand. But what Dijkstra pointed out is that if you look at these diagrams, there's a big difference between goto
and the rest. For everything except goto
, flow control comes in the top → [stuff happens] → flow control comes out the bottom. We might call this the "black box rule": if a control structure has this shape, then in contexts where you don't care about the details of what happens internally, you can ignore the [stuff happens] part, and treat the whole thing as regular sequential flow. And even better, this is also true of any code that's composed out of those pieces. When I look at this code:
print("Hello world!")
print("Hello world!")
문자열 포매팅, 버퍼 관리, 크로스플랫폼 이슈 등... print
가 어떻게 구현되어 있는지 알 필요는 없습니다. 그저 당신이 입력한 문자열이 표시될 것이라는 것만 알면 코드의 다른 부분을 작성하는 데 전념할 수 있습니다. 데이크스트라는 이러한 추상화가 프로그래밍 언어 수준에서 제공되길 원했습니다.
이 지점에서, 블록 문법이 발명되었고, ALGOL과 같은 언어에는 5가지 정도의 서로 다른 흐름 제어 구문이 있게 되었습니다. 여전히 순차적으로 실행되고 goto
도 있었지만요.
앞서 나왔던 순차 진행과 goto 진행을 봅시다.
그리고 비교문, 반복문, 함수 호출 등이 생겨났죠.
이 고급 기능을 goto
로 만들 수도 있고, 초창기 사람들은 실제로 편리한 줄여 쓰기 정도로 여겼습니다. 하지만 데이크스트라는 이러한 다이어그램을 봤을 떄, goto
와 다른 것들 사이에는 차이가 있다고 지적했습니다. goto
말고 나머지는 위에서 시작해서 → [뭔가 하고] 나서 → 아래로 내려가는 식으로 흘러갑니다. 이렇게 생겨 내부에서 뭘 하는지 신경 쓸 필요가 없는 모습을 "블랙박스 룰"이라고 불러보죠. [뭔가 하고] 부분을 무시하고 나면 전체적으로 봤을 때 그저 차례대로 흘러가는 것으로 볼 수 있습니다. 그리고 이런식으로 구성된 그 어떤 코드들에 대해서도 똑같이 여길 수 있으니까 좋죠. 이 코드를 다시 볼까요.
print("Hello world!")
I don't have to go read the definition of print
and all its transitive dependencies just to figure out how the control flow works. Maybe inside print
there's a loop, and inside the loop there's an if/else, and inside the if/else there's another function call... or maybe it's something else. It doesn't really matter: I know control will flow into print
, the function will do its thing, and then eventually control will come back to the code I'm reading.
print
의 정의나 그것의 전이적 의존성(transitive dependencies)을 찾아보지 않더래도 일이 어떻게 돌아가는지 알 수 있습니다. print
안에 반복문이 있을 수도 있고, 그 반복문 안에 비교문이 있고, 또 그 안에 다른 함수 호출이 있고... 뭐 이것저것 있을 수 있죠. 하지만 뭔 상관이에요. print
내부로 흘러갔다가 그 안에서 뭔가 하고, 결국엔 제가 읽고 있는 코드로 돌아올 게 뻔하니까요.
It may seem like this is obvious, but if you have a language with goto
– a language where functions and everything else are built on top of goto
, and goto
can jump anywhere, at any time – then these control structures aren't black boxes at all! If you have a function, and inside the function there's a loop, and inside the loop there's an if/else, and inside the if/else there's a goto
... then that goto
could send the control anywhere it wants. Maybe control will suddenly return from another function entirely, one you haven't even called yet. You don't know!
되게 뻔한 것처럼 보이겠지만, 만약 goto
가 있는 언어를, 아니, 모든 것들이 goto
위에서 만들어진 언어를 생각해보세요. 그리고 이 goto
는 아무 때나 아무 곳으로나 갈 수 있죠. 이런 상황에서는 제어 구조가 전혀 블랙박스화 되지 않아요! 함수가 있는데, 그 함수 안에 반복문이 있어요. 그 안에 비교문이 있는데, 그 비교문 안에 goto
가 있고... 그리고 goto
는 어디든 간에 원하는 대로 가버릴 수 있죠. 호출한 적도 없는 완전히 다른 함수로 갑자기 가버릴 수도 있어요. 이걸 어떻게 알죠!
And this breaks abstraction: it means that every function call is potentially a goto
statement in disguise, and the only way to know is to keep the entire source code of your system in your head at once. As soon as goto
is in your language, you stop being able do local reasoning about flow control. That's why goto
leads to spaghetti code.
이렇게 추상화가 무너집니다. 다시 말하자면 이건 모든 함수 호출이 잠재적으로 goto
의 변형된 형태라고 볼 수 있다는 것이고, 이걸 알려면 모든 시스템의 코드를 머릿속에 넣고 있어야 한다는 뜻입니다. 프로그래밍 언어에 goto
가 있는 한, 흐름 제어의 지역적 추론이 불가능하다는 것입니다. 이래서 goto
가 스파게티 코드를 만들게 되죠.
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.
데이크스트라가 이 문제를 이해한 덕분에, 해결할 수 있었습니다. 이 혁명적인 제안을 보시죠. 우리는 비교문/반복문/함수 호출을 goto
의 줄임말이라고 생각하지 말고, 각각의 기능이 있는 근본적인 기본 요소로 삼아야 합니다. 그러려면 goto
를 우리 언어에서 완전히 쫓아내야 합니다.
From here in 2018, this seems obvious enough. But have you seen how programmers react when you try to take away their toys because they're not smart enough to use them safely? Yeah, some things never change. In 1969, this proposal was incredibly controversial. Donald Knuth defended goto
. People who had become experts on writing code with goto
quite reasonably resented having to basically learn how to program again in order to express their ideas using the newer, more constraining constructs. And of course it required building a whole new set of languages.
Left: A traditional goto
. Right: A domesticated goto
, as seen in C, C#, Golang, etc. The inability to cross function boundaries means it can still pee on your shoes, but it probably won't rip your face off.
왼쪽: 전통적인 goto
. 오른쪽: 길들여진 goto
로 C, C#, Golang 등에서 찾아볼 수 있다. 함수 경계를 넘을 수 없다는 것은, 이 녀석이 신발에 오줌을 쌀지언정, 얼굴을 물어뜯지 못한다는 것을 뜻한다.
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.
결국, 현대 언어들은 데이크스트라의 원래 형식보다는 덜 엄격한 형태를 취했습니다. break
, continue
, return
등을 이용해서 중첩된 구조에서 한 번에 나올 수는 있죠. 하지만 근본적으로 그 경계가 제한된 방식 아래에서만 가능하니, 모두 데이크스트라의 아이디어에 근거하고 있다고 볼 수 있습니다. 특히, 흐름 제어를 감싸 블랙박스화하는데 쓰이는 "함수"는 불가침 영역입니다. 한 함수에서 다른 함수로 break
할 수 없고, return
으로 함수에서 현재 함수에서 나올 수는 있지만, 더 이상은 불가합니다. 한 함수 내에서 내부적으로 지지고 볶는 흐름 제어를 한다고 해도, 다른 함수는 신경 쓸 거리 조차 없습니다.
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 2, the classic, unrestricted goto
is gone. Dijkstra won.
이는 goto
그 자체에도 마찬가지입니다. C, C#, Golang과 같이 goto
를 아직 가지고 있는 몇몇 언어들을 찾아볼 수 있습니다. 하지만 상당히 제한된 형태로 추가되어 있죠. 최소한 한 함수에서 다른 함수로 점프할 수 없을 겁니다. 역사적인 어셈블리2 언어의 goto
는 이제 안녕입니다. 데이크스트라, 당신이 이겼어요.
A surprise benefit: removing goto
statements enables new features
의외의 이득: goto
를 없앴더니 생긴 새로운 기능
And once goto
disappeared, something interesting happened: language designers were able to start adding features that depend on control flow being structured.
goto
가 없어지고 나니, 흥미로운 일이 일어났습니다. 언어 설계자들이 구조화된 흐름 제어에 의존하는 새로운 기능을 추가할 수 있게 되었습니다.
For example, Python has some nice syntax for resource cleanup: the with
statement. You can write things like:
예를 들면, 파이썬은 자원을 정리하기 위한 with
라는 멋진 문법을 가지고 있습니다. 이렇게 쓸 수 있죠.
# Python
with open("my-file") as file_handle:
...
# Python
with open("my-file") as file_handle:
...
and it guarantees that the file will be open during the ...
code, but then closed immediately afterward. Most modern languages have some equivalent (RAII, using
, try-with-resource, defer
, ...). And they all assume that control flows in an orderly, structured way. If we used goto
to jump into the middle of our with
block... what would that even do? Is the file open or not? What if we jumped out again, instead of exiting normally? Would the file get closed? This feature just doesn't work in any coherent way if your language has goto
in it.
이는 ...
코드가 실행되는 동안 파일이 열려 있다가, 종료되는 대로 바로 닫히는 것을 보장합니다. 대부분의 현대 언어들은 RAII, using
, try-with-resource, defer
와 같은 비슷한 기능을 가지고 있습니다. 그리고 다들 질서 정연하고 체계적으로 코드가 실행될 것을 가정합니다. 우리가 with
블록 내에서 갑자기 goto
를 쓰면 ... 어떻게 될까요? 파일은 열려있을까요 닫혀있을까요? 정상적으로 종료하는 대신에 그냥 점프해서 나가버린다면요? 파일은 닫힐까요? 이 기능은 언어에 goto
가 있는 한 일관되게 동작할 수 없습니다.
Error handling has a similar problem: when something goes wrong, what should your code do? Often the answer is to pass the buck up the stack to your code's caller, let them figure out how to deal with it. Modern languages have constructs specifically to make this easier, like exceptions, or other forms of automatic error propagation. But your language can only provide this help if it has a stack, and a reliable concept of "caller". Look again at the control-flow spaghetti in our FLOW-MATIC program and imagine that in the middle of that it tried to raise an exception. Where would it even go?
에러 핸들링도 비슷한 문제가 있습니다. 뭔가 잘못되면 코드는 뭘 해야 할까요? 보통은 스택을 호출자에게 돌려주고 알아서 하라고 하는 쪽입니다. 현대적인 언어들은 이 문제를 쉽게 다룰 수 있도록 예외(exception)나, 이와 비슷한 형태의 자동 오류 전파 같은 것들을 가지고 있습니다. 하지만 이것도 스택과 "호출자"라는 신뢰할 수 있는 개념이 있는 경우에만 가능합니다. FLOW-MATIC 프로그램에 있던 흐름 제어를 놓고 그 안에서 예외가 발생했을 때 어떤 일이 일어날지 상상해보세요. 어디로 가야만 할까요?
goto
statements: not even once
goto
문: 절대 안 돼요
So goto
– the traditional kind that ignores function boundaries – isn't just the regular kind of bad feature, the kind that's hard to use correctly. If it were, it might have survived – lots of bad features have. But it's much worse.
goto
, 그러니까 함수 경계를 넘나드는 과거의 goto
라는 건, 단순히 나쁘거나 제대로 쓰기 어려운 기능 정도가 아닙니다. 만약 그랬다면 수없이 많은 나쁜 기능에도 불구하고 살아남았을 겁니다. 하지만 그 정도 수준이 아닙니다.
Even if you don't use
goto
yourself, merely having it as an option in your language makes everything harder to use. Whenever you start using a third-party library, you can't treat it as a black box – you have to go read through it all to find out which functions are regular functions, and which ones are idiosyncratic flow control constructs in disguise. This is a serious obstacle to local reasoning. And you lose powerful language features like reliable resource cleanup and automatic error propagation. Better to removegoto
entirely, in favor of control flow constructs that follow the "black box" rule.
스스로
goto
를 사용하지 않는다고 해도, 그게 언어에 존재하는 한 모든 것을 사용하기 어렵게 됩니다. 써드 파티 라이브러리를 쓰려고 해도 블랙박스처럼 다룰 수 없습니다. 어떤 함수가 정상적인 함수인지 아니면 변칙적인 흐름을 가진 함수인지 알아내기 위해 모든 부분을 샅샅이 읽어봐야 합니다. 이래서는 지역적 추론을 할 수가 없습니다. 게다가 자원 정리나 자동 오류 전파 등의 기능도 쓸 수 없습니다.goto
를 완전히 버리고 "블랙박스" 룰을 따르는 구조적 흐름 제어를 가지는 편이 낫습니다.
go
statement considered harmful
go
문의 해로움
So that's the history of goto
. Now, how much of this applies to go
statements? Well... basically, all of it! The analogy turns out to be shockingly exact.
이렇게 goto
의 역사를 살펴보았습니다. 이제 이걸 go
문에 적용해볼까요? 음... 기본적으로 하나부터 열까지 같아요. 과정이 놀랄 정도로 같습니다.
Go statements break abstraction. Remember how we said that if our language allows goto
, then any function might be a goto
in disguise? In most concurrency frameworks, go
statements cause the exact same problem: 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. If you have go
statements, then functions are no longer black boxes with respect to control flow. In my first post on concurrency APIs, I called this "violating causality", and found that it was the root cause of many common, real-world issues in programs using asyncio and Twisted, like problems with backpressure, problems with shutting down properly, and so forth.
Go 문은 추상화를 깨버립니다. goto
가 가능한 언어에서 어떤 기능들이 goto
의 다른 형태로 나타나는지 기억나시나요? 대부분의 동시성 프레임워크에서 go
문은 같은 문제를 일으킵니다. 함수를 호출할 때마다 백그라운드 작업이 생성되거나 생성되지 않을 수 있습니다. 함수는 돌아온 것 같지만 백그라운드에서 아직 실행 중일까요? 소스 코드를 다 읽기 전까지는 알 도리가 없죠. 작업은 언제 종료될까요? 답하기가 어렵군요. go
문이 있는 한, 함수는 흐름 제어와 관련해서 더 이상 블랙박스가 될 수 없습니다. 제가 썼던 첫 번째 동시성 API에 대한 글에서 "인과율 위반"이라 칭한 이것이, 다양한 실제적인 문제들의 근본적인 원인임을 찾아냈습니다. asyncio와 Twisted에서의 배압 문제, 제대로 종료되지 않는 문제 등이요.
Go statements break automatic resource cleanup. Let's look again at that with
statement example:
Go 문은 자동 자원 정리를 불가능하게 합니다. with
를 예로 들어 보겠습니다.
# Python
with open("my-file") as file_handle:
...
# Python
with open("my-file") as file_handle:
...
Before, we said that we were "guaranteed" that the file will be open while the ...
code is running, and then closed afterwards. But what if the ...
code spawns a background task? 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.
앞서, 우리는 ...
코드가 실행되는 동안 파일이 열려 있을 것을 "보장"받고, 끝나면 닫힌다고 얘기했었죠. 하지만 ...
코드에서 백그라운드 작업을 생성한다면 어떻게 될까요? 더 이상 보장할 수 없게 됩니다. with
블록 안에 있는 것처럼 보였던 동작이 실제로는 with
블록이 끝나도 계속 동작하고 있을 수 있고, 그러다가 파일이 닫히면 사용하고 있던 쪽에서는 오류가 발생할 수 있습니다. 다시 한 번 얘기하지만, 이런 식으로는 부분만 봐서 알 수 없게 됩니다. ...
코드에서 호출되는 함수의 모든 소스 코드를 살펴봐야만 합니다.
If we want this code to work properly, we need to somehow keep track of any background tasks, and manually arrange for the file to be closed only when they're finished. It's doable – unless we're using some library that doesn't provide any way to get notified when the task is finished, which is distressingly common (e.g. because it doesn't expose any task handle that you can join on). But even in the best case, the unstructured control flow means the language can't help us. We're back to implementing resource cleanup by hand, like in the bad old days.
이 코드를 제대로 돌아가게 하려면, 백그라운드로 동작하는 작업들을 어떻게든 추적하고 완료될 때까지 기다려서 파일을 닫아야 합니다. 뭐 가능한 일이긴 하죠. 작업이 끝났을 때 알려주는 라이브러리를 사용하는 한 괴롭긴 하지만 할 수는 있습니다. (예: 나중에 다시 결합(join)될 수 있도록 하는 핸들을 제공하지 않는 경우) 하지만 아무리 최상의 상황을 가정해봐도 비구조적인 흐름 제어하에서는 언어 차원에서 도움을 줄 수가 없습니다. 다시 옛날처럼 수작업으로 자원 정리를 해야만 하겠죠.
Go statements break error handling. Like we discussed above, modern languages provide powerful tools like exceptions to help us make sure that errors are detected and propagated to the right place. But these tools depend on having a reliable concept of "the current code's caller". As soon as you spawn a task or register a callback, that concept is broken. 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. If you're lucky it might print something on the console. (The only other software I've used that thinks "print something and keep going" is a good error handling strategy is grotty old Fortran libraries, but here we are.) Even Rust – the language voted Most Obsessed With Threading Correctness by its high school class – is guilty of this. If a background thread panics, Rust discards the error and hopes for the best.
Go 문은 오류를 다루지 못하게 합니다. 위에서 얘기했던 것과 같이, 현대의 언어들은 오류를 검출하고 제대로 전파하는 데 도움을 주는 예외와 같은 강력한 도구를 제공합니다. 하지만 이 도구들도 신뢰할 수 있는 "현재 코드의 호출자"라는 개념에 의존하고 있습니다. 작업을 생성하고 콜백을 등록하면 이 개념은 바로 무너집니다. 제가 아는 한, 많이 사용되는 대부분의 동시성 프레임워크들은 이를 그냥 포기했습니다. 백그라운드 작업에서 오류가 발생했는데, 그걸 수동으로 처리하지 않았다면 런타임은... 이걸 그냥 대충 치워버리고 사실은 중요하지 않았다는냥 행세를 하죠. 운이 좋다면 콘솔에 뭐라도 찍을 수 있었겠네요. (제가 이제까지 썼던 소프트웨어들 중에 "뭔가 인쇄하고 계속 수행해버린다" 전략이 그럭저럭 통했던건 쉰내 나는 포트란 라이브러리 정도였습니다. 이제와서 그러면 안 되죠.) 심지어 Rust마저도 – 전국 고등학생 투표 결과 쓰레드 정합성에 가장 집착한 언어로 꼽힌 – 면죄부를 받을 수는 없습니다. Rust는 오류를 버리고 잘 되기를 기원하는 편이죠.
Of course you can handle errors properly in these systems, by carefully making sure to join every thread, or by building your own error propagation mechanism like errbacks in Twisted or Promise.catch in Javascript. But now you're writing an ad-hoc, fragile reimplementation of the features your language already has. You've lost useful stuff like "tracebacks" and "debuggers". All it takes is forgetting to call Promise.catch
once and suddenly you're dropping serious errors on the floor without even realizing. And even if you do somehow solve all these problems, you'll still end up with two redundant systems for doing the same thing.
물론 이런 시스템에서도 쓰레드 결합을 조심스럽게 다루거나 Twisted의 errbacks나 JavaScript의 Promise.catch처럼 자체적인 오류 전파 구조를 작성해서 오류를 제대로 다룰 수는 있습니다. 하지만 이미 언어에 있는 기능을 임시변통으로 재구현한 것뿐이죠. "역추적(traceback)"이나 "디버거" 등의 기능은 다 갖다 버리고서요. Promise.catch
한 번만 까먹었다간 갑자기 알아채지도 못했던 심각한 문제가 발생하고 말 겁니다. 이 모든 문제를 해결했다 치더라도, 똑같은 일을 하는 두 개의 너저분한 시스템과 함께해야 할 뿐입니다.
go
statements: not even once
go
문: 절대 안 돼요
Just like goto
was the obvious primitive for the first practical high-level languages, go
was the obvious primitive for the first practical concurrency frameworks: it matches how the underlying schedulers actually work, and it's powerful enough to implement any other concurrent flow pattern. But again like goto
, it breaks control flow abstractions, so that merely having it as an option in your language makes everything harder to use.
goto
가 최초의 고급 프로그래밍 언어에서 기초 요소로 존재했던 것과 같이, go
또한 최초의 실용적인 동시성 프레임워크에서는 당연히 기초 요소 대접을 받았습니다. 기본 스케쥴러가 실제로 동작하는 방식과 일치하고, 그 어떤 동시성 흐름 패턴도 구현할 만큼 강력하죠. 하지만 goto
가 그랬던 것처럼, 추상화를 깨트려, 이게 언어에 존재한다는 것만으로도 모든 일이 어려워집니다.
The good news, though, is that these problems can all be solved: Dijkstra showed us how! We need to:
그럼에도 좋은 소식이 있다면, 이 문제는 이미 완전히 해결되었다는 것이죠. 데이크스트라가 보여줬잖아요? 뭘 해야 하나면,
- Find a replacement for
go
statements that has similar power, but follows the "black box rule", - Build that new construct into our concurrency framework as a primitive, and don't include any form of
go
statement.
go
와 같은 기능을 가진 비슷한 것 중에서, "블랙박스 룰"을 따르는 것을 찾기.- 동시성 프레임워크에 새로운 구조를 기초 요소로 만들고,
go
같은 건 포함하지 말기.
And that's what Trio did.
이게 바로 Trio가 하는 것입니다.
Nurseries: a structured replacement for go
statements
Nursery: go
를 대체하는 구조적 용법
Here's the core idea: every time our control splits into multiple concurrent paths, we want to make sure that they join up again. So for example, if we want to do three things at the same time, our control flow should look something like this:
핵심 아이디어를 말씀드리겠습니다. 흐름이 여러 갈래로 갈라질 때마다, 다시 합쳐지는 것을 명확하게 하고자 합니다. 세 가지 일을 한꺼번에 하는 경우를 예로 들자면, 흐름 제어는 아래와 같을 겁니다.
Notice that this has just one arrow going in the top and one coming out the bottom, so it follows Dijkstra's black box rule. Now, how can we turn this sketch into a concrete language construct? There are some existing constructs that meet this constraint, but (a) my proposal is slightly different than all the ones I'm aware of and has advantages over them (especially in the context of wanting to make this a standalone primitive), and (b) the concurrency literature is vast and complicated, and trying to pick apart all the history and tradeoffs would totally derail the argument, so I'm going to defer that to a separate post. Here, I'll just focus on explaining my solution. But please be aware that I'm not claiming to have like, invented the idea of concurrency or something, this draws inspiration from many sources, I'm standing on the shoulders of giants, etc. 3
하나의 화살표가 위에서 그대로 아래로 가고 있는 것에 주목해 주세요. 바로 데이크스트라의 블랙박스 룰을 따른다는 것이죠. 이제, 이 모습을 어떻게 언어의 견고한 요소로 만들 수 있을까요? 이 제약에 걸맞은 몇 가지 구조가 있습니다만, (a) 제가 제안하려는 건 이제까지의 것들과 조금은 다르고요(특히 독립 실행형 요소로 만들고 싶다는 점에서), (b) 동시성과 관련된 이야기는 너무 방대하고 복잡해서 역사를 따지고 장단점을 구분하려면 삼천포로 빠지는 일이라, 나중에 따로 적도록 하겠습니다. 이 글에서는 제 솔루션을 설명하는 데 집중하겠습니다. 하지만 제가 동시성과 관련된 뭔가를 발명했다는 얘기를 하려는 게 아니고, 여러 곳에서 영감을 끌어다 썼으며, 그저 거인의 어깨 위에서 서 있다는 것만 알아주세요. 3
Anyway, here's how we're going to do it: first, we declare that a parent task cannot start any child tasks unless it first creates a place for the children to live: a nursery. It does this by opening a nursery block ; in Trio, we do this using Python's async with
syntax:
어쨌거나, 이렇게 해보려고 합니다. 먼저, 부모 작업에서 nursery라 불리는 자식을 위한 장소를 마련하지 않는 한, 그 어떤 자식 작업도 시작하지 못한다고 합시다. nursery 블록을 열어서 시작하죠. 트리오에서는 이걸 async with
문법으로 사용합니다.
Opening a nursery block automatically creates an object representing this nursery, and the as nursery
syntax assigns this object to the variable named nursery
. Then we can use the nursery object's start_soon
method to start concurrent tasks: in this case, one task calling the function myfunc
, and another calling the function anotherfunc
. Conceptually, these tasks execute inside the nursery block. In fact, it's often convenient to think of the code written inside the nursery block as being an initial task that's automatically started when the block is created.
nursery 블록을 엶과 동시에 이 nursery를 나타내는 객체가 생성되고, as nursery
문법을 통해 이를 nursery
라는 변수에 할당합니다. 그 다음 nursery 객체의 start_soon
기능을 통해 동시 작업을 시작할 수 있습니다. 이 경우에 한 작업은 myfunc
함수를 호출하고, 다른 하나는 anotherfunc
를 호출하게 됩니다. 개념적으로 이 작업들은 nursery 블록 내부에서 실행됩니다. nursery 블록의 코드들은 블록이 생성됨과 동시에 시작되는 초기 작업들이라고 생각하면 편합니다.
Crucially, the nursery block doesn't exit until all the tasks inside it have exited – if the parent task reaches the end of the block before all the children are finished, then it pauses there and waits for them. The nursery automatically expands to hold the children.
결정적으로, nursery 블록은 그 안의 모든 작업이 종료될 때까지 끝나지 않습니다. 자식 작업이 모두 끝나기 전에 부모 작업이 끝에 다다르면, 멈춰서 끝나길 기다립니다. Nursery가 자동으로 확장되어 자식들을 기다리는 것이죠.
Here's the control flow: you can see how it matches the basic pattern we showed at the beginning of this section:
이 흐름을 보시면 이 섹션의 첫 부분에 보여드린 것과 같은 패턴임을 확인할 수 있습니다.
This design has a number of consequences, not all of which are obvious. Let's think through some of them.
이 그림은 여러 중요한 내용을 담고 있지만, 모두 명확하지는 않습니다. 하나씩 알아보죠.
Nurseries preserve the function abstraction.
Nursery는 함수 추상화를 보존합니다.
The fundamental problem with go
statements is that when you call a function, you don't know whether it's going to spawn some background task that keeps running after it's finished. With nurseries, you don't have to worry about this: any function can open a nursery and run multiple concurrent tasks, but the function can't return until they've all finished. So when a function does return, you know it's really done.
go
문의 근본적인 문제는 함수를 호출할 때, 함수가 종료된 뒤에도 백그라운드 작업을 생성하는지 여부를 알 수 없다는 데 있습니다. Nursery와 함께라면 이런 걱정을 할 필요가 없죠. 어떤 함수라도 nursery를 열고 여러 동시 작업을 실행할 수 있지만, 모두 끝날 때까지 함수는 반환되지 않을 겁니다. 그러니까 함수에서 돌아왔다면, 실제로 끝난 겁니다.
Nurseries support dynamic task spawning.
Nursery는 동적 작업 복제를 지원합니다.
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:
위의 흐름 제어 다이어그램을 충족시키는 더 단순한 형태가 있습니다. 썽크(thunk) 목록을 받아 모두 동시에 실행합니다.
run_concurrently([myfunc, anotherfunc])
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. For example, server programs generally have accept
loops, that take incoming connections and start a new task to handle each of them. Here's a minimal accept
loop in Trio:
async with trio.open_nursery() as nursery:
run_concurrently([myfunc, anotherfunc])
이런 부류의 문제점은 실행하기 전에 모든 작업의 목록을 알아야 한다는 데 있습니다. 늘 그럴 순 없죠. 예를 들어, 일반적인 서버 프로그램들이 가지고 있는 accept
루프는 들어오는 연결을 받아 개별적인 처리를 위해 새로운 작업을 시작합니다. Trio로 구현된 최소한의 accept
루프를 보시죠.
async with trio.open_nursery() as nursery:
With nurseries, this is trivial, but implementing it using run_concurrently
would be much more awkward. And if you wanted to, it would be easy to implement run_concurrently
on top of nurseries – but it's not really necessary, since in the simple cases run_concurrently
can handle, the nursery notation is just as readable.
There is an escape.
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. There's no rule that only the code directly inside the async with open_nursery()
block can call nursery.start_soon
– so long as the nursery block remains open 4, then anyone who acquires a reference to the nursery object gets the capability of spawning tasks into that nursery. You can pass it in as a function argument, send it through a queue, whatever.
In practice, this means that you can write functions that "break the rules", but within limits:
- 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.
- Any tasks the function spawns are still bound by the lifetime of the nursery that was passed in.
- And the calling code can only pass in nursery objects that it itself has access to.
So this is still very different from the traditional model where any code can at any moment spawn a background task with unbounded lifetime.
One place this is useful is in the proof that nurseries have equivalent expressive power to go
statements, but this post is already long enough so I'll leave that for another day.
You can define new types that quack like a nursery.
The standard nursery semantics provide a solid foundation, but sometimes you want something different. Perhaps you're envious of Erlang and its supervisors, and want to define a nursery-like class that handles exceptions by restarting the child task. That's totally possible, and to your users, it'll look just like a regular nursery:
async with my_supervisor_library.open_supervisor() as nursery_alike:
Nursery에서는 굉장히 쉬운 일이지만, run_concurrently
같은 것으로 구현하려면 훨씬 버거울 겁니다. 원한다면 nursery 상에서도 run_concurrently
를 구현할 수 있겠지만, 그 정도로 단순한 경우에는 Nursery 표기법이 훨씬 읽기 쉬우니 그럴 필요가 없습니다.
탈출구가 있습니다.
Nursery 객체는 탈출구도 제공합니다. 백그라운드 작업이 그 자체보다 더 오래 걸리는 백그라운드 작업을 생성하는 경우에는 어떻게 할까요? 간단합니다. 함수에 Nursery 객체를 전달하면 됩니다. async with open_nursery()
블록 안에서만 nursery.start_soon
을 호출하라는 법은 없습니다. Nursery 블록이 열려 있는 한4, nursery 객체에 참조를 얻을 수 있는 누구라도 nursery 내에 작업을 생성할 수 있습니다. 함수 인자로 전달하거나 대기열에 넣거나, 뭐든지요.
실제로는, 이는 "규칙을 어기는" 함수를 작성할 수 있음을 의미합니다. 몇 가지 제약이 있지만요.
- Nursery 객체를 명시적으로 전달해야 하므로, 일반적인 흐름 제어를 위반하는 경우를 호출하는 시점에서 즉시 알아낼 수 있습니다. 여전히 지역 추론은 가능합니다.
- 함수가 생성한 작업들은 전달된 nursery 객체와 생사를 같이하게 됩니다.
- 호출하는 코드는 자체적으로 접근할 수 있는 nursery 객체 내에서만 전달될 수 있습니다.
그러므로 임의의 코드가 영원히 끝나지 않을지도 모를 백그라운드 작업을 생성할 수 있는 기존 모델과는 차별됩니다.
이를 통해 Nursery가 go
문과 동등한 표현력을 가짐을 증명할 수도 있지만, 이미 글이 길어지고 있으니 별도로 적도록 하겠습니다.
Nursery처럼 동작하는 새로운 타입을 정의할 수 있습니다.
기본 nursery 문법으로도 충분한 토대를 제공할 수 있지만, 때로는 특별한 것을 원하는 경우도 있습니다. Erlang의 supervisors가 부러워 nursery 유사 클래스에서 자식 작업을 재시작하는 식으로 예외를 다루고 싶은 경우에도 사용될 수 있습니다. 일반적인 nursery와 비슷합니다.
async with my_supervisor_library.open_supervisor() as nursery_alike:
If you have a function that takes a nursery as an argument, then you can pass it one of these instead to control the error-handling policy for the tasks it spawns. Pretty nifty. But there is one subtlety here that pushes Trio towards different conventions than asyncio or some other libraries: it means that start_soon
has to take a function, not a coroutine object or a Future
. (You can call a function multiple times, but there's no way to restart a coroutine object or a Future
.) I think this is the better convention anyway for a number of reasons (especially since Trio doesn't even have Future
s!), but still, worth mentioning.
No, really, nurseries always wait for the tasks inside to exit.
It's also worth talking about how task cancellation and task joining interact, since there are some subtleties here that could – if handled incorrectly – break the nursery invariants.
In Trio, it's possible for code to receive a cancellation request at any time. After a cancellation is requested, then the next time the code executes a "checkpoint" operation (details), a Cancelled
exception is raised. This means that there's a gap between when a cancellation is requested and when it actually happens – it might be a while before the task executes a checkpoint, and then after that the exception has to unwind the stack, run cleanup handlers, etc. When this happens, the nursery always waits for the full cleanup to happen. We never terminate a task without giving it a chance to run cleanup handlers, and we never leave a task to run unsupervised outside of the nursery, even if it's in the process of being cancelled.
Automatic resource cleanup works.
Because nurseries follow the black box rule, they make with
blocks work again. There's no chance that, say, closing a file at the end of a with
block will accidentally break a background task that's still using that file.
Automated error propagation works.
As noted above, in most concurrency systems, unhandled errors in background tasks are simply discarded. There's literally nothing else to do with them.
In Trio, since every task lives inside a nursery, and every nursery is part of a parent task, and parent tasks are required to wait for the tasks inside the nursery... we do have something we can do with unhandled errors. If a background task terminates with an exception, we can rethrow it in the parent task. The intuition here is that a nursery is something like a "concurrent call" primitive: we can think of our example above as calling myfunc
and anotherfunc
at the same time, so our call stack has become a tree. And exceptions propagate up this call tree towards the root, just like they propagate up a regular call stack.
There is one subtlety here though: when we re-raise an exception in the parent task, it will start propagating in the parent task. Generally, that means that the parent task will exit the nursery block. But we've already said that the parent task cannot leave the nursery block while there are still child tasks running. So what do we do?
The answer is that when an unhandled exception occurs in a child, Trio immediately cancels all the other tasks in the same nursery, and then waits for them to finish before re-raising the exception. The intuition here is that exceptions cause the stack to unwind, and if we want to unwind past a branch point in our stack tree, we need to unwind the other branches, by cancelling them.
This does mean though that if you want to implement nurseries in your language, you may need some kind of integration between the nursery code and your cancellation system. This might be tricky if you're using a language like C# or Golang where cancellation is usually managed through manual object passing and convention, or (even worse) one that doesn't have a generic cancellation mechanism.
A surprise benefit: removing go
statements enables new features
Eliminating goto
allowed previous language designers to make stronger assumptions about the structure of programs, which enabled new features like with
blocks and exceptions; eliminating go
statements has a similar effect. For example:
- Trio's cancellation system is easier to use and more reliable than competitors, because it can assume that tasks are nested in a regular tree structure; see Timeouts and cancellation for humans for a full discussion.
- Trio is the only Python concurrency library where control-C works the way Python developers expect (details). This would be impossible without nurseries providing a reliable mechanism for propagating exceptions.
Nurseries in practice
So that's the theory. How's it work in practice?
Well... that's an empirical question: you should try it and find out! But seriously, we just won't know for sure until lots of people have pounded on it. At this point I'm pretty confident that the foundation is sound, but maybe we'll realize we need to make some tweaks, like how the early structured programming advocates eventually backed off from eliminating break
and continue
.
And if you're an experienced concurrent programmer who's just learning Trio, then you should expect to find it a bit rocky at times. You'll have to learn new ways to do things – just like programmers in the 1970s found it challenging to learn how to write code without goto
.
But of course, that's the point. As Knuth wrote (Knuth, 1974, p. 275):
Probably the worst mistake any one can make with respect to the subject of go to statements is to assume that "structured programming" is achieved by writing programs as we always have and then eliminating the go to 's. Most go to 's shouldn't be there in the first place! What we really want is to conceive of our program in such a way that we rarely even think about go to statements, because the real need for them hardly ever arises. The language in which we express our ideas has a strong influence on our thought processes. Therefore, Dijkstra asks for more new language features – structures which encourage clear thinking – in order to avoid the go to 's temptations towards complications.
And so far, that's been my experience with using nurseries: they encourage clear thinking. They lead to designs that are more robust, easier to use, and just better all around. And the limitations actually make it easier to solve problems, because you spend less time being tempted towards unnecessary complications. Using Trio has, in a very real sense, taught me to be a better programmer.
For example, consider the Happy Eyeballs algorithm (RFC 8305), which is a simple concurrent algorithm for speeding up the establishment of TCP connections. Conceptually, the algorithm isn't complicated – you race several connection attempts against each other, with a staggered start to avoid overloading the network. But if you look at Twisted's best implementation, it's almost 600 lines of Python, and still has at least one logic bug. The equivalent in Trio is more than 15x shorter. More importantly, using Trio I was able to write it in minutes instead of months, and I got the logic correct on my first try. I never could have done this in any other framework, even ones where I have much more experience. For more details, you can watch my talk at Pyninsula last month. Is this typical? Time will tell. But it's certainly promising.
Conclusion
The popular concurrency primitives – go
statements, thread spawning functions, callbacks, futures, promises, ... they're all variants on goto
, in theory and in practice. And not even the modern domesticated goto
, but the old-testament fire-and-brimstone goto
, that could leap across function boundaries. These primitives are dangerous even if we don't use them directly, because they undermine our ability to reason about control flow and compose complex systems out of abstract modular parts, and they interfere with useful language features like automatic resource cleanup and error propagation. Therefore, like goto
, they have no place in a modern high-level language.
Nurseries provide a safe and convenient alternative that preserves the full power of your language, enables powerful new features (as demonstrated by Trio's cancellation scopes and control-C handling), and can produce dramatic improvements in readability, productivity, and correctness.
Unfortunately, to fully capture these benefits, we do need to remove the old primitives entirely, and this probably requires building new concurrency frameworks from scratch – just like eliminating goto
required designing new languages. But as impressive as FLOW-MATIC was for its time, most of us are glad that we've upgraded to something better. I don't think we'll regret switching to nurseries either, and Trio demonstrates that this is a viable design for practical, general-purpose concurrency frameworks.
Acknowledgments
Many thanks to Graydon Hoare, Quentin Pradet, and Hynek Schlawack for comments on drafts of this post. Any remaining errors, of course, are all my fault.
Credits: Sample FLOW-MATIC code from this brochure (PDF), as preserved by the Computer History Museum. Wolves in Action, by i:am. photography / Martin Pannier, licensed under CC-BY-SA 2.0, cropped. French Bulldog Pet Dog by Daniel Borker, released under the CC0 public domain dedication.
Footnotes
At least for a certain kind of person.↩
And WebAssembly even demonstrates that it's possible and at least somewhat desirable have a low-level assembly language without
goto
: reference, rationale↩For those who can't possibly pay attention to the text without first knowing whether I'm aware of their favorite paper, my current list of topics to include in my review are: the "parallel composition" operator in Cooperating/Communicating Sequential Processes and Occam, the fork/join model, Erlang supervisors, Martin Sústrik's article on Structured concurrency and work on libdill, and crossbeam::scope / rayon::scope in Rust. Edit: I've also been pointed to the highly relevant golang.org/x/sync/errgroup and github.com/oklog/run in Golang. If I'm missing anything important, let me know.↩
If you call
start_soon
after the nursery block has exited, thenstart_soon
raises an error, and conversely, if it doesn't raise an error, then the nursery block is guaranteed to remain open until the task finishes. If you're implementing your own nursery system then you'll want to handle synchronization carefully here.↩
Nursery를 인자로 받는 함수가 있을 때, 생성된 작업을 위해 오류 처리를 위한 정책을 제어하는 대신 nursery를 인자로 전달할 수 있습니다. 멋지네요. Trio를 asyncio나 다른 라이브러리들과 구별되게 하는 미묘한 부분이 있습니다. 바로 start_soon
이 coroutine 객체나 Future
가 아닌 함수를 받는다는 점입니다. (함수는 여러 번 실행될 수 있지만, coroutine 객체나 Future
는 그럴 수 없으니까요.) 이게 여러 가지 이유에서(특히 Trio는 Future
같은 게 필요 없으니까) 더 나은 문법이라고 생각하지만, 언급할 필요는 있겠죠
아니요, 사실, nursery는 항상 내부 작업이 끝나기를 기다립니다.
잘못 사용하는 경우에 한해서지만, nursery 불변성을 깨트리는 미묘한 부분이 있을 수 있으므로, 어떻게 작업이 취소되며 작업 결합이 이뤄지는지 설명할 필요가 있겠습니다.
Trio에서 코드는 언제든지 취소 요청을 받을 수 있습니다. 취소가 요청되면, 코드는 그 후에 "체크포인트" 작업을 수행하고, Cancelled
예외를 발생시킵니다. 즉, 취소가 요청된 시점과 실제로 취소가 수행된 시점에 차이가 있다는 것입니다. 작업이 체크포인트를 실행하기까지 시간이 걸리고, 그 이후에 예외가 스택을 따라 돌아가 정리하는 작업을 수행하거나 합니다. 이러한 일이 생겨도, nursery는 정리 작업이 항상 완전히 수행될 때까지 기다립니다. 정리할 기회조차 주지 않고 작업을 종료해 버리거나 완전히 취소되지 않은 상태로 남겨지는 일은 절대로 일어나지 않습니다.
자동으로 자원을 정리합니다.
Nursery는 블랙박스 룰을 따르기에, with
블록을 다시 사용할 수 있습니다. with
블록의 끝에 도달해 파일을 닫아버리는 바람에 백그라운드로 동작하던 작업이 갑자기 종료하는 일은 없습니다.
자동으로 오류를 전파합니다.
위에서 말했듯, 대부분의 동시성 시스템은 백그라운드 작업에서 다루지 못한 에러는 그냥 무시해버리는 편입니다. 말 그대로 그걸로 뭘 할 수 없기 때문입니다.
Trio에서는 모든 작업이 nursery 안에서 이뤄지는데, 모든 nursery는 부모 작업의 일부이므로, 부모 작업은 nursery 내의 작업이 끝나기를 기다려줘야 합니다. 그러니 처리되지 않은 오류를 제대로 다룰 수 있습니다. 백그라운드 작업이 예외와 함께 종료되면, 부모 작업으로 예외를 돌려보낼 수 있습니다. 여기서 nursery를 "동시 호출"을 수행하는 기초 요소로 본다는 것이 핵심입니다. myfunc
와 anotherfunc
를 동시에 호출하는 예제에서 호출 스택이 트리로 구성됩니다. 그러므로 예외는 일반적인 호출 스택과 같이 트리 구조를 따라 전파될 수 있습니다.
부모 작업에서 예외를 다시 발생시키면, 부모 작업 내에서 전파가 시작된다는 점이 미묘합니다. 일반적으로 이는 부모 작업이 nursery 블록을 종료시킨다는 의미입니다. 하지만 앞서 부모 작업은 자식 작업이 실행되는 동안 nursery 블록을 벗어날 수 없다고 말했었죠. 어떻게 해야 할까요?
자식에서 처리되지 않은 예외가 발생하면 Trio가 nursery 내의 다른 작업을 모두 취소하고 완료될 때까지 기다린 뒤에 예외를 다시 발생시키는 식으로 이 문제를 처리합니다.
이는 프로그래밍 언어에서 nursery를 구현할 때, nursery 코드와 취소 시스템 사이에 일종의 통합이 필요할 수도 있다는 것을 의미합니다. 취소를 위해 객체를 수동으로 전달해야 하는 관례를 가진 C# 이나 Golang과 같은 언어나 일반적인 취소 구현이 없는 언어에서는 다소 까다로운 작업이 될 겁니다.
의외의 이득: go
를 없앴더니 생긴 새로운 기능
goto
를 없애므로 언어 설계자들이 프로그램 구조에 대해 보다 명확한 가정을 할 수 있게 되어 만들 수 있었던 with
블록과 예외 처리와 같이, go
를 없앰으로 비슷한 효과가 있었습니다.
- Trio의 취소 시스템은 작업이 일반적인 트리 구조로 이루어져 있다고 가정할 수 있어, 경쟁자들에 비해 더 쉽고 안정적으로 사용할 수 있습니다. 인간을 위한 시간제한과 취소를 통해 확인해보세요.
- Trio는 파이썬 개발자가 기대하는 방식으로 control-C가 동작하는 유일한 파이썬 동시성 라이브러리입니다. (자세히) 이는 nursery와 같이 예외 전파를 위한 신뢰할 수 있는 구조를 제공하지 않으면 불가능한 일입니다.
Nursery를 써보자
이제까지 이론적인 것을 알아봤습니다. 실제로는 어떨까요?
음... 해보지 않으면 모를텐데요. 꼭 시도해보고 찾아보세요! 하지만 정말 진지하게 경험해보지 않으면 알 수 없는 부분이 많겠죠. 이 지점에서는 제 얘기가 꽤 그럴싸하게 들릴 거라 확신하긴 하지만, 초기의 구조적 프로그래밍 옹호론자들이 break
와 continue
를 허용하며 물러난 것과 같이 약간의 변경이 필요하다는 것은 인정해야 할지도 모릅니다.
만약 당신이 경험이 많은 동시성 프로그래머라면 Trio를 배우는데 다소 힘든 시간을 보내야 합니다. 1970년대의 프로그래머가 goto
없이 코드를 배우느라 고생했던 것과 같이 새로운 방식으로 일하는 법을 배우기도 해야 합니다.
물론 그게 핵심이죠. 커누스는 이렇게(Knuth, 1974, p. 275) 말했습니다.
아마도
go to
문과 관련하여 저지를 수 있는 가장 큰 실수는 늘 하던 대로 프로그램을 작성한 다음에 go to만 싹 제거한 다음에 "구조적 프로그래밍"이라고 부르는 것일 겁니다. 대부분의 go to는 애초에 있어야 하지 않을 곳에 있는 겁니다. 우리가 정말로 원하는 것은 애초에 go to문을 생각조차 하지 않고 프로그램을 구상하는 것이기 때문입니다. 그게 반드시 필요한 곳은 사실상 거의 없기 때문입니다. 우리가 언어를 통해 아이디어를 구현하는 것은 우리의 사고 과정에 강한 영향을 받습니다. 그런 연유로 데이크스트라는 복잡성에 대한 go to의 유혹을 피할 수 있는 언어의 새로운 기능들, 즉 명확한 사고를 장려하는 구조를 요구했던 것입니다.
이것이 바로 제가 이제까지 nursery를 사용했던 경험과 같습니다. 이는 저를 명확한 사고로 이끌었습니다. 더 견고하고, 사용하기 쉬우며, 전체적으로 나은 디자인으로 이어집니다. 제약 사항들 덕에 불필요한 복잡도를 다루는 일에서 벗어나 문제를 더 쉽게 해결할 수 있게 됩니다. Trio를 사용하는 것은, 실질적인 의미에서 제가 더 나은 프로그래머가 되도록 이끌어 주었습니다.
TCP 연결 맺는 속도를 높이는 단순한 동시성 알고리즘인 Happy Eyeballs 알고리즘(RFC 8305)을 생각해봅시다. 개념적으로, 이 알고리즘은 복잡하지 않습니다. 네트워크에 과부하가 걸리지 않도록 시차를 두고 서로 경쟁적으로 연결을 시도하게 하는 것입니다. 그러나 Twisted의 최적 구현체는 거의 600줄에 달하는 파이썬 코드이며, 여전히 하나의 로직 버그를 가지고 있는 것을 알 수 있습니다. Trio로 구현한 동일한 결과물의 길이는 1/15밖에 되지 않습니다. 더 중요한 것은, Trio를 사용하여 몇 달이 아니라 몇 분 만에 작성할 수 있었고, 단박에 정확한 로직을 구현했다는 것입니다. 제가 오랫동안 사용했던 그 어떤 프레임워크로도 이렇게 하진 못했습니다. 지난 달에 Pyninsula에서의 제 발표를 살펴봐 주세요. 뻔한 이야기인가요? 하지만 시간이 말해주겠죠. 저는 유망하다고 봅니다.
결론
인기 있는 동시성 요소들인 – go
문, 쓰레드 복제 함수, 콜백, futures, promises, ... 이런 것들은 이론적으로도 실제적으로도 모두 goto
의 변형일 뿐입니다. 게다가 현대화된 goto
도 아니고 함수 경계를 넘나드는, 호랑이 담배 피우던 시절의 goto
수준입니다. 이런 요소들은 우리가 직접 사용하지 않더래도 매우 위험합니다. 우리가 흐름을 읽어내는 것도 방해하며, 추상화된 모듈식 구성으로 복잡한 시스템을 만들지도 못하게 하며, 자동화된 자원 정리와 오류 전파와 같은 언어 수준의 유용한 기능도 쓰기 힘들게 만들기 때문입니다. 그 결과, 현대의 고급 언어에는 goto
는 갈 곳이 없어졌죠.
Nursery는 언어의 기능을 해치지 않으며 안전하고 편리한 대안을 제공할 뿐 아니라, 강력한 새로운 기능(Trio의 취소 범위와 control-C 처리로 입증된)을 제공합니다. 이는 가독성과 생선성, 정확한 구현의 극적인 향상을 이끌어 냅니다.
아쉽게도, 이러한 이점을 충분히 가져가려면, 기존 요소를 완전히 제거하고 아마도 바닥부터 완전히 새로운 동시성 프레임워크를 만들어야 할지도 모릅니다. goto
가 없는 새로운 언어를 설계하는 것과 같이요. 하지만 FLOW-MATIC이 나왔을 당시에 인상적이었던 것만큼, 더 좋은 나은 무언가로 좋아지는 것은 반길만한 일입니다. 저는 nursery로 전환하는 것을 후회할 거라 생각하지 않습니다. Trio를 통해 이것이 실용적이며 범용적인 동시성 프레임워크 디자인임은 입증했다고 생각합니다.
붙임
초안을 검토해준 Graydon Hoare, Quentin Pradet, 그리고 Hynek Schlawack에게 감사드립니다. 남아있는 오류는 모두 제 탓입니다.
저작권: FLOW-MATIC 샘플 코드는 컴퓨터 역사 박물관이 보관중인 이 브로슈어 (PDF)에서 발췌. Wolves in Action, by i:am. photography / Martin Pannier, CC-BY-SA 2.0 라이센스, cropped. French Bulldog Pet Dog by Daniel Borker, released under the CC0 public domain dedication.
각주
최소한 특정 부류의 인간에게는.↩
WebAssembly는
goto
없이도 충분히 저수준 언어로 사용될 수 있음을 보여주었다: reference, rationale↩제가 관심을 기울이고 있는 논문이 어떤 것인지 모르고는 도저히 집중할 수 없는 분들을 위해 알려드리자면, 이 리뷰에 포함된 논문 목록은 다음과 같습니다: the "parallel composition" operator in Cooperating/Communicating Sequential Processes and Occam, the fork/join model, Erlang supervisors, Martin Sústrik's article on Structured concurrency and work on libdill, and crossbeam::scope / rayon::scope in Rust. Edit: I've also been pointed to the highly relevant golang.org/x/sync/errgroup and github.com/oklog/run in Golang. 제가 빼먹은 중요한게 있다면 알려주세요.↩
Nursery 블록이 종료된 후에
start_soon
을 호출하면start_soon
은 오류를 발생시키고, 만약 오류가 발생하지 않는다면, nursery 블록은 남은 작업이 끝날 때까지 열린 상태로 유지될 것입니다. 직접 nursery 시스템을 구현하는 경우에 이 부분의 동기화를 신중하게 다뤄야 합니다.↩