Go 脱初心者への道

公開:2020/10/17
更新:2020/10/17
19 min読了の目安(約17600字TECH技術記事
Likes34

最近、 Go の学習を始めました。入門書や 公式のチュートリアル を終えてもなかなか解らないことをまとめてみました。

1. パッケージ作成・パッケージ管理

1. a. ディレクトリ構成は非公式ながら標準と目されるものがある(らしい)

/cmd にmainのアプリケーションのソースを置きます。 /pkg にライブラリのソースを置きます。 /src にソースを置いてはいけません。

なお、このディレクトリ構成は go コマンド(go build 等)がデフォルトで認識するディレクトリ (/src , /pkg , /bin) とは全く異なるので注意が必要です。

【参考資料】

1. b. これから始めるプロジェクトでは、最初から新しいパッケージ管理システムである Go Modules を使う

cloneしたリポジトリは $GOPATH 配下 以外 に置きます。配下に置くと、toolingが module-aware mode と呼ばれる Go Modules を有効にした状態で動作しない場合があるためです (Go Modules 以前は $GOPATH 配下に置いていました)。

通常であれば、リポジトリのルートディレクトリに go.mod ファイルを置きます。

go.mod
module example.com/path/to/my-library-name

go 1.15

replace (
    example.com/path/to/my-library-name v0.0.0 => ./
)

モジュール名は公開プロジェクトであれば、 github.com/my-account-name/my-repo-name 等とするのが一般的ですが、非公開プロジェクトであればドメイン形式を取らなくても良いです (例: my-project-name/my-library-name)。

replace(...) は、自他のモジュール名が実際にはどこに存在するかを定義します。上記では go.mod からの相対パスで記載してますが、絶対パスでも、ドメイン形式(example.com/path/to/module)でも良いです。

1つのリポジトリで複数モジュールを管理する mono repo の場合は、モジュールのルートとなるサブディレクトリに go.mod を配置します。

go.mod
module example.com/path/to/my-library-a

go 1.15

replace (
    example.com/path/to/my-library-a v0.0.0 => ./
    example.com/path/to/my-library-b v0.0.0 => ../library-b/
)

go.mod の雛形は、 go init example.com/path/to/my-library-namego.mod を配置したいディレクトリでコマンド実行することで作ることができます。

1. c. 自モジュール内の他(サブ)モジュールの参照

go.mod を配置している場合は相対パスでの import ができません。絶対パスで書く必要があります。

foo.go
package foo

import (
    "example.com/path/to/my-library-a/pkg/bar"
)

1. d. 依存関係のインストールとビルド

go.mod のあるディレクトリで

bash
# ./cmd/my-app はソースモジュール名
# ./dist/cli/ は出力ディレクトリ

go build -o ./dist/cli/my-app.exe ./cmd/my-app

とすれば、自動的に依存関係がインストールされ、ビルドされます。

依存関係のインストール先は $GOPATH/pkg/mod です。
依存関係はバージョン別にインストールされます。

go.mod
module example.com/path/to/my-library-a

go 1.15

replace (
    example.com/path/to/my-library-a v0.0.0 => ./
    example.com/path/to/my-library-b v0.0.0 => ../library-b/
)

require golang.org/x/sync v0.0.0-20201008141435-b3e1573b7520

これにより、 go.mod に依存関係のリストが追加されます。

不要な依存関係を削除するには以下を行います。

bash
go mod tidy

Lint を実行するには以下を行います。

bash
# ... はすべてのサブパッケージ
go vet ./...

ユニットテストを実行するには以下を行います。

bash
go test ./...

すべてを手作業でするのは現実的ではないので、 Makefile を作る必要があります。

参考資料

1. e. リリースの publish

私はまだ publish していないので実体験したわけではありませんが、 semver 形式 (v0.0.0) で GitHub リポジトリでタグを打つと、 https://proxy.golang.org にミラーが作られるようです (それが publish したことになる)。そして、作られたミラーは消せないらしい。なにそれ怖い。

モジュール名のドメインを自ドメイン等にすると、自分で配信しなければならないような動作をしていました (GitHub 等の場合に参照先が https://proxy.golang.org に向くように細工されているようです)。

【参考資料】

1. f. メジャーバージョンアップ

メジャーバージョンを 2 以上に上げる場合は、 go.mod のモジュール名の末尾に v{major} を付けなければ https://proxy.golang.org から取得出来なくなります。

go.mod
module example.com/path/to/my-library-a/v2

go 1.15

replace (
    example.com/path/to/my-library-a/v2 v0.0.0 => ./
    example.com/path/to/my-library-b v0.0.0 => ../library-b/
)

各ソース内の import も修正が必要です。

foo.go
package foo

import (
    "example.com/path/to/my-library-a/v2/pkg/bar"
)

【参考資料】

2. 構文とその動作

2. a. 型定義と型エイリアス

type キーワードを使うと新しい型の作成(型定義)と、型の別名作成をすることができます。

example.go
// 型定義
//  type TypeName 型リテラル または type TypeName OriginalTypeName
type Foo struct{}
type Bar interface{}
type Email string

// 型エイリアス
type Baz = Foo

型定義された型はオリジナルの型と「同じデータレイアウト (underlying type)」を持つ別の型となり、相互に代入できません。

一方で、型エイリアスは別名を付けただけで同じ型です (C の typedef と同じ)。従って相互に代入できます。

2. b. 型変換と型アサーション

Goにおいて、ある値の型を別の型に変換する方法は2つあります。Goでは暗黙的な型変換は (確定した型を持たない数値リテラルから数値型への変換という例外を除いて) 全く許されないので、目的に応じてどちらかの変換を選ばなければなりません (別の方を選んでもコンパイルエラーとなるので安心です) 。

【型変換】 (Type conversions)

「同じデータレイアウト (underlying type)」を持つ型同士の間で型を変換できます。 Foo(x) の記法で行います。 コンパイル時に検査 されます。数値型は特別扱いで、別の underlying type 間でキャストできます。概ね C++ の static_cast と同じような動きをします。

example.go
type Number float64
type SNil struct{}
type SNull struct{}
type SFoo struct{
    a int
}

// 型変換
var a float64 = 0.25           // 型の無い浮動小数点リテラルから暗黙的に float64 に変換
var a_dash Number = Number(a)  // 同じunderlying typeを持つ型は型変換できる
var a_dash Number = a.(Number) // コンパイルエラー。
                               // インターフェイス以外に対して型アサーションは使えない
var b int = int(a_dash)        // 同じ構文で、数値型同士はキャストすることができる
var b_str string = string(b)   // コンパイルエラー。文字列には変換できない
var c SNil = SNil{}
var c_dash SNull = SNull(c)    // 同じunderlying typeを持つ型は型変換できる
var d SFoo = SFoo{}
var d_dash = SNull(d)          // コンパイルエラー。 underlying typeが異なる

【型アサーション】 (Type assertions)

インターフェイスからのダウンキャスト専用の変換です。 実行時に検査 されます。 v := x.(Foo) で受けてダウンキャストが失敗するとpanicが発生します。 v, ok := x.(Foo) の記法でテストできます。概ね C++ の dynamic_cast と同じような動きをします。

example.go
// インターフェイス定義
type Greeter interface{
    Greet() string
    Greet2() string
}

// 値レシーバーで実装
type Foo struct{
    p int
}
func (m Foo) Greet() string { return "Hello!" }
func (m Foo) Greet2() string { return "Hello!!" }
func (p *Foo) Greet3() string { return "Hello!!" } // インターフェイスに含まないレシーバー

// 値レシーバーとポインタレシーバーが混在
type Bar struct{
    q float64
}
func (m *Bar) Greet() string { return "Bonjour!" }
func (m Bar) Greet2() string { return "Bonjour!!" }

// インターフェイス型変数にセットする
var a Greeter = Foo{}   // 値としてインターフェイス型変数に代入。
                        // 「値」なので、コピーがインターフェイスに格納される。
a.Greet()               // Hello!
var b Greeter = Bar{}   // コンパイルエラー。structがポインタレシーバーを持っていると
                        // インターフェースには値しか代入できない
var c Greeter = &Foo{}  // 値レシーバーで実装されていてもポインタを代入できる。
var d Greeter = &Bar{}  // ポインタであれば、structが値レシーバーとポインタレシーバー
                        // を混在させていても代入できる
d.Greet()               // Bonjour!

// 型アサーション
a_dash := a.(Foo)       // GreeterからFooへのダウンキャストを試みる。
                        // インターフェースには値が格納されているので、コピーが発生する
a_dash := Foo(a)        // コンパイルエラー。
                        // インターフェイスからのダウンキャストに型変換は使えない
c_dash := c.(Foo)       // panicが発生。 *Foo は Foo と互換性が無い。
c_dash, ok := c.(Foo)   // {}, false
c_dash, ok := c.(*Foo)  // &{}, true
a.(Foo).Greet()         // Hello!
a.(Foo).Greet2()        // Hello!!
a.(Foo).Greet3()        // コンパイルエラー。式の中で a.(Foo) のアドレスを取ることはできない
a_dash2 := a.(Foo)
a_dash2.Greet3()        // Hello!!!
d.(*Bar).Greet()        // Bonjour!
                        // ポインタは自動的にデリファレンスされるので、
                        // 値の場合と書き方に違いはない
d.(*Bar).Greet2()       // Bonjour!!

他の言語と比べて変わっていると思うのは、インターフェイスを実装するレシーバー (structのメソッドかのように振る舞う) が値を取るかポインタを取るかでインターフェイスに値を入れられるかどうかが決まる点だと思いました (ポインタは常に入れられます)。

値を入れる場合、元の型からインターフェイスに代入する際とインターフェイスからダウンキャストして元の型に戻す際の 2回 コピーが発生します。 値が struct の場合、インターフェイスに持つのはポインタで、実体はヒープに確保されます。値がプリミティブ型 (すべての型かどうかは調べていませんが…) の場合、インターフェイスのデータ構造内に実体を持ちます (インターフェイスの内部構造は型情報へのポインタとデータを格納/ポイントする共用体を持っています)。

上述の特性から考えれば、殆どの場合、インターフェイスにはポインタを渡すほうが良いように思います (つまり、ポインタレシーバーを実装するのが望ましい)。

もし、やむを得ない事情 (他の型との利用感の統一等) で値として使わせたいが、コピーセマンティクスで壊れるデータを内包しているのならば、メンバーにポインタ変数を持たせることになります。

example.go
type FooInner struct{ 何かコピーで壊れるもの }
type Foo struct{
    ptr *FooInner
}

【参考資料】

2. c. for ループ内の変数キャプチャ

Goにおいて、クロージャーは変数をリファレンスとしてキャプチャします (クロージャーから変数に代入できます)。 range を使ったときに制御文のスコープ寿命がループ 1回 なら良かったのでしょうが、残念ながら for を抜けるまでの寿命であるため bug を埋め込みやすくなっています (言語仕様を単純化しすぎだと思います)。

example.go
type Item struct {
    a int
}

func() {
    items := [...]Item{
        {a: 3},
        {a: 5},
        {a: 7},
    }
    for i, v := range items {
        defer func() {
            fmt.Println("defer", i, v)
        }()
        go func() {
            fmt.Println("go", i, v)
        }()
    }
}()

// defer 2 {7}
// defer 2 {7}
// defer 2 {7}
// go 2 {7}
// go 2 {7}
// go 2 {7}

Goroutine の方は go vet (linter) に警告されましたが、 defer の方はされませんでした。

for のループ各回のスコープでループ変数を別の変数に代入するか、クロージャーに対してパラメーターで渡せば、ループ時点の値がキャプチャされます。

example.go
func() {
    items := [...]Item{
        {a: 3},
        {a: 5},
        {a: 7},
    }
    for i, v := range items {
        ii, vv := i, v
        defer func() {
            fmt.Println("defer", ii, vv)
        }()
        go func() {
            fmt.Println("go", ii, vv)
        }()
    }
}()

// defer 2 {7}
// defer 1 {5}
// defer 0 {3}
// go 2 {7}
// go 0 {3}
// go 1 {5}
example.go
func() {
    items := [...]Item{
        {a: 3},
        {a: 5},
        {a: 7},
    }
    for i, v := range items {
        defer func(i int, v Item) {
            fmt.Println("defer", i, v)
        }(i, v)
        go func(i int, v Item) {
            fmt.Println("go", i, v)
        }(i, v)
    }
}()

// defer 2 {7}
// defer 1 {5}
// defer 0 {3}
// go 2 {7}
// go 0 {3}
// go 1 {5}

defer の場合、クロージャーにキャプチャさせなければ、 defer 内について各変数の値は、 defer 実行時の値となります。

example.go
func() {
    items := [...]Item{
        {a: 3},
        {a: 5},
        {a: 7},
    }
    for i, v := range items {
        defer fmt.Println("defer", i, v)
    }
}()

// defer 2 {7}
// defer 1 {5}
// defer 0 {3}

2. d. Channel のデッドロック条件、 Channel のバッファサイズ

Channel は (なぜか使い方をミスして) panic を起こしやすい気がします。

Channel の送受信はバッファに書けない/バッファから読めない状況で、他の「待機状態 (= Channel, I/O, Mutex等待ち) ではない」アクティブな goroutine が存在しないと、ランタイムが deadlock として検知し panic を発生させます。

example.go
func() {
    fmt.Println("start")

    ch := make(chan string) // バッファサイズ = 0
    go func() {
        ch <- ""  // main側が受信するまでブロックされる
        fmt.Println("sub")
    }()

    // goroutineがバッファ上限までchに書き込むまで待つタイミング調整
    // (本番で使うコードでこのようなタイミング調整をしてはならない)
    time.Sleep(time.Second * 3)

    for {
        <-ch // ループの2回目で、送信する可能性のある他のgoroutineがいないため
	     // deadlockしpanic
        fmt.Println("main")
    }
    time.Sleep(time.Second * 3) // sub側出力待ちのタイミング調整
}()

// start
// main
// sub
// fatal error: all goroutines are asleep - deadlock!
example.go
func() {
    fmt.Println("start")

    ch := make(chan string, 1) // バッファサイズ = 1
    go func() {
        ch <- ""
        fmt.Println("sub")
        ch <- ""  // main側が受信するまでブロックされる
        fmt.Println("sub")
    }()

    time.Sleep(time.Second * 3)
    for {
        <-ch // ループの3回目で、送信する可能性のある他のgoroutineがいないため
	     // deadlockしpanic
        fmt.Println("main")
    }
    time.Sleep(time.Second * 3)
}()

// start
// sub
// main
// main
// sub
// fatal error: all goroutines are asleep - deadlock!

deadlock が起こるということは、goroutine 間のシーケンスに誤りや考慮漏れがあるからです。
deadlock 検出だけでは判らないバグもあるので、使用し終わった Channel は速やかに close() すべきです。

example.go
func() {
    fmt.Println("start")

    ch := make(chan string) // バッファサイズ = 0
    var chSend chan<- string = ch
    go func() {
        chSend <- ""  // main側が受信するまでブロックされる
        close(chSend)
        fmt.Println("sub")
    }()

    time.Sleep(time.Second * 3)
    for data := range ch {
        fmt.Println("main" + data)
    }
    time.Sleep(time.Second * 3)
}()

// start
// main
// sub

なお、 Channel をノンブロッキングで読み書きするためには、 default 付きの select 文で Channel に対して読み書きします。

example.go
func() {
    fmt.Println("start")

    chNoReceivers := make(chan string) // バッファサイズ = 0
    ch := make(chan string) // バッファサイズ = 0

    go func() {
        select {
        case chNoReceivers <- "":
            fmt.Println("sub: sent!")
        default:
            fmt.Println("sub: default")
        }
        ch <- ""
        fmt.Println("sub")
    }()

    time.Sleep(time.Second * 3)
    LOOP: for {
        select {
        case <-ch:
            fmt.Println("main")
        default:
            fmt.Println("break")
            break LOOP;
        }
    }
    time.Sleep(time.Second * 3)
}()

// start
// sub: default
// main
// break
// sub

【参考資料】

2. e. panic

panic が発生するとプロセス全体が異常終了させられてしまうので、 ユーザーが渡す任意の関数を実行するような goroutine では、予防として goroutine 関数内で必ず recover() を書いておくべきです。

example.go
// 何かフレームワークの一部として提供するメソッド
func (ctx *SomeFrameworkContext) Go(goBlock GoFn) {
    defer func() {
        if e := recover(); e != nil {
            fmt.Println(e)
        }
    }()
    goBlock()
}

2. f. 並列実行における変数へのアクセス、 Mutex によるロック

goroutine はアクティブな場合において、OSのスレッドによって実行されます。Node.js のようなシングルスレッドのイベントループによる並行 (Concurrent; 論理的な同時実行) 処理ではなく、本当に同時に並列 (Parallel; 物理的な同時実行) 処理されます。

【volatile な 1 変数へのアクセス】

コンパイラはプログラムのコードを最適化します。たとえコンパイラの最適化オプションを最低にしても、メインメモリは CPU にとって遅すぎるので、メモリへのアクセスは最小にしてレジスタ上で処理を進めるコードを出力します。そのため他のスレッドによって書き換えられた変数を (少し前の処理で格納した) レジスタから読もうとするかもしれません。最適化を有効にすれば (自身のスレッドしか存在しなければ) 作用のない処理は除去されます。

C では volatile という型修飾子があり、これが付いた型のデータへのアクセスはコンパイラが最適化しません。つまり、無意味なアクセスの除去 (文や複文の除去を含む) やレジスタからの使い回しを行いません。

【不可分操作 (atomic operation) と逐次一貫性 (sequential consistency)】

同時に実行されるということは、例え 1 つのポインタと同サイズ (32bit or 64bit) の変数のインクリメントであっても正しく実行される (インクリメントした回数だけ +1 される) とは限りません。

ポインタと同サイズのアラインメントされた変数は一般的に 1 命令で読み込みまたは書き込みできます。さらに、 amd64 アーキテクチャではメモリアドレスに対して 1 命令でインクリメントする INC 命令がありますが、それでも何回かのインクリメントは他のスレッドとバッティングして上書きされてしまいます。

さらに、CPUアーキテクチャによってはアウト・オブ・オーダー実行によって 1 つ以上のメモリアドレスへの Read または Write アクセスの順序が保証されない (プログラムに書いた通りの順序で実行されない) ケースがあります。

例えば、 amd64 アーキテクチャでは volatile 変数へのアクセス *ptr_to_volatile_a = 1; v = *ptr_to_volatile_b; の順序が保証されないため、複数CPU間で v の値を元に処理するアルゴリズムを用いた場合に予期しない結果となります。 (例: ピーターソンのアルゴリズム)

amd64 アーキテクチャでは 命令前後のメモリ整合を保証するメモリフェンス命令を用いるか、 lock 命令とメモリアクセス命令を組み合わせる、あるいは XCHG 系命令を用いることでメモリアクセスの順序を保証できます。
(他のアーキテクチャでも概ね同様の機能を持ちます。)

Go では sync/atomic パッケージの Add*, Compare*, Load*, Store*, Swap* 関数を使用することで volatile アクセスの問題も不可分操作・逐次一貫性の問題も解決できます。
(sync/atomic パッケージの処理の多くはアセンブラで書かれています。)

example.go
package main

import (
    "fmt"
    "sync/atomic"
    "time"
)

func main() {
    var flag int32 = 0

    go func() {
        for atomic.LoadInt32(&flag) == 0 {
            fmt.Print(".")
            time.Sleep(time.Nanosecond * 1)
        }
        fmt.Println("done!")
    }()

    time.Sleep(time.Nanosecond * 5)
    atomic.StoreInt32(&flag, 1)

    time.Sleep(time.Second * 1)
}
// .....done!

インターフェースは 1 つの値に見えますが、内部的に 2 つのポインタから構成されているので、不可分操作として変数に代入するためには sync/atomic パッケージの Value を用いて取得・代入します。 Value は内部でスピンロックを使って 2 変数の整合性を保っています。それぞれのポインタ変数の読み込み・書き込みは sync/atomic パッケージ内の各関数を用いています。

example.go
package main

import (
    "fmt"
    "sync/atomic"
    "time"
)

type Foo struct {
    a int
    b int
}

func main() {
    var config atomic.Value

    go func() {
        for {
            c := config.Load()
            if c == nil {
                fmt.Print(".")
            } else if c.(Foo).a == 0 {
                fmt.Print("*")
            } else {
                fmt.Println("done!")
                return
            }
            time.Sleep(time.Nanosecond * 1)
        }
    }()

    time.Sleep(time.Nanosecond * 3)
    config.Store(Foo{})
    time.Sleep(time.Nanosecond * 3)
    config.Store(Foo{ a: 1, b: 1 })

    time.Sleep(time.Second * 1)
}
// ...****done!

【Mutexによるロック】

sync/atomicValue が行っているスピンロックによる排他はごく短時間であれば効率的ですが、タイトループを回しているので時間が掛かる処理には向きません。代わりに sync パッケージの Mutex を利用します。Read アクセスと Write アクセスを分けてロックできる RWMutex も用意されています。

example.go
package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    mutex := sync.Mutex{}

    go func() {
        mutex.Lock()
        fmt.Println("sub: get lock.")
        mutex.Unlock()
        fmt.Println("sub: unlocked.")
    }()

    mutex.Lock()
    fmt.Println("main: get lock.")
    time.Sleep(time.Nanosecond * 5)
    mutex.Unlock()
    fmt.Println("main: unlocked.")

    time.Sleep(time.Second * 1)
}
// main: get lock.
// main: unlocked.
// sub: get lock.
// sub: unlocked.

ただ、標準パッケージの Mutex はロックを取れないときにブロックされないようにしたり、タイムアウト付きで待ち受けたり、他の Channel と一緒に待つことができないので、自分で再実装すると捗ります。

example.go
package sync

import (
    "time"
)

type TryableMutex struct {
    ch chan struct{}
}

func NewTryableMutex() *TryableMutex {
    m := TryableMutex{}
    m.ch = make(chan struct{}, 1)
    return &m
}

func (m *TryableMutex) ChannelForLock() chan<- struct{} {
    var ret chan<- struct{} = m.ch
    return ret
}

func (m *TryableMutex) Lock() {
    m.ch <- struct{}{}
    // Lock acquired
}

func (m *TryableMutex) Unlock() {
    <-m.ch
    // Lock released
}

func (m *TryableMutex) TryLock() bool {
    select {
    case m.ch <- struct{}{}:
        // Lock acquired
        return true
    default:
        // Lock not acquired
        return false
    }
}

func (m *TryableMutex) TryLockWithTimeout(d time.Duration) bool {
    select {
    case m.ch <- struct{}{}:
        // Lock acquired
        return true
    case <-time.After(d):
        // Lock not acquired
        return false
    }
}

【参考資料】

さいごに

Go らしいプログラムを書くための金言集
Go Proverbs