누워서 풀어보는 Go Concurrency Quiz

"동시성 처리... 법규 !! (F..k you)"

복잡한 Multi-Threaded 환경에서 동시성 처리나 자원 공유 등의 난해함에 넌더리를 내는 사람들이 많은데요.
이런 사람들은 Go 에서 제공하는 Channel 과 고루틴을 만나면 그 단순함과 깔끔한 인터페이스에 환호를 외치게 됩니다.

C/C++ 같은 언어를 이용하던 개발자가 Go 를 처음 사용하기 시작하면 엄청 쉽게 느껴집니다.
Go 언어가 단순함과 실용성을 추구하기 때문에 어쩌면 당연한 것이죠.

하지만 !!

세상에 공짜가 어디 있겠어요.
Go 동시성을 구현할 때도 역시 법규(?)한 상황을 많이 만나게 됩니다.

조금이라도 재밌게 공부를 해보죠.

코드를 연습하는 제일 좋은 방법은 물론 수없이 작성하면서 고민하는 것이겠지만...
누워서 뇌 시뮬레이션 해보는 방법도 나름 재미 있더라고요.
"누워서 읽는 알고리즘"이라는 책도 있잖아요.

그래서, 아이큐 100 언저리 정도라면(저보다 좋은...) 뇌시뮬 가능한 퀴즈 몇 개 준비해 봤어요.

정답과 해설은 각 Quiz 의 코드 바로 아래에 있습니다.
수행 결과는 흰색 화면을 마우스로 드래그하면 보이실 거예요. (커닝 금지 !)

제일 쉬운 것부터...
아무리 쉬어도 Go 의 Channel 과 Goroutine 사용법 정도는 알고 시작해야겠죠?
(요기요기)


Quiz 1.

package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan int)

    go func() {
        fmt.Println("before")
        ch <- 1
        fmt.Println("after")
    }()

    for {
        time.Sleep(1 * time.Second)
    }
}

수행 결과: (바로 아래 부분을 마우스로 긁어보세요)
$ go run chan.go

before

해설:
before 만 찍고 프로그램은 죽지 않고 조용히 대기합니다.

(1) main 고루틴은 for 반복문에서 1 초씩 쉬면서 돌고 있습니다. (1 초 간격이니 cpu 거의 안먹습니다.)
(2) 별도 고루틴은 생성되자마자 before 를 먼저 찍은 후 channel 로 1 을 전송하려고 대기합니다.
channel 로부터 1 을 받아줄 놈이 어디에도 없으니 무한 대기합니다.
(channel 에서는 수신하는 놈이 없으면 생길 때까지 송신하는 놈은 바보처럼 기다립니다.)


Quiz 2.

첫번째 Quiz 를 살짝 바꿔봤어요.

package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan int)
    i := 0

    go func() {
        for {
            i++; ch <- i
            time.Sleep(1 * time.Second)
        }
    }()

    for {
        fmt.Println(<- ch)
    }
}

수행 결과: (바로 아래 부분을 마우스로 긁어보세요)
$ go run chan.go
1
2
3
...

해설 :
1 초 간격으로 1,2,3,... 과 같이 숫자를 1 씩 증가하면서 무한히 화면에 찍습니다.
1 초 마다 별도 고루틴이 전송하면, main 고루틴에서 계속 받아주니 말입니다.
컴퓨터가 고장나지 않는 한 영원할 거예요.


Quiz 3.

퀴즈에는 가끔 함정도 숨어있으니 뇌를 꼼꼼히 굴려보세요.
이번에는 2 개의 고루틴을 생성하는 코드인데요.
아래 코드를 실행하면 어떻게 될까요?

package main

import (
    "fmt"
    "time"
)

func pingpong(name string, ch chan int) {
    for {
        i := <- ch
        fmt.Println(name, ":", i)

        ch <- i
        time.Sleep(1 * time.Second)
    }
}

func main() {
    ch := make(chan int)

    go pingpong("player1", ch)
    go pingpong("player2", ch)
}

수행 결과: (바로 아래 부분을 마우스로 긁어보세요)
$ go run chan.go

해설:
아무것도 안찍힙니다.
pingpong 함수 코드를 보면 고루틴 2 개가 서로 뭔가 주고 받을 것 같지만...

(1) 고루틴 2 개가 생성되면 두 놈다 channel 에서 받으려고 대기를 하네요. 공은 누가 던져주나요...;;
(2) 고루틴 2 개가 생성되자마자 main 고루틴은 나가버리네요.
(3) 따라서, 프로그램이 끝날 당시에 별도 생성된 2 개의 고루틴은 멍때리고 있다가 프로그램 종료를 당하고 맙니다.


Quiz 4.

이번에는 위의 코드에서 두번째 고루틴만 살짝 go 를 빼보겠습니다.
직접 실행해보지 마시고 뇌로만 돌려보세요.
늘 태평했던 우리 뇌가 오늘 고생 좀 할라나요?
지금까지는 뭐... 뇌섹같은거 필요없는 퀴즈죠?

package main

import (
    "fmt"
    "time"
)

func pingpong(name string, ch chan int) {
    for {
        i := <- ch
        fmt.Println(name, ":", i)

        ch <- i
        time.Sleep(1 * time.Second)
    }
}

func main() {
    ch := make(chan int)

    go pingpong("player1", ch)
    pingpong("player2", ch)    // <- go 만 살짝 사라졌습니다...;;
}

수행 결과: (바로 아래 부분을 마우스로 긁어보세요)
$ go run chan.go
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan receive]:
...
goroutine 6 [chan receive]:
...

해설:
deadlock 이 발생합니다.

(1) 두번째 go 를 빼버렸으니 두번째 pingpong 함수는 main 고루틴으로 수행됩니다.
(2) 첫번째 고루틴과 main 고루틴은 pingpong 함수 시작하자마자 둘다 channel 로부터 뭔가를 받으려고 대기합니다.
공을 던져주는 놈은 없고 받을 준비만 하는 놈들만 있으니 deadlock 이 발생합니다.


Quiz 5.

pingpong 코드 인데 이번에는 공을 좀 던져주죠.
main 고루틴이 다른 고루틴 두 놈에게 "옛다~ 1 이나 먹어라" 하고 공을 하나 던져줍니다.
악어 무리속에 물고기 하나 던져주듯이 말이죠.

package main

import (
    "fmt"
    "time"
)

func pingpong(name string, ch chan int) {
    for {
        i := <- ch
        fmt.Println(name, ":", i)

        i++
        ch <- i
        time.Sleep(1 * time.Second)
    }
}

func main() {
    ch := make(chan int)

    go pingpong("player1", ch)
    go pingpong("player2", ch)

    ch <- 1     // <- 공을 던져줍니다.

    for {
        time.Sleep(1 * time.Second)  // <- 그리고 쉽니다.
    }
}

수행 결과: (바로 아래 부분을 마우스로 긁어보세요)
$ go run chan.go
player2 : 1
player1 : 2
player2 : 3
player1 : 4
player2 : 5
...

해설:
player1 과 player2 가 번갈아가면서 숫자를 1 씩 증가시키며 화면에 찍습니다.

(1) 별도로 생성된 2 개의 pingpong 고루틴이 channel 에서 데이터를 받으려고 대기합니다.
(2) main 고루틴이 1 을 channel 에 던져 넣습니다.
(3) 2 개의 pingpong 고루틴 중 어떤 놈이 main 에서 보내는 1 을 받게 될까요? (cpu 먼저 잡는 놈이 먼저입니다. 즉, 예측할 수 없습니다.)
(4) 어느 한 놈이 1 을 받은 이후부터는 고루틴 2 개가 서로 주고 받고를 번갈아 수행하겠죠. 이 때 공을 던져준 main 고루틴은 Sleep 하면서 쉬고 있을테구요.


Quiz 6.

자~ 이번에는 고루틴들의 경쟁(race) 상황입니다.
변수 하나를 가지고 여러 놈이 서로 싸웁니다.
아무 방어없는 고루틴 간 격렬한 싸움이 어떤 결과를 만들어낼까요?
이 코드의 목표는 count 값을 4 천만으로 만드는 건데요.
여러 놈이 작업을 하면 더 빨리 끝낼 수 있으니 4 개의 고루틴에게 천만번씩 일을 나누어 시켰습니다.
WaitGroup 에 대해서 전혀 모르시는 분은 여기를 먼저 읽어보시면 좋겠네요.

package main

import (
    "fmt"
    "sync"
    "time"
)

var count = 0

func add(c int, wg *sync.WaitGroup) {
    time.Sleep(1 * time.Second)

    defer wg.Done()

    for i:=0; i<c; i++ {
        count++
    }
}

func main() {
    var wg sync.WaitGroup

    wg.Add(4)

    go add(10000000,&wg)
    go add(10000000,&wg)
    go add(10000000,&wg)
    go add(10000000,&wg)

    wg.Wait()

    fmt.Println(count)
}

수행 결과: (바로 아래 부분을 마우스로 긁어보세요)
$ go run chan.go
10772876

해설:
결과 값은 그때그때 달라집니다.

(1) 고루틴 4 개는 생성되자마자 1 초 정도 쉰 후에 count 변수 값을 올리기 시작합니다.
(2) 각각의 고루틴이 ++ 연산을 수행하게 되면, count 의 값을 메모리에서 cpu 내부의 register 로 읽어온 후, 이 값에 1 을 더해서 이를 cpu cache -> 메모리로 update 합니다(중간 과정이 복잡하지만 대충 이렇습니다).
(3) 이런 작업을 하나의 고루틴만 하는게 아니죠. 그러니 다수의 고루틴이 특정 시점에서는 메모리에서 같은 값을 읽어갈 수도 있습니다. 같은 값을 이용해서 ++ 연산을 수행하니 update 되는 값도 같을 수 있겠네요. 즉, 여러 놈이 ++ 을 수행해도 딱 1 만 값을 증가시키는 경우도 비일비재하게 발생하게 됩니다. 그러니까 결과가 4 천만이 아닌거죠.
(4) go run -race chan.go 와 같이 수행해 보세요.
      race detection 에 대한 좋은 글 꼭 읽어보시구요.

그러면 위와 같은 경쟁상황에서 결과가 4 천만이 나오도록 하는 방법은 없을까요?
3 가지 방법 정도가 있지 않을까 싶네요.

  • mutex 를 사용해서 특정 고루틴이 값을 변경중이면 다른 놈은 기다리도록 만드는 방법
  • semaphore 역할을 할 수 있는 channel 을 하나 만들어서 고루틴들이 이를 공유해서 사용하는 방법
  • atomic 연산을 사용하는 방법

mutex 를 사용하는 방법은 정확하게 처리할 수는 있지만 기다리는 시간이 많으니 성능이 떨어집니다.
구현은 아주 쉽죠.
찾아보면 많으니 skip 합니다.

channel 을 이용해서 semaphore 를 구현하는 방법은 다양할 수 있는데요.
대략 다음과 같이 구현할 수 있습니다.
사실 아래 코드에서 semaphore 갯수를 1 로 사용하면 mutex 와 동일하게 사용될 수도 있어요.

type empty {}
type semaphore chan empty
sem = make(semaphore, N)

// acquire
func (s semaphore) P(n int) {
    e := empty{}
    for i := 0; i < n; i++ {
        s <- e
    }
}

// release
func (s semaphore) V(n int) {
    for i := 0; i < n; i++ {
        <-s
    }
}

atomic 연산은 count 의 값을 변경하는 연산을 하나의 명령어(instruction)로 처리하는 방법입니다.
++ 연산은 내부적으로 메모리에서 값을 읽어들이고, 이에 1 을 더하고, 다시 메모리에 저장하고... 이렇게 명령어가 2 개 이상으로 수행됩니다.
그러니까 동시성 처리에서 문제를 발생시킬 수 있죠.
이런 일련의 처리 과정을 단 하나의 cpu 명령어(instruction)로 처리 가능하도록 한 것이 atomic 연산입니다.
count 변수의 값을 변경하는 연산을 all or nothing(atomic)으로 처리할 수 있게 만든거예요.
소스 코드를 다음과 같이 수정하면 됩니다.
count++ 부분을 sync/atomic 패키지에서 제공하는 함수로 대체하면 됩니다.

import (
    "sync/atomic"
    ...
)

var count int32 = 0

func add(c int, wg *sync.WaitGroup) {
    ...
    for i:=0; i<c; i++ {
        atomic.AddInt32(&count,1)  // <-- atomic 연산을 사용하세요.
    }
}

$ go run chan.go
40000000

Quiz 7.

자~ 이제 done 채널이라는걸 가지고 놀아볼까요?
struct{}{} 는 실제 데이터를 보내는건 아니고 그냥 시그널을 보내기 위한건 아시죠?
시그널만 보내면 되는데 메모리같은 귀중한 리소스를 사용할 필요는 없죠.
잠자는 누군가를 깨울 때 길게 말을 할 필요는 없잖아요.
그냥 툭 치면 되지.

package main

import "fmt"

func main() {
    done := make(chan struct{})
    num := 0

    go func() {
        num = 5
        done <- struct{}{}
    }()

    <- done
    <- done

    fmt.Println(num)
}

수행 결과: (바로 아래 부분을 마우스로 긁어보세요)
$ go run chan7.go

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:
main.main()
/gochannel/chan.go:15 +0xb3
exit status 2

해설:
전송하는 놈은 없는데 channel 에서 뭘 받으려고 하니 deadlock 이 발생합니다.

(1) 생성된 고루틴은 num 변수 갱신 후 done 채널로 시그널을 보내려고 대기합니다.
(2) main 고루틴은 done 채널로부터 데이터를 받으려고 첫번째 <- done 부분에서 대기합니다.
(3) 위의 (1) 번과 (2)번은 누가 먼저 대기할지는 알 수 없습니다. 어쨌건 별도 고루틴의 첫번째 전송은 main 고루틴의 첫번째 수신부(<- done)에서 받아줍니다.
(4) 그럼 main 고루틴의 두번째 수신부는 어떻게 되나요? 별도 고루틴은 시그널을 한번 전송 후 사라져버릴 것이니 이제 시그널을 전송해줄 놈은 없습니다. 보내줄 놈 없는데 받는 놈만 있으면 역시 deadlock 입니다.

위의 코드 수행 결과는 사실 아래의 초간단 코드와 동일합니다.
채널 만들고 받는 놈만 코드에 넣으니 deadlock 이 발생하잖아요.

package main

func main() {
    done := make(chan struct{})

    <- done
}

$ go run chan.go
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:
main.main()
	/gochannel/chan.go:6 +0x4d
exit status 2

Quiz 8.

그러고 보니, 지금까지 channel 을 사용하고 닫은(close) 적이 없네요.
이번에는 화끈하게 한번 닫아보죠.

package main

import "fmt"

func main() {
    done := make(chan struct{})

    go func() {
        done <- struct{}{}
        close(done)
    }()

    <- done
    <- done

    fmt.Println("done")
}

수행 결과: (바로 아래 부분을 마우스로 긁어보세요)
$ go run chan.go

done

해설:
정상적으로 main 의 끝부분에서 done 을 출력합니다.

(1) main 고루틴의 첫번째 수신부(<- done)는 별도 고루틴에서 보내는 첫번째 시그널(struct{}{})을 정상적으로 수신합니다.
(2) 그러면, 두번째 수신부(<- done)는 어떻게 대기가 풀리는걸까요?
      close(done)때문에 그렇습니다. close 한 channel 에 대한 수신 대기는 바로 풀립니다(non-blocking). 그러니, channel 의 송신쪽에서는 항상 원하는 데이터를 모두 보냈다 싶으면 정상적으로 channel 을 close 해주는게 좋습니다.


Quiz 9.

이번에는 channel 에서 시그널이 아니라 실제 데이터(integer)를 받아봅니다.
이러는 와중에 보내는 쪽에서 냉정하게 close 도 한번 해보죠.

package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan int)

    go func() {
        for i:=0; i<5; i++ {
            ch <- i
            time.Sleep(1 * time.Second)
        }
        close(ch)
    }()

    for {
        fmt.Println(<- ch)
    }

    fmt.Println("done")
}

수행 결과: (바로 아래 부분을 마우스로 긁어보세요)
$ go run chan.go

0
1
2
3
4
0
0
...

해설:
(1) 별도로 생성된 고루틴은 1 초 간격으로 0~4 까지 숫자를 channel 로 전송합니다.
(2) main 고루틴에서는 별도 고루틴이 보낸 0~4 까지의 숫자를 반복문을 돌며 차례로 읽은 후 멈추지 않고 다시 반복문을 돌아 다음 데이터를 기다립니다.
(3) 별도로 생성된 고루틴은 4 까지 전송한 후에는 channel 을 close 해버리네요.
(4) 이 때 main 고루틴은 어떻게 동작할까요?
     channel 이 close 되었으니 <- ch 부분에서 바로 리턴하기는 합니다. close 된 channel 에서 읽는 값은 0 이 리턴됩니다.
     그런데, for 반복문이 있어서 다시 <- ch 를 수행하게 되네요.
     Sleep 도 없으니 빠른 속도로 무한 반복하게 됩니다.

위와 같이 close 된 channel 일 경우 main 고루틴에서 for 반복문을 빠져 나가는 깔끔한 방법이 없을까요?
그러면 무한반복은 없어질텐데요.
2 가지 방법이 있어요.

  • channel 에서 데이터를 읽을 때 channel 의 open 여부를 검사하는 방법
  • range 구문을 사용하는 방법

channel 의 open 여부를 검사하는 방법은 다음과 같습니다.
간단하고 직관적이죠?

    for {
        data, open := <- ch
        if !open {
            break
        }

        fmt.Println(data)
    }

이보다 더 간단하고 좋은 방법이 range 를 이용하는 것인데요.
마치 array, slice, map 등에서 range 를 이용하여 item 을 읽는 것처럼 channel 도 동일한 방법으로 사용할 수 있습니다.

    for data := range ch {
        fmt.Println(data)
    }

channel 에서 range 를 이용해 데이터를 읽어들이는 과정에서 만약 해당 channel 이 close 되면 range 는 false 를 리턴해주나 보네요.
그러니까 for 반복문을 빠져나가게 되겠죠.


Quiz 10.

자~, 저도 힘드니까 오늘은 10 번째 퀴즈까지만 하죠.
다음 번에는 뇌를 좀 더 빡세게 돌려야하는 퀴즈들을 들고 와서 다시 만나보기로 하구요.

코드에서 channel 을 close 하는 놈이 누군지 잘 살펴보세요.

package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    ch := make(chan int)
    var wg sync.WaitGroup

    wg.Add(1)
    go func() {
        defer wg.Done()

        for i:=0; ;i++ {
            ch <- i
            time.Sleep(1 * time.Second)
        }
    }()

    for i:=0; i<5; i++ {
        fmt.Println(<- ch)
    }
    close(ch)

    wg.Wait()

    fmt.Println("done")
}

수행 결과: (바로 아래 부분을 마우스로 긁어보세요)
$ go run chan10.go

0
1
2
3
4
done
panic: send on closed channel

goroutine 6 [running]:
main.main.func1(0xc00001a090, 0xc000072060)
    /gochannel/chan.go:18 +0x6d
created by main.main
    /gochannel/chan.go:14 +0xaa
exit status 2

해설:
별도의 고루틴은 0~4 까지의 숫자를 전송한 후 panic 이 발생해서 죽습니다.
하지만, main 고루틴 자체는 정상적으로 종료하게 됩니다.

(1) 별도의 고루틴은 0 부터 시작하여 1 씩 증가시킨 숫자를 channel 로 1 초 간격으로 전송합니다. 무한히...
(2) main 고루틴은 0~4 까지만 channel 로부터 숫자를 읽어들인 후 냉정하게 close 해버리네요.
(3) 별도의 고루틴은 close 된 channel 에 쓰기를 하려고 하니 panic 이 발생하여 종료됩니다. (wg.Done 호출)
(4) 데이터 전송측 고루틴이 끝났으니 어쨌건 main 고루틴에서 wg.Wait() 은 정상적으로 리턴됩니다.

You may also like...

5 1 vote
Article Rating
Subscribe
Notify of
guest
0 Comments
Inline Feedbacks
View all comments
0
Would love your thoughts, please comment.x
()
x