この記事は
Rob Pike氏が提唱したGoの設計哲学やベストプラクティスを完結にまとめたGo Proverbsですが意外と日本語訳している記事が少なかったので、まとめてみようと思います。
少し長くなりそうなので、前編・後編くらいに分けて書いていこうかなと思います。
それぞれ元ネタのyoutubeのリンクを貼っているので、気になったものがあれば是非そちらも見てみてください。
内容
メモリを共有することで通信するのではなく、通信することでメモリを共有せよ
原文: Don't communicate by sharing memory, share memory by communicating.
これはGoの並行処理における思想を説明しています。
javaやpythonなど従来の言語では、複数のスレッドが同じメモリ領域を共有し、ロック(mutexなど)を使って同期しています。これはデータ競合やデッドロックのリスクが高いアプローチになります。
そこでGoではゴルーチン間でチャネルを使ってメッセージをやり取りするアプローチをとりました。これにより、データの所有権が明確で、安全にデータをやり取りできるようになります。すごいですね。(小並感)
詳しくは他記事をご参照ください。
個人的にはこの記事がとても分かりやすかったです。
https://zenn.dev/farstep/articles/f712e05bd6ff9d
並行性は並列性ではない
原文: Concurrency is not parallelism.
ここでは並行性と並列性の違いについて解説されています。
- 並行性
- 複数のタスクを同時に進めるように見せること
- タスクの切り替えによって実現されており、効率的なリソース利用が目的
- 実行順序が保たれる
- 1人のウェイターが複数のテーブルを順番に回るイメージ
- 並列性
- 複数のタスクを物理的に同時に実行すること
- 複数のゴルーチンを同時実行し複数のCPUコアを使用しており、処理速度の向上が目的
- 複数のウェイターがそれぞれ別のテーブルを担当するイメージ
- 実行順序が保たれない
と言う違いらしいです。簡単なコードで書くとこんな感じです。
// 並行処理
func concurrencyExample() {
ch := make(chan int)
go func() {
for i := 0; i < 5; i++ {
ch <- i
time.Sleep(100 * time.Millisecond)
}
close(ch)
}()
for num := range ch {
fmt.Println("Concurrent:", num)
}
}
// 並列処理
func parallelismExample() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
fmt.Println("Parallel:", n)
}(i)
}
wg.Wait()
}
チャネルはデータの流れを調整し、ミューテックスは直列化する必要がある
原文: Channels orchestrate; mutexes serialize.
チャネルとミューテックスという二つのメモリの安全性を保つための仕組みについて、特性を理解した上で目的に応じて使い分けようね、と言うことが書かれています。
- チャネル: ゴルーチン間のデータの流れを管理するための仕組み
- データの所有権が明確
- チャネルが自動的に同期を取る
- 明示的なロックは不要
- ただし、データの流れを設計する必要がある
- ミューテックス(排他制御): 共有リソースへのアクセス制御を管理するための仕組み
- 共有リソースへのアクセスを制御
- 明示的なロック/アンロックが必要
- デッドロックのリスクがある
- クリティカルセクションを意識する必要がある(直列化する必要がある)
普段単一のゴルーチンで十分なアプリケーションを開発しているのでちゃんと腹落ちしておらず、もし詳しい方いらしゃったら補足お願いします。
インターフェースが大きいほど、抽象化が弱くなる
原文: The bigger the interface, the weaker the abstraction.
インターフェースが大きくなれば目的が曖昧になり、柔軟性や再利用性が失われるということが書かれています。
そもそもインターフェースは実装の詳細に依存せず必要な振る舞いを抽象化することで、依存性の逆転、テスト容易性、拡張性を担保する
ことを目的に利用されます。
しかし、インターフェースが大きくなると
- 特定の機能に焦点が当たらず、抽象化の目的がぼやける
- 特定の機能だけを使いたい場合でも、大きなインターフェース全体に依存し、再利用性が低下する
- 多くのメソッドのテストが必要
というデメリットが生じます。
例えば以下のようなインターフェースを定義されても、ファイルの処理でしか利用できず、再利用性は薄くなってしまっています。
type File interface {
Read(p []byte) (n int, err error)
Write(p []byte) (n int, err error)
Seek(offset int64, whence int) (int64, error)
Close() error
Stat() (os.FileInfo, error)
}
以下のようにインターフェースを小さく設計すれば、読み取り専用の用途でも使え、操作対象を意識せず実装することができます。操作対象をファイルからコンソールに切り替える際も影響範囲が少なく済みそうですね。
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
type Closer interface {
Close() error
}
少し話が逸れますが、クリーンアーキテクチャが普及し、とりあえず依存性逆転の原則を実現するためにインターフェースを定義しよう
とする開発者が多くなったような印象を持っています。意味のないインターフェースは可読性を落とすだけなので、きちんとその必要性を吟味し設計していきたいところです。
ゼロ値を有用にせよ
原文: Make the zero value useful.
ゼロ値がその型の基本的な状態として有用であるべきで、そのことを意識して設計すれば以下のようなメリットがあるよ、と言うことが書かれていました。
- 明示的な初期化が不要
- ゼロ値がその型の基本的な状態として表現できる
個人的にはgRPCのインターフェースにフィールドを追加する時、特にゼロ値のありがたさを感じていたりします。
interface{} は何も語らない
空のインターフェースは何の情報も提供しないので慎重に使おうね、と言うことが書かれています。
interface{}は型情報も振る舞いも表現できておらず、コードの意図が不明確になってしまいます。
使うとすると以下のように型アサーションが必要になるので、特別な事情がない限り使うのは控えた方が良さそうですね。
func Process(data interface{}) {
// 型アサーションが必要
if str, ok := data.(string); ok {
fmt.Println("String:", str)
} else if num, ok := data.(int); ok {
fmt.Println("Number:", num)
} else {
fmt.Println("Unknown type")
}
}
感想
並列処理周りは理解が浅いのでちゃんと勉強しないとなあ、と言う気持ちになりました。
定期的に基本に立ち返るのは大切ですね。気持ちが離れないうちに後編も書き切りたい所存です。