Go 동시성 문제 해결 방법

독수리박박·2023년 10월 7일
0

프로그래밍에서 동시성이 왜 필요한가?

동시성 프로그래밍은 최근 여러 언어 및 개발 환경에서 인기 있는 주제입니다. 기존의 무어의 법칙 하드웨어가 발전과 함께 발열,경제성 및 성능 부분에서 한계가 보이기 시작했습니다. 따라서 멀티 코어 프로세서가 각광을 받기 시작했습니다.

하지만 여러개의 프로세서를 동시에 사용한다는 것은 각각의 프로서세의 작업 순서 지정이나 범위 제한 등 많은 어려움이 존재했습니다.

이렇게 멀티 코어 프로세서가 제공하는 환경을 잘 활용할 수 있는 프로그래밍이 점차 각광 받기 시작하면서 동시성, 병렬성이란 개념은 점점 중요한 주제로 자리잡고 있습니다.

동시성 vs 병렬성

대충 본다면 동시성과 병렬성은 비슷한 느낌의 단어들입니다.

하지만 이 동시성과 병렬성은 명확한 차이가 존재합니다.

동시성은 여러 일을 한꺼번에 다루는 데 관한 것이다.
병렬성은 여러 일을 한꺼번에 처리하는 데 관한 것이다

동시성(Concurrency)

  • 여러 개의 논리적 통제 흐름을 갖습니다.
  • 병렬로 실행될 수도 있고 아닐 수도 있습니다.

병렬성(Pararrelism)

  • 연산을 한꺼번에 실행함으로써 순차적인 처리에 비해서 성능을 높입니다.

이 그림은 동시성과 병렬성에 차이에 대해 잘 보여주고 있습니다.

동시성은 논리적인 순서를 지정해 줘서 마치 한번에 여러개의 프로그램이 돌아가는 것 처럼 보이게 하는 것 입니다. 즉 소프트웨어 단위에서 프로그램의 실행 순서나 접근을 제어해서 프로그램이 한번에 돌아가는 것 처럼 보이게 하는 것 입니다.

이에 반해 병렬성은 2개 이상의 다른 하드웨어를 통해서 한번에 직접 여러가지 작업을 수행하는 것 입니다.

동시성의 어려움

이 처럼 동시성은 프로그램의 수행 절차를 제어하는 부분에서 상당히 어렵습니다.
아래에서 실제 예시를 보면서 설명하겠습니다.

레이스 컨디션

레이스 컨디션이란 둘 이상의 작업이 실행중인 과정에서 논리적인 진행 순서가 보장되지 않아서 일어나는 현상입니다.

package main

import (
	"fmt"
	"runtime"
)

func main() {
	fmt.Println("CPUs : ", runtime.NumCPU())
	fmt.Println("Go routines : ", runtime.NumGoroutine())

	counter := 0
	const gs = 100

	for i := 0; i < gs; i++ {
		go func() {
			v := counter
			v++
			counter = v
		}()
		fmt.Println("Go routines : ", runtime.NumGoroutine())
	}
	fmt.Println("Go routines : ", runtime.NumGoroutine())
	fmt.Println("Counter : ", counter)
}

이 코드를 보면 고 루틴이 100번 실행되는데 각각의 고 루틴들은 작업 순서를 보장 받지 못해 제 각각 실행이 되고 변수에 대한 접근 시도를 동시에 하기 때문에 많은 에러 및 올바른 결과(v,counter == 100)을 보장 받지 못합니다.

따라서 해당 코드를 실행 시킨다면 실행할 떄마다 각기 다른 값들이 출력 될 것 입니다.

이처럼 작업 순서 및 제어에 관한 부분을 논리적으로 완벽함을 추구해야 레이스 컨디션을 완벽하게 관리할 수 있습니다.

레이스 컨디션을 해결한 예제 코드를 보겠습니다.

package main

import (
	"fmt"
	"runtime"
	"sync"
)

// 아래의 코드를 실행시켜 보면 go routine의 개수가 오르락 내리락하고 Counter도 100이 아닌 숫자가 계속 바뀔 것이다.
// 이런 이유는 경쟁상태에 있어서이다

func main() {
	fmt.Println("CPUs : ", runtime.NumCPU())
	fmt.Println("Go routines : ", runtime.NumGoroutine())

	counter := 0
	var wg sync.WaitGroup
	const gs = 100
	wg.Add(gs)
	for i := 0; i < gs; i++ {
		go func() {
			v := counter
			// 1초 기다린다
			// time.Sleep(time.Second)
			// 다른 go routine에게 양보(yield)
			runtime.Gosched()
			v++
			counter = v
			wg.Done()
		}()
		fmt.Println("Go routines : ", runtime.NumGoroutine())
	}
	wg.Wait()
	fmt.Println("Go routines : ", runtime.NumGoroutine())
	fmt.Println("Counter : ", counter)
}


// go run -race로 실행해보면 1개의 레이스가 있다. 레이스가 실행되고 있는 이유는 다수의 Go routine이 있기 때문이다.

중간 중간에 코드를 추가했습니다.

sync.WaitGroup을 사용해서 go routine들에 대해 작업 순서를 보장하려 하고 있습니다. 함수가 종료되기 전에 Wait()로 모든 고 루틴이 실행이 끝날때 까지 기다리고 있고 100개의 고 루틴이 존재한다고 윗 부분에 선언해 두었기 때문에 100개의 고 루틴이 끝나기 전까지는 main 함수가 종료되지 않을 것 입니다. 그리고 runtime의 Gosched를 사옹하여 작업이 어느 정도 끝난 고 루틴은 다른 고 루틴에게 해당 작업에 대한 권한을 양보할 수 있습니다.

원자성

동시성 프로그래밍에서 원자성이란 더 이상 논리적으로 나눌 수 없는 연산의 단위를 이야기 합니다.

number++

이 한줄의 코드에서도 여러가지 작업이 일어나게 됩니다.
1. 변수에 접근한다.
2.값을 증가시킨다.
3.값을 저장한다.
이렇게 3부분으로 나눌 수 있기 때문에 이런 부분까지 고려해야 여러 작업들이 겹쳐서 원하지 않는 결과를 가져오는 상황을 방지해야 합니다.


package main

import (
	"fmt"
	"runtime"
	"sync"
	"sync/atomic"
)

func main() {
	fmt.Println("CPUs : ", runtime.NumCPU())
	fmt.Println("Go routines : ", runtime.NumGoroutine())

	var counter int64
	var wg sync.WaitGroup
	const gs = 100
	wg.Add(gs)

	for i := 0; i < gs; i++ {
		go func() {
			atomic.AddInt64(&counter, 1)
			fmt.Println("Counter\t", atomic.LoadInt64(&counter))
			runtime.Gosched()
			wg.Done()
		}()
		fmt.Println("Go routines : ", runtime.NumGoroutine())
	}
	wg.Wait()
	fmt.Println("Go routines : ", runtime.NumGoroutine())
	fmt.Println("Counter : ", counter)
}

위의 코드는 원자성을 고려하여 동시성과 관련된 문제를 해결한 예제 코드 입니다.
값을 가져오고 값을 더하는 상황을 모두 나누어 실행시켜 동시에 많은 고 루틴들이 하나의 작업에 접근할 수 없게 만들었습니다.

뮤텍스

또 하나의 방법으로는 아예 코드를 잠궈버려 다른 고 루틴들이 접근하지 못하게 하는 방법이 있습니다.


package main

import (
	"fmt"
	"runtime"
	"sync"
)

func main() {
	fmt.Println("CPUs : ", runtime.NumCPU())
	fmt.Println("Go routines : ", runtime.NumGoroutine())

	counter := 0
	var wg sync.WaitGroup
	const gs = 100
	wg.Add(gs)

	var mu sync.Mutex

	for i := 0; i < gs; i++ {
		go func() {
			// 코드를 잠궈서 counter 변수에 아무도 접근하지 못하게한다. == 동시에 여러개의 루틴이 접근 불가능
			// v++ -> counter = v로 넘어가는 과정에서 만약 다른 go routine이  v:= counter를 실행하게 되면서 값이 늘어난 것이 다시 원상태로 돌아온 것이다.
			mu.Lock()
			v := counter
			runtime.Gosched()
			v++
			counter = v
			// 동작이 끝나면 잠금을 해제한다.
			mu.Unlock()
			wg.Done()
		}()
		fmt.Println("Go routines : ", runtime.NumGoroutine())
	}
	wg.Wait()
	fmt.Println("Go routines : ", runtime.NumGoroutine())
	fmt.Println("Counter : ", counter)
}

go func 부분을 보면 mu.Lock()을 사용하여 코드를 잠궈서 아무도 해당 변수 및 작업에 대해 접근을 못하게 하는 것을 볼 수 있다. 작업이 끝나면 잠금을 해제하여 다시 다른 고루틴들이 작업을 할 수 있도록 합니다.


go에서 동시성과 관련된 문제를 해결하는 방법에 대해서 알아 보았다. go는 멀티 코어 프로세스를 활용한 개발이 가능하기 때문에 go 언어를 학습하는 과정에서 매우 중요한 파트였고 실제 프로젝트를 하면서도 꾸준히 고려해야하는 문제라고 생각했다. 이 부분에 대해서 추후에 더 많은 학습이 필요할 것 같다..!

0개의 댓글