10장 동시성

규모가 큰 프로그램은 다수의 더 작은 프로그램으로 구성된다. 예를 들어 웹 서버는 웹 브라우저에서 오는 요청을 처리해 HTML 웹 페이지를 응답으로 보낸다. 이때 각 요청은 자그마한 프로그램처럼 처리된다.

이 같은 작업을 동시에 각기 더 작은 구성요소로 실행할 수 있다면 이상적일 것이다(웹 서버의 경우 여러 요청을 처리하는 것이 여기에 해당한다). 하나 이상의 작업을 동시에 진행하는 것을 동시성(concurrency)라 한다. Go에서는 고루틴(goroutine)과 채널(channel)을 통해 동시성을 풍부하게 지원한다.

10.1 고루틴

고루틴은 다른 함수를 동시에 실행할 수 있는 함수를 일컫는다. 고루틴을 생성하려면 go라는 키워드 다음에 함수 호출을 지정하면 된다.

package main

import "fmt"

func f(n int) {
    for i := 0; i < 10; i++ {
        fmt.Println(n, ":", i)
    }
}

func main() {
    go f(0)
    var input string
    fmt.Scanln(&input)
}

이 프로그램은 두 개의 고루틴으로 구성돼 있다. 첫 번째 고루틴은 암시적이고 main 함수 자체다. 두 번째 고루틴은 go f(0)을 호출할 때 만들어진다. 보통 함수를 호출하면 프로그램에서는 함수 안의 문장을 모두 실행한 다음 해당 호출 구문 다음에 있는 줄로 반환된다. 고루틴을 이용하면 즉시 다음 줄로 실행 흐름이 반환되고 함수 호출이 완료되기까지 기다리지 않는다. 이러한 이유로 예제 프로그램에서 Scanln 함수를 호출한 것이다. 이 문장이 없으면 프로그램에서는 숫자가 모두 출력되기 전에 프로그램이 종료될 것이다.

고루틴은 생성하는 데 비용이 많이 들지 않아서 수천 개에 달하는 고루틴도 손쉽게 생성할 수 있다. 프로그램을 다음과 같이 수정해 10개의 고루틴이 실행되게 할 수 있다.

func main() {
    for i := 0; i < 10; i++ {
        go f(i)
    }
    var input string
    fmt.Scanln(&input)
}

이 프로그램을 실행하면 고루틴이 동시에 실행되는 것이 아니라 순서대로 실행되는 것처럼 보인다는 사실을 알 수도 있다. 이번에는 함수에 time.Sleeprand.Intn을 이용해 지연시간을 더해 보자.

package main

import (
    "fmt"
    "time"
    "math/rand"
)

func f(n int) {
    for i := 0; i < 10; i++ {
        fmt.Println(n, ":", i)
        amt := time.Duration(rand.Intn(250))
        time.Sleep(time.Millisecond * amt)
    }
}

func main() {
    for i := 0; i < 10; i++ {
        go f(i)
    }
    var input string
    fmt.Scanln(&input)
}

f는 0에서 10까지 숫자를 출력하는데, 각 숫자를 출력할 때마다 0에서 250밀리초 사이의 시간 동안 기다린다. 이제 고루틴은 동시에 실행될 것이다.

10.2 채널

채널(channel)은 두 고루틴이 서로 통신하고 실행흐름을 동기화하는 수단을 제공한다. 다음은 채널을 사용하는 예제 프로그램이다.

package main

import (
    "fmt"
    "time"
)

func pinger(c chan string) {
    for i := 0; ; i++ {
        c <- "ping"
    }
}
func printer(c chan string) {
    for {
        msg := <- c
        fmt.Println(msg)
        time.Sleep(time.Second * 1)
    }
}
func main() {
    var c chan string = make(chan string)

    go pinger(c)
    go printer(c)

    var input string
    fmt.Scanln(&input)
}

이 프로그램은 "ping"을 끊임없이 출력할 것이다(중단하려면 엔터 키를 친다). 채널의 타입은 chan이라는 키워드 다음에 채널에 전달되는 것의 타입을 지정해서 나타낸다(여기서는 문자열을 전달한다). <-(왼쪽 화살표) 연산자는 채널에 메시지를 전달하고 채널로부터 메시지를 전달받는 데 사용한다. c <- "ping""ping"을 전달한다는 의미다. msg := <- c는 메시지를 받아 그것을 msg에 저장한다는 의미다. fmt가 나오는 줄은 fmt.Println(<-c)라고도 작성할 수 있다. 이 경우 앞의 줄을 제거해도 된다.

이 같은 식으로 채널을 이용하면 두 고루틴이 동기화된다. pinger가 메시지를 채널에 전송하려고 시도할 경우 printer가 해당 메시지를 받을 준비가 될 때까지 대기할 것이다(이것은 블로킹으로 알려져 있다). 이번에는 프로그램에 다른 전달자를 추가해서 무슨 일이 일어나는지 살펴보자. 다음 함수를 추가한다.

func ponger(c chan string) {
    for i := 0; ; i++ {
        c <- "pong"
    }
}

그리고 main 함수를 다음과 같이 수정한다.

func main() {
    var c chan string = make(chan string)

    go pinger(c)
    go ponger(c)
    go printer(c)

    var input string
    fmt.Scanln(&input)
}

이제 프로그램에서는 "ping"과 "pong"이 번갈아 가면서 출력될 것이다.

채널 방향

채널 타입에 방향을 지정할 수 있는데, 그렇게 함으로써 채널이 보내거나 받기만 하도록 제한할 수 있다. 예를 들어, pinger 함수의 서명을 다음과 같이 변경할 수 있다.

func pinger(c chan<- string)

이제 c는 보내기만 할 수 있다. c로부터 메시지를 받으려고 하면 컴파일 오류가 발생할 것이다. 이와 비슷하게 출력기를 다음과 같이 변경할 수 있다.

func printer(c <-chan string)

이러한 제한이 없는 채널을 양방향 채널이라 한다. 양방향 채널은 전송 전용 채널이나 수신 전용 채널에 인자로 받는 함수에 전달할 수 있지만 그 역은 성립하지 않는다.

Select

Go에는 switch와 비슷하게 동작하지만 채널에 대해서만 동작하는 select라는 특별한 구문이 있다.

func main() {
    c1 := make(chan string)
    c2 := make(chan string)

    go func() {
        for {
            c1 <- "from 1"
            time.Sleep(time.Second * 2)
        }
    }()
    go func() {
        for {
            c2 <- "from 2"
            time.Sleep(time.Second * 3)
        }
    }()
    go func() {
        for {
            select {
                case msg1 := <- c1:
                fmt.Println(msg1)
                case msg2 := <- c2:
                fmt.Println(msg2)
            }
        }
    }()

    var input string
    fmt.Scanln(&input)
}

이 프로그램은 "from 1"을 2초마다 출력하고 "from 2"를 3초마다 출력한다. select는 준비된 첫 번째 채널을 골라 해당 채널로부터 메시지를 받는다(또는 해당 채널로 메시지를 보낸다). 하나 이상의 채널이 준비되면 어느 채널로부터 메시지를 받을지 무작위로 선택한다. 준비된 채널이 없으면 사용 가능해질 때까지 문장 실행이 차단된다.

select 구문은 제한 시간을 구현할 때 자주 사용된다.

select {
    case msg1 := <- c1:
        fmt.Println("Message 1", msg1)
    case msg2 := <- c2:
        fmt.Println("Message 2", msg2)
    case <- time.After(time.Second):
        fmt.Println("timeout")
}

time.After는 채널을 생성한 후 지정한 시간이 지나면 현재 시간을 해당 채널로 보낸다(여기서는 현재 시간에 관심을 두지 않으므로 변수에 저장하지 않았다). 아울러 default 케이스를 지정할 수도 있다.

select {
    case msg1 := <- c1:
        fmt.Println("Message 1", msg1)
    case msg2 := <- c2:
        fmt.Println("Message 2", msg2)
    case <- time.After(time.Second):
        fmt.Println("timeout")
    default:
        fmt.Println("nothing ready")
}

default 케이스는 준비된 채널이 없을 경우 즉시 실행된다.

버퍼 채널

채널을 만들 때 make 함수에 두 번째 매개변수를 전달하는 것도 가능하다.

c := make(chan int, 1)

이렇게 하면 용량이 1인 버퍼 채널이 만들어진다. 보통 채널은 동기적으로 동작한다. 즉, 채널의 양쪽이 다른 쪽이 준비될 때까지 기다린다. 버퍼 채널은 비동기적이다. 즉, 메시지를 보내거나 받을 때 채널이 이미 꽉 차 있지 않는 이상 기다리지 않는다.

연습 문제

  1. 채널 타입의 방향을 지정하는 방법은 무엇인가?
  2. time.After를 이용해 Sleep 함수를 직접 작성하라.
  3. 버퍼 채널이란 무엇인가? 용량이 20인 버퍼 채널을 생성하려면 어떻게 해야 하는가?
← 이전다음 →