[Go] Go에서 랜덤값 생성하기
목차
Golang을 사용해서 숫자 맞추기 게임을 만들어보았다. 게임을 만들면서 rand 패키지를 사용하여 랜덤값을 생성하였는데, 이때 학습한 내용을 간단히 정리하고자 한다.
rand.Intn(range)
math 패키지의 rand.Intn(range) | 0 < range 함수를 사용하면 랜덤 한 int를 생성할 수 있다. 이때 range의 범위는 half-open [0, range)이다.
여기서 주의할 점은 rand 패키지를 통해 생성되는 랜던값은 완전한 랜덤이 아닌 유사 랜덤값이다.
유사 랜덤이란 어떤 알고리즘에 의해서 마치 랜덤인 것처럼 보이는 값을 말한다. 이는 컴퓨터의 논리 회로와 산술 연산은 같은 입력에 대해서 같은 결괏값을 만들어내기 때문이다.
그렇기 때문에 단순히 rand.Intn(range) 함수만 사용하면 매번 같은 값만 생성된다. 이는 랜덤값을 산출할 때 랜덤 시드(seed)라고 하는 초기값에 기반해서 값을 생성하기 때문이다. 즉, 함수를 실행할 때마다 다른 랜덤값이 산출되기 위해서는 매번 변경되는 값으로 시드를 설정해 주어야 한다. 시드를 설정하는 방법은 아래와 같다.
rand.New(rand.NewSource(time.Now().UnixNano()))
위의 코드에서는 일반적으로 많이 사용하는 time 패키지의 시간을 이용했다. UnixNano() 메서드를 사용하면 현재 시간을 나노초 단위, 타입은 float64 값으로 반환한다. UnixNano() 는 UTC 시간 기준으로 1970년 1월 1일부터 계산한 시간이다.
NewSource() 함수는 주어진 값으로 시드 된 새로운 pseudo-random Source를 반환한다. 반환된 소스는 Source64를 구현한다.
// 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)
}
위와 같이 시간으로 시드를 설정하면 rand.Intn(range) 함수는 지속적으로 변하는 시간값을 사용해서 매번 다른 랜덤값을 생성한다.
그렇다면 랜덤값 즉, 난수를 생성하는 실체는 누구일까?
잠시 rand.Intn(n) 함수를 따라가 보자.
// rand.go
func Intn(n int) int { return globalRand().Intn(n) }
rand.go 패키지에서 rand.Intn(n) 함수를 확인할 수 있는데, 내부적으로 globalRand()라는 전역 함수를 사용하고 있다. gloablRand()의 반환값은 Rand 타입의 구조체인데 다음과 같다.
// rand.go
// A Rand is a source of random numbers.
type Rand struct {
src Source
s64 Source64 // non-nil if src is source64
// readVal contains remainder of 63-bit integer used for bytes
// generation during most recent Read call.
// It is saved so next Read call can start where the previous
// one finished.
readVal int64
// readPos indicates the number of low-order bytes of readVal
// that are still valid.
readPos int8
}
Rand 타입은 내부에 Source를 가지고 있는 것을 확인할 수 있고, 이 Source가 이전에 봤었던 NewSoruce에서 반환한 시드가 적용된 Source이다. 그리고 내부적으로 함수를 추적하면 Source가 난수를 생성하는 실체라는 것을 확인할 수 있다.
// rand.go
func (r *Rand) Int63() int64 { return r.src.Int63() }
// rng.go
// Int63 returns a non-negative pseudo-random 63-bit integer as an int64.
func (rng *rngSource) Int63() int64 {
return int64(rng.Uint64() & rngMask)
}
rand.Intn(range) 함수가 매번 새로운 난수를 생성할 수 있도록 NewSource()를 통해서 시드값을 시간 지정하고 내부적으로 난수를 생성하는 실제 객체에 대해서 알아보았다.
하지만 여기서 주의해야 할 점이 있다. 최상위 함수에서 사용하는 기본 Source와 달리 NewSource()에서 반환한 Source는 여러 고루틴에서 동시에 사용하기에는 안전하지 않다는 것이다.
그렇다면 왜 최상위 함수는 고루틴에서 안전한 것일까? 잠시 이유를 찾아보자.
lockedSource & rngSource
위에서 rand.Intn(range) 함수는 내부적으로 globalRand() 전역 함수를 사용하는 것을 확인해 보았다. 이때 globalRand() 함수의 내부를 살펴보면 lockedSource를 통해서 Rand 객체의 초기화 로직을 확인할 수 있다. 앞서 살펴본 것과 같이 Rand 구조체 내부의 Source가 난수를 생성할 것이고, 이는 lockedSource가 그 역할을 수행할 것이라고 유추할 수 있다.
func globalRand() *Rand {
...
var r *Rand
if randautoseed.Value() == "0" {
randautoseed.IncNonDefault()
r = New(new(lockedSource)) // <-----
r.Seed(1)
} else {
r = &Rand{
src: &fastSource{},
s64: &fastSource{},
}
}
...
return r
}
그리고 lockedSource 내부를 살펴보면 최상위 함수가 고루틴에서 안전하다는 이유를 알 수 있는데, 다음과 같이 sync.Mutex를 가지고 있기 때문이다.
type lockedSource struct {
lk sync.Mutex
s *rngSource
}
lockedSource는 sync.Mutex를 사용하여 Int63 및 Seed 메서드에서 고루틴 간의 동시성을 안전하게 제어하고 있는 것을 확인할 수 있다. 따라서 최상위 함수(rand.Intn 등)는 여러 고루틴에서 안전하게 호출할 수 있는 것이다.
func (r *lockedSource) Int63() (n int64) {
r.lk.Lock()
n = r.s.Int63()
r.lk.Unlock()
return
}
func (r *lockedSource) Seed(seed int64) {
r.lk.Lock()
r.seed(seed)
r.lk.Unlock()
}
하지만 NewSource()를 통해서 Source를 생성하게 되면 lockedSource가 아닌 rngSource가 생성되고, rngSource는 내부적으로 sync.Mutex를 사용하여 동시성 제어를 하지 않는다. 그렇기 때문에 NewSource()를 통해서 반환된 Source는 고루틴에서 동시에 접근 시 안전하지 않다는 것이다.
func (rng *rngSource) Seed(seed int64) {
rng.tap = 0
rng.feed = rngLen - rngTap
seed = seed % int32max
if seed < 0 {
seed += int32max
}
if seed == 0 {
seed = 89482311
}
x := int32(seed)
for i := -20; i < rngLen; i++ {
x = seedrand(x)
if i >= 0 {
var u int64
u = int64(x) << 40
x = seedrand(x)
u ^= int64(x) << 20
x = seedrand(x)
u ^= int64(x)
u ^= rngCooked[i]
rng.vec[i] = u
}
}
}
만약, 여러 고루틴에서 안전하게 사용하려면 NewSource()로 생성한 Source 객체를 lockedSource로 랩핑해 주어야 한다. 또는 math/rand 대신 crypto/rand 패키지를 사용하는 것이 좋다.
즉, 최상위 함수는 내부적으로 sync.Mutex를 통해서 동시성 제어를 하기 때문에 여러 고루틴에서 안전하게 호출될 수 있지만, NewSource()에서 반환된 Source는 sync.Mutex를 사용하지 않기 때문에 여러 고루틴에서 호출 시 안전하지 않다는 것이다.
참조
- https://pkg.go.dev/math/rand#Source
- https://pkg.go.dev/math/rand#NewSource