Open7

Go 1.22 の math/rand と ChaCha8

SpiegelSpiegel

Go 1.22 の math/rand と追加された math/rand/v2 について調べてみる。

おそらく Go 1.22 の疑似乱数生成器に関して最大のトピックは ChaCha8 がランタイムにおける疑似乱数生成の既定アルゴリズムになったことだろう。

ChaCha8 に関してはとりあえず以下の記事を参考にするのがいいのか?

ChaCha 自体はストリーム暗号の一種で,延々と疑似乱数を生成して,それを平文と XOR するというものらしい。 ワンタイムパッド暗号だな。 このうちの疑似乱数を生成する部分を切り出しているようだ。ストリーム暗号に使うものなので,暗号技術的にセキュアでかつ速いというのが特徴になるだろうか。

ChaCha の後ろについている 20 とか 8 とかはラウンド数を示しているらしい。

ChaCha は OpenSSL か OpenSSH でも見かけたような。結城浩さんが『暗号技術入門』の第4版を出される機会があれば,付録でいいので是非 ChaCha にも言及して欲しい。

SpiegelSpiegel

まず ChaCha8 の本体は以下にコーデイングされている。

  • src/internal/chacha8rand/chacha8.go

中身については割愛。これを runtime パッケージが使っている。ちょっと長いけどご容赦。

src/runtime/rand.go
// OS-specific startup can set startupRand if the OS passes
// random data to the process at startup time.
// For example Linux passes 16 bytes in the auxv vector.
var startupRand []byte

// globalRand holds the global random state.
// It is only used at startup and for creating new m's.
// Otherwise the per-m random state should be used
// by calling goodrand.
var globalRand struct {
    lock  mutex
    seed  [32]byte
    state chacha8rand.State
    init  bool
}

var readRandomFailed bool

// randinit initializes the global random state.
// It must be called before any use of grand.
func randinit() {
    lock(&globalRand.lock)
    if globalRand.init {
        fatal("randinit twice")
    }

    seed := &globalRand.seed
    if startupRand != nil {
        for i, c := range startupRand {
            seed[i%len(seed)] ^= c
        }
        clear(startupRand)
        startupRand = nil
    } else {
        if readRandom(seed[:]) != len(seed) {
            // readRandom should never fail, but if it does we'd rather
            // not make Go binaries completely unusable, so make up
            // some random data based on the current time.
            readRandomFailed = true
            readTimeRandom(seed[:])
        }
    }
    globalRand.state.Init(*seed)
    clear(seed[:])
    globalRand.init = true
    unlock(&globalRand.lock)
}

// readTimeRandom stretches any entropy in the current time
// into entropy the length of r and XORs it into r.
// This is a fallback for when readRandom does not read
// the full requested amount.
// Whatever entropy r already contained is preserved.
func readTimeRandom(r []byte) {
    // Inspired by wyrand.
    // An earlier version of this code used getg().m.procid as well,
    // but note that this is called so early in startup that procid
    // is not initialized yet.
    v := uint64(nanotime())
    for len(r) > 0 {
        v ^= 0xa0761d6478bd642f
        v *= 0xe7037ed1a0b428db
        size := 8
        if len(r) < 8 {
            size = len(r)
        }
        for i := 0; i < size; i++ {
            r[i] ^= byte(v >> (8 * i))
        }
        r = r[size:]
        v = v>>32 | v<<32
    }
}

これは ChaCha8 の状態(主にシード)を管理してる部分かな。最初のシードは乱数デバイスから取ってるんだね。これに失敗すると時刻から生成する,と。ユーザ側は明示的にシードを指定する必要がないということやね。

src/runtime/rand.go
// readTimeRandom stretches any entropy in the current time
// into entropy the length of r and XORs it into r.
// This is a fallback for when readRandom does not read
// the full requested amount.
// Whatever entropy r already contained is preserved.
func readTimeRandom(r []byte) {
    // Inspired by wyrand.
    // An earlier version of this code used getg().m.procid as well,
    // but note that this is called so early in startup that procid
    // is not initialized yet.
    v := uint64(nanotime())
    for len(r) > 0 {
        v ^= 0xa0761d6478bd642f
        v *= 0xe7037ed1a0b428db
        size := 8
        if len(r) < 8 {
            size = len(r)
        }
        for i := 0; i < size; i++ {
            r[i] ^= byte(v >> (8 * i))
        }
        r = r[size:]
        v = v>>32 | v<<32
    }
}

// bootstrapRand returns a random uint64 from the global random generator.
func bootstrapRand() uint64 {
    lock(&globalRand.lock)
    if !globalRand.init {
        fatal("randinit missed")
    }
    for {
        if x, ok := globalRand.state.Next(); ok {
            unlock(&globalRand.lock)
            return x
        }
        globalRand.state.Refill()
    }
}

// bootstrapRandReseed reseeds the bootstrap random number generator,
// clearing from memory any trace of previously returned random numbers.
func bootstrapRandReseed() {
    lock(&globalRand.lock)
    if !globalRand.init {
        fatal("randinit missed")
    }
    globalRand.state.Reseed()
    unlock(&globalRand.lock)
}

// rand32 is uint32(rand()), called from compiler-generated code.
//go:nosplit
func rand32() uint32 {
    return uint32(rand())
}

// rand returns a random uint64 from the per-m chacha8 state.
// Do not change signature: used via linkname from other packages.
//go:nosplit
//go:linkname rand
func rand() uint64 {
    // Note: We avoid acquirem here so that in the fast path
    // there is just a getg, an inlined c.Next, and a return.
    // The performance difference on a 16-core AMD is
    // 3.7ns/call this way versus 4.3ns/call with acquirem (+16%).
    mp := getg().m
    c := &mp.chacha8
    for {
        // Note: c.Next is marked nosplit,
        // so we don't need to use mp.locks
        // on the fast path, which is that the
        // first attempt succeeds.
        x, ok := c.Next()
        if ok {
            return x
        }
        mp.locks++ // hold m even though c.Refill may do stack split checks
        c.Refill()
        mp.locks--
    }
}

// mrandinit initializes the random state of an m.
func mrandinit(mp *m) {
    var seed [4]uint64
    for i := range seed {
        seed[i] = bootstrapRand()
    }
    bootstrapRandReseed() // erase key we just extracted
    mp.chacha8.Init64(seed)
    mp.cheaprand = rand()
}

mrandinit() 関数でランタイムを初期化して,それを使って実際に乱数を取得してるのが rand() 関数だね。ふむふむ。

SpiegelSpiegel

math/rand パッケージでは,ランタイムに組み込んだ疑似乱数生成器を以下のコードでリンクしている。

src/math/rand/rand.go
//go:linkname runtime_rand runtime.rand
func runtime_rand() uint64

go:linkname ディレクティブの説明については割愛する。こうやってリンクしてるということで飲み込んでください(笑)

これを使って runtimeSource を定義している。

src/math/rand/rand.go
// runtimeSource is an implementation of Source64 that uses the runtime
// fastrand functions.
type runtimeSource struct {
    // The mutex is used to avoid race conditions in Read.
    mu sync.Mutex
}

func (*runtimeSource) Int63() int64 {
    return int64(runtime_rand() & rngMask)
}

func (*runtimeSource) Seed(int64) {
    panic("internal error: call to runtimeSource.Seed")
}

func (*runtimeSource) Uint64() uint64 {
    return runtime_rand()
}

Seed メソッドを呼び出したら panic 吐くとか容赦ないな(笑)

実際に runtimeSource がどう使われるかというと,こんな感じ。

src/math/rand/rand.go
// globalRandGenerator is the source of random numbers for the top-level
// convenience functions. When possible it uses the runtime fastrand64
// function to avoid locking. This is not possible if the user called Seed,
// either explicitly or implicitly via GODEBUG=randautoseed=0.
var globalRandGenerator atomic.Pointer[Rand]

var randautoseed = godebug.New("randautoseed")

// globalRand returns the generator to use for the top-level convenience
// functions.
func globalRand() *Rand {
    if r := globalRandGenerator.Load(); r != nil {
        return r
    }

    // This is the first call. Initialize based on GODEBUG.
    var r *Rand
    if randautoseed.Value() == "0" {
        randautoseed.IncNonDefault()
        r = New(new(lockedSource))
        r.Seed(1)
    } else {
        r = &Rand{
            src: &runtimeSource{},
            s64: &runtimeSource{},
        }
    }

    if !globalRandGenerator.CompareAndSwap(nil, r) {
        // Two different goroutines called some top-level
        // function at the same time. While the results in
        // that case are unpredictable, if we just use r here,
        // and we are using a seed, we will most likely return
        // the same value for both calls. That doesn't seem ideal.
        // Just use the first one to get in.
        return globalRandGenerator.Load()
    }

    return r
}
src/math/rand/rand.go
// Seed uses the provided seed value to initialize the default Source to a
// deterministic state. Seed values that have the same remainder when
// divided by 2³¹-1 generate the same pseudo-random sequence.
// Seed, unlike the [Rand.Seed] method, is safe for concurrent use.
//
// If Seed is not called, the generator is seeded randomly at program startup.
//
// Prior to Go 1.20, the generator was seeded like Seed(1) at program startup.
// To force the old behavior, call Seed(1) at program startup.
// Alternately, set GODEBUG=randautoseed=0 in the environment
// before making any calls to functions in this package.
//
// Deprecated: As of Go 1.20 there is no reason to call Seed with
// a random value. Programs that call Seed with a known value to get
// a specific sequence of results should use New(NewSource(seed)) to
// obtain a local random generator.
func Seed(seed int64) {
    orig := globalRandGenerator.Load()

    // If we are already using a lockedSource, we can just re-seed it.
    if orig != nil {
        if _, ok := orig.src.(*lockedSource); ok {
            orig.Seed(seed)
            return
        }
    }

    // Otherwise either
    // 1) orig == nil, which is the normal case when Seed is the first
    // top-level function to be called, or
    // 2) orig is already a runtimeSource, in which case we need to change
    // to a lockedSource.
    // Either way we do the same thing.

    r := New(new(lockedSource))
    r.Seed(seed)

    if !globalRandGenerator.CompareAndSwap(orig, r) {
        // Something changed underfoot. Retry to be safe.
        Seed(seed)
    }
}

つまり,環境変数 GODEBUG で明示的に指定(randautoseed=0)するか最初に rand.Seed() 関数を呼び出すかしない限りランタイムに組み込んだ ChaCha8 の疑似乱数生成器が有効になるっちうわけだ。ちなみに lockedSource は math/rand パッケージで定義されている従来の疑似乱数生成器で,ちゃんと Mutex で排他処理している。

SpiegelSpiegel

では,いよいよ math/rand/v2 パッケージを見てみる。

math/rand パッケージとの違いのひとつは Source interface だろう。 math/rand の Source interface はこんな感じ。

src/math/rand/rand.go
// A Source represents a source of uniformly-distributed
// pseudo-random int64 values in the range [0, 1<<63).
//
// A Source is not safe for concurrent use by multiple goroutines.
type Source interface {
    Int63() int64
    Seed(seed int64)
}

// A Source64 is a [Source] that can also generate
// uniformly-distributed pseudo-random uint64 values in
// the range [0, 1<<64) directly.
// If a [Rand] r's underlying [Source] s implements Source64,
// then r.Uint64 returns the result of one call to s.Uint64
// instead of making two calls to s.Int63.
type Source64 interface {
    Source
    Uint64() uint64
}

これが math/rand/v2 ではこんな感じにシンプルになった。

src/math/rand/v2/rand.go
// A Source is a source of uniformly-distributed
// pseudo-random uint64 values in the range [0, 1<<64).
//
// A Source is not safe for concurrent use by multiple goroutines.
type Source interface {
    Uint64() uint64
}

Seed() メソッドがなくなったのは大きいね。つまり,何らかの seed を与える必要がある場合は(ChaCha8 の実装のように)状態管理で暗黙的に seed を与えるか,ソース生成時にパラメータとして seed を与えるような作りにしないとダメかもしれない。

もうひとつ。 math/rand/v2 パッケージでは Read メソッドがなくなった。トップレベルの rand.Read() 関数もなくなった。ただし math/rand パッケージでも rand.Read() 関数は Deprecated になってるので当然の成り行きかもしれない。リリースノートを見ると Read() 関数が使いたきゃ crypto/rand パッケージの rand.Read() 関数を使えみたいなことが書いてあるな。

SpiegelSpiegel

math/rand/v2 パッケージでは疑似乱数生成器のアルゴリズムとして,これまで述べた ChaCha8 の他に PCG (Permuted Congruential Generator) が実装された。

線形合同法(LCG)のバリエーションだが LCG の統計学上の欠点を改善したものだそうで,かなり性能はいいらしい。当然ながら暗号技術分野では使えない。

ChaCha8 の Source は以下で生成できる。

src/math/rand/v2/chacha8.go
import "internal/chacha8rand"

// A ChaCha8 is a ChaCha8-based cryptographically strong
// random number generator.
type ChaCha8 struct {
    state chacha8rand.State
}

// NewChaCha8 returns a new ChaCha8 seeded with the given seed.
func NewChaCha8(seed [32]byte) *ChaCha8 {
    c := new(ChaCha8)
    c.state.Init(seed)
    return c
}

math/rand/v2 でもトップレベルの各関数のソースは math/rand と同じくランタイムの ChaCha8 実装を使っているので,明示的に ChaCha8 を Source にすることはないんじゃないのかなぁ。

PCG の Source は以下で生成できる。

src/math/rand/v2/pcg.go
// A PCG is a PCG generator with 128 bits of internal state.
// A zero PCG is equivalent to NewPCG(0, 0).
type PCG struct {
    hi uint64
    lo uint64
}

// NewPCG returns a new PCG seeded with the given values.
func NewPCG(seed1, seed2 uint64) *PCG {
    return &PCG{seed1, seed2}
}

なお math/rand/v2 の rand.Rand は

src/math/rand/v2/rand.go
// A Rand is a source of random numbers.
type Rand struct {
    src Source
}

// New returns a new Rand that uses random values from src
// to generate other random values.
func New(src Source) *Rand {
    return &Rand{src: src}
}

などと定義されていて math/rand と同じく並行的に安全(concurrency safe)ではないと思われ(要検証)。ただし ChaCha8 のランタイム実装はそれ自体が排他処理を行うようになっているので,トップレベルの関数を使う限りは大丈夫。

SpiegelSpiegel

math/rand/v2 パッケージに N[intType]() Generics 関数が追加された。

src/math/rand/v2/rand.go
// N returns a pseudo-random number in the half-open interval [0,n) from the default Source.
// The type parameter Int can be any integer type.
// It panics if n <= 0.
func N[Int intType](n Int) Int {
    if n <= 0 {
        panic("invalid argument to N")
    }
    return Int(globalRand.uint64n(uint64(n)))
}

type intType interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64 |
        ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}

これで任意の整数型に対して [0,n) の範囲で乱数を生成できる。