본문 바로가기

remote-craft

RemoteCraft 12 - Debugging (feat. Data Race) 실패.

프로젝트가 잘 실행되니... 문제를 찾고 고쳐야 한다!

오늘의 작업은 아직 발견되지 않은 문제점들(?) 중 오늘은 Data-race에 대해 확인해 보았다.

 

Data race란?

A data race occurs when:
1. two or more threads in a single process access the same memory location concurrently, and
2. at least one of the accesses is for writing, and
3. the threads are not using any exclusive locks to control their accesses to that memory.

data race는 다음과 같은 조건을 모두 만족시키는 상황에서 발생한다.

1. 두개 이상의 쓰레드가 동시에 공유자원(메모리)에 접근

2. 그 중 최소 한개의 접근은 data를 쓰기 위함

3. 하나의 쓰레드가 메모리를 점유하는 lock을 사용하지 않음

 

간단하게 말하여 여러 쓰레드가 공유자원에 동시에 접근하려 할 때, 일어나는 경쟁 상황을 말한다.

쓰레드가 공유자원을 안전하게 사용하기 위해 흔히 사용되는 방식은 상호배제(mutual exclusion)를 이용하는 것이다. 공유자원을 여러 쓰레드가 원하는 때 언제든지 접근할 수 있도록 허용하지 않고, 한 쓰레드가 해당 자원을 온전히 점유 하도록 lock을 걸어 잠그는 것이다. 

 

Lock은 결과를 보장하기는 하지만 그에 걸맞는 오버헤드를 발생시킨다.

 

직접 data race를 해결 하기전에 구글링을 했다면 좀 더 빨리 끝났겠지만 역시 직접 부딫혀 보는 것 또 한 즐겁다..!

나의 코드는 역시 data race를 유발하고 있었고 그 디버깅의 과정을 적어보고싶다.

 

 

Debugging

Golang은 data race를 잡아주는 기능이 built-in으로 존재한다. 매우 편리하다.

// -race 플래그만 붙혀서 실행하면 된다.
go run -race ./main.go

수정 전 프로그램을 실행했을때 나오는 data race 경고문이다.

데이터 레이스!

해석해 보자면..

  • Write at 0x00c0003412c8 by goroutine 119에 의하여 room.DeleteRTCSession(): 함수에서 메모리에 대한 쓰기가 발생!
  • Previous read at 0x00c0003412c8 by goroutine 96 room.streamAudio(): 함수에서 읽기 발생!

해당 문제가 발생한 코드다.

r.RTCSessions 수정

// RTCSessions slice에서 i번째 아이템 삭제
r.RTCSessions = append(r.RTCSessions[0:i], r.RTCSessions[i+1:]...)

// RTCSession 슬라이스를 순회하면서 각 세션에 비디오프레임을 보내는 기능
_, rtcSession := range r.RTCSessions {
    ...
}

Go에서 제공하는 sync.Mutex를 이용하여 락을 걸어보자..

 

1. 나쁜 예

 

 

write 부분에 lock을 걸자!

 

read 부분에도 lock을 걸자!

data race는 해결됐다. 하지만 for-loop에서 임계영역을 거는 것은 성능적으로 매우 불리하다. 정말 말도 안될 만큼 느려졌다. 비디오와 오디오의 싱크가 틀어지기 시작하고 input조차도 종종 밀리는 것이 플레이하면서도 느껴질 정도이다. 이건 사용할 수 없다.

임계영역이란 여러개 프로세스가 공유하는 데이터 및 자원에 대하여 어느 한 시점에서는 하나의 프로세스만 자원 또는 데이터를 사용하도록 지정된 공유 자원 으로, 임계영역은 특정 프로세스가 독점할 수 없다.

Mutex의 Lock() 과 Unlock() 사이의 영역을 최소화 하는 리팩토링을 고민 하던중... r.RTCSessions 슬라이스를 접근할때 data race가 일어났으니 r.RTCSessions 슬라이스를 thread-safe하게 만들면 되겠다고 생각했고 라이브러리화한다면 슬라이스 공유 자원이 생길 시에 사용할 수 있다!

 

Thread-safe한 Slice 만들기

보통 슬라이스보다 성능은 떨어져도 data race에 대한 걱정없이 사용 할 수 있는 Slice 구조체를 만들어 보자!

 

Slice가 가지고 있으면 좋은 기능

  1. Append()
  2. Delete()
  3. free-type
  4. iterator

1, 2, 3번은 아래서 보다시피 아주 간단했고! iterator는 꽤나 시간을 많이 잡아먹었다. 효율적인 iterator인지는 또 다른 방식이 생각나면 비교해 보아야 겠지만! 지금은 잘 작동한다! golang에서 제공하는 range 키워드를 그대로 적용해 사용할 수 있으면 코드가 간결해지기 때문에 꼭 만들어 볼 가치 있는 기능이라고 생각했다.

 

ThreadSafeSlice.go

type ThreadSafeSlice struct {
    sync.RWMutex
    items  []interface{}
}

type Item struct {
    Index    int
    Value    interface{}
}

func New() *ThreadSafeSlice {
    slice := &ThreadSafeSlice{
        items: make([]interface{}, 0),
    }
}
func (tss *ThreadSafeSlice) Append(item interace{}) {
    tss.Lock()
    defer tss.Unlock()
    
    tss.items = append(tss.items, item)
}

func (tss *ThreadSafeSlice) Delete(index int) {
    tss.Lock()
    defer tss.Unlock()
    
    if index < 0 || index >= len(tss.items) {
        return
    }
    
    tss.items = append(tss.items[0:index], tss.items[index+1:]...)
}
func (tss *ThreadSafeSlice) Iterate() <-chan Item {
    itemChan := make(chan Item, 1)
    
    go func() {
        tss.RLock()
        defer tss.RUnlock()
        
        for index, item := range tss.items {
            itemChan <- Item{Index: index, Value: value}
        }
        close(itemChan)
    }()
    
    return itemChan
}

 

 

실험결과

Iterator benchmark
benchmark 결과

처참하다... Append는 거의 다르지 않았지만.. Iterator의 속도차이가 보이는가.. 자그마치 200배 느리다. 슬라이스 길이는 5만, 랜덤한 숫자를 가지고 있는 slice of struct로 실험했다.  현재 RemoteCraft에서 오버헤드가 생기는 부분은 끊임없이 비디오와 오디오 데이터를 받는 for-loop에서 []RTCSessions 를 순회한다는 것인데, 여기에 꽤나 부담스러운 오버헤드를 더해 줄 것이다.  스트리밍 부분은 닫힌 채널로 데이터가 전송되어 panic이 일어났을때 그냥 recover()를 통해 goroutine을 재생성하도록 해야겠다..

 

적용 보류.. feature 브랜치 따로 파서 하길 잘했다. 10배 정도만 더 빨라지면 그래도 써볼까 했는데.. 아쉽다..