golang 비동기. synchronization primitive(5). sync.Cond의 동작 원리
해당 아티클은 다음 수준의 지식을 요구합니다.
- 고루틴을 사용해본 경험
다음은 다루지 않습니다.
- sync.Cond을 활용하는 방법
이번에는 sync 패키지에서 사용하는 synchronization primitive 시리즈의 마지막 자료구조인 sync.Cond에 대하여 알아보겠습니다. 일반적으로 sync.Mutex와 sync.WaitGroup, channel과 같은 요소는 많이 사용되지만 sync.Cond는 그만큼 사용되지는 않습니다. 그러나 특정한 경우에는 sync.Cond가 다른 primitive보다 강력하게 사용되는 경우가 있습니다. 또한 동시성 처리에서 성능을 향상시키는 방식으로 사용되기도 합니다. 이번에는 여러 고루틴을 한 번에 깨우는 기본적인 케이스를 간단히 알아보고 golang의 condition variable인 sync.Cond의 내부를 탐색해봅시다.
sync.Cond 소개
sync.Cond는 이벤트를 전달하는 데, 하나의 producer에서 여러 consumer에 이벤트를 전달하고 싶은 경우 사용합니다. 물론 해당 테크닉은 채널을 닫는 것으로 구현할 수 있습니다. 그러나 이번에는 다른 테크닉인 sync.Cond를 이용해봅시다. 가장 기본적인 예시 코드를 살펴보겠습니다.
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
func main() {
n := 3 // consumer 개수
cond := sync.NewCond(&sync.Mutex{}) // mutex와 함께 초기화
ok := false // 확인하고 싶은 조건
var count atomic.Int64 // 결과값
// consumer측
for range n {
go func() {
cond.L.Lock()
for !ok { // 락을 잡고 반복적으로 조건이 만족했는지 확인
cond.Wait() // Broadcast메서드가 호출될 때까지 Wait메서드로 대기
}
cond.L.Unlock()
count.Add(1) // 로직 실행
}()
}
// producer측
time.Sleep(time.Second)
cond.L.Lock()
ok = true // 조건 활성화
cond.Broadcast() // Wait중인 모든 고루틴 활성화
// 만약 대기중인 하나의 고루틴만 깨우고 싶은 경우
// cond.Signal()
cond.L.Unlock()
time.Sleep(time.Second)
fmt.Println(count.Load()) // 3
}
코드에서 cond는 sync.NewCond 함수를 이용하여 mutex로 간단히 초기화됩니다. 내부에서 mutex를 사용하는 로직이 존재하고 조건 제어를 할 때 mutex를 활용하므로 mutex를 이용하여 초기화하는 것을 확인할 수 있습니다. 사용자는 cond.L로 접근할 수 있습니다. 또한 해당 코드에서 2가지의 메서드를 활용하는데 이들의 역할은 다음과 같습니다.
- Wait(): 조건을 다시 체크하기 위해 Broadcast나 Signal이 호출될 때까지 블로킹 대기합니다.
- Broadcast(): Wait() 메서드에서 블로킹 대기 중인 모든 고루틴을 동시에 깨울 때 호출합니다.
실제 코드를 살펴보면 producer측 부분은 크게 예상과 다른 게 없어보입니다. producer측은 다음과 같이 동작을 요약할 수 있습니다.
- 락 획득
- 조건 활성화
- Broadcast
- 락 제거
따라서 코드를 읽어보면 락을 획득하고 일반적인 흐름으로 동작합니다.
한편 consumer측 코드는 그렇지 않습니다. consumer측은 다음과 같이 동작을 요약할 수 있습니다.
- 락 획득
- 루프 내에서 조건절 확인 및 Wait
- 락 제거
- 로직 실행
여기서 가장 이상한 지점은 2번입니다. 락을 획득하고 루프로 진입하면 락을 제거하지 못하여 다른 고루틴이 실행되지 못할 것처럼 보입니다. 왜 이렇게 구현됐는지 살펴보기 전에 sync.Cond 내부를 확인해봅시다.
sync.Cond 내부 동작
sync.Cond 내부 필드는 다음과 같습니다. 내부에서 많이 사용되는 필드로 L인 Locker(Lock과 Unlock이 구현된 인터페이스)와 Wait로 대기하는 고루틴을 저장할 notify 필드가 있습니다.
// https://github.com/golang/go/blob/a10e42f219abb9c5b/src/sync/cond.go#L36
type Cond struct {
noCopy noCopy
// L is held while observing or changing the condition
L Locker
notify notifyList
checker copyChecker
}
한편 sync.Cond의 생성자는 다음과 같습니다. Locker를 외부에서 가져와 필드 L에 저장합니다.
// https://github.com/golang/go/blob/a10e42f219abb9c5b/src/sync/cond.go#L47
// NewCond returns a new Cond with Locker l.
func NewCond(l Locker) *Cond {
return &Cond{L: l}
}
sync.Cond 내부 필드의 notifyList는 동작에 핵심적인 역할을 합니다. 따라서 내부 필드를 살펴볼 필요가 있습니다. 내부는 channel과 마찬가지로 sudog로 연결리스트 구조로 이루어져 있습니다. sync.Cond에 Wait로 대기하는 고루틴은 대기 티켓을 발급받는데 그 부분에 관여하는 필드가 wait과 notify입니다. 고루틴이 Wait으로 대기하게 되면 notifyList의 wait값에 해당하는 티켓을 발급받고 Broadcast로 깨우게 되면 notify보다 작은 티켓값을 가진 고루틴은 활성화 상태로 변하게 됩니다. 이 특성을 이해하고 보면 sync.Cond 내부 동작을 이해하기 쉽습니다.
// https://github.com/golang/go/blob/a10e42f219abb9c5b/src/runtime/sema.go#L503
type notifyList struct {
// wait is the ticket number of the next waiter. It is atomically
// incremented outside the lock.
wait atomic.Uint32 // Wait()가 호출된 고루틴이 발급받을 티켓 넘버: 0부터 시작
// notify is the ticket number of the next waiter to be notified. It can
// be read outside the lock, but is only written to with lock held.
//
// Both wait & notify can wrap around, and such cases will be correctly
// handled as long as their "unwrapped" difference is bounded by 2^31.
// For this not to be the case, we'd need to have 2^31+ goroutines
// blocked on the same condvar, which is currently not possible.
// 다음으로 깨어날 고루틴의 티켓 번호
// notify보다 작은 티켓을 부여받은 고루틴은 활성화됐거나 바로 활성화될 예정
notify uint32
// List of parked waiters.
lock mutex
head *sudog
tail *sudog
}
Wait 메서드입니다. Wait 메서드의 경우 runtime_notifyListAdd라는 함수를 호출하고 그 다음 Unlock, runtime_notifyListWait을 호출 후 Lock을 획득합니다. 따라서 runtime_notifyListAdd함수는 Lock으로 감싸지지만 runtime_notifyListWait함수는 Lock으로 감싸지지 않다는 것을 확인할 수 있습니다. Wait 함수는 락을 잡고 티켓을 발급받은 후 락을 해제하고 대기로 등록합니다.
// https://github.com/golang/go/blob/a10e42f219abb9c5b/src/sync/cond.go#L66
func (c *Cond) Wait() {
c.checker.check()
t := runtime_notifyListAdd(&c.notify) // 티켓 t 발급
c.L.Unlock()
runtime_notifyListWait(&c.notify, t) // 티켓 t로 대기 등록
c.L.Lock()
}
리눅스의 경우 runtime_notifyListAdd는 다음 함수가 호출됩니다. 다음 함수의 역할은 기존에 Wait가 호출된 고루틴의 개수를 리턴하는 것입니다. 기존 Wait가 호출된 고루틴의 개수가 티켓 번호가 됩니다.
// https://github.com/golang/go/blob/a10e42f219abb9c5b/src/runtime/sema.go#L534
func notifyListAdd(l *notifyList) uint32 {
return l.wait.Add(1) - 1
}
또한 리눅스의 경우 runtime_notifyListWait는 다음 함수가 호출됩니다. 코드가 다소 길어 동작을 요약하면 다음과 같습니다.
- 알람 선도착 확인: 대기 중인 티켓번호 t가 이미 활성화된 상태인지(t < l.notify) 검사합니다. 만약 활성화됐다면 빠르게 리턴합니다.
- 알람이 도착하지 않은 경우, 해당 고루틴을 대기에 등록: 대기 상태 등록을 위해 sudog를 만듭니다.
- 고루틴, 티켓, 릴리즈 타임 필드 초기화
- l.head, l.tail 필드 갱신 및 연결리스트에 등록
- 대기
// https://github.com/golang/go/blob/a10e42f219abb9c5b/src/runtime/sema.go#L544
func notifyListWait(l *notifyList, t uint32) {
lockWithRank(&l.lock, lockRankNotifyList)
// Return right away if this ticket has already been notified.
if less(t, l.notify) {
unlock(&l.lock)
return
}
// Enqueue itself.
s := acquireSudog()
s.g = getg()
s.ticket = t
s.releasetime = 0
t0 := int64(0)
if blockprofilerate > 0 {
t0 = cputicks()
s.releasetime = -1
}
if l.tail == nil {
l.head = s
} else {
l.tail.next = s
}
l.tail = s
goparkunlock(&l.lock, waitReasonSyncCondWait, traceBlockCondWait, 3)
if t0 != 0 {
blockevent(s.releasetime-t0, 2)
}
releaseSudog(s)
}
이렇게 Wait의 세부 구현을 살펴봤습니다. 다음은 Broadcast 메서드입니다. Broadcast의 경우 굉장히 단순합니다.
// https://github.com/golang/go/blob/a10e42f219abb9c5b/src/sync/cond.go#L90
func (c *Cond) Broadcast() {
c.checker.check()
runtime_notifyListNotifyAll(&c.notify)
}
리눅스의 경우 runtime_notifyListNotifyAll은 다음 함수가 호출됩니다.
- 대기가 없다면 바로 리턴: 대기하는 고루틴 개수와 이미 활성화된 고루틴 개수를 비교해서 같으면 리턴합니다.
- head, tail 필드 초기화: 연결리스트를 초기화합니다.
- notify를 현재까지 대기했던 고루틴 개수로 갱신
- 연결리스트를 순회하면서 고루틴 상태를 ready상태로 변경
// https://github.com/golang/go/blob/a10e42f219abb9c5b/src/runtime/sema.go#L579
func notifyListNotifyAll(l *notifyList) {
if l.wait.Load() == atomic.Load(&l.notify) {
return
}
lockWithRank(&l.lock, lockRankNotifyList)
s := l.head
l.head = nil
l.tail = nil
atomic.Store(&l.notify, l.wait.Load())
unlock(&l.lock)
for s != nil {
next := s.next
s.next = nil
readyWithTime(s, 4)
s = next
}
}
sync.Cond의 동작은 Channel의 동작 원리를 보고 나서 살펴보면 크게 어렵지 않습니다. sudog를 이용하여 구현되어 있기 때문에 같이 연결리스트를 사용하는 점도 비슷합니다. 그럼 sync.Cond의 동작을 다음으로 간단히 요약할 수 있습니다.
Wait의 경우:
- 락 획득 후 티켓 발급
- 티켓으로 고루틴 대기리스트 등록
Broadcast의 경우:
- 현재까지 발급된 티켓에 대한 고루틴 전부 깨우기 실행
주의: 연결리스트로 고루틴이 저장되었다고 실행도 연결리스트 순으로 일어나지 않습니다. 왜냐하면 고루틴 스케줄러에 의해 작업 훔치기 등의 로직이 활성화되어 실행 순서가 보장되지 않기 때문입니다. 이 부분은 해당 포스팅에서 확인할 수 있습니다.
spurious wakeup
sync.Cond를 사용하는 코드에서 락을 획득한 후 루프에 진입하는 패턴을 채택하는 데는 spurious wakeup이라는 문제가 있습니다. spurious wakeup은 C++에서 condition variables를 사용 시 wait로 대기하던 부분에서 의도치 않게 블로킹이 해제되는 현상을 의미합니다. 여러 자료에 따르면 하드웨어와 OS 최적화 부분이 관여된 문제로 낮은 확률로 발생한다고 합니다. 만약 condition variables를 대기시킨 쓰레드가 갑자기 원치 않게 활성화되어 조건절을 대충 건너뛰고 로직을 실행하면 문제가 발생할 수 있습니다. 따라서 C++11의 경우 condition variables 사용 시 spurious wakeup을 방지하기 위해 활성 조건 점검은 반복절 내부에서 실행하도록 구현하고 있습니다. java의 경우에도 해당 문제가 발생 가능한 것으로 보입니다. golang도 이런 문제에 대해 대응하기 위해 sync.Cond를 위와 같이 반복절에서 조건을 점검하도록 구현하는 것으로 보입니다.
참고 자료
Leave a comment