쿼카러버의 기술 블로그

[golang] 콘텍스트 (context)란? - 1탄 간략한 소개 본문

[Golang]

[golang] 콘텍스트 (context)란? - 1탄 간략한 소개

quokkalover 2021. 8. 1. 23:35

본 글을 읽기 전에 다음 개념에 대한 이해가 우선돼야 한다.

  1. 고루틴 : https://etloveguitar.tistory.com/39
  2. 채널 : https://etloveguitar.tistory.com/40

  1. Context 간략한 소개
  2. Context 생성
  3. How to accept and use contexts in your functions
  4. 예제

1. Context 간략한 소개

golang을 사용해보면 context라는 패키지가 있다. 이는 작업을 지시할 때 작업 가능 시간, 작업 취소 등의 조건을 지시할 수 있는 작업 명세서 역할을 한다. 새로운 고루틴으로 작업을 시작할 때 일정 시간 동안만 작업을 지시하거나 외부에서 작업을 취소할 때 사용한다.

 

이 패키지가 하는 일에 대한 간략한 설명을 하자면 프로그램에 내부에서 "context"를 넘겨주는 개념이다. 넘겨주는 것들은  타임아웃, 데드라인, 채널을 통해서 실행을 멈추도록 하는 것 등이 있다.

 

예를 들어 특정 서버에 request를 보내는데 production level에서는 적절한 timeout을 설정해주는 것이 좋다. 특정 호출 때문에 서버가 느려지면서 다른 영역에도 손해가 발생할 수 있기 때문이다. 근데! timeout이나 deadline context가 매우 쉽게 예방할 수 있다.

 

 

2. Context 생성

context package는 context 생성을 위한 다양한 함수를 제공한다

context.Background() ctx Context ) => create context

highest level (root of all derived context)
위 함수를 실행하면 empty context를 받을 수 있따.
이를 활용해서 다른 context를 얻을 수도 있다.

ctx, cancel := context.Background()

context.TODO() ctx Context => create context

위 함수를 실행하면 마찬가지로 empty context가 생성된다. context는 highest level에서 사용하거나, 아직 잘 모르겠지만 미래에 context를 사용할지도 모른다고 생각될 때 사용한다.

ctx, cancel := context.TODO()

흥미롭게도 코드를 직접 까보면 bacground와 동일한 코드를 가지고 있다. (https://golang.org/src/context/context.go)
다만, 차이점은 TODO()를 사용하면 static analysis tools 등을 사용해 context가 적절히 pass됐는지 등의 이슈를 조사하여, 잠재적인 버그를 미리 찾아내고 CI/CD pipeline에 의해 연결될 수도 있다.

var (
   background = new(emptyCtx)
   todo       = new(emptyCtx)
)

context.WithCancel(parent Context) (ctx Context, cancel CancelFunc) => derive context

위 함수를 싱행하게 되면 parent context로부터 상속받은 새로운 context를 생성할 수 있다.
parent context는 background일 수도 있고, function을 통해 pass된 context일 수도 있다.

위 함수를 사용하게 되면,derived context를 얻을 수 있을 뿐 아니라 cancel function도 얻을 수 있다.
주의 할 점은 이 cancel function을 생성한 함수에서만 이 함수를 실행할 수 있다는 것이지만, 사용을 추천하지 않는다고 한다.
함부로 cancel function을 주고받고 사용했다가 뒤에 어떤 상황이 발생할지 예상할 수 없는 경우가 많기 때문이다.

ctx, cancel := context.WithCancel(context.Background())

context.WithDeadline(parent Context, d time.Time) (ctx Context, cancel CancelFunc) => derive context

위 함수를 실행하게 되면 parent로부터 context를 받을 수 있는데, 이 context는 설정된 deadline을 넘게되면 자동으로 cancel되거나 cancel function을 호출을 통해서 cancel 될 수 있다. 이렇게 설정된 context는 또 자식들에게 pass될 수 있다. 만약 context가 cacel되게 되면, 해당 context를 가진 모든 함수들은 모두 하던 일을 멈추고 return을 실행한다.

ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(2 * time.Second))

context.WithTimeout(parent Context, timeout time.Duration) (ctx Context, cancel CancelFunc) => derive context

이 함수는 context.WithDeadLine과 매우 유사하다. 차이는 time Duration을 input으로 받는다는 점이다.
마찬가지로 정해진 시간이 넘게되거나 cancel Func이 실행됐을 때 context가 cancel된다.

ctx, cancel := context.WithTimeout(context.Background(), time.Duration(150)*time.Millisecond)

3. How to accept and use context in your function

아래의 예제를 보면, context를 받아서 고루틴을 실행하고, 해당 고루틴이 return하거나, context가 cancel되기까지 기다리는 함수를 볼 수 있다.
select statement를 사용하면 둘 중에 어느게 먼저 발생하는지 골라서 return 할 수 있다.

<-ctx.Done()는 Done 채널이 close되면 caste <-ctx.Done():이 select되고,
해당 함수는 하던 일을 버리고 return을 준비한다.
즉 모든 pipe와 free resource를 종료시키고 함수로 return해야 하는 것을 의미한다.
종종 resource를 비우는 동안 return을 hold하는 경우도 있다. context를 return하는 구간을 정하기 전에 이런 가능성도 고려하는 것이 좋다.

다음 코드를 읽어보고 이해해보자

//Function that does slow processing with a context
//Note that context is the first argument
func sleepRandomContext(ctx context.Context, ch chan bool) {

  //Cleanup tasks
  //There are no contexts being created here
  //Hence, no canceling needed
  defer func() {
    fmt.Println("sleepRandomContext complete")
    ch <- true
  }()

  //Make a channel
  sleeptimeChan := make(chan int)

  //Start slow processing in a goroutine
  //Send a channel for communication
  go sleepRandom("sleepRandomContext", sleeptimeChan)

  //Use a select statement to exit out if context expires
  select {
  case <-ctx.Done():
    //If context expires, this case is selected
    //Free up resources that may no longer be needed because of aborting the work
    //Signal all the goroutines that should stop work (use channels)
    //Usually, you would send something on channel,
    //wait for goroutines to exit and then return
    //Or, use wait groups instead of channels for synchronization
    fmt.Println("Time to return")
  case sleeptime := <-sleeptimeChan:
    //This case is selected when processing finishes before the context is cancelled
    fmt.Println("Slept for ", sleeptime, "ms")
  }
}

4. 예제

앞서 언급했듯이 context를 사용하면 deadline, timeout, cancel function등을 사용해서 해당 context를 derive한 모든 함수들을 멈추고 return시킬 수 있다.

예를 들기전에 각 함수들이 어떤 역할을 수행하는지 설명하겠다.

  • main function:
    • context with cancel을 생성
    • 랜덤한 시간에 cancel function을 실행
  • doWorkContext function
    • timeout context를 derive한다.
    • 이 context는
      • main function이 cancel Function을 콜 했을 때
      • timeout이 발생했을 때
      • doWorkContext에서 자신의 cancel function을 실행했을 때
    • 느린 작업을 수행하는 go routine을 실행시킬 때 derived context를 넘김
    • go routine이 끝나기까지 기다리거나 main함수에서 cancel을 시키거나 두 가지중 먼저 발생하는걸 기다린다.
  • sleepRandomContext function
    • 느린 작업을 수행하는 고루틴 생성
    • 고루틴이 끝나길 기다리거나
    • main에서 context를 끝내거나, timeout이 발생하거나, 자신의 cancelFunction을 실행시켜 context를 cancel시킴
  • sleepRandom function
    • random 한 시간동안 sleep함
    • random processing time을 위해 사용하는거고 (real-world에서 실제로 랜덤하게 발생하기 때문에)
      • 실제 에서는 채널을 활용해서 cleanup하고 cleanup이 완료됐다는 신호를 받도록 시그널을 보낼 수도 있다.

Playground: https://play.golang.org/p/grQAUN3MBlg (Looks like random seed I use, time, in playground is not really changing. You may have to executing this in your local machine to see randomness)

Github: https://github.com/pagnihotry/golang_samples/blob/master/go_context_sample.go

참고자료:
http://p.agnihotry.com/post/understanding_the_context_package_in_golang/

package main

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

//Slow function
func sleepRandom(fromFunction string, ch chan int) {
  //defer cleanup
  defer func() { fmt.Println(fromFunction, "sleepRandom complete") }()

  //Perform a slow task
  //For illustration purpose,
  //Sleep here for random ms
  seed := time.Now().UnixNano()
  r := rand.New(rand.NewSource(seed))
  randomNumber := r.Intn(100)
  sleeptime := randomNumber + 100
  fmt.Println(fromFunction, "Starting sleep for", sleeptime, "ms")
  time.Sleep(time.Duration(sleeptime) * time.Millisecond)
  fmt.Println(fromFunction, "Waking up, slept for ", sleeptime, "ms")

  //write on the channel if it was passed in
  if ch != nil {
    ch <- sleeptime
  }
}

//Function that does slow processing with a context
//Note that context is the first argument
func sleepRandomContext(ctx context.Context, ch chan bool) {

  //Cleanup tasks
  //There are no contexts being created here
  //Hence, no canceling needed
  defer func() {
    fmt.Println("sleepRandomContext complete")
    ch <- true
  }()

  //Make a channel
  sleeptimeChan := make(chan int)

  //Start slow processing in a goroutine
  //Send a channel for communication
  go sleepRandom("sleepRandomContext", sleeptimeChan)

  //Use a select statement to exit out if context expires
  select {
  case <-ctx.Done():
    //If context is cancelled, this case is selected
    //This can happen if the timeout doWorkContext expires or
    //doWorkContext calls cancelFunction or main calls cancelFunction
    //Free up resources that may no longer be needed because of aborting the work
    //Signal all the goroutines that should stop work (use channels)
    //Usually, you would send something on channel, 
    //wait for goroutines to exit and then return
    //Or, use wait groups instead of channels for synchronization
    fmt.Println("sleepRandomContext: Time to return")
  case sleeptime := <-sleeptimeChan:
    //This case is selected when processing finishes before the context is cancelled
    fmt.Println("Slept for ", sleeptime, "ms")
  }
}

//A helper function, this can, in the real world do various things.
//In this example, it is just calling one function.
//Here, this could have just lived in main
func doWorkContext(ctx context.Context) {

  //Derive a timeout context from context with cancel
  //Timeout in 150 ms
  //All the contexts derived from this will returns in 150 ms
  ctxWithTimeout, cancelFunction := context.WithTimeout(ctx, time.Duration(150)*time.Millisecond)

  //Cancel to release resources once the function is complete
  defer func() {
    fmt.Println("doWorkContext complete")
    cancelFunction()
  }()

  //Make channel and call context function
  //Can use wait groups as well for this particular case
  //As we do not use the return value sent on channel
  ch := make(chan bool)
  go sleepRandomContext(ctxWithTimeout, ch)

  //Use a select statement to exit out if context expires
  select {
  case <-ctx.Done():
    //This case is selected when the passed in context notifies to stop work
    //In this example, it will be notified when main calls cancelFunction
    fmt.Println("doWorkContext: Time to return")
  case <-ch:
    //This case is selected when processing finishes before the context is cancelled
    fmt.Println("sleepRandomContext returned")
  }
}

func main() {
  //Make a background context
  ctx := context.Background()
  //Derive a context with cancel
  ctxWithCancel, cancelFunction := context.WithCancel(ctx)

  //defer canceling so that all the resources are freed up 
  //For this and the derived contexts
  defer func() {
    fmt.Println("Main Defer: canceling context")
    cancelFunction()
  }()

  //Cancel context after a random time
  //This cancels the request after a random timeout
  //If this happens, all the contexts derived from this should return
  go func() {
    sleepRandom("Main", nil)
    cancelFunction()
    fmt.Println("Main Sleep complete. canceling context")
  }()
  //Do work
  doWorkContext(ctxWithCancel)
}

4. Best practices

  1. context.Background는 프로그램의 최상위 레벨에서 사용되는게 바람직하다 (root of all derived contexts)
  2. context.TODO는 아직 뭘 써야 할지 모를때 혹은 미래에 사용될 수도 있을 것 같을 때 사용한다
  3. context cancellation은 주의가 필요하다. 함수가 cleanup하고 exit하는데 시간이 필요하기 때문이다.
  4. context.Valule는 optional parameter를 넘기기 위해 사용하지 않는다.
  5. context를 struct안에 담지 말아라. function에 넘기게 된다면 첫번째 argument로 넘겨라
  6. nil context를 pass하지 말아라. 차라리 TODO를 만들자
  7. Context는 cancel method가 없다. context를 derive하는 함수만 캔슬할 수 있어야 하기 때문이다.
Comments