Go 언어는 동시성 프로그래밍에 매우 특화된 언어다. 이러한 특징은 Go 언어가 탄생하게 된 유래에서도 관련이 있다. 지금은 매우 당연하게 사용되고 있는 멀티 코어 프로세서가 상용으로 세상에 나온지는 사실 생각보다 그리 오래되지 않았다. 2005 년에서 2006 년 사이에 인텔에서 상용 듀얼 코어 프로세서를 출시했다. 그리고 바로 2007년, Google 에서는 이러한 멀티 코어의 이점을 언어에서 native 레벨로 챙길 수 있는 언어로 Go 언어를 개발하기 시작하였다.
실제로 Go 언어로 멀티스레딩 코드를 만들어보면 이를 체감할 수 있다. 다른 프로그래밍 언어에 비해서 비교적 쉽게 동시성을 다룰 수 있도록 지원하고 있다. Java 의 경우처럼 Runnable
인터페이스를 구현하거나 Thread
를 상속받아서 run
메소드를 작성할 필요없이, 별도의 스레드로 실행할 함수 앞에 go
라는 키워드만 붙여주면 된다.
이러한 문법적 편리성은 여기서 그치지 않는다. 각 스레드들끼리의 데이터 공유 수단으로 channel
이라는 빌트인 타입을 만들어 제공하고 있다. channel
은 queue
와 그 쓰임새가 매우 유사하다. enqueue
/ dequeue
기능이 있고 선입선출의 순서를 가진다. 그럼 Go 언에서 이 channel
이란 타입을 빌트인으로 만들어서 제공한 이유는 뭘까? 그냥 표준 패키지에서 구현해도 되는 자료구조를 왜 언어의 native 기능으로 추가했을까?
Go 언어에서 channel
개념을 학습하다보면 꼭 접하게 되는 문장이 있다.
Do not communicate by sharing memory; instead, share memory by communicating
언뜻보면 금방 이해가 안가는 문장이다. 하지만 이해하고 나면 Go 언어에서 동시성 문제를 얼마나 중요하게 다뤘는지에 대해 알 수 있다.
간단한 샘플 코드를 짜보자.
var counter = 0
func main() {
for i := 0; i < 100; i++ {
go increaseCounter()
}
fmt.Println(counter)
}
func increaseCounter() {
counter += 1
}
간단한 내용의 코드다. counter
변수를 증가시키는 로직을 수행하는 increaseConter
라는 함수가 있고 메인 스레드에서는 이 함수를 각각 별도의 스레드로 100번 실행시킨 후에 counter
변수의 값을 실행한다. 직관적으로는 당연히 counter
의 값이 100이 될 것 같지만 사실 그렇지 않다. 내 맥북을 기준으로 결과값은 80 후반대에서 90 초반대의 값이 출력된다. 하지만 만약 이 코드를 싱글코어 머신에서 실행하면 값은 의도한 대로 100이 나올 것이다.
이렇게 되는 이유는, counter
라는 공유 자원을 각각의 스레드에서 동시에 접근하게 되기 때문이다. 듀얼 코어 이상의 머신에서는 counter
를 증가시키는 코드가 동시에 여러 스레드에서 실행되는 케이스가 발생하고, 이 때문에 counter
의 값이 오롯이 100 이 안되는 것이다.
멀티스레드 프로그래밍을 하면서 가장 많이 하게 되는 실수 중 하나다. 각 스레드들간 공유하고 있는 자원을 적절하게 핸들링하지 못하게 되는 케이스다. 이런 문제를 해결하는 가장 간단한 방법은 Lock 을 이용하는 것이다. 공유자원에 접근하기 전에 먼저 lock 을 걸고 자원 사용이 끝나면 다시 unlock 을 호출하는 것이다. Go 언어에서도 Mutex 라는 구조체를 통해 이러한 lock 을 이용한 접근을 지원하고 있기는 하다.
사실 이러한 방법은 Go 언어에서 지양해야 하는 접근 중 하나이다. 위의 문장에서도 Do not communicate by sharing memory
라고 이야기하고 있다. 본 예제 코드에서는 counter
라는 메모리 상의 자원을 스레드들간에 공유한 것이기 때문이다.
이제 channel
을 이용해서 다시 짜보자.
func main() {
ch := make(chan int)
go receiver(ch)
sender(ch)
}
func receiver(ch chan int) {
counter := 0
for value := range ch {
counter += value
fmt.Println(counter)
}
}
func sender(ch chan int) {
for i := 0; i < 100; i++ {
ch <- 1
}
}
이 코드는 두 개의 스레드에서 실행된다. 하나는 receiver
함수를 실행시키면서 channel
을 통해 들어오는 데이터를 계속해서 받아서 counter
지역변수에 계속 더하고 있고, 다른 하나는 메인 스레드로 실행되면서 sender
함수를 실행시켜 1이라는 데이터를 계속해서 channel
을 통해서 보내고 있다.
이 코드에서 각 스레드들은 오직 channel
만을 공유하도록 했다. 앞의 코드처럼 별도의 공유자원을 가지지 않도록 말이다. 이렇게 되면 별도의 lock 을 이용한 접근이 필요없게 된다. channel
을 통해 communicating 함으로써 데이터를 공유하도록 한 셈이다.
멀티스레드 프로그래밍는 사실 생각보다 까다롭고 신경써야 하는 부분들이 많다. 제일 문제가 많이 발생하는 경우들이 이러한 공유자원을 잘못 핸들링해서 생기는 경우다. 공유 자원 문제를 방지하려고 lock 를 사용하면 코드는 복잡해지고 가독성을 잃으면서 다시 다른 문제를 유발하기 십상이다. 또한 매번 lock 을 걸고 빼고 하면서 성능 문제가 생기기도 쉽다.
이러한 문제를 애초부터 방지하기 위해 Go 언어에서는 goroutine 생성과 channel 이라는 타입을 빌트인으로 넣어가면서 까지 동시성 프로그래밍에서의 베스트 프랙티스를 가이드해주고 있는 셈이다.