並列処理管理ライブラリ task を含む、l4goの公開
2022年02月21日 月曜日
CONTENTS
こんにちは、くまさかです。
今回は、Go言語開発を支えるライブラリを公開しましたので、そちらの紹介記事です。
ちなみに今回の記事は、どうしてもGo言語寄りな話が少し登場します。
Go言語に関する説明は、本記事では割愛しますので、本記事を読む前や、読んだ後にGo言語自体を勉強したいと思った方は、IIJ Bootcamp で、僕が作成した Go言語入門コンテンツ か、Go言語 本家 Go tour をお試しください。
さて、では本題のライブラリ紹介です。
GitHub上では、l4go というところにまとめています。
ライブラリの組織名を色々検討したのですが、シンプルに、Go言語の為のライブラリなので、Library for Goからとり、l4go としました。
現在、10個ほどのライブラリを公開しており、どれも開発メンバがかゆいと思ったところに手を届かせるために開発しました。
これらは、eng-blogで既に紹介している別の記事シリーズ や、2020年の弊社イベント でも紹介させていただいた社内ツール、CHAGE の開発にも役立っています。
今までの紹介では、特に開発言語に触れたことはありませんでしたが、、、実は、一部にGo言語を採用しています。
一部にGo言語を採用する理由
Go言語を採用する理由はいくつかありますが、そのうちの1つに、並列実行を支援してくれるgoroutineという仕組みがあることです。
ご存知の方もいると思いますが、それぞれに対し、並列実行の生成や管理、終了の待機…とそれぞれで行うのはそれなりに面倒です。
(開発チャットでは、もっと速度を出すならpthreadで頑張るべきだろうが、管理するのは骨が折れそうというか、開発サイクル的にも現実的ではないだろうと話題に上がります…)
その点、Go言語では、go func() {処理}()
で、並列実行をオーダできます。とってもお手軽です。
CHAGEでは、多くの並列処理を行っており、それらを現実的な開発速度でメンテナンスしていくために、このお手軽な仕組みもコードの至る所に存在します。
さて、そのお手軽なgoroutineですが、並列実行をよろしくしておしまい。というわけにもいきません。
というのも、並列実行して放置してしまうと、働いていないgoroutineが待ち惚けていたり、終了処理をすべきなのに実行されないということが発生します。
これを防ぐためには、goroutineの状態の適切な管理が大切です。
お手軽に管理するために、ライブラリを作った
goroutineをお手軽に管理するために作成したライブラリが、今回公開した、l4go 内の、task というライブラリです。
以下は、l4go/task で最も高機能な構造、l4go/task.Mission の簡単なサンプル です。
(サンプルを簡略化する為に、deferをあえて使わないようにしています。もう少しGo言語な書き方や詳しくみたい方は、l4go/task.Misson の サンプル をご覧ください。例えば、cancel待ちやDone待ちは、selectを使った非同期な待ち方も可能なため、タイマ等と組み合わせて使うことも可能です。)
package main
import (
"fmt"
"time"
"github.com/l4go/task"
)
func main() {
parent_msn := task.NewMission()
// =============== 子 スレッド ここから =============== ※
go func (child_msn *task.Mission) { // 1 子を生成 するコードの始まり
fmt.Println("子: 働く準備をする")
time.Sleep(time.Second)
select {
case <-child_msn.RecvCancel(): // キャンセルの確認をする
fmt.Println("子: 親からキャンセルされたので、終わる")
child_msn.Done()
return
default: // キャンセルがなかったのでスルーし、後続の処理へ進む
}
fmt.Println("子: 働いている")
time.Sleep(time.Second)
fmt.Println("子: 働き終えた")
child_msn.Done() // 3. 子が終了を通知 し、親の、2が知る
}(parent_msn.New())
// =============== 子 スレッド ここまで ===============
fmt.Println("親: 子に働いてくれるようお願いした")
parent_msn.Done() // 2 子の終了を待つ
// 3. 子の通知を受け取り、この後に進める
fmt.Println("親: 子も、親も終わった")
// 4. 親が終了
}
このサンプルは、main関数の中身の親と、go func...
の中身の子がそれぞれ並列処理されるものです。
(非同期というか、同時というか、に動きます。)
ステップとしては、サンプルのコメントにもあるように大きく4つから成り立ちます。
task.Misson
は、.New()
から生まれた全てのtask.Misson
が、完了(.Done()
) を実行するまで待ちます。
サンプルでは、parent_msn
から、parent_msn.New()
によって、child_msn
が1つ生まれています。
そのため、このchild_msn
がDone()
するまでは、parent_msn
は、先へ進みません。(2. 子の終了を待つ
で止まり続ける)
この動きにより、子の、fmt.Println("子: 働き終えた")
が必ず実行され、child_msn.Done()
を待ってから、親が終了します。
task管理は必要なのか
ちなみに、管理しないのであれば、l4go/task.Mission はいらないので、先ほどのサンプルソースコードは、以下ぐらいに減ります。
package main
import (
"fmt"
"time"
)
func main() {
// =============== 子 スレッド ここから =============== ※
go func () {
fmt.Println("子: 働く準備をする")
time.Sleep(time.Second)
fmt.Println("子: 働いている")
time.Sleep(time.Second)
fmt.Println("子: 働き終えた")
}()
// =============== 子 スレッド ここまで ===============
fmt.Println("親: 子に働いてくれるようお願いした")
fmt.Println("親: 子も、親も終わった")
}
10行以上も減りますし、やることだけが書いてあるので、とてもシンプルです。(いやー、読みやすいw)
しかし、このソースコードには、大きな問題があります。
お察しの方もいるかもしれませんが、実は、fmt.Println("子: 働き終えた")
のメッセージを待つことなく、fmt.Println("親: 子も、親も終わった")
が実行されます。
Go言語では、親のgoroutineが終了すると、プロセス自体が終了する特性があります。
そのため、親が先に終了するケースでは、子のgoroutineは、中途半端な状態で終了するケースもあるのです。
サンプルでは、文字列を出力するだけなので、大した影響はありませんが、
本番の処理で中途半端に終わると、いつの日かデータが壊れたり、パフォーマンスが出ない事があります。
例えば、「1000行読み込んで、並び替えた結果を書き込む」処理が子 であった場合、半分の500行しか処理が終わらないような状況となったりします。
というか、綺麗に500行書き込めればまだいい方です。行の途中で中途半端な状態となったり、データが壊れてしまう可能性すらあります。
もしかしたら、推しの大切な写真を綺麗にするプログラムが、中途半端に終了すると、推しの大切な写真は、壊れて修復不能になってしまうかもしれません。そんなの僕は耐えられません。
データを壊さないためにも、並列処理の場合、親は子にオーダした処理がどのような状態になっているか、そして終了しているのかを把握できることが重要です。
加えて、子を待つことだけではなく、不要であれば処理を停止(キャンセル) することも重要です。
例えば、複数の子を生成し、それらがデータを読み込む処理があったとします。
そして、子を生成した直後に、その子の処理が不要となった場合であっても、何も伝えなければもちろん、子は働き続けます。
困ったことに、こちらの画像の例では、子Aも子Bもデータを読み続けているので、子Bのデータを読む効率が半分に落ちます。
子Aも、データを読み込む必要があれば、もちろんしょうがないことですが、不要となった子Aがデータを読み続ける事が原因で、子Bの処理が遅くなることは望まれません。
そのため、不要となった処理を停止(キャンセル) できるように、親から伝えてあげる仕組みも大切です。
コンピュータのリソースは有限です。
データの読み取り以外でも、効率化の目的からCPU等の負荷を減らす等、不要な処理は停止(キャンセル)する事が重要です。
goroutineは、お手軽な並列処理オーダできる記法だが、管理が無い
さて、実は、先ほど紹介した、goroutine。並列処理のオーダは簡単にできますが、簡単に管理ができません。
というか、管理するインタフェースが提供されていません。
インタフェースが無いのが悪いわけではなく、例えば、C言語のpthreadのような、それなりのコードを書かなくてもよい。というメリットすらあります。
ただ、管理したくなった時、goroutine単体では、管理するすべが無いのです。
Go言語の標準ライブラリで管理は、一応できる
言葉遊びをしてすいません。goroutineにはありませんが、Go言語には管理する仕組みが存在します。
context
ライブラリと、sync
ライブラリのWaitGroup
です。
Go言語標準ライブラリ: context
context ライブラリの説明ページには、APIの境界を越えて、プロセス間で、期限、キャンセルシグナル、およびその他のリクエストスコープの値を伝達します。
とあります。
関係のあるところだけニュアンスをそろえると、親がキャンセルしたい時に、キャンセルシグナルを子に伝搬させることができるということです。
(今回は、 l4go/task と大きく関係ない、値の伝搬やDeadlineな使い方の説明を大きく省きます。気になる方は、context
ライブラリ自体をご覧ください。)
サンプルコードは、以下のようになります。
package main
import (
"fmt"
"time"
"context"
)
func main() {
parent_ctx, cancel := context.WithCancel(context.Background())
child_ctx, _ := context.WithCancel(parent_ctx) //parent_ctx の共連れを発行
// =============== 子 スレッド ここから =============== ※
go func (child_ctx context.Context) {
fmt.Println("子: 働く準備をする")
time.Sleep(time.Second)
select {
case <- child_ctx.Done(): // キャンセルの確認をする
fmt.Println("子: 親からキャンセルされたので、終わる")
return
default: // キャンセルがなかったのでスルーし、後続の処理へ進む
}
fmt.Println("子: 働いている")
time.Sleep(time.Second)
fmt.Println("子: 働き終えた")
}(child_ctx)
// =============== 子 スレッド ここまで ===============
fmt.Println("親: 子に働いてくれるようお願いした")
fmt.Println("親: 子にやっぱりキャンセルをお願いする")
cancel() //cancelの実行
fmt.Println("親: 子にキャンセルをした")
fmt.Println("親: 子も、親も終わった")
}
最初に、parent_ctx
は、自身がキャンセルを発動した際に、共連れキャンセルを行うchild_ctx
を発行しています。(context.WithCancel
関数で紐づけている)
これにより、parent_ctx
に対してキャンセルを発行するcancel()
が実行された際に、child_ctx
もキャンセルできる状況を作ることが可能です。
イメージとしては、以下のような形です。
context.WithCancel
関数でつながった*_ctx
を介し、child_ctx.Done()
で、子は、親が発行したキャンセルを受け取っています。
このcontext.Context
を扱えば、context.WithCancel
関数で、キャンセルのツリーを構築し、複数の子の内1つだけをキャンセルするような使い方もできます。
先ほどのリソースの例の、子Aを停止して、子Bを効率的に動作させるなんてことも可能です。
Go言語標準ライブラリ: sync の WaitGroup
sync ライブラリ / 構造体 WaitGroup の説明ページに、WaitGroupは、goroutineのコレクションが終了するのを待ちます。
とあるように、sync.WaitGroup
を用いることで、子のgoroutineの終了まで、親は待つことができます。
子を待つサンプルコードを書くと以下のようになります。
package main
import (
"fmt"
"time"
"sync"
)
func main() {
wg := new(sync.WaitGroup)
wg.Add(1)
// =============== 子 スレッド ここから =============== ※
go func () { // 1 子を生成 するコードの始まり
fmt.Println("子: 働く準備をする")
time.Sleep(time.Second)
fmt.Println("子: 働いている")
time.Sleep(time.Second)
fmt.Println("子: 働き終えた")
wg.Done() // 3. 子が終了を通知 し、親の、2が知る
}()
// =============== 子 スレッド ここまで ===============
fmt.Println("親: 子に働いてくれるようお願いした")
wg.Wait() // 2 子の終了を待つ
// 3. 子の通知を受け取り、この後に進める
fmt.Println("親: 子も、親も終わった")
// 4. 親が終了
}
wg.Wait()
は、事前に呼ばれたwg.Add(n)
のn回分の、wg.Done()
が実行されるまで待ちます。
そのため、fmt.Println("親: 子も、親も終わった")
は、必ず、fmt.Println("子: 働き終えた")
の後に実行されることが保証されています。
これにより、子が1000行出力する場合、子が1000行出力してから、wg.Done()
を実行すれば、親はそれを待ってから終了するような動作をしてくれます。
推しの写真も壊れなさそうです。
キャンセルと、終了待ちの組み合わせ で goroutineを管理する
これら2つの標準ライブラリを組み合わせることで、以下のように、キャンセルと終了待ちの管理を実現できます。
...
select {
case <- child_ctx.Done(): //キャンセル待ち
fmt.Println("子: 親からキャンセルされたので、終わる")
wg.Done() //終了通知
return
default:
}
...
wg.Wait() //終了待ち
fmt.Println("親: 子も、親も終わった")
...
では、これをこのまま扱えばよい…という方針には至らず、l4go/task は、開発されました。
というのも、context
やsync.WaitGroup
では、困った点がいくつかあったからです。
既存の仕組みで困った点
1. 親子間の受け渡し方法を常に悩まなくてはいけない
context
やsync.WaitGroup
は、使い方の自由度が非常に高いです。
どのような場面でも柔軟に書けるメリットもありますが、
常にどのような書き方をするか選択する必要があるというデメリットもあります。
例えば、context
ですが、親が子用のcontext.Context
を生成する方法と、子自身が親のcontext.Context
を用い、子用を生成する 書き方ができます。
パターン1: 親が、子用のcontext.Context
を生成する方法
func main() {
parent_ctx, cancel := context.WithCancel(context.Background())
child_ctx, c_cancel := context.WithCancel(parent_ctx)
func (child_ctx context.Context, c_cancel context.CancelFunc) {
... //child_ctx を使える
}(child_ctx, c_cancel)
...
}
パターン2: 子自身が親のcontext.Context
を用い、子用を生成する
func main() {
parent_ctx, cancel := context.WithCancel(context.Background())
func (parent_ctx context.Context) {
child_ctx, c_cancel := context.WithCancel(parent_ctx)
... //child_ctx を使える
}(parent_ctx)
...
}
たかが1行が、関数の内側か、外側か。ぐらいの違いですが、関係性が多くなってくると問題がおきます。
例えば、4階層ぐらいだと…
- 親は、子用作成
- 子は、孫用は、作成しない
- 孫は、自身とひ孫のを作成
- ひ孫は…
と、組み合わせゲーム状態になります。
これは、ルールを定めておけばどうにかなる話ではあるのですが、都度ルールを見返すのは現実的ではありません。
そして、もしかしたらどこかで、間違えてルール以外の形を取ってしまうかもしれません。
複数人で、統一的なコードを実現する為には、ルール化では足りず、困ってしまいました。
ちなみに、例では、context
を取り上げましたが、sync.WaitGroup
の、AddやWaitでも同じことがおきます。
具体的には、親がAddする場合と、子がAddし孫に働かせるパターンが考えられます。
2. 終了管理をフラットにしか行えない
context.Context
は、context.withCancel
等で関係性を構築できますが、sync.WaitGroup
はそうではありません。
sync.WaitGroup
では、Addして数のみ管理するので、親からみて、一階層下の管理しか行えないのです。
単に親が、終了を待つようなケースでは、大きな問題になりませんが、子が孫の終了を待ちたいケースには対応できません。
単一のsync.WaitGroup
では、孫が終了してもAddした値が1減るだけで、子は、それが孫によって終了したのか、お隣の子が終了したのかわからないのです。
そのため、sync.WaitGroup
で頑張って管理する場合、階層毎に、sync.WaitGroup
を発行する方法が考えられます。
親の、sync.WaitGroup
は、子でDoneし、子のsync.WaitGroup
は、孫でDoneするような使い方です。
しかし、この方法にも困った点はあります。
きれいなツリー状で、親と子が継続的に1対1であれば、なんとか管理できるかもしれませんが、複雑になると難しくなってきます。
仮に頑張るのだとしても、コードの末尾に全部のDone()をするのは、どれか忘れてしまいそうです。
親wg.Done()
叔父wg.Done()
叔母wg.Done()
いとこwg.Done()
はとこwg.Done()
....
3. 引数が多くなる
並列処理を管理する必要性については、既に説明しているので、重要性は伝わっていると思います。
では、その大切なプログラムをきちんと管理するのであれば、goroutineを発行するたびに、管理物を紐づける必要がありますね。
強めの表現をすると、goroutineするのであれば、引数に、context.Context
と、sync.WaitGroup
が必須となる。ということです。
ただ、これらを組み合わせると、引数が長くなります。
困った点1の、使い方のルール次第ですが、最小の引数が2つ。最大3つも必要です。ここに、本来扱いたい引数が加わるので、6個とか7個とかになるかもしれません…。
func Two(ctx context.Context, wg *sync.WaitGroup, ....) {}
func Three(ctx context.Context, cancel func, wg *sync.WaitGroup, ....) {}
やたら長くなるので、可読性が下がってしまいそうです。
l4go/task を開発
では、これらの困った点を解決しようということで誕生したのが、l4go/task です。
改めてそれっぽい紹介をすると、
l4go/task は、goroutineのキャンセル処理、終了処理の管理を支援するためのライブラリです。
サンプルコードで既に紹介していますが、l4go/task.Mission では、子の終了を待つことや親からキャンセルを送ることができます。
困った点1の解決: 親子間の受け渡し方法を統一
l4go/task では、渡し方が統一しています。
- 親.New() で、子のを作る。
- 子.New() で、孫のを作る。
- 孫.New() で、ひ孫のを作る。
- ひ孫….
のような形です。
もう迷う必要はありません。(*task.Mission).New()
でgoroutineをお手軽に管理できます。
困った点2の解決: 終了管理をツリー構造に管理可能にした
親.New()や、子.New()で生成する l4go/task.Mission は、ツリー構造の繋がりを持ちます。
context.withCancel
関数 と似たようなイメージですが、l4go/task.Mission では、終了処理にもツリー構造の繋がりを持たせています。
そのため、親は、子や孫も終わらないと終了することなく、子は孫が終わらないと終了することができないという書き方ができます。
これにより、孫が終わり次第、子の終了処理。子が終わり次第、親の終了処理と、すべてを安全に終了できます。
そして、キャンセルと終了がツリー構造になっているので、
親が子Bを生成し、孫B…と階層をつなげていき、元々存在した子Aだけを終了したいようなケースにも対応できます。
子A配下にのみキャンセルを流し込むことで、子A自身やその先の孫A…をキャンセルすることができ、かつ、終了のツリー構造により、孫A及び子Aは安全終了できます。
func myGraundChildA(msn *task.Mission) {
defer msn.Done() //defer: 関数スコープの終了処理キューに積む
...
}
func myGraundChildB(msn *task.Mission) {
defer msn.Done() //defer: 関数スコープの終了処理キューに積む
...
}
func myChildA(msn *task.Mission) {
defer msn.Done() //defer: 関数スコープの終了処理キューに積む
go myGraundChildA(msn.New())
...
//タイムアウト等の、何らかの条件判定
msn.Cancel() //myChildA配下をキャンセルする
...
}
func myChildB(msn *task.Mission) {
defer msn.Done() //defer: 関数スコープの終了処理キューに積む
go myGraundChildB(msn.New())
...
}
func main() {
msn := task.NewMission()
defer msn.Done() //defer: 関数スコープの終了処理キューに積む
go myChildA(msn.New())
go myChildB(msn.New())
...
}
困った点3の解決: 構造が1つなので、引数が少ない
これは説明不要かもしれません。
l4go/task.Mission は、構造体が1つです。
そのため、引数に追加すべきは、1つですみます。
func myChild(msn *task.Mission) {
defer msn.Done()
...
}
困った事は解決され、taskの管理を現実的にできた
いくつかの困った点の解決により、goroutineで行う仕事(task)は、お手軽に管理できるようになりました。
仕事が動的に増減するようなコードであっても、開発効率も動作も、ある程度現実的に管理できるようになったのです。
(もう御察しかもしれませんが、ライブラリの名前が task なのは、goroutineで行う 仕事(task) の管理からきています。)
ちなみに、l4go/task が解決しているのは、この困ったことだけではありません。
l4go/task には、 l4go/task.Mission の他にも、軽量なキャンセルのみの l4go/task.Cancel や、終了処理を管理するための、l4go/task.Finish も存在します。
これらは、context.WithCancel
関数や、sync.WaitGroup
と単体の機能は被りますが、l4go/task.Misson をそれぞれとして、受け渡しできる特徴を有しています。
(l4go/task.Canceller インターフェースによって、変換するような使い方です。)
詳細は、ライブラリの説明に記載がありますが、ツリー構造が必要なタスク管理と、あえてツリー構造のいらない、横並びにしたいキャンセル処理を、統一的なコードで書く事ができるという強みを持っています。
統一的なコードを書くことができるので、動的に構造が増える構造や動的に選ばれる関数に対して、同じようなインタフェースを定義できます。
親の都合で、l4go/task.Mission を渡すか、l4go/task.Cancel を渡すか選択することも容易です。
func myTask(cc task.Canceller) {
...
}
func main () {
msn := task.NewMission()
defer msn.Done() //defer: 関数スコープの終了処理キューに積む
go func (child_msn *task.Mission) {
defer child_msn.Done() //defer: 関数スコープの終了処理キューに積む
myTask(child_msn) //*task.Missonを、task.Cancellerインタフェースとして渡す
} (parent_msn.New())
go func (cc *task.Cancel) {
myTask(cc) //*task.Cancelを、task.Cancellerインタフェースとして渡す
} (parent_msn.NewCancel())
}
このように、呼び出し元が、l4go/task.Mission を渡してくるのか、l4go/task.Cancel を渡してくるのか、子(myTask
)は、意識する必要がありません。
子は、task.Canceller
を受け取り、関数が行う作業と、l4go/task.Canceller の受け取り処理を開発するだけです。
AsContextもあるので、既存ライブラリとの取り入れもできる
また、l4go/task では、context
との乗り入れが可能です。
これは、context.Context
が、キャンセルツリーを構築できる機能を有し、l4go/task をつなぐ事で、l4go/task に対応していないライブラリをも l4go/task で管理可能にする目的です。
context
は、Go言語標準ライブラリなので、context.Context
を使用している標準ライブラリやOSSが多くあります。
これらに対して、安全な管理ツリーを構築しつつ開発が行えます。
なお、sync.WaitGroup
については、互換性インタフェースを用意していません。
困ったことでも紹介したように、ツリーの関係性を持たない為、l4go/task と関係性を持たせた発行に意味はない為です。
どうしても参照しているライブラリがsync.WaitGroup
を受け取りたい場合は、その呼び出し元で、ツリーではないsync.WaitGroup
を構築し、その領域だけ個別の管理をすれば扱えます。
おわりに
さて、今回紹介した、l4go/task によって、お手軽なgorotineを、お手軽に管理することができ、現実的な速度で開発を進めることができました。
CHAGEの記事で解説や紹介をしているような、1000並列以上の効率的な動作は、マトリクス関係性なgoroutineをゴリゴリ管理できる l4go/task あってのものです。
他にもライブラリを沢山公開しました
他にも、l4go では、弱参照や、可変長キューライブラリなど、10個のライブラリを公開しています。
- https://github.com/l4go/task
- 本記事で紹介しました
- https://github.com/l4go/mutex
- golangのsyncライブラリ、Mutexで不足する機能を追加したモジュール群です
- 最近Golangに、TryLockが増えたので、UpgradeLockの機能ぐらいが意味のあるものになりました
- golangのsyncライブラリ、Mutexで不足する機能を追加したモジュール群です
- https://github.com/l4go/timer
- time.After()及び、time.Timer() での先勝ち処理に対応すべく作成したタイマーモジュールです
- https://github.com/l4go/cmdio
- コマンドの標準入出力をまとめて管理できるようにしたモジュールです
- https://github.com/l4go/csvio
- csvベースでの読み込み/書き込みを行うライブラリです
- https://github.com/l4go/lineio
- 行ベースでの読み取り処理を行うライブラリです
- https://github.com/l4go/vqueue
- goroutine間通信用の可変長キューライブラリです
- https://github.com/l4go/list
- 可変長の連結リストを提供するライブラリです
- https://github.com/l4go/var_mtx
- 動的に生成される値での細かな粒度の排他制御を簡単に実現するためのライブラリです
- https://github.com/l4go/weak_ref
- 弱参照の機能提供のライブラリです
ちなみに、実は、私が開発メンバにJoinした時から既にいくつかのライブラリは完成しており、私自身は、主開発をしたものはとても少なく、師匠作がほとんどです。
ただ、l4go/task については、真髄に迫るべく、l4go/task.Cancel の開発を進めたりと、価値を生み出せたと自負しているので、ぜひ僕に紹介記事を書かせてくれと、奪い取ってきました。
今回のブログは、シリーズものではないので、他のライブラリの紹介はありませんが、他にもかゆいところに手が届きまくるライブラリがあります。ぜひ各ライブラリを訪れ、サンプルと説明を見てください。
※) 各サンプルの、=============== 子 スレッド ここから ===============
は、サンプルの意図をわかりやすくするために、親スレッド側のコードに書いてあります。厳密には、go func() {この内側} ()
の範囲のみが、スレッドとしての子の領域です
※The Go gopher was designed by Renée French.