Go 동시성 프로그래밍의 내용을 참고하여 작성했습니다.
동시성 프로그램에서 시간 초과, 취소, 에러로 인해 작업을 선점해야 하는 경우가 있습니다. 이런 경우 done 채널을 이용해서 동시에 수행되는 작업들을 취소할 수 있습니다. 하지만, 취소가 되었다는 단순한 신호를 받을 뿐 취소된 이유의 전달이나 함수가 취소되어야 하는 마감 시한 등에 대한 정보는 전달할 수 없습니다. Go 언어 1.7에서는 done 채널을 감싸는 표준 라이브러리인 context 패키지를 도입했습니다.
Context 타입은 done 채널처럼 선점하고자 하는 프로세스 전체에 전달해야 하는 타입입니다. 그래서 context 패키지를 사용하는 경우, 최초 호출되는 루틴으로부터 호출되는 모든 서브루틴의 첫 번째 인수로 Context를 받습니다.
type Context interface { // 컨텍스트가 완료되었을때 그 시간을 반환 // daedline이 설정되지 않은 경우 ok는 false를 반환 // 여러번 호출해도 동일한 결과를 반환 Deadline() (deadline time.Time, ok bool) // 컨텍스트가 취소 또는 Timeout 되었을때 닫힌 채널을 반환 // 컨텍스트가 절대로 취소되지 않을 수 있다면 nil을 반환할 수도 있음 // 여러번 호출해도 동일한 결과를 반환 Done() <-chan struct{} // Done이 닫힌 후에 nil이 아닌 에러 값을 반환 // 컨텍스트가 취소되면 Canceled를 반환 // 시간 제한을 초과하면 DeadlineExceeded를 반환 // Done이 닫힌 후에 여러번 호출해도 동일한 결과를 반환 Err() error // key에 대해서 컨텍스트에 맵핑된 값을 반환 // 맵핑된 값이 없으면 nil을 반환 Value(key interface{}) interface{} }
context 패키지는 크게 두 가지 목적으로 사용됩니다.
함수에서의 취소는 크게 3가지 사항을 고려해야 합니다.
context 패키지를 이용하면 위 3가지 사항을 모두 관리할 수 있습니다.
Context 타입은 불변입니다. 부모 Context에 영향을 주지 않으면서 자신만의 Context를 만들 수 있습니다. Context가 불변이라면, 어떻게 호출 스택에서 현재 함수 아래에 있는 함수들을 취소시킬 수 있을까요?
// 반환된 cancel 함수가 호출될 때 done 채널을 닫는 새 Context를 반환 func WithCancel(parent Context) (ctx Context, cancel CancelFunc) { if parent == nil { panic("cannot create context from nil parent") } c := newCancelCtx(parent) propagateCancel(parent, &c) return &c, func() { c.cancel(true, Canceled) } } func propagateCancel(parent Context, child canceler) { ... } else { atomic.AddInt32(&goroutines, +1) go func() { select { case <-parent.Done(): child.cancel(false, parent.Err()) case <-child.Done(): } }() } } // 주어진 시간이 넘었을 때 done 채널을 닫는 새로운 Context를 반환 func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) // 주어진 실행 시간 후 done 채널을 닫는 새로운 Context를 반환 func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
위 함수들은 모두 Context를 인자로 받고 context를 반환합니다. 이 함수들은 모두 각 함수와 관련된 옵션들로 Context의 새 인스턴스를 생성합니다. 함수가 호출 그래프에서 아래쪽에 있는 함수들을 취소해야 하는 경우 함수는 위 함수들 중 하나를 호출해 자신에게 주어진 Context를 전달하고 리턴된 Context를 자식들에게 전달합니다. Context를 전달받은 함수가 취소 동작을 수정하지 않아도 되는 경우에는 주어진 Context를 전달만 합니다.
함수 호출 스택의 모든 함수에 Context를 전달해야 하기 때문에 객체의 멤버 변수로 Context를 관리하고 싶을 수도 있지만, 그렇게 하지 않는 것이 바람직합니다.
context 패키지는 함수 호출 스택을 시작하기 위한 Context를 만드는 함수를 제공합니다.
// 빈 Context를 반환 func Background() Context // 빈 Context를 반환. Context를 제공받기를 기대하지만 아직 상위 함수에서 코드를 제공하지 않았을 때 자리를 표시하는 역할 func TODO() Context
context 패키지를 사용했을 경우의 이점을 설명하기 위해서 먼저 done 채널을 사용하는 예제를 살펴보겠습니다.
동시에 안부 인사와 작별 인사를 출력하는 코드입니다.
func main() { var wg sync.WaitGroup done := make(chan interface{}) // <1> defer close(done) wg.Add(1) go func() { defer wg.Done() if err := printGreeting(done); err != nil { fmt.Printf("%v", err) return } }() wg.Add(1) go func() { defer wg.Done() if err := printFarewell(done); err != nil { fmt.Printf("%v", err) return } }() wg.Wait() } func printGreeting(done <-chan interface{}) error { greeting, err := genGreeting(done) if err != nil { return err } fmt.Printf("%s world!\n", greeting) return nil } func printFarewell(done <-chan interface{}) error { farewell, err := genFarewell(done) if err != nil { return err } fmt.Printf("%s world!\n", farewell) return nil } func genGreeting(done <-chan interface{}) (string, error) { switch locale, err := locale(done); { case err != nil: return "", err case locale == "EN/US": return "hello", nil } return "", fmt.Errorf("unsupported locale") } func genFarewell(done <-chan interface{}) (string, error) { switch locale, err := locale(done); { case err != nil: return "", err case locale == "EN/US": return "goodbye", nil } return "", fmt.Errorf("unsupported locale") } func locale(done <-chan interface{}) (string, error) { select { case <-done: return "", fmt.Errorf("canceled") case <-time.After(5 * time.Second): } return "EN/US", nil } <출력결과> goodbye world! hello world!
레이스 컨디션 때문에 작별 인사가 먼저 출력 되었습니다. <1>
에서는 done 채널을 만들고 이를 서브루틴에 전달함으로써 선점 가능하도록 설정했습니다. 이제 done 채널을 닫으면 모든 서브루틴이 취소됩니다.
done 채널을 이용한 구현에서 아래와 같은 요구 사항이 추가된다면 어떻게 처리할 수 있을까요?
genGreeting
이 오래 걸리는 경우 시간 초과를 설정하고 싶다.printGreeting
이 실패한다면 printFarewell
을 취소한다.genGreeting
이 locale
에 호출에 대해서 1초의 타임아웃을 갖도록 설정하고 싶습니다. 또한 printGreeting
이 실패할 경우 printFarewell
도 취소하도록 처리하고 싶습니다.
func main() { var wg sync.WaitGroup ctx, cancel := context.WithCancel(context.Background()) // <1> defer cancel() wg.Add(1) go func() { defer wg.Done() if err := printGreeting(ctx); err != nil { fmt.Printf("cannot print greeting: %v\n", err) cancel() // <2> } }() wg.Add(1) go func() { defer wg.Done() if err := printFarewell(ctx); err != nil { fmt.Printf("cannot print farewell: %v\n", err) } }() wg.Wait() } func printGreeting(ctx context.Context) error { greeting, err := genGreeting(ctx) if err != nil { return err } fmt.Printf("%s world!\n", greeting) return nil } func printFarewell(ctx context.Context) error { farewell, err := genFarewell(ctx) if err != nil { return err } fmt.Printf("%s world!\n", farewell) return nil } func genGreeting(ctx context.Context) (string, error) { ctx, cancel := context.WithTimeout(ctx, 1*time.Second) // <3> defer cancel() switch locale, err := locale(ctx); { case err != nil: return "", err case locale == "EN/US": return "hello", nil } return "", fmt.Errorf("unsupported locale") } func genFarewell(ctx context.Context) (string, error) { switch locale, err := locale(ctx); { case err != nil: return "", err case locale == "EN/US": return "goodbye", nil } return "", fmt.Errorf("unsupported locale") } func locale(ctx context.Context) (string, error) { select { case <-ctx.Done(): return "", ctx.Err() // <4> case <-time.After(5 * time.Second): } return "EN/US", nil } <출력결과> cannot print greeting: context deadline exceeded cannot print farewell: context canceled
<1>
에서는 context.Background()
로 Context를 만들고 취소할 수 있도록 하기 위하여 context.WithCancel
로 감쌉니다. <2>
에서는 에러가 반환될 경우 Context를 취소합니다.
<3>
에서 genGreeting
은 자신의 Context를 context.WithTimeout
으로 감싸서 반환된 Context를 locale
에 전달합니다. 이 Context는 1초 후에 취소될 것이고, 이를 받은 locale
함수도 함께 취소됩니다.
<4>
에서는 Context가 취소된 이유를 반환합니다. 이 에러는 부모 루틴으로 전달되어 main
까지 보고됩니다. locale
이 실행되는 데는 5초가 걸리기 때문에 genGreeting
의 호출은 제한 시간이 초과될 것이고, main
은 printFarewell
의 호출 그래프를 취소합니다.
genGreeting
은 부모의 Context에 영향을 미치지 않고 자신의 필요에 맞는 Context를 구축했습니다. 호출 그래프상의 루틴 간에 서로의 동작 방식을 모르더라도 구성할 수 있음으로 인해 관심사가 분리된 상태로 커다란 시스템을 작성할 수 있습니다.
다음은 Context에 데이터를 저장하고 조회하는 예제입니다.
func main() { ProcessRequest("gump", "abc123") } func ProcessRequest(userID, authToken string) { // <1> ctx := context.WithValue(context.Background(), "userID", userID) ctx = context.WithValue(ctx, "authToken", authToken) HandleResponse(ctx) } func HandleResponse(ctx context.Context) { // <2> fmt.Printf("handle response for %v (%v)", ctx.Value("userID"), ctx.Value("authToken"), ) } <출력결과> handle response for gump (abc123)
<1>
에서 키+값 형태로 Context에 데이터를 저장하고 있습니다. 그리고 <2>
에서 키를 이용해 맵핑된 값을 조회하고 있습니다. Context에 데이터를 저장할 때는 다음을 고려해야 합니다.
Context의 카와 값이 interface{}
로 정의돼 있기 때문에 검색 시 타입 안전성을 잃어버리게 됩니다. 이 때문에 Context에 값을 저장하고 조회할 때 몇 가지 규칙을 따르는 것이 안전합니다.
우선 패키지에 맞춤형 키를 사용하면 키값 충돌을 방지할 수 있습니다. 그 이유를 알아보기 위해서 맵을 이용한 간단한 예제를 살펴보겠습니다.
func main() { type foo int type boo int rawValue := 1 m := make(map[interface{}]int) m[foo(rawValue)] = 2 m[boo(rawValue)] = 3 fmt.Printf("m = %v\n", m) fmt.Printf("m[foo(%d)] = %d\n", rawValue, m[foo(rawValue)]) fmt.Printf("m[boo(%d)] = %d\n", rawValue, m[boo(rawValue)]) } <결과출력> m = map[1:3 1:2] m[foo(1)] = 2 m[boo(1)] = 3
타입을 감싸기 전에 전달한 값은 같지만 서로 다른 타입으로 감싼 데이터는 맵 내에서 서로 구분됩니다. 패키지의 키를 위해서 정의한 타입은 외부로 내보내지 않으므로 다른 패키지에서 사용하는 키와 충돌할 수 없게 됩니다. 그렇다면 패키지의 외부에서 값을 조회해야 할 필요가 생기면 어떻게 할까요? 이때는 데이터를 검색하여 제공하는 접근자 함수를 만들어 이를 가능하게 합니다.
func main() { ProcessRequest("gump", "abc123") } type ctxKey int const ( ctxUserID ctxKey = iota ctxAuthToken ) func UserID(ctx context.Context) string { return ctx.Value(ctxUserID).(string) } func AuthToken(ctx context.Context) string { return ctx.Value(ctxAuthToken).(string) } func ProcessRequest(userID, authToken string) { ctx := context.WithValue(context.Background(), ctxUserID, userID) ctx = context.WithValue(ctx, ctxAuthToken, authToken) HandleResponse(ctx) } func HandleResponse(ctx context.Context) { fmt.Printf("handle response for %v (%v)", UserID(ctx), AuthToken(ctx), ) } <결과출력> handle response for gump (abc123)
접근자 함수를 사용하면 다른 패키지에서도 타입 안전성이 확보된 정적 함수를 사용할 수 있기 때문에 바람직합니다. 하지만, 이 방법에도 문제는 존재합니다.
HandleResponse
가 response
라는 다른 패키지에 존재하는 예제입니다.
// sample.go package main import "your-project/process" func main() { process.ProcessRequest("gump", "abc123") } // process.go package process import ( "context" "your-project/response" ) type ctxKey int const ( ctxUserID ctxKey = iota ctxAuthToken ) func UserID(ctx context.Context) string { return ctx.Value(ctxUserID).(string) } func AuthToken(ctx context.Context) string { return ctx.Value(ctxAuthToken).(string) } func ProcessRequest(userID, authToken string) { ctx := context.WithValue(context.Background(), ctxUserID, userID) ctx = context.WithValue(ctx, ctxAuthToken, authToken) response.HandleResponse(ctx) } // response.go package response import ( "context" "fmt" "your-project/process" ) func HandleResponse(ctx context.Context) { fmt.Printf("handle response for %v (%v)", process.UserID(ctx), process.AuthToken(ctx), ) } <오류메시지> package your-project imports your-project/process imports your-project/response imports your-project/process: import cycle not allowed
Context에 키를 저장하는 데 사용하는 타입이 process
패키지에 정의되어 있고 외부로 제공되지 않기 때문에 response
패키지는 내보내기 함수를 이용할 수밖에 없습니다. 이 때문에 process
패키지와 response
패키지가 순환 의존성을 발생시킵니다. 결국 다시 두 패키지가 모두 접근 가능한 위치에 정의된 타입을 중심으로 패키지를 구성하는 방법을 사용할 수밖에 없습니다.
context 패키지는 잘 설계되어 있지만, 다소 논란이 존재합니다.
첫째는, Context에 임의의 데이터를 저장할 수 있고, 타입에 안전하지 않은 방식 채택으로 인한 논란이 존재합니다. 잘못된 타입을 저장해 잠재적인 버그 발생 가능성을 만들기 때문입니다.
두 번째는, 개발자가 Context에 데이터를 저장 가능한데, 어떤 방식으로 데이터를 저장하는 게 바람직한지에 대한 가이드라인이 모호하다는 것입니다.
// context.go Use context values only for request-scoped data that transits processes and API boundaries, not for passing optional parameters to functions. API나 프로세스 경계를 통과하는 요청 범위의 데이터에 대해서만 컨텍스트 값을 사용하고, // <1> 함수에 선택적 매개 변수를 전달하는 용도로는 사용하지 않는다. // <2>
<2>
의 가이드라인인 비교적 명확하게 그 의미를 알 수 있습니다. 문제는 <1>
의 상황이 명확하지 않다는 것입니다. Concurrency in Go에서 저자는 아래와 같은 경험적 가이드라인을 제시합니다.
여러번 곱 씹었지만, 정확한 의미를 알기 어렵다는게 함정이네요. 고오오급 개발자 분들의 조언이 필요하군요.