Tuesday, 29 November 2011

Go 언어의 동시성 By 마소



지난 1회에서 현재 Go언어가 가진 약점을 간략히 소개했었다. 상용 소프트웨어에 Go언어를 적용하려는 개발자에게 Go언어의 로드맵이 정확히 제공되지 않아 일부 개발자는 답답함을 토로하기도 한다는 내용이었다. 버전마다 문법이 달라 지난달에 작성한 코드가 현재 버전에서는 컴파일이 안되는 경우도 있었다. 언어가 초기에 발전해 나가는 시점이라 어쩔 수 없지만 언제쯤 상용 혹은 안정적인 버전이 나올까에 대한 언급마저 없는 것은 필자가 봐도 이해하기는 어려운 점이었다. 이러다 보니 참고할 만한 Go관련 책이 나오기도 힘든 상황이다. 이런 사항들은 Go언어를 아끼는 개발자라면 누구나 느끼는 점이다.

이런 문제들은 Go언어 개발팀에 의해 내년 상반기에 공개될 ‘Go version 1’로 해결될 것으로 기대된다. 한번 작성한 코드는 꽤 오랜 시간 동안 수정 없이 컴파일과 실행을 할 수 있게 될 것이다. 물론 1.1 혹은 1.2 등으로 버전업이 되면서 version 1에서 나온 버그들을 수정해 나갈 것이고 일부 기능은 추가될 것으로 전망된다. 이에 맞춰서 내년 상반기에는 다양한 자료와 관련 프로젝트들이 쏟아져 나올 것이라 예상된다. 이제 곧 Go언어의 안정 버전이 나온다고 하니 이 글을 읽는 독자라면 조금 일찍 준비하는 개발자가 돼 보는 것은 어떨까?
병렬성! 동시성?
병렬성은 <그림 1>과 같이 문제를 여러 연산으로 나눠 이를 프로세서나 코어 혹은 분산 환경에서 동시에 실행한다. 다시 말해 실행이 동시에 수행된다는 것을 의미한다.
<그림 1> 병렬성
동시성은 <그림 2>와 같이 여러 연산들이 동시에 수행돼 이 연산들이 서로 상호작용이 발생할 수 있는 시스템의 특성을 말한다. 연산들은 단일 프로세서나 코어에서 시분할(time-sharing)방법으로 동시에 실행되거나 여러 프로세서나 코어 혹은 분산 환경에서 동시에 실행될 수도 있다. 동시성을 가지는 시스템은 앞서 언급했듯이 연산들 사이에 상호작용이 발생할 수 있으며 이때 공유 리소스에 대해서 교착상태(deadlock)나 기아상태(starvation)와 같은 문제가 야기될 수 있다. 프로그래밍 관점에서 추상화된 개념이라고 생각할 수도 있다.
<그림 2> 동시성
뿌리 찾기

1970년대 후반으로 오면서 멀티프로세서가 연구주제로 떠오르기 시작했다. 멀티프로세서 프로그래밍은 운영체제, 인터럽트, 입출력 시스템, 메시지 전달과 연관이 깊고 이를 통해 아래와 같은 개념들이 도입됐다.
 · 세마포어(Semaphores) (Dijkstra, 1965)
 · 모니터(Monitors)(Hoare, 1974)
 · 뮤텍스와 락 (Mutexes and Locks)
 · 메시지 전달(Message passing) (Lauer & Needham 1979)
1978년 영국의 컴퓨터 과학자인 토니 호아(C. A. R. Hoare)가 CACM 논문에서 처음으로 동시성을 지원하는 시스템에서 상호작용 패턴을 표현하는 언어인 CSP(Communicating sequential processes)를 소개했다. CSP에선 동시성을 커뮤니케이션의 입출력으로 본다. 메모리를 공유하는 방식이 아니라 동기화 방식으로 통신된다. 이후 동시성을 지원하는 개발언어에 지대한 영향을 끼쳤고 지금까지도 관련된 많은 연구가 행해지고 있다. 한편, 토니 호아는 프로그래밍 언어에 공헌을 인정받아 1980년에 튜링상(Turing Award)를 수상했었다.
<그림 3> CSP에 영향을 받은 언어들

<그림 3>을 보면 CSP에서 영향을 받은 언어들을 볼 수 있다. 생소한 언어들도 있고 한 번쯤은 들어본 언어들도 있을 것이다. Occam은 1983년에 발표된 언어로 기본 CSP에 가장 가까운 언어로 인모스(Inmos)라는 영국의 반도체 회사에서 만들었다. Erlang은 CSP의 영향을 받은 함수형 언어로 스위칭 소프트웨어에 적용하기 위해 에릭슨(Ericsson)에서 개발했다. Erlang란 이름은 덴마크의 수학자(Agner Krarup Erlang)의 이름을 따서 지었고 1998년에 오픈소스로 공개됐다.

Newsqueak는 C와 유사한 언어로 동시성 지원을 위한 연구를 목적으로 개발됐다. Limbo는 벨 연구소(Bell Labs)에서 만든 언어로 동시성을 지원하는 분산 시스템을 위해 개발됐다. <그림 3>을 보면 News queak과 Limbo 그리고 Go언어를 같은 박스에 넣어뒀다. 3개 언어는 어떤 연관성이 있을까? 모두 Go언어를 만든 사람 중에 한 명인 롭 파이크(Rob Pike)가 개발한 언어로 채널이라는 개념을 도입했다는 공통점이 있다. 롭 파이크는 80년대부터 동시성을 지원하는 언어를 연구하고 개발했기에 Go언어 동시성은 그의 노력의 결정체라고 할 수 있다.
기초 다지기

본격적으로 Go언어의 기초를 다져보자.
goroutine
Go언어는 동시성을 지원하기 위해 자체적인 goroutine을 갖고 있고 함수를 비동기적으로 실행하는데 사용된다. 이해를 돕기 위해 자바와 C언어에서 goroutine괴 유사한 예제를 살펴보자.
doSomething이라는 함수 혹은 메소드로 스레드를 실행하는 방법을 살펴보자.
<리스트 1> 자바에서 스레드 실행하기
class SimpleThread extends Thread{
     public void doSomething(){
          //...
     }
     public void run() {
          doSomething();
     }
}
public class ThreadTest {
     public static void main(String[] args) {
          new SimpleThread().start();
     }
}

<리스트 1>은 자바에서 스레드를 실행시키는 방법이다. 스레드를 상속하고 run 메소드를 오버라이드한다. 실제로 호출하는 쪽에서는 해당 스레드 인스턴스를 생성하고 start 메소드를 호출하면 새로 생성된 스레드로 doSomething 메소드를 실행시킬 수 있다.
<리스트 2> C언어에서 스레드 실행하기
void *doSomething()
{
     //...
}
int main()
{
    pthread_t thread_t;
     if (pthread_create(&thread_t, NULL, doSomething, NULL) < 0)
     {
          perror("thread create error");
          exit(0);
     }
     return 1;
}

<리스트 2>는 C언어에서 pthread를 선언하고 pthread_create를 이용해서 실행시키는 방법이다. pthread_create 함수의 인자로 실행시킬 함수 포인터를 전달한다. 이렇게 하면 새로 생성한 pthread로 doSomething 함수를 실행시킬 수 있다.
<리스트 3> Go언어에서 goroutine 실행하기
func doSomething(){
     //....
}
func main(){
     go doSomething()
}

<리스트 3>은 Go언어에서 goroutine을 실행시킨다. 문법이 아주 간단하다. goroutine으로 실행시킬 함수를 go 다음에 적으면 된다.

goroutine은 다른 언어에서 사용하고 있는 ‘스레드’나 ‘코루틴(coroutine)’과 유사하지만 다른 속성을 가진다. 동일한 주소 공간에서 다른 goroutine들과 함께 동시에 실행되며 스레드에 비해서 가볍고 스택 주소 공간의 할당이 적다.  
<그림 4> C언어 메모리 구조
C언어 프로그램이 실행될 때 주소 영역은 <그림 4>와 같다. 하단부터 보면 코드 영역, static data 영역이 있고 상단에는 스택 영역이 있다. 힙(heap) 메모리가 필요하면 상단으로  힙 메모리가 증가되고 스택 메모리가 더 필요하면 스택 영역이 아래로 증가된다. 기존의 C언어에서 스택은 메모리 블록의 연속이다. 스레드의 생성과 함께 필요한 최대 크기의 메모리 블록을 할당하며 일반적으로 1MB의 스택 영역을 할당받는다. 스레드의 수명이 짧으면 몇 KB만으로 충분하지만 강제로 1MB가 할당돼 32bit 운영체제에서 메모리 공간이 4GB로 제한되므로 4,000개 이상의 pthread를 만드는 것이 불가능하다.

Go언어의 경우 스택을 링크드 리스트(linked-list)로 관리한다. 따라서 할당받은 공간이 충분하지 않으면 추가로 스택을 늘려달라고 요청해야 한다. goroutine은 매번 커널 스레드를 생성하지 않고 <그림 5> 처럼 일부 커널 스레드를 멀티플렉싱하여 스택을 확장한다. 그러므로 C언어에서 스레드를 매번 생성하는 것보다 goroutine이 효율적이고 더 많은 goroutine을 만들 수 있다.
<그림 5> gorutine의 멀티플렉싱 개념
채널

<리스트 3>의 코드에 doSomething 함수에 문자 출력 소스를 추가해고 실행해도 화면에 아무것도 출력되지 않고 프로그램이 종료된다. goroutine의 동작 여부를 확인할 수 없는 상황이다. 실제 goroutine이 생성됐더라도 goroutine을 생성하고 화면에 문자를 출력하기 전에 main 함수가 끝나버린다.

doSomething 함수를 실행하는 goroutine과 main 함수 사이에 통신이 필요하다. 즉 doSomething은 실행이 완료됐음을 보내야하고 doSomething은 완료됐다는 것을 통보 받아야 한다. <리스트 4>는 상호간 통신을 위한 코드가 추가된 소스로 Go언어에서 gorutine 사이의 통신은 ‘채널’을 통해 이뤄진다. 
<리스트 4> 채널로 통신하는 예
func main(){
     c := make(chan int)
     go func(){
          //..
          c <- 1
     }()
     <- c
     fmt.Printf("main End")
}

<그림 6>은 C언어나 자바에서는 흔히 볼 수 있는 스레드간 메모리를 공유하는 방식이다. 공유 메모리에 각 스레드는 I/O가 가능하며 하나의 스레드만 해당 메모리에 접근 하는 경우 ‘lock’을 이용한다. 물론 Go언어에서도 동일한 방식을 지원한다.
<그림 6> thread에서 메모리 공유
Go언어는 CSP 개념을 이용한 채널로 두 개의 goroutine간에 메모리를 공유할 수 있다. 
<그림 7> goroutie A와 goroutie B 사이의 채널
<그림 7>과 같이 두 개의 goroutine 사이는 채널이 존재한다. 개념적으로 메모리를 공유할 수 있는 추상적인 통로가 존재한다고 생각하면 된다.
<그림 8> goroune A, B 사이의 채널을 통해 동기화로 공유 방법
<그림 8>은 두 goroutine이 공유할 데이터가 있는 경우 채널을 통해 상대 goroutine으로 데이터를 전달할 수 있음을 보여준다. goroutine은 채널을 통해 데이터 ‘V’를 goroutine A에서 goroutine B로 전달한다.

채널은 어떤 속성이 있고 또 어떻게 사용하는지 살펴보자. <리스트 5>와 같이 ‘chan’ 키워드를 사용한다. elementType는 데이터 형으로 만약 ‘chan int’ 라고 선언하면 채널이 int형임을 의미하며 struct도 될 수 있다.
<리스트 5> chan 기본 사용법
chan elementType

<리스트 6>처럼 채널 변수에 make 키워드를 할당하면 참조 타입이 되고 chan 변수를 타른 변수에 할당하면 동일한 채널을 공유하게 된다. 이는 두 개의 변수로 상호간 데이터 공유가 가능한 것으로 마지막 예제에서 보다 자세히 살펴보자.
<리스트 6> Go언어에서 make 키워드의 역할
var c = make(chan int)

<리스트 7>은 이미 변수 선언을 다룬 2회회의 ‘:=’를 통해 채널의 선언 및 할당이 이뤄질 수 있음을 말해준다.
<리스트 7> :=을 통한 선언 및 할당
ci := make(chan int) //int형 채널 생성
cs := make(chan string) //string형 채널 생성

채널은 연산자로 ‘<-’가 사용되며 ‘<-’는 단항연산자(unary operator)와 이항연산자(binary operator)의 두 가지 용도를 갖고 있다. 화살표 방향은 데이터의 흐름을 의미하며 직관적으로 이해하기 쉽다. <리스트 8>은 채널 c ‘<- 1’은 화살표가 가리키는 채널 c로 데이터를 보내겠다는 뜻이다.
<리스트 8> 이항연산자로 사용된 <- (send)
c := make(chan int)
c <- 1 //1을 채널 c로 보낸다.

goroutine간에 통신은 아래와 같은 동기화 특성을 가진다.
1) 보내는 동작 : 받는 쪽이 동일 채널에 대해서 유효할 때까지 블록된다.
2) 받는 동작 : 전달 쪽이 동일 채널에 대해서 유효할 때까지 블록된다.
따라서 goroutine간 통신은 동기화의 형태를 가지며 채널로 연결된 두 개의 goroutine은 통신 시점에서 동기화가 이뤄진다.

메모리 모델
메모리 모델이란? 스레드와 메모리간에 어떻게 상호작용이 이뤄지는지를 설명하거나 컴파일러가 어떤식으로 메모리 관리와 관련된 코드를 만드는지를 설명하는 것이다. Go언어의 메모리 모델은 두 개의 goroutine이 동일한 메모리에 접근할 때의 상호작용을 명시하고 있다.

왜 메모리 모델이 필요한 것일까? 대부분의 컴파일러는 컴파일시 성능 최적화를 위해 처리 순서를 조정하며 순서의 재조정은 논리적으로 전혀 다른 수행을 의미하진 않는다. 단일 스레드 수행 관점에서 동일하게 동작하는 수준의 재조정이 이뤄진다.

그러나 동시성 안에선 여러 스레드들이 동일한 메모리를 I/O할 경우 컴파일러의 순서 재종이 개발자가 의도한 동작을 반드시 보장하진 않는다. 스레드는 디버깅이 어려워 단순히 코드를 읽는 것만으로 실제 동작을 예측하기 쉽지 않고 문제가 된 상황을 재현하기도 만만치 않다. 메모리 모델에 대한 이해는 동시성을 갖는 프로그래밍에서 필수적임을 알아야 한다. 지금부터 Go언어가 제공하는 메모리 모델에 대해서 하나씩 살펴보기로 하자.
초기화 시점

기본적인 초기화 원칙은 단일 goroutine에서 초기화가 이루워는 동안 다른 새로운 goroutine이 생성돼도 기존 goroutine이 초기화를 마칠 때까지 새로운 goroutine은 실행되지 않다는 것이다. 몇 가지 경우에 대해서 구체적으로 알아보자.
· 패키지 p가 패키지 q를 import하면 p패키지 내의 동작은 q패키지의 init함수가 종료된 이후에 실행된다.
· 함수 main.main의 시작은 모든 init함수들이 끝나고 나서 일어난다.
· init함수 실행 동안 생성된 goroutine은 init함수가 완료된 후에 실행이 이루어진다.
goroutine의 생성
<리스트 9> 단항연산자로 사용된 <- (receive)
v = <-c // 채널 c에서 값을 받아서 v에 대입한다
<-c // 채널 c에서 값을 받아 사용하지 않고 버림.
i := <-c // 채널 c에서 값을 받아서 i를 초기화 한다.
<리스트 10>에서 go를 사용한 구문의 실행은 실제로 goroutine으로 실행되기 전 시점에서 실행이 이루어진다. 그러므로 “hello, world”가 출력되는 것을 볼 수 있다. 타이밍으로 예상해 보면 출력되는 시점은 아마 hello() 함수를 빠져나간 이후일 것이다.
goroutine의 소멸
goroutine의 종료 시점은 특정 이벤트 이전에 종료됨을 보장받지 못한다. <리스트 11>에서 예를 들어 a에 ‘hello’를 할당한 이후 다른 goroutine과 동기화 이벤트가 없으므로 print(a)로 ‘hello’가 출력됨을 확신할 수 없다. 의도한 동작을 보장 받으려면 a에 ‘hello’를 할당하거나 해당 goroutine이 종료된 시점에서 출력하는 goroutine에게 갱신된 a를 출력할 수 있다고 알려야 한다. 이는 다음에 다룰 채널을 이용한 동기화 이벤트로 할 수 있다.
<리스트 10> goroutine 실행 시점
var a string
func f() {
     print(a)
}
func hello() {
     a = "hello, world"
     go f()
}
<리스트 11> goroutine 종료 시점
var a string
func hello() {
     go func() { a = "hello" }()
     print(a)
}
채널 통신

goroutine간 동기화할 수 있는 주요 방법은 채널 통신이다. 채널을 생성하면 그 채널을 통해 goroutine간에 I/O를 할 수 있다. 
<리스트12>는 “hello, world”가 출력됨을 보장한다. a에 ”hello, world”를 할당한 것은 채널 c로 보내기 전에 실행된다. 또한 main에서 데이터를 받는 동작도 이미 Go언어 메모리 모델에서 살펴봤듯 a에 “hello, world”를 할당한 이후에 일어난다.
<리스트 12> 채널을 이용한 통신 예제
var c = make(chan int, 10)
var a string
func f() {
      a = "hello, world"
     c <- 0
}
func main() {
     go f()
     <-c
     print(a)
}
Lock
Go언어가 제공하는 sync 패키지에는 두 가지 lock 관련 데이터 타입이 있다. 바로 sync.Mutex와 sync.RWMutex이다.
<리스트 13>은 main함수에서 2번째 l.Lock() 이전에 f함수에서 l.Unlock()이 호출되는 구조로 모두 print(a)가 호출되는 시점 이전이므로 “hello, world”가 출력됨을 보장한다.
<리스트 13> Lock/Unlock을 이용한 동기화
var l sync.Mutex
var a string
func f() {
     a = "hello, world"
     l.Unlock()
}
func main() {
     l.Lock()
     go f()
     l.Lock()
     print(a)
}
Once

다양한 goroutine으로 복잡도가 높을 경우 sync.Once를 이용하면 안전하게 초기화할 수 있다.
<리스트 14>에서 goroutine은 두 번 doprint을 호출하지만 결과적으로 setup 함수는 한 번만 실행되며 setup이 반환될 때까지 대기한다. a는 “hello, world”로 정상적으로 초기화되며 예제는 두 번 출력된다.
<리스트 14> Once 이용하기
var a string
var once sync.Once
func setup() {
     a = "hello, world"
}
func doprint() {
     once.Do(setup)
     print(a)
}
func twoprint() {
     go doprint()
     go doprint()
}
채널을 이용한 소수 구하기

소수의 정의는 1과 수 자신으로만 나누어지는 자연수이다. <그림 9>는 소수를 구하는 방법 중 ‘아리스토텔레스의 체체’를 이용한 방법을 그림으로 표현한 것이다. 
<그림 9> 소수를 구하기 위한 체(sieve)

<리스트 15>는 Go언어 웹사이트에 공개된 채널을 이용한 동기화 방법을 설명하는 예제로 5개의 소수를 출력한다. 그럼 지금까지 배운 Go언어의 동시성에 대한 지식으로 코드가 어떻게 동작하는지 생각해보자.
<리스트 15> 소수 구하기

package main
import "fmt"
func generate(ch chan int){
     for i:=2; ; i++{
         ch <- i
     }
}
func filter(in, out chan int, prime int){
     for{
          i := <-in
          if i % prime != 0{
               out <- i
          }
     }
}
func main(){
     ch := make(chan int)
     go generate(ch)

     for i:=0; i<5; i++{
          prime := <- ch
          fmt.Println("prime : ", prime)
          ch1 := make(chan int)
         go filter(ch, ch1, prime)
         ch = ch1
     }
}

채널이 어떻게 동작하여 소수가 출력되는지 쉽게 이해가 되는가? 이해가 어렵다면 <리스트 16>처럼 출력문을 곳곳에 넣어보기 바란다. 
<리스트 16> 출력문으로 소수 구하기 예제 이해하기
package main
import "fmt"
func generate(ch chan int){
     for i:=2; ; i++{
          fmt.Println("generate : ", i)
          ch <- i
     }
}
func filter(in, out chan int, prime int){
     fmt.Println("filter create : ", prime)
     for{
          i := <-in
          fmt.Println("filter:", prime, "in : ", i)
          if i % prime != 0{
               out <- i
               fmt.Println("filter:", prime, " out : ", i)
          }
     }
}
func main(){
     ch := make(chan int)
     go generate(ch)

     for i:=0; i<5; i++{
          prime := <- ch
          fmt.Println("prime : ", prime)
          ch1 := make(chan int)
          fmt.Println("filter create before: ", prime)
          go filter(ch, ch1, prime)
          ch = ch1
     }
}

<리스트 16>은 필터(filter)가 생성되는 시점, 필터 내부로 값이 들어오는 시점과 나가는 시점을 화면에 출력하도록 출력문을 추가했다. 이제는 어떤 순서로 동작하는지 이해하기가 더 편해졌을 것이다. 더 명확한 이해를 위해 단계 단계마다 그림을 그리는 것도 좋은 방법이다. <리스트 17>의 1, 2에 대한 진행 상황을 그린 것이 <그림 10>과 <그림 11>이다. 채널을 이용한 경우 goroutine간에 동기화가 이뤄지므로 실행할 때마다 동작 순서에 큰 차이가 있진 않다. 
<리스트 17> 소수 구하기 실행 결과
generate : 2
generate : 3
prime : 2
filter create before: 2
filter create : 2
filter: 2 in : 3
generate : 4
filter: 2 out : 3
prime : 3
filter: 2 in : 4 --- 1
filter create before: 3
generate : 5
generate : 6
filter create : 3
filter: 2 in : 5
filter: 2 out : 5
filter: 3 in : 5
filter: 2 in : 6
filter: 3 out : 5
generate : 7
prime : 5 --- 2
...

<그림 10> 소수를 구하끼 위한 체(sieve) 생성과정-1
<그림 11> 소수를 구하끼 위한 체(sieve) 생성과정-2
채널을 처음 접하는 개발자라면 완벽한 이해를 위해 출력문을 살펴보거나 그림을 그려 이해하는 것도 한 방법이다.
이번 글에서 Go언어의 가장 큰 무기인 동시성을 살펴봤다. Go언어는 개발단계부터 동시성의 지원을 고려해 Go언어 내부적으로 동시성을 지원한다. 지난 2회에서 설명했듯 C언어 계열이기에 문법을 익히는데 큰 어려움은 없지만 채널이라는 개념은 많은 노력이 필요하다. 이해가 쉽지 않더라도 여유를 갖고 Go언어의 동시성을 하나씩 알아가면 동시성의 완벽히 이해할 수 있을 것이다.

Go언어를 보다 명확하고 쉽게 정리하고자 했지만 주변 일들로 실천하기가 쉽지 않았다. 하지만 월간 마이크로소프트웨어에 연재하며 막연히 공부했던 것들을 정리할 수 있는 기회가 됐고 이 글을 읽는 독자뿐만 아니라 필자에게도 의미 있는 시간이었다. 독자들도 Go언어에 대한 지식을 넓힐 수 있는 기회가 됐기를 바란다.
서두에 언급했듯 Go언어는 내년 초에는 더 안정적인 버전으로 우리에게 찾아온다. 실무에 바로 적용할 수 있을 만큼 충분한 완성도를 가질 것이라 확신한다. 여러분도 고퍼(gopher)가 되어 보는 것은 어떤가?

No comments:

Post a Comment

가장 많이 본 글