Go루틴을 활용한 비동기 처리

 

Asynchronous Non-blocking 작업

- Synchronous vs Asynchronous - Blocking vs Non-Blocking - Asynchronous Non-blocking Request Sync / Async 두 항목을 나누는 기준은 요청한 작업이 진행되는 순서이다. Synchronous - 동기: 요청된 작업이..

denev6.tistory.com

위 글에서 설명했듯이 비동기 처리는 속도와 효율성 면에서 큰 이점을 가진다. 

그리고 Go는 이러한 비동기 루틴 실행을 아주아주아주 쉽게 할 수 있다. 


Go 루틴

Go의 루틴은 2KB 스택 공간만을 사용하기 때문에 가볍고, 하나의 코어 당 하나의 OS 스레드만 할당하기 때문에 스레드의 컨텍스트 스위칭 비용이 발생하지 않는다. 

go function()

함수를 호출할 때 앞에 go를 붙이면 끝난다.

package main

import (
    "fmt"
    "time"
)

func main() {
    secs := []int{7, 1, 5, 3}

    for _, sec := range secs {
        Test(sec)
    }
}

func Test(sec int) {
    time.Sleep(time.Second * time.Duration(sec))
    fmt.Println(sec)
}
7
1
5
3
  • Test: sec초 후에 sec를 출력
  • main: 7, 1, 5, 3초 후에 출력되는 함수를 순차적으로 호출한다.

 

우선 비동기 처리를 하지 않은 함수는 순서대로 출력된다. 

아래 예시는 go루틴을 활용한 비동기 처리를 진행한다. 

func main() {
    secs := []int{7, 1, 5, 3}

    for _, sec := range secs {
        go Test(sec)
    }
}

비동기 처리를 했으니 효율적으로 코드가 진행될 것 같지만 아무 일도 일어나지 않는다. Go루틴은 main 함수가 실행될 동안만 실행된다. 위 예시의 경우, main 함수에서 Test를 호출한 후 실행할 동작이 없기 때문에 그대로 종료되어 버린다. 

func main() {
    secs := []int{7, 1, 5, 3}

    for _, sec := range secs {
        go Test(sec)
    }
    
    time.Sleep(time.Second * 10)
}
1
3
5
7

만약 main 함수를 종료하지 않고 10초동안 기다리도록 하면 1 3 5 7 순서로 출력되는 것을 볼 수 있다


Waitgroup

앞에서 비동기가 완료되지 않아도 프로그램이 종료되는 것을 확인하였다. 이러한 상황을 방지하기 위해 비동기 실행 결과 값을 기다리도록 할 수 있다. 

  • wg.Add: 예약할 go 루틴 갯수를 설정한다.
  • wg.Done: 하나의 go 루틴이 종료될 때 호출한다. Add의 카운트를 감소시킨다.
  • wg.Wait: 예약한 go 루틴의 갯수만큼 종료되면 넘어간다. 
import (
	"fmt"
	"sync"
	"time"
)

var wg sync.WaitGroup

func SleepAndPrint(i int) {
    time.Sleep(time.Duration(i) * time.Second)
    fmt.Print(i)
    wg.Done()
}

func main() {
    iter := 5
    wg.Add(iter)
    for i := iter; i > 0; i-- {
        go SleepAndPrint(i)
    }
    wg.Wait()
}

공유 자원

비동기 처리에서 메모리 자원을 공유할 때 문제가 발생할 수 있다. 대안으로 Lock을 활용할 수 있다. 자원을 사용하는 루틴이 Lock을 걸어 자원을 독점한 후, 사용이 완료되면 Lock을 해제한다. 

var wg sync.WaitGroup
var mutex sync.Mutex

func AsyncFunc(i int) {
    mutex.Lock()
    defer mutex.Unlock()

    // Do something
    wg.Done()
}

하지만 mutex를 사용하는 것도 문제가 있다. 자원을 독점하기 때문에 동시성이 실현되지 않고, Dead-Lock 문제가 발생할 가능성이 있다. 


채널

채널이름 := make(chan 자료형)

채널 루틴 간 통신을 위한 큐(Queue)이다. 하나의 채널은 하나의 자료형만을 사용한다. 

채널이름 <- 데이터
변수 <- 채널이름

<- 을 이용해 채널로 데이터를 보내거나, 채널로부터 데이터를 받아온다. 

func Test(sec int, channel chan int) {
    time.Sleep(time.Second * time.Duration(sec))
    channel <- sec
}

함수의 파라미터로 통신에 사용할 채널을 전달한다. 그 후 <-를 통해 데이터를 channel로 전송한다. 

func main() {
    channel := make(chan int)
    secs := []int{7, 1, 5, 3}

    for _, sec := range secs {
        go Test(sec, channel)
    }
    for i := 0; i < len(secs); i++ {
        fmt.Print(<-channel)
    }
}

비동기 함수 Test가 총 네 번 실행되기 때문에 채널로부터 네 번 데이터를 받아와야 한다. 위 코드의 경우, <-channel이 그 역할을 수행한다. 네 번 데이터를 받아와 출력한 뒤에 main 함수가 종료된다. 

 

Chan 크기

채널은 버퍼를 가진다. 

func main() {
    channel := make(chan int)
    go AsyncFunc()
    channel <- 3
    
    fmt.Println("Hello World!")
}

만약 채널이 꽉 차게 되면 값을 가져갈 때까지 대기한다. 따라서 위 코드는 영원히 종료되지 않는다. 

func main() {
    channel := make(chan int, 5)
    go AsyncFunc()
    channel <- 3
    
    fmt.Println("Hello World!")
}

 

make를 이용해 채널 버퍼의 크기를 지정할 수 있다. 


예제 - 비동기 request

package main

import (
    "fmt"
    "net/http"
)

type res struct {
    name   string
    status int
}

func main() {

    names := []string{
        "google",
        "naver",
        "reddit",
        "facebook",
	}

    channel := make(chan res)

    for _, name := range names {
        go getStatus(name, channel)
    }
    for i := 0; i < len(names); i++ {
        ret := <-channel
        fmt.Printf("%s: %d\n", ret.name, ret.status)
    }
}

func getStatus(name string, channel chan res) {
    url := fmt.Sprintf("https://www.%s.com/", name)
    resp, _ := http.Get(url)
    channel <- res{name: name, status: resp.StatusCode}
}
reddit: 403
naver: 200
google: 200
facebook: 200

Go는 http.Get을 통해 request를 받아 오는 함수를 기본 패키지(net/http)로 제공한다. 

위 코드의 실행 결과, 먼저 완료된 결과를 먼저 보여주는 것을 확인할 수 있다. 이러한 방식으로 request 대기 시간을 효율적으로 사용해 전체 실행 시간을 줄일 수 있다.