对于 golang 而言,我们避不开的要谈到它一直被吹捧的一大优势 - 原生支持并发。并发编程是自从摩尔定律逐渐失效,硬件工程师将性能提升的任务逐渐以多核、的方式委托给软件开发者后作为软件工程师必要掌握的一项技能。而由于并非的不确定性,对于许多业务需求又存在着需要确定顺序的地方,也就衍生出各式各样的同步、通信方式本文就以 golang context 包来谈谈 go 的并非控制与通信。

context 是什么?

golang 1.12 src/context

1
2
3
// Package context defines the Context type, which carries deadlines,
// cancelation signals, and other request-scoped values across API boundaries
// and between processes.

Go 1.7 标准库引入 context,中文译作“上下文”,准确说它是 goroutine 的上下文,包含 goroutine 的运行状态、环境、现场等信息。作为 goroutine 之间的传输媒介。

tips: 在开发 go 程序时,需要使用多个 goroutine 并发处理任务,而每个 goroutine 成功创建之后我们可以简单的看成是一个 线程(绿色线程)。而在并发程序中,往往牵扯到同步与通信的问题。在 Context 出现包之前,主要使用 共享全局变量、select + channel 事件的形式实现同步通信。


context 主要用来在 goroutine 之间传递上下文信息,包括:取消信号、超时时间、截止时间、k-v 等。

随着 context 包的引入,标准库中很多接口因此加上了 context 参数,例如 database/sql 包。context 几乎成为了并发控制和超时控制的标准做法。

golang 并发控制与通信的方式

开发go程序的时候,时常需要使用goroutine并发处理任务,有时候这些goroutine是相互独立的,而有的时候,多个goroutine之间常常是需要同步与通信的。另一种情况,主goroutine需要控制它所属的子goroutine,总结起来,实现多个goroutine间的同步与通信大致有:

  • 共享全局变量
  • channel 通信(CSP 模型)
  • context 包

共享全局变量 (共享内存)

这是最简单的一种方式,简单示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import (
	"fmt"
	"time"
)

var flag = true

func main() {
	go say()
	time.Sleep(time.Second * 2)
	flag = false;
	fmt.Println("main ending")
}

func say() {
	for flag {
		fmt.Println("hello world!")
		time.Sleep(time.Second)
	}
}

共享全局变量是一种优缺点很明显的同步方式, * 其通过一个变量控制所有子 goroutine(逻辑上的父子关系)的状态。当然此示例中只支持多读一写,否则将出现脏数据问题(可以用锁来解决)。 * 此外该方式能传递的信息量很少,如需要传递很多信息,又会牵扯到更加复杂的同步代码。

share

画外音:在很多主流的编程语言中,当我们想要并发执行一些代码时,我们往往都会在多个线程之间共享变量,同时为了解决线程冲突的问题,我们又需要在读写这些变量时加锁。在 Java 中就是基于共享内存实现线程间的通信,所以锁在 java 中的地位举足轻重。

channel 通信(消息传递)

channel 是 Go 语言中一个重要的特性。而要理解 channel 就需要先了解 CSP 模型。

CSP 模型

CSP 是 Communicating sequential processes 的简称,是一种并发编程模型
wiki

In computer science, communicating sequential processes (CSP) is a formal language for describing patterns of interaction in concurrent systems.

简单来说,CSP 模型由并发执行的实体(线程或者进程)所组成,实体之间通过发送消息进行通信,这里发送消息时使用的就是通道,或者叫 channel。CSP 模型的关键是关注 channel,而不关注发送消息的实体。Go 语言实现了 CSP 部分理论,goroutine 对应 CSP 中并发执行的实体。

channel默认为同步模式,即不创建缓冲区,发送和接收需要一一配对,不然发送方会被一直阻塞,直到数据被接收。需要注意的是,同步的channel不能在一个协程中发送&接收,因为会被阻塞而永远跑不到接收的语句, 示例代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package main

import "fmt"

func main() {
	ch := make(chan int)

	go func() {
		for {
			if c, ok := <-ch; ok { // !ok 说明 ch 已经被 close
				fmt.Println(c)
			}
		}
	}()

	ch <- 1
	ch <- 2
	close(ch)
}

channel

channel 通信控制基于 CSP 模型,相比于传统的线程与锁并发模型,避免了大量的加锁解锁的性能消耗同时又做到了不同 goroutine 之间的解耦。而使用 CSP 模型,channel 是第一对象,可以被独立地创建,写入和读出数据,更容易进行扩展。

在 go 中,推崇的设计模式是 不要通过共享内存的方式进行通信,而是应该通过通信的方式共享内存 虽然我们在 Golang 中也能使用共享内存加互斥锁来实现并发编程。至于两者之间的优劣就不再此深入展开。而 channel 的更多使用姿势(channel + select 超时控制)已经实现原理,将在后续补充。

上下文 Context

在 Go 语言中每一个请求都是通过一个单独的 Goroutine 进行处理,HTTP/RPC 请求的处理往往都会启动新的 Goroutine 访问数据库和 RPC 服务。而通过使用 Context 就可以实现在多个不同 goroutine 之间进行传递

context

这些 goroutine 需要共享这个请求的基本数据,例如登陆的 token,处理请求的最大超时时间(如果超过此值再返回数据,请求方因为超时接收不到)等等。当请求被取消或是处理时间太长,这有可能是使用者关闭了浏览器或是已经超过了请求方规定的超时时间,请求方直接放弃了这次请求结果。这时,所有正在为这个请求工作的 goroutine 需要快速退出,因为它们的“工作成果”不再被需要了。在相关联的 goroutine 都退出后,系统就可以回收相关的资源。


用简练一些的话来说,在Go 里,我们不能直接杀死协程,协程的关闭一般会用 channel+select 方式来控制。但是在某些场景下,例如处理一个请求衍生了很多协程,这些协程之间是相互关联的:需要共享一些全局变量、有共同的 deadline 等,而且可以同时被关闭。再用 channel+select 就会比较麻烦,这时就可以通过 context 来实现。

context 实际应用

今日头条Go建千亿级微服务的实践 在头条的实践中抽象出了两种并发请求常见模型

wait

wait

cancel

cancel

示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
package main

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

type favContextKey string

func main() {
	wg := &sync.WaitGroup{}
	values := []string{"https://www.baidu.com/", "https://www.zhihu.com/"}
	ctx, cancel := context.WithCancel(context.Background())

	for _, url := range values {
		wg.Add(1)
		subCtx := context.WithValue(ctx, favContextKey("url"), url)
		go reqURL(subCtx, wg)
	}

	go func() {
		time.Sleep(time.Second * 3)
		cancel()
	}()

	wg.Wait()
	fmt.Println("exit main goroutine")
}

func reqURL(ctx context.Context, wg *sync.WaitGroup) {
	defer wg.Done()
	url, _ := ctx.Value(favContextKey("url")).(string)
	for {
		select {
		case <-ctx.Done():
			fmt.Printf("stop getting url:%s\n", url)
			return
		default:
			r, err := http.Get(url)
			if r.StatusCode == http.StatusOK && err == nil {
				body, _ := ioutil.ReadAll(r.Body)
				subCtx := context.WithValue(ctx, favContextKey("resp"), fmt.Sprintf("%s%x", url, md5.Sum(body)))
				wg.Add(1)
				go showResp(subCtx, wg)
			}
			r.Body.Close()
			//启动子goroutine是为了不阻塞当前goroutine,这里在实际场景中可以去执行其他逻辑,这里为了方便直接sleep一秒
			// doSometing()
			time.Sleep(time.Second * 1)
		}
	}
}

func showResp(ctx context.Context, wg *sync.WaitGroup) {
	defer wg.Done()
	for {
		select {
		case <-ctx.Done():
			fmt.Println("stop showing resp")
			return
		default:
			//子goroutine里一般会处理一些IO任务,如读写数据库或者rpc调用,这里为了方便直接把数据打印
			fmt.Println("printing ", ctx.Value(favContextKey("resp")))
			time.Sleep(time.Second * 1)
		}
	}
}

Wait 和 Cancel 两种并发控制方式,在使用 Go 开发服务的时候到处都有体现,只要使用了并发就会用到这两种模式。GW 启动5个协程发起5个并行的 RPC 调用之后,主协程就会进入等待状态,需要等待这5次 RPC 调用的返回结果,这就是 Wait 模式。另一中 Cancel 模式,在5次 RPC 调用返回之前,已经到达本次请求处理的总超时时间,这时候就需要 Cancel 所有未完成的 RPC 请求,提前结束协程。Wait 模式使用会比较广泛一些,而对于 Cancel 模式主要体现在超时控制和资源回收。而“context”,这个库几乎成为了并发控制和超时控制的标准做法。

context 使用规范

  • Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it. The Context should be the first parameter, typically named ctx;不要把Context存在一个结构体当中,显式地传入函数。Context变量需要作为第一个参数使用,一般命名为ctx (这也无形导致 context 满天飞,如果不需要的话,且也不想每次都传一个 context.BackGround() 可以对方法包装一层);

    1
    2
    
    // Do sends an HTTP request and returns an HTTP json response.
    func (client *Client) Do(c context.Context, args ...args) (err error) {}
  • Do not pass a nil Context, even if a function permits it. Pass context.TODO if you are unsure about which Context to use;即使方法允许,也不要传入一个nil的Context,如果你不确定你要用什么Context的时候传一个context.TODO;

    1
    2
    3
    4
    5
    6
    7
    
    func test() {
        client := newClient()
    
        err := client.Do(context.TODO) // or 使用 context.BackGround()
    }
    
    func (client *Client) Do(c context.Context)(err error){}
  • Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions;使用 context 的 Value 相关方法只应该用于在程序和接口中传递的和请求相关的元数据,不要用它来传递一些可选的参数(尽量少的传递一些可有可无的参数 ctx.withValue() 实质上是一个 interface{},这给予了程序员足够大的自由,但也于此同时使得其类型转换时容易出现偏差,因为直接透过 context 接口并不能确定上层传递了什么具体参数这依赖与调用方的注释);

  • The same Context may be passed to functions running in different goroutines; Contexts are safe for simultaneous use by multiple goroutines;同样的 Context 可以用来传递到不同的 goroutine 中,Context 在多个 goroutine 中是安全的(mutex + channel 实现保证了线程安全);

参考