Go -- 标准库Context优雅退出gorouting

  • 初识Go标准库Context
    • 1、为什么需要Context
      • 1.1、示例1:使用变量的方式
      • 1.2、示例2:使用channel的方式
      • 1.3、示例3:官方版的方案
    • 2、Context初识
    • 3、Context接口
    • 4、Background()和TODO()
  • 二、With系列函数
    • 1、WithCancel
    • 2、WithDeadline
    • 3、WithTimeout
    • 4、WithValue 传递值
  • 三、使用Context的注意事项
  • 四、客户端超时取消示例

初识Go标准库Context

在 Go http包的Server中,每一个请求在都有一个对应的 goroutine 去处理。请求处理函数通常会启动额外的 goroutine 用来访问后端服务,比如数据库和RPC服务。用来处理一个请求的 goroutine 通常需要访问一些与请求特定的数据,比如终端用户的身份认证信息、验证相关的token、请求的截止时间。 当一个请求被取消或超时时,所有用来处理该请求的 goroutine 都应该迅速退出,然后系统才能释放这些 goroutine 占用的资源。

1、为什么需要Context

  • 我们启动一个一直运行的gorouting后,当需要通知这个gorouting终止,就需要像下面代码中设置标签的方式进行通知
  • Go的Context包提供了这种功能

1.1、示例1:使用变量的方式

import (
	"fmt"
	"sync"
	"time"
)

var wg sync.WaitGroup
var tag bool

func f() {
	defer wg.Done()
	for !tag {
		fmt.Println("hello world !!! ")
		time.Sleep(time.Millisecond * 500)
	}
}

func main() {
	wg.Add(1)
	go f()
	time.Sleep(5 * time.Second)
	tag = true
	wg.Wait()
}

1.2、示例2:使用channel的方式

var wg sync.WaitGroup
var tag chan bool = make(chan bool)

func f(c <-chan bool) {
	defer wg.Done()
Out:
	for {
		select {
		case <-c:      // 没有值,执行default
			break Out  // 指定跳出到标签位置
		default:
		}
		fmt.Println("hello world !!! ")
		time.Sleep(time.Millisecond * 500)

	}

}

func main() {
	wg.Add(1)
	go f(tag)
	time.Sleep(5 * time.Second)
	tag <- true
	wg.Wait()
}

输出结果

hello world !!! 
hello world !!! 
hello world !!! 
hello world !!! 
hello world !!! 
hello world !!! 
hello world !!!
hello world !!!
hello world !!!
hello world !!!

1.3、示例3:官方版的方案

var wg sync.WaitGroup
func worker(ctx context.Context) {
LOOP:
	for {
		fmt.Println("hello world !!!")
		time.Sleep(time.Second)
		select {
		case <-ctx.Done(): // ctx.Done()返回一个只读的channel,等待上级通知退出的信号
			break LOOP
		default:
		}
	}
	wg.Done()
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	wg.Add(1)
	go worker(ctx)
	time.Sleep(time.Second * 3)
	cancel()  // 通知子goroutine结束
	wg.Wait()
	fmt.Println("over")
}

当子goroutine又开启另外一个goroutine时,只需要将ctx传入即可:

package main

import (
	"context"
	"fmt"
	"sync"

	"time"
)

var wg sync.WaitGroup

func worker(ctx context.Context) {
	go worker2(ctx)
LOOP:
	for {
		fmt.Println("worker")
		time.Sleep(time.Second)
		select {
		case <-ctx.Done(): // 等待上级通知
			break LOOP
		default:
		}
	}
	wg.Done()
}

func worker2(ctx context.Context) {
LOOP:
	for {
		fmt.Println("worker2")
		time.Sleep(time.Second)
		select {
		case <-ctx.Done(): // 等待上级通知
			break LOOP
		default:
		}
	}
}
func main() {
	ctx, cancel := context.WithCancel(context.Background())
	wg.Add(1)
	go worker(ctx)
	time.Sleep(time.Second * 3)
	cancel() // 通知子goroutine结束
	wg.Wait()
	fmt.Println("over")
}

2、Context初识

  • Go1.7加入了一个新的标准库context,它定义了Context类型,专门用来简化 对于处理单个请求的多个 goroutine 之间与请求域的数据、取消信号、截止时间等相关操作,这些操作可能涉及多个 API 调用。

  • 对服务器传入的请求应该创建上下文,而对服务器的传出调用应该接受上下文。

    • 它们之间的函数调用链必须传递上下文,或者可以使用WithCancel、WithDeadline、WithTimeout或WithValue创建的派生上下文。
      • WithCancel() 结束gorouting的函数
      • WithDeadline() 明确一个终止时间(具体的时间),到了规定的时间,请求未完成就强制返回结束gorouting
      • WithTimeout() 明确一个超时时间(相对的时间),作用和WithDeadline()相似
      • WithValue() 创建上下文,当一个上下文被取消时,它派生的所有上下文也被取消。

3、Context接口

  • context.Context是一个接口,该接口定义了四个需要实现的方法。具体签名如下:
    • Deadline()方法需要返回当前Context被取消的时间,也就是完成工作的截止时间(deadline);
    • Done()方法需要返回一个Channel,这个Channel会在当前工作完成或者上下文被取消之后关闭,多次调用Done方法会返回同一个Channel;
    • Err()方法会返回当前Context结束的原因,它只会在Done返回的Channel被关闭时才会返回非空的值;
      • 如果当前Context被取消就会返回Canceled错误;
      • 如果当前Context超时就会返回DeadlineExceeded错误;
    • Value()方法会从Context中返回键对应的值,对于同一个上下文来说,多次调用Value 并传入相同的Key会返回相同的结果,该方法仅用于传递跨API和进程间跟请求域的数据;
type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}

4、Background()和TODO()

  • Go内置两个函数:Background()TODO(),这两个函数分别返回一个实现了Context接口的background和todo。我们代码中最开始都是以这两个内置的上下文对象作为最顶层的partent context,衍生出更多的子上下文对象。

  • Background()主要用于main函数、初始化以及测试代码中,作为Context这个树结构的最顶层的Context,也就是根Context。调用context.Background()返还的是一个根节点,后面的都属于衍生的子节点

  • TODO(),它目前还不知道具体的使用场景,如果我们不知道该使用什么Context的时候,可以使用这个。

  • backgroundtodo本质上都是emptyCtx结构体类型,是一个不可取消,没有设置截止时间,没有携带任何值的Context

二、With系列函数

context包中还定义了四个With系列函数。

1、WithCancel

  • WithCancel的函数签名如下:func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
    WithCancel传入根节点,返回的是一个子的context节点和一个cancel取消groouting的函数
  • WithCancel返回带有新Done通道的父节点的副本。当调用返回的cancel函数或当关闭父上下文的Done通道时,将关闭返回上下文的Done通道,无论先发生什么情况。

示例:取消此上下文将释放与其关联的资源,因此代码应该在此上下文中运行的操作完成后立即调用cancel。

func gen(ctx context.Context) <-chan int {
		dst := make(chan int)
		n := 1
		go func() {
			for {
				select {
				case <-ctx.Done():
					return // return结束该goroutine,防止泄露
				case dst <- n:
					n++
				}
			}
		}()
		return dst
	}
func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel() // 当我们取完需要的整数后调用cancel

	for n := range gen(ctx) {
		fmt.Println(n)
		if n == 5 {
			break
		}
	}
}

注意:上面的示例代码中,gen函数在单独的goroutine中生成整数并将它们发送到返回的通道。 gen的调用者在使用生成的整数之后需要取消上下文,以免gen启动的内部goroutine发生泄漏。

2、WithDeadline

  • WithDeadline的函数签名如下:func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)
    • 如下示例,返回父上下文的副本,并将deadline调整为不迟于d。
    • 如果父上下文的deadline已经早于d,则WithDeadline(parent, d)在语义上等同于父上下文。
    • 当截止日过期时,当调用返回的cancel函数时,或者当父上下文的Done通道关闭时,返回上下文的Done通道将被关闭,以最先发生的情况为准。

示例:取消此上下文将释放与其关联的资源,因此代码应该在此上下文中运行的操作完成后立即调用cancel。

func main() {
	d := time.Now().Add(50 * time.Millisecond)
	ctx, cancel := context.WithDeadline(context.Background(), d)

	// 尽管ctx会过期,但在任何情况下调用它的cancel函数都是很好的实践。
	// 如果不这样做,可能会使上下文(context)及其父类存活的时间超过必要的时间。
	defer cancel()
	// 哪个先调用,就执行那个case
	select {
	case <-time.After(1 * time.Second): // 一秒钟取一次值
		fmt.Println("overslept")
	case <-ctx.Done():
		fmt.Println(ctx.Err())
	}
}
  • 上面的代码中,定义了一个50毫秒之后过期的deadline,然后我们调用context.WithDeadline(context.Background(), d)得到一个上下文(ctx)和一个取消函数(cancel),然后使用一个select让主程序陷入等待:等待1秒后打印overslept退出或者等待ctx过期后退出。

  • 在上面的示例代码中,因为ctx 50毫秒后就会过期,所以ctx.Done()会先接收到context到期通知,并且会打印ctx.Err()的内容。

3、WithTimeout

  • WithTimeout的函数签名如下:func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
  • WithTimeout返回WithDeadline(parent, time.Now().Add(timeout))
  • 取消此上下文将释放与其相关的资源,因此代码应该在此上下文中运行的操作完成后立即调用cancel,通常用于数据库或者网络连接的超时控制。

示例如下:worker()函数为初始化db数据库连接的函数,假设正常情况下10毫秒就会完成函数退出,我们就可以用WithTimeout()设置超时时间,50毫秒后函数还未退出,ctx.Done()就会接收到值,执行下面case中的break退出。

package main

import (
	"context"
	"fmt"
	"sync"

	"time"
)

// context.WithTimeout

var wg sync.WaitGroup

func worker(ctx context.Context) {
LOOP:
	for {
		fmt.Println("db connecting ...")
		time.Sleep(time.Millisecond * 10) // 假设正常连接数据库耗时10毫秒
		select {
		case <-ctx.Done(): // 50毫秒后自动调用
			break LOOP
		default:
		}
	}
	fmt.Println("worker done!")
	wg.Done()
}

func main() {
	// 设置一个50毫秒的超时
	ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*50)
	wg.Add(1)
	go worker(ctx)
	time.Sleep(time.Second * 5)
	cancel() // 通知子goroutine结束
	wg.Wait()
	fmt.Println("over")
}

4、WithValue 传递值

  • WithValue函数能够将请求作用域的数据与 Context 对象建立关系。

  • 声明如下:func WithValue(parent Context, key, val interface{}) Context

    • WithValue返回父节点的副本,其中与key关联的值为val。
  • 应用场景:仅对API和进程间传递请求域的数据使用上下文值,而不是使用它来传递可选参数给函数。将gorouting的值进行传递

  • 注意点:所提供的键必须是可比较的,并且不应该是string类型或任何其他内置类型,以避免使用上下文在包之间发生冲突。WithValue的用户应该为键定义自己的类型。为了避免在分配给interface{}时进行分配,上下文键通常具有具体类型struct{}。或者,导出的上下文关键变量的静态类型应该是指针或接口。

示例如下:

package main

import (
	"context"
	"fmt"
	"sync"

	"time"
)

// context.WithValue

type TraceCode string

var wg sync.WaitGroup

func worker(ctx context.Context) {
	key := TraceCode("TRACE_CODE")
	traceCode, ok := ctx.Value(key).(string) // 在子goroutine中获取trace code
	if !ok {
		fmt.Println("invalid trace code")
	}
LOOP:
	for {
		fmt.Printf("worker, trace code:%s\n", traceCode)
		time.Sleep(time.Millisecond * 10) // 假设正常连接数据库耗时10毫秒
		select {
		case <-ctx.Done(): // 50毫秒后自动调用
			break LOOP
		default:
		}
	}
	fmt.Println("worker done!")
	wg.Done()
}

func main() {
	// 设置一个50毫秒的超时
	ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*50)
	// 在系统的入口中设置trace code传递给后续启动的goroutine实现日志数据聚合
	ctx = context.WithValue(ctx, TraceCode("TRACE_CODE"), "12512312234") // 将字符串强制转换成自定义的TraceCode类型的key
	wg.Add(1)
	go worker(ctx)
	time.Sleep(time.Second * 5)
	cancel() // 通知子goroutine结束
	wg.Wait()
	fmt.Println("over")
}

补充知识点:上面示例中 ctx.Value(key).(string) 后面的.(string)是一个断言,将获取到的值转换成字符串格式

三、使用Context的注意事项

  • 推荐以参数的方式显示传递Context
  • 以Context作为参数的函数方法,应该把Context作为第一个参数。
  • 给一个函数方法传递Context的时候,不要传递nil,如果不知道传递什么,就使用context.TODO()
  • Context的Value相关方法应该传递请求域的必要数据,不应该用于传递可选参数
  • Context是线程安全的,可以放心的在多个goroutine中传递

四、客户端超时取消示例

############################################ server端 ################################################
// context_timeout/server/main.go
package main

import (
	"fmt"
	"math/rand"
	"net/http"

	"time"
)

// server端,随机出现慢响应

func indexHandler(w http.ResponseWriter, r *http.Request) {
	number := rand.Intn(2)
	if number == 0 {
		time.Sleep(time.Second * 10) // 耗时10秒的慢响应
		fmt.Fprintf(w, "slow response")
		return
	}
	fmt.Fprint(w, "quick response")
}

func main() {
	http.HandleFunc("/", indexHandler)
	err := http.ListenAndServe(":8000", nil)
	if err != nil {
		panic(err)
	}
}

##################################### client端 #############################################################
// context_timeout/client/main.go
package main

import (
	"context"
	"fmt"
	"io/ioutil"
	"net/http"
	"sync"
	"time"
)

// 客户端

type respData struct {
	resp *http.Response
	err  error
}

func doCall(ctx context.Context) {
	transport := http.Transport{
	   // 请求频繁可定义全局的client对象并启用长链接
	   // 请求不频繁使用短链接
	   DisableKeepAlives: true, 	}
	client := http.Client{
		Transport: &transport,
	}

	respChan := make(chan *respData, 1)
	req, err := http.NewRequest("GET", "http://127.0.0.1:8000/", nil)
	if err != nil {
		fmt.Printf("new requestg failed, err:%v\n", err)
		return
	}
	req = req.WithContext(ctx) // 使用带超时的ctx创建一个新的client request
	var wg sync.WaitGroup
	wg.Add(1)
	defer wg.Wait()
	go func() {
		resp, err := client.Do(req)
		fmt.Printf("client.do resp:%v, err:%v\n", resp, err)
		rd := &respData{
			resp: resp,
			err:  err,
		}
		respChan <- rd
		wg.Done()
	}()

	select {
	case <-ctx.Done():
		//transport.CancelRequest(req)
		fmt.Println("call api timeout")
	case result := <-respChan:
		fmt.Println("call server api success")
		if result.err != nil {
			fmt.Printf("call server api failed, err:%v\n", result.err)
			return
		}
		defer result.resp.Body.Close()
		data, _ := ioutil.ReadAll(result.resp.Body)
		fmt.Printf("resp:%v\n", string(data))
	}
}

func main() {
	// 定义一个100毫秒的超时
	ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*100)
	defer cancel() // 调用cancel释放子goroutine资源
	doCall(ctx)
}

你可能感兴趣的:(GoLang,golang,开发语言,后端)