Recommanded Free YOUTUBE Lecture: <% selectedImage[1] %>

Contents

future

컴퓨터 공학에서 future, promise, delay, deferred는 동시성 프로그램에서 계산이 완료되지 않은 결과에 대한 프록시 역할을 하는 객체를 설명하기 위해서 사용한다. 알 수 없는 미래에 완료될 계산을 기다리고 처리하기 위한 객체다.

Futures 와 promises는 함수형 프로그래밍과 그 관련 패러다임에서(logic programming)에서 사용하며, "값(미래)"과 "계산 방법(약속)"을 분리하여 병렬화된 계산을 유연하게 수행 할 수 있도록 한다. 이러한 구조는 분산 컴퓨팅에서 통신 지연 시간을 줄이는데 사용되었다. 요즘에는 continuation-passing style이 아닌 direct style로 프로그램을 작성하기 위한 목적으로 널리 사용하고 있다.

채널

Future는 미래에 발생할 사건을 처리하기 위한 코드를 지금 약속(promise) 하는 것이다. 따라서 지금의 약속과 미래에 발생할 사건을 연결하기 위한 통신 장치가 필요하다. Go에서 제공하는 채널(channel)을 이용해서 두 객체를 연결할 수 있다.

 Go Channel

c := make(chan int)
채널 c를 이용해서 미래로 연결할 수 있다. 채널은 미래의 시점에 준비가되거나 준비되지 않은 결과를 전달하기 위한 프록시가 된다. Go 에서 채널은 값이 준비 될 때까지 대기(block)하게 된다.

고루틴

무어의 법칙은 오래전에 깨졌다. 2004년 3GHz의 CPU가 세상에 선보였지만 20년이 지난 지금도 CPU의 클럭은 4GHz 수준이며, 이를 4GHz의 벽이라고 부르고 있다. 이렇게 클럭속도가 정체되어 있지만 CPU 성능은 계속 좋아지고 있다. 하이퍼스레딩, 멀티코어, 캐시 기술 덕분이다.

두 개 이상의 코어들이 사용되면서 멀티코어를 지원하기 위한 소프트웨어를 만들어서 성능을 높여야 했다. 이때 사용했던 기술이 멀티스레딩이었다. 이들은 좋은 도구이긴 했으나 복잡하고 구현이 어려워서 개발자가 쉽게 접근할 수 없었다. Go언어는 고루틴(Goroutines)이라는 동시성 모델을 이용한 새로운 방식의 멀티 프로세싱을 제공한다.

Go 개발자는 고루틴을 이용해서 동시에 작동하는 코드를 쉽고 편하게 만들 수 있게 됐다. 고루틴의 패턴은 아래와 같다.
package main


import (
    "fmt"
    "time"
)

func compute(value int) {
    for i := 0; i < value; i++ {
        time.Sleep(time.Second)
        fmt.Println(i)
    }
}

func main() {
    fmt.Println("Goroutine Tutorial")

    go compute(10)
    go compute(10)

    var input string
    fmt.Scanln(&input)
}
go 키워드를 이용하는 것으로 함수를 동시에 실행시킬 수 있다. 위 코드는 2개의 compute 함수를 동시에 실행하고 있다. compute 함수와 main 함수가 동시에 실행되기 때문에 fmt.Scanln()을 호출해서 main 함수가 종료되지 않도록 했다.

Go 언어에서의 future

Go 언어에서 future는 채널과 로루틴을 이용해서 구현 할 수 있다.
c := make(chan int)       // future
go func() { c <- f() } () // async(goroutine)
value := <-c              // await

Go 언어에서 future의 구현 방법은
  1. 고루틴을 사용해서 특정 작업을 하는 함수를 동시에 실행한다.
  2. 채널을 이용해서 고루틴으로 부터의 메시지를 읽어서 처리 할 수 있다.
이다.

아래의 Javascript 코드를 보자.
const longRunningTask = async () => {
  // simulate a workload
  sleep(3000);
  return Math.floor(Math.random() * Math.floor(100));
};

const r = await longRunningTask();
console.log(r);
  • async 키워드를 이용해서 longRunningTask를 비동기 함수로 만들었다.
  • await 키워드를 이용해서 longRunningTask를 비동기로 호출 한다.
위 코드를 Go로 바꿔보자.
package main

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

func longRunningTask() <-chan int {
	r := make(chan int)
	go func() {
		defer close(r)
		for {
			time.Sleep(time.Second * 2)
			r <- rand.Intn(100)
		}
	}()
	return r
}

func main() {
	r := <-longRunningTask()
	fmt.Println(r)
}

분석

위 예제에서 우리는 아래의 정보를 얻을 수 있다.
  1. 외부 패키지 없이 future를 구현 할 수 있다.
  2. 고루틴은 사용하기 간단하다.
  3. 채널을 이용해서 메시지를 기다리는 것으로 await를 구현 할 수 있다.
이제 예제코드를 확장해 보자.

고루틴이 더 많은 일을 하도록 하자

A 함수가 고루틴 B를 실행하고 있다고 가정해보자. A 함수는 고루틴 B의 실행 결과를 채널을 통해서 읽어서 어떤 작업을 처리한다. 그 후 다시 채널에 메시지가 있는지 확인을 할 것이다. 그동안 고루틴 B는 채널이 비워질 때까지 기다려야 한다.

A 함수가 작업을 진행하는 동안에서 고루틴 B가 작업을 하게 하려면 "채널을 크게 만들면" 된다. 즉 메시지 버퍼를 두는 것이다.
c := make(chan int, 2)

이제 고루틴은 메시지를 전달하고 즉시 다음 작업을 실행 할 수 있다.

Context

Context는 Go 동시성 패턴에서 널리 사용한다. Go 서버에 들어오는 각 요청은 독립적인 고루틴에서 처리한다. 이들 고루틴은 데이터베이스 접근, 사용자의 인증/권한 검사, 외부 HTTP/GRPC 서비스 호출과 같은 작업을 수행한다. 하나의 Go 서버는 다수의 고루틴을 실행 할 수 있기 때문에, 빠르고 정확하게 종료되어야 자원을 효과적으로 사용 할 수 있다.

Context 패키지는 프로세스 & API간에 전달되는 Context interface Type을 정의하는 패키지다. Context interface는 아래와 같이 정의되어 있다.
type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <- chan struct{}
	Err() error
	Value(key interface{}) interface{}
}
  • Deadline : 첫 번째 인자는 기한(데드라인)이며, 이 시점이 도래하면 컨텍스트가 취소작업을 자동으로 트리거한다. 두 번째 인자가 true 이면 기한이 설정됨을 의미한다.
  • Done : 읽기 전용의 채널을 반환한다. 이 채널을 읽을 수 있다는 것은 부모 컨텍스트가 취소 요청을 시작했음을 의미한다. 이 신호를 받아서 고루틴을 정리하는 등의 정리 작업을 수행 할 수 있다.
  • Err : 컨텍스트가 취소된 이유를 반환한다.
  • Value: 컨텍스트에 Key-Value 데이터를 전달한다.
간단한 예제 프로그램을 ㅁ나들었다.
package main

import (
	"bufio"
	"context"
	"fmt"
	"os"
	"strconv"
	"time"
)

type fn func(context.Context, string, time.Duration)

func NewHandler(t time.Duration) *Handler {
	return &Handler{
		taskTime: t,
	}
}

type Handler struct {
	taskTime time.Duration
}

func (t *Handler) Run(taskName string, wait time.Duration) {
	ctx, cancel := context.WithTimeout(context.Background(), t.taskTime)
	go func() {
		fmt.Println("[RUN]", taskName)
		time.Sleep(wait)
		cancel()
	}()
	select {
	case <-ctx.Done():
		if ctx.Err() == context.Canceled {
			fmt.Println("작업완료", taskName, ":", ctx.Err().Error())
		} else if ctx.Err() == context.DeadlineExceeded {
			fmt.Println("시간초과", taskName, ":", ctx.Err().Error())
		}
	}
}

func main() {
	handler := NewHandler(time.Second * 5)

	for {
		reader := bufio.NewReader(os.Stdin)
		s, _ := reader.ReadString('\n')
		t, _ := strconv.ParseInt(s[:len(s)-1], 10, 64)
		taskName := fmt.Sprintf("Task-%d", t)
		go handler.Run(taskName, time.Duration(t)*time.Second)
	}
}
Handler는 Run 메서드를 이용해서, 새로운 Task를 생성한다. 각 Task는 context.WithTimeout메서드를 이용해서, 타임아웃과 종료를 제어한다.

고루틴은 표준입력으로 입력한 값만큼을 sleep 한다. sleep를 끝내고나면 CancleFunc인 cancel()를 호출한다. 그러면 부모 함수는 ctx.Done()으로 cancel 이벤트를 받아서 처리한다.

TimeOut(5초) 전에 작업이 완료되면 context.canceled가 리턴되고, TimeOut으로 종료되면 context.DeadLineExceeded가 리턴된다. 아래 테스트 결과를 보자.

# go run main.go
3
[RUN] Task-3
작업완료 Task-3 : context canceled
10
[RUN] Task-10
시간초과 Task-10 : context deadline exceeded