반응형
Recent Posts
Recent Comments
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Archives
Today
Total
관리 메뉴

Bbaktaeho

[Go/Concurrency] goroutine에서 slice에 append하는 여러가지 방법 (동시성 프로그래밍에서 주의할 점) 본문

프로그래밍 (Programming)/고랭 (Golang)

[Go/Concurrency] goroutine에서 slice에 append하는 여러가지 방법 (동시성 프로그래밍에서 주의할 점)

Bbaktaeho 2022. 9. 18. 15:54
반응형
  • 2022-11-10: 채널도 내부에서 mutex 이용

들어가며

동시성 프로그래밍을 할 때 독립적인 로직이 아니라면 예기치 못한 오작동을 맛볼 수 있습니다.

심하면 에러를 추적하기도 어려운데요. 특히 여러 고루틴을 수행 후 결과를 slice로 가져올 때 바로 문제에 직면할 수 있었습니다.

이번 글에서 여러 고루틴에서 로직이 수행된 후 slice에 안전하게 append 하는 법을 알아보겠습니다.

단순하게 작성한 코드

단순하게 slice를 선언하고 고루틴에 slice를 append 하도록 코드를 짜 보면 어떨까요?

func main() {
	size := 10
	arr := make([]int, 0, size)

	var w sync.WaitGroup
	w.Add(size)
	for i := 0; i < size; i++ {
		go func(data int) {
			defer w.Done()
			arr = append(arr, data)
		}(i)
	}
	w.Wait()

	log.Println(len(arr)) // 10개가 되야 하는데..
}

초기 slice를 10개가 들어갈 수 있는 용량으로 설정하고 10개의 고루틴을 실행시킨 후 slice의 요소 개수를 확인했습니다.

실행 결과

몇 번 실행해보면 원하는 결과 값이 안나오는 것을 볼 수가 있습니다.

size를 1,000,000 으로 설정하고 결과를 확인해보겠습니다.

100만 개로 변경한 코드
실행 결과

100만 개 근처도 못가는 모습이네요.

이러한 문제는 같은 자원을 동시에 점유하는 문제로서 동시성 프로그래밍을 할 때 주의해야 할 점 중 하나입니다.

위 로그를 출력한 이미지를 보면 3, 6이 slice에 append될 때 6은 추가되지 않은 모습을 볼 수 있습니다.

이러한 현상은 6과 3이 같은 자원에 접근해서 slice 즉, 배열 인덱스에 값이 추가될 때 두 고루틴이 동일한 인덱스 주소를 바라보고 있어서 발생한 현상입니다.

6, 3을 append 하는 고루틴 (동일한 자원에 접근)

따라서 18이 추가될 때 크기는 19로 마무리가 됩니다.

Mutex 사용하기

Mutex는 자원에 대한 상호 배제 접근을 보장해줍니다.

slice에 append 하는 부분을 고루틴이 동시 접근을 하지 못하도록 코드를 작성하겠습니다.

func testingCode1(size int) {
	start := time.Now()
	arr := make([]int, 0, size)

	var w sync.WaitGroup
	var m sync.Mutex
	w.Add(size)
	for i := 0; i < size; i++ {
		go func(data int) {
			defer w.Done()

			m.Lock()
			arr = append(arr, data)
			m.Unlock()
		}(i)
	}
	w.Wait()

	if size == len(arr) {
		log.Println(size, time.Since(start))
	} else {
		log.Fatal()
	}
}

mutex는 데이터가 아닌 arr = append(arr, data) 코드 블록 자체의 비동기 실행을 방지해줍니다.

size 1000 ~ 1000000 결과

이제 안전하게 append 할 수가 있게 되었습니다.

동기 Channel 사용하기

buffer channel을 이용해서 채널에 잠시 담아뒀다가 꺼내서 slice로 옮길 수 있습니다.

func testingCode2(size int) {
	start := time.Now()
	arr := make([]int, 0, size)
	bufferChannel := make(chan int, size) // buffer channel

	var w sync.WaitGroup
	w.Add(size)
	for i := 0; i < size; i++ {
		go func(data int) {
			defer w.Done()
			bufferChannel <- data
		}(i)
	}
	w.Wait()

	close(bufferChannel)
	for data := range bufferChannel {
		arr = append(arr, data)
	}

	if size == len(arr) {
		log.Println(size, time.Since(start))
	} else {
		log.Fatal()
	}
}

buffer channel을 이용하면 채널에 buffer 공간을 활용하여 deadlock을 발생시키지 않습니다.

따라서 비동기로 처리하지 않고 buffer size 만큼 담아뒀다가 고루틴의 로직이 모두 끝나고 나서 처리해도 됩니다.

채널을 닫지 않고 열려있다면 append를 수행하는 channel range에서 deadlock이 발생하므로 더 이상 데이터가 채널로 들어오지 않는 게 보장된다면 close 하고 쓰는 게 유용합니다.

size 1000 ~ 1000000 결과

문제없이 잘 추가된 모습을 확인할 수 있습니다.

비동기 Channel 사용하기

이전 buffer channel 처럼 담아뒀다가 나중에 처리하는 것이 아닌, 즉시 비동기로 처리할 수 있도록 channel을 이용해보겠습니다.

func testingCode3(size int) {
	arr := make([]int, 0, size)
	channel := make(chan int, size) // buffer channel

	var w sync.WaitGroup
	w.Add(size)
	for i := 0; i < size; i++ {
		go func(data int) {
			channel <- data
		}(i)
	}

	go func() { // A
		for data := range channel {
			arr = append(arr, data)
			w.Done()
		}
	}()
	w.Wait()
	close(channel) // A goroutine을 종료하기 위함

	log.Println(len(arr))
}

채널에 데이터가 들어오자마자 비동기로 arr에 append 하고 있습니다.

WaitGroup의 Done() 함수도 채널에 전송할 때가 아닌, 채널에서 꺼낼 때 Done()을 실행시킴으로써 arr에 모든 데이터가 append 될 수 있도록 보장해줍니다.

여기서 채널 역시 닫아줌으로써 A goroutine을 종료합니다.

size 1000 ~ 1000000 결과

성능 측정 및 비교

위 캡처된 결과 로그를 보면 실행 속도는 큰 차이가 없어 보입니다.

3가지 방식 모두 실행해서 다시 비교해보고 int 타입이 아닌 구조체 타입으로 변경해서 테스트해보겠습니다.

확실한 건 channel을 사용한 코드가 mutex보다 그나마 좋은 성능을 보였습니다.

이번엔 타입을 바꾸고 밴치마크 테스트로 실행해보겠습니다.

 

새로운 타입은 아래와 같습니다.

type TestStruct struct {
	A string
	B string
	C uint64
	D struct {
		Da int64
		Db map[int]string
	}
}

크게 유의미한 차이는 아닌 것 같습니다.

 

마지막으로 밴치마크 코드를 작성 후 비교해보겠습니다.

var table = []struct {
	input int
}{
	{input: 100},
	{input: 1000},
	{input: 10000},
	{input: 100000},
	{input: 1000000},
	{input: 10000000},
}

func BenchmarkCode1(b *testing.B) {
	for _, v := range table {
		b.Run(fmt.Sprintf("1 input_size_%d", v.input), func(b *testing.B) {
			for i := 0; i < b.N; i++ {
				testingCode1(v.input)
			}
		})
	}
}

func BenchmarkCode2(b *testing.B) {
	for _, v := range table {
		b.Run(fmt.Sprintf("2 input_size_%d", v.input), func(b *testing.B) {
			for i := 0; i < b.N; i++ {
				testingCode2(v.input)
			}
		})
	}
}

func BenchmarkCode3(b *testing.B) {
	for _, v := range table {
		b.Run(fmt.Sprintf("3 input_size_%d", v.input), func(b *testing.B) {
			for i := 0; i < b.N; i++ {
				testingCode3(v.input)
			}
		})
	}
}

go test -bench=. 명령어를 실행합니다.

goos: darwin
goarch: arm64
pkg: test-go
BenchmarkCode1/1_input_size_100-8                  47252             31182 ns/op
BenchmarkCode1/1_input_size_1000-8                  2224            523673 ns/op
BenchmarkCode1/1_input_size_10000-8                  214           5616656 ns/op
BenchmarkCode1/1_input_size_100000-8                  19          52640746 ns/op
BenchmarkCode1/1_input_size_1000000-8                  3         494659028 ns/op
BenchmarkCode1/1_input_size_10000000-8                 1        4830442166 ns/op
BenchmarkCode2/2_input_size_100-8                  26709             44874 ns/op
BenchmarkCode2/2_input_size_1000-8                  2337            501800 ns/op
BenchmarkCode2/2_input_size_10000-8                  241           4853410 ns/op
BenchmarkCode2/2_input_size_100000-8                  25          45494685 ns/op
BenchmarkCode2/2_input_size_1000000-8                  3         386271319 ns/op
BenchmarkCode2/2_input_size_10000000-8                 1        4084440084 ns/op
BenchmarkCode3/3_input_size_100-8                  26313             45075 ns/op
BenchmarkCode3/3_input_size_1000-8                  2318            493613 ns/op
BenchmarkCode3/3_input_size_10000-8                  261           4691851 ns/op
BenchmarkCode3/3_input_size_100000-8                  25          47408735 ns/op
BenchmarkCode3/3_input_size_1000000-8                  3         383141389 ns/op
BenchmarkCode3/3_input_size_10000000-8                 1        3969721791 ns/op
PASS
ok      test-go 38.482s

1은 mutex, 2는 동기 채널, 3은 비동기 채널입니다.

코드에서 실행 시간을 측정했던 것과 큰 차이는 없었네요.

마치며

mutex lock 보다 channel을 이용하는 방법이 월등히 뛰어날 줄 알았는데 10만 단위의 데이터까진 큰 차이가 없어서 의아했습니다.

이 포스팅을 통해서 제가 아는 방식보다 유용한 방식이 있는지 좀 더 연구 및 공부가 필요하다고 느꼈습니다.

참고

https://mingrammer.com/gobyexample/mutexes/

https://www.geeksforgeeks.org/buffered-channel-in-golang/#:~:text=Channels%20can%20be%20defined%20as,the%20another%20end%20using%20channels.

https://stackoverflow.com/questions/18499352/golang-concurrency-how-to-append-to-the-same-slice-from-different-goroutines

반응형