공대생 정리노트

동시성 성능 테스트 mutex VS chan VS atomic 본문

언어/Go

동시성 성능 테스트 mutex VS chan VS atomic

woojinger 2021. 12. 19. 15:10

Go에서는 동시성을 다룰 수 있는 고루틴이라는 훌륭한 기능이 있다.

다만 여러 고루틴이 한 변수에 동시에 접근할 일이 있을 때 race condition이 생길 수 있다.

이번 포스팅에서는 race condition을 제거하는 방법 3가지의 성능을 비교하려고 한다.

  • Mutex
  • chan
  • atomic

채널은 고루틴 간 통신을 할 수 있는 자료구조이다. 채널의 버퍼가 가득차게 되면 다른 고루틴이 채널에서 받아가기 전까지 블락이 되는 것을 이용하여 race condition을 해결한다.

atomic 패키지는 lock을 하지 않고 한번에 operation을 할 수 있도록 도와준다. 예를 들어 변수 A의 값을 증가시키고 싶을 때 어셈블리어 명령에서는 A의 값을 로드하고, 증가시키고, 저장하는 3개의 스텝으로 이루어진다. atomic 패키지는 하나의 operation으로 처리를 하기 때문에 race condition이 일어날 가능성을 차단한다.

chan

type hchan struct {
	qcount   uint           // total data in the queue
	dataqsiz uint           // size of the circular queue
	buf      unsafe.Pointer // points to an array of dataqsiz elements
	elemsize uint16
	closed   uint32
	elemtype *_type // element type
	sendx    uint   // send index
	recvx    uint   // receive index
	recvq    waitq  // list of recv waiters
	sendq    waitq  // list of send waiters

	// lock protects all fields in hchan, as well as several
	// fields in sudogs blocked on this channel.
	//
	// Do not change another G's status while holding this lock
	// (in particular, do not ready a G), as this can deadlock
	// with stack shrinking.
	lock mutex
}

https://go.dev/src/runtime/chan.go

 

- go.dev

 

go.dev

chan의 소스 코드를 보면 내부 구조에서 mutex를 사용한다. 이를 통해 우리는 mutex보다 chan이 동시성 토제가 더 오래 걸릴 것이라고 추측할 수 있다.

atomic

https://pkg.go.dev/sync/atomic

 

atomic package - sync/atomic - pkg.go.dev

The following example shows how to maintain a scalable frequently read, but infrequently updated data structure using copy-on-write idiom. package main import ( "sync" "sync/atomic" ) func main() { type Map map[string]string var m atomic.Value m.Store(make

pkg.go.dev

atomic 패키지의 add operation은 atomic하게 *addr += delta; return *addr을 수행한다.

테스트 전 주의사항 - time.sleep을 사용하지 말자

// time.Sleep을 이용한 동시성 성능 테스트
for i:=0; i<1000; i++ {
        wg.Add(1)
        go func(){
            defer wg.Done()
            mu.Lock()
            count +=1
            mu.Unlock()
            time.Sleep(30 * time.Millisecond)
            mu.Lock()
            count -=1
            mu.Unlock()
        }()
    }
	wg.Wait()

// time.Sleep을 이용하지 않음
func spendManyTime() int {
    result := 0
    for i:=0; i<100000000; i++ {
        result += 1
    }
    return result
}
for i:=0; i<1000; i++ {
        wg.Add(1)
        go func(){
            defer wg.Done()
            mu.Lock()
            count +=1
            mu.Unlock()
            spendManyTime()
            mu.Lock()
            count -=1
            mu.Unlock()
        }()
    }
	wg.Wait()

 

time.sleep을 하면 golang에서 컨텍스트 스위치를 해주고 timesleep이 끝나면 다시 ownership을 가져간다.

우리가 원하는 것은 고루틴이 30ms 동안 쉬고 작업을 시작하는 것이지만, 실제로는 고루틴은 30ms를 쉬지 않고 컨텍스트 스위치를 하여 다른 작업을 수행한다.

따라서 테스트를 할 시 time.Sleep을 사용하지 말고, 실제로 시간이 많이 걸리는 작업을 만들어서 수행해야 한다.

Mutex VS chan

func main() {
	var mu sync.Mutex
	var wg sync.WaitGroup
	var count int
	// use mutex
	startTime := time.Now()
	for i:=0; i<100; i++ {
		wg.Add(1)
		go func(){
			defer wg.Done()
			mu.Lock()
			count +=1
			mu.Unlock()
		}()
	}
	wg.Wait()
	MutexTime := time.Now().Sub(startTime)

	// use chan
	done := make(chan int)
	count = 0
	go func(){
		for {
			count += <-done
		}
	}()
	startTime = time.Now()
	for i:=0; i<100; i++ {
		wg.Add(1)
		go func(){
			defer wg.Done()
			done <- 1
		}()
	}
	wg.Wait()
	chanTime := time.Now().Sub(startTime)

	fmt.Println("use Mutex : ",MutexTime,"use Chan : ", chanTime)
}

// use Mutex :  336.791µs use Chan :  589.958µs

실험은 100개의 고루틴을 만들고, 각 고루틴이 count 변수를 증가시킨다.

mutex를 이용한 실험은 락을 걸고 count 값을 증가시킨 후 락을 푼다.

chan을 사용한 실험은 별도의 고루틴을 만들어 무한 루프로 done 채널에서 값을 계속 받아온다.

 

실험 결과는 예상했던대로 mutex를 이용한 것이 훨씬 빨랐다.

Atomic Package VS Mutex

func main() {

	jobTime := recordTime(spendManyTime)
	fmt.Println("one job time : ", jobTime, " GOMAXPROCS : ", runtime.GOMAXPROCS(0))

	mutexTime := recordTime(mutexTask)
	fmt.Println("mutex job with 1000 goroutine : ", mutexTime)

	atomicTime := recordTime(atomicTask)
	fmt.Println("atomic job with 1000 goroutine : ", atomicTime)

}

func recordTime(f func() int) time.Duration{
	startTime := time.Now()
	count := f()
	fmt.Println("count : ", count)
	return time.Now().Sub(startTime)
}

func spendManyTime() int {
	result := 0
	for i := 0; i < 100000000; i++ {
		result += 1
	}
	return 1
}

func mutexTask() int {
	var mu sync.Mutex
	var wg sync.WaitGroup
	var count int
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			mu.Lock()
			count +=1
			mu.Unlock()
			spendManyTime()
		}()
	}
	wg.Wait()
	return count
}

func atomicTask() int {
	var atomicCount int64
	var wg sync.WaitGroup
	for i:= 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			atomic.AddInt64(&atomicCount,1)
			spendManyTime()
		}()
	}
	wg.Wait()
	return int(atomicCount)
}
/*
count : 1
one job time :  33.020625ms  GOMAXPROCS :  8
count :  1000
mutex job with 1000 goroutine :  6.240904875s
count :  1000
atomic job with 1000 goroutine :  6.142810625s
*/

spendManyTime 함수는 실행시 33ms의 시간을 소요한다.

mutex를 이용한 작업은 6.24s가 걸렸고, 1000개의 고루틴을 통해 처리하였다. 이때 GOMAXPROCS가 8이므로 각 고루틴의 평균 수행시간은 6.24/1000*8 = 49.92ms라고 판단 가능하다.

atomic 패키지를 이용한 작업은 6.14s가 걸렸으므로 같은 방식으로 49.12ms이다.

엄청난 성능 개선은 없지만, atomic 패키지를 사용하는 것이 mutex를 사용하는 것보다 성능이 뛰어나다는 것을 알 수 있다.

결론

단순 작업의 성능 속도는 atomic>mutex>chan이다.

하지만 Mutex가 당연하게도 atomic보다 활용도가 높고, chan은 Mutex로는 하기 힘든 채널간 통신을 쉽게 구현할 수 있다

프로그래밍을 할 때 현재 자신이 구현하고자 하는 작업이 무엇인지 정확히 파악한 후 그에 맞는 방법을 선택해서 사용하면 될 것이다.

Comments