誤った並行処理の実装をしていると、string
の比較でもヌルポのセグフォが発生する。
正しく実装していればお目にかかることはないが、とても学びになったのでメモしておく。
TL;DR
- Goの
string
はプリミティブな型でポインタ参照はしていない(ように操作できる) - が、誤った並行処理を行っていると、ヌルポのpanicが発生する
panic: runtime error: invalid memory address or nil pointer dereference
- runtime上で
string
はstringStruct
型で長さと具体的な値へのポインタを持つ - 並行処理の実装が誤っていると、長さを有した状態でヌルポインタなオンメモリデータが生成される
- CPUのレジスタあるいはキャッシュの状態がメモリに半端な状態で反映されるため
サンプルコードは以下。
https://play.golang.org/p/lD5-kBCwET4
package main
func main() {
hello()
}
var a string
func hello() {
go func() { a = "hello" }()
for a != "hello" {
}
print(a)
}
play ground上でも何回か実行すると実際に文字列a
変数をa != "hello"
と比較しているところで以下のような結果が得られる。
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x45eb46]
goroutine 1 [running]:
main.hello()
/tmp/sandbox884085650/prog.go:11 +0x46
main.main()
/tmp/sandbox884085650/prog.go:4 +0x25
stringなのにnil pointer dereference
横浜Go読書会でGoのメモリモデルの勉強をしていた。
- The Go Memory Model
発端は忘れたが、誤った並行処理のサンプルコードを書いていたら文字列の比較でヌルポが発生してしまった。
サンプルコードはTL;DRに書いた先の通り。
メモリモデルと並行処理
資料を読む中で柴田(@yoshiki_shibata)さんから以下の補足があった。
f()を実行するゴルーチンとg()を実行するゴルーチンが、別々のOSスレッド上で実行された場合、コンパイラによる実行命令の入れ替え、あるいはout-of-order CPUのreorder bufferの影響でg()関数内で a をメ モリから読み出したときに、f()関数内でのa = 1の書き込みはメモリに反映されていない可能性があります。
また、メモリモデル本文にも次のような一節がある。
If the channel were buffered (e.g., c = make(chan int, 1)) then the program would not be guaranteed to print “hello, world”. (It might print the empty string, crash, or do something else.)
(チャネル操作を間違えると文字列の値が不定になったりクラッシュする)
ざっくりいうと、CPUのコアはコアごとにキャッシュやレジスタを持っている。キャッシュやレジスタは個別のコアのみアクセス可能である。
なので、マルチコアCPUの場合、その下の層のメモリにまでコアの処理結果が反映されてないと他のコアの変更結果が見えない。
ここで、CPUは必ずしも処理の順でメモリに結果を反映するわけではない。
また、他のゴルーチンがアクセスする際に、メモリへの反映が正しく完了している保証もない1。
では、それがstring
の参照でヌルポが発生するのとどんな関係があるのだろうか?
runtime上での実体確認
まず、runtime上のstring
型はstringStruct
という長さと参照ポインタをもった構造体として処理される。
type stringStruct struct {
str unsafe.Pointer
len int
}
この構造体の動きはreflect#StringHeader
で確認することができる。
先述のコードにreflect#StringHeader
を使ってstringStruct
の状態を出力しつづけるようにしたのが次のコードだ。
https://play.golang.org/p/1oCesyqPjh-
package main
import (
"fmt"
"reflect"
"unsafe"
)
func main() {
hello()
}
var a string
func hello() {
sh := (*reflect.StringHeader)(unsafe.Pointer(&a))
go func() {
for ;a != "hello?" ;{
fmt.Printf("%#v\n", sh)
}
}()
go func() { a = "hello" }()
for a != "hello" {
}
print(a)
}
このコードは何も排他制御をしていないため、hello
関数内のゴルーチンがa = "hello"
とstringStruct
を操作している。
そして、その結果が正しくメモリへ反映されるかされていないかにかかわらず a != "hello"
といった参照を行う。その結果stringStruct.len
の長さが!0
なのにstringStruct.str
がnil
の状態が発生していると考えられる。
出力としては &reflect.StringHeader{Data:0x4bcccf, Len:5}
というデータも取れているが、
実際はメモリ上でLen != 0
の状態でもData
フィールドはnil
の状態になってしまったのだと思われる。
&reflect.StringHeader{Data:0x0, Len:0}
&reflect.StringHeader{Data:0x0, Len:0}
&reflect.StringHeader{Data:0x0, Len:0}
&reflect.StringHeader{Data:0x4bcccf, Len:5}
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x498ece]
goroutine 1 [running]:
main.hello()
/tmp/sandbox091726292/prog.go:23 +0x6e
main.main()
/tmp/sandbox091726292/prog.go:10 +0x25
終わりに
以前はHPCや組み込み系のプログラムを書いていたので、久しぶりにCPUなどを意識した気がする。
普段Webサービスを作る上でCPU上のデータの挙動やメモリモデルまで気にしないが、いつかなにかのデバッグとかで役に立つかなと思う2。
また、今回のようなサンプルコードを動かしてみてやっと一見当たり前のことしか書いていない事前発生(happens before)の意味がわかってきた気がする。
To specify the requirements of reads and writes, we define happens before, a partial order on the execution of memory operations in a Go program. If event e1 happens before event e2, then we say that e2 happens after e1. Also, if e1 does not happen before e2 and does not happen after e2, then we say that e1 and e2 happen concurrently.
詳細は忘れたとしても次の理解だけでもしておくほうがよさそうだ。
- 並行処理を書くときは正しく排他制御をしておくこと
- 文字列型の処理行でセグフォが出たら並行処理の排他制御を疑うこと
余談だが、メモリモデルの文書の冒頭にも「この文書の理解が必要になる"賢い"プログラミングするな」と注意書きがある(とはいえ書かなくても理解しているのが望ましいだろう)。
If you must read the rest of this document to understand the behavior of your program, you are being too clever. Don’t be clever.