标签 并发 下的文章

通过实例理解Go标准库context包

  • 原weibo账号处于jy状态,临时先用小号 https://weibo.com/u/6484441286,欢迎大家关注!
  • “Gopher部落”知识星球双十一新人特惠,领劵加入即享立减88元优惠 – https://t.zsxq.com/078E1QTjM

本文永久链接 – https://tonybai.com/2022/11/08/understand-go-context-by-example

自从context包在Go 1.7版本加入Go标准库,它就成为了Go标准库中较难理解和易误用的包之一。在我的博客中目前尚未有一篇系统介绍context包的文章,很多来自Go专栏《Go语言精进之路》的读者都希望我能写一篇介绍context包的文章,今天我就来尝试一下^_^。

1. context包入标准库历程

2014年,Go团队核心成员Sameer Ajmani在Go官博上发表了一篇文章“Go Concurrency Patterns: Context”,介绍了Google内部设计和实现的一个名为context的包以及该包在Google内部实践后得出的一些应用模式。随后,该包被开源并放在golang.org/x/net/context下维护。两年后,也就是2016年,golang.org/x/net/context包正式被挪入Go标准库,这就是目前Go标准库context包的诞生历程。

历史经验告诉我们:但凡Google内部认为是好东西的,基本上最后都进入到Go语言或标准库当中了。context包就是其中之一,后续Go 1.9版本加入的type alias语法也印证了这一点。可以预测:即将于Go 1.20版本以实验特性身份加入的arena包离最终正式加入Go也只是时间问题了^_^!

2. context包解决的是什么问题?

正确定义问题比解决问题更重要。在Sameer Ajmani的文章中,他在一开篇就对引入context包要解决的问题做了明确的阐述:

在Go服务器中,每个传入的请求都在自己的goroutine中处理。请求的处理程序经常启动额外的goroutine来访问后端服务,如数据库和RPC服务。处理一个请求的一组goroutine通常需要访问该请求相关的特定的值,比如最终用户的身份、授权令牌和请求的deadline等。当一个请求被取消或处理超时时,所有在该请求上工作的goroutines应该迅速退出,以便系统可以回收他们正在使用的任何资源。

从这段描述中,我至少get到两点:

  • 传值

后端服务程序有这样的需求,即在处理某请求的函数(Handler Function)中调用其他函数时,传递与请求相关的(request-specific)、请求内容之外的值信息(以下称之为上下文中的值信息),如下图所示:

我们看到:这种函数调用以及传值可以发生在同一goroutine的函数之间(比如上图中的Handler函数调用middleware函数)、同一进程的多个goroutine之间(如被调用函数创建了新的goroutine),甚至是不同进程的goroutine之间(比如rpc调用)。

  • 控制

同一goroutine下因处理外部请求(request)而发生函数调用时,如果被调用的函数(callee)并没有启动新goroutine或进行跨进程的处理(如rpc调用),这时更多的是在函数间传值,即传递上下文中的值信息。

但当被调用的函数(callee)启动新goroutine或进行跨进程处理时,这通常会是一种异步调用。为什么要启动新goroutine进行异步调用呢?更多是为了控制。如果是同步调用,一旦被调用方出现延迟或故障,这次调用很可能长期阻塞,调用者自身既无法消除这种影响,也不能及时回收掉处理这次请求所申请的各种资源,更无法保证服务接口之间的SLA。

注意:调用者与被调用者之间可以是同步调用,也可以是异步调用,而被调用者则通常启动新的goroutine来实现一种“异步调用”。

那么怎么控制异步调用呢?这回我们在调用者与被调用者之间传递的不再是一种值信息,而是一种“默契”,即一种控制机制,如下图所示:

当被调用者在调用者的限定时间内完成任务,调用成功,被调用者释放所有资源;当被调用者无法在限定时间内完成或被调用者收到调用者取消调用的通知时,也能结束调用并释放资源。

接下来,我们就来看看Go标准库context包是如何解决上述两个问题的。

3. context包的构成

Go将对上面两个问题“传值与控制”的解决方案统一放到了context包下的一个名为Context接口类型中了:

// $GOROOT/src/context/context.go
type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key any) any
}

注:“上下文”本没有统一标准,很多第三方包也有自己Context的定义,但Go 1.7之后都逐渐转为使用Go标准库的context.Context了。

如果你读懂了前面context包要解决的问题,你大致也能将Context接口类型中的方法分为两类,第一类就是Value方法,用于解决“传值”的问题;其他三个方法(Deadline、Done和Err)划归为第二类,用于解决“传递控制”的问题。

如果仅仅是定义Context这样一个接口类型,统一了对Context的抽象,那事情就未得到彻底解决(但也比log包做的要好了),Go context包“好人做到底”,还提供了一系列便利的函数以及若干内置的Context接口的实现。下面我们逐一来看一下。

1) WithValue函数

首先我们看一下用于传值的WithValue函数。

// $GOROOT/src/context/context.go
func WithValue(parent Context, key, val any) Context

WithValue函数基于parent Context创建一个新的Context,这个新的Context既保存了一份parent Context的副本,同时也保存了WithValue函数接受的那个key-val对。 WithValue其实返回一个名为*valueCtx类型的实例,*valueCtx实现了Context接口,它由三个字段组成:

// $GOROOT/src/context/context.go

type valueCtx struct {
    Context
    key, val any
}

结合WithValue的实现逻辑,valueCtx中的Context被赋值为parent Context,key和val分别保存了WithValue传入的key和val。

在新Context创建成功后,处理函数后续将基于该新Context进行上下文中的值信息的传递,我们来看一个例子:

// github.com/bigwhite/experiments/tree/master/context-examples/with_value/main.go

package main

import (
    "context"
    "fmt"
)

func f3(ctx context.Context, req any) {
    fmt.Println(ctx.Value("key0"))
    fmt.Println(ctx.Value("key1"))
    fmt.Println(ctx.Value("key2"))
}

func f2(ctx context.Context, req any) {
    ctx2 := context.WithValue(ctx, "key2", "value2")
    f3(ctx2, req)
}

func f1(ctx context.Context, req any) {
    ctx1 := context.WithValue(ctx, "key1", "value1")
    f2(ctx1, req)
}

func handle(ctx context.Context, req any) {
    ctx0 := context.WithValue(ctx, "key0", "value0")
    f1(ctx0, req)
}

func main() {
    rootCtx := context.Background()
    handle(rootCtx, "hello")
}

在上面这段代码中,handle是负责处理“请求”的入口函数,它接受一个由main函数创建的root Context以及请求内容本身(“hello”),之后handle函数基于传入的ctx,通过WithValue函数创建了一个包含了自己附加的key0-value0对的新Context,这个新Context将在调用f1函数时作为上下文传给f1;依次类推,f1、f2都基于传入的ctx通过WithValue函数创建了包含自己附加的值信息的新Context,在函数调用链的末端,f3通过Context的Value方法从传入的ctx中尝试取出上下文中的各种值信息,我们用一幅示意图来展示一下这个过程:

我们运行一下上述代码看看结果:

$go run main.go
value0
value1
value2

我们看到,f3不仅从上下文中取出了f2附加的key2-value2,还可以取出handle、f1等函数附加的值信息。这得益于满足Context接口的*valueCtx类型“顺藤摸瓜”的实现:

// $GOROOT/src/context/context.go

func (c *valueCtx) Value(key any) any {
    if c.key == key {
        return c.val
    }
    return value(c.Context, key)
}

func value(c Context, key any) any {
    for {
        switch ctx := c.(type) {
        case *valueCtx:
            if key == ctx.key {
                return ctx.val
            }
            c = ctx.Context
        case *cancelCtx:
            if key == &cancelCtxKey {
                return c
            }
            c = ctx.Context
        case *timerCtx:
            if key == &cancelCtxKey {
                return &ctx.cancelCtx
            }
            c = ctx.Context
        case *emptyCtx:
            return nil
        default:
            return c.Value(key)
        }
    }
}

我们看到在*valueCtx case中,如果key与当前ctx的key不同,就会继续沿着parent Ctx路径继续查找,直到找到为止。

我们看到:WithValue用起来不难,也好理解。不过由于每个valueCtx仅能保存一对key-val,这样即便在一个函数中添加多个值信息,其使用模式也必须是这样的:

ctx1 := WithValue(parentCtx, key1, val1)
ctx2 := WithValue(ctx1, key2, val2)
ctx3 := WithValue(ctx2, key3, val3)
nextCall(ctx3, req)

而不能是

ctx1 := WithValue(parentCtx, key1, val1)
ctx1 = WithValue(parentCtx, key2, val2)
ctx1 = WithValue(parentCtx, key3, val3)
nextCall(ctx1, req)

否则ctx1中仅会保存最后一次的key3-val3的信息,而key1、key2都会被覆盖掉。

valueCtx的这种设计也导致了Value方法的查找key的效率不是很高,是个O(n)的查找。在一些对性能敏感的Web框架中,valueCtx和WithValue可能难有用武之地。

在上面的例子中,我们说到了root Context,下面简单说一下root Context的构建。

2) root Context构建

root Context,也称为top-level Context,即最顶层的Context,通常在main函数、初始化函数、请求处理的入口(某个Handle函数)中创建。 Go提供了两种root Context的构建方法Background和TODO:

// $GOROOT/src/context/context.go

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

func Background() Context {
    return background
}

func TODO() Context {
    return todo
}

我们看到,虽然标准库提供了两种root Context的创建方法,但它们本质是一样的,底层都返回的是一个与程序同生命周期的emptyCtx类型的实例。有小伙伴可能会问:Go所有代码共享一个root Context会不会有问题呢?

答案是不会!因为root Context啥“实事”也不做,就像“英联邦国王”一样,仅具有名义上的象征意义,它既不会存储上下文值信息,也不会携带上下文控制信息,整个生命周期内它都不会被改变。它只是作为二级上下文parent Context的指向,真正具有“功能”作用的Context是类似于首相或总理的second-level Context:

通常我们都会使用Background()函数构造root Context,而按照context包TODO函数的注释来看,TODO仅在不清楚应该使用哪个Context的情况下临时使用。

3) WithCancel函数

WithCancel函数为上下文提供了第一种控制机制:可取消(cancel),它也是整个context包控制机制的基础。我们先直观感受一下WithCancel的作用,下面是Go context包文档中的一个例子:

package main

import (
    "context"
    "fmt"
)

func main() {
    gen := func(ctx context.Context) <-chan int {
        dst := make(chan int)
        n := 1
        go func() {
            for {
                select {
                case <-ctx.Done():
                    return // returning not to leak the goroutine
                case dst <- n:
                    n++
                }
            }
        }()
        return dst
    }

    ctx, cancel := context.WithCancel(context.Background())
    defer cancel() // cancel when we are finished consuming integers

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

在这个例子,main函数通过WithCancel创建了一个具有可取消属性的Context实例,然后在调用gen函数时传入了该实例。WithCancel函数除了返回一个具有可取消属性的Context实例外,还返回了一个cancelFunc,这个cancelFunc就是握在调用者手里的那个“按钮”,一旦按下该“按钮”,即调用者发出“取消”信号,异步调用中启动的goroutine就应该放下手头工作,老老实实地退出。

就像上面这个示例一样,main函数将cancel Context传给gen后,gen函数启动了一个新goroutine用于生成一组数列,而main函数则从gen返回的channel中读取这些数列中的数。main函数在读完第5个数字后,按下了“按钮”,即调用了cancel Function。这时那个生成数列的goroutine会监听到Done channel有事件,然后完成goroutine的退出。

这就是前面说过的那种调用者和被调用者(以及调用者创建的新goroutine)之间应具备的那种“默契”,这种“默契”要求两者都要基于上下文按一定的“套路”进行处理,在这个例子中就体现在调用者适时调用cancel Function,而gen启动的goroutine要监听可取消Context实例的Done channel

并且通常,我们在创建完一个cancel Context后,立即会通过defer将cancel Function注册到deferred function stack中去,以防止因未调用cancel Function导致的资源泄露!在这个例子中,如果不调用cancel Function,gen函数创建的那个goroutine就会一直运行,虽然它生成的数字已经不会再有其他goroutine消费。

相较于WithValue函数,WithCancel的实现略复杂:

// $GOROOT/src/context/context.go

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 newCancelCtx(parent Context) cancelCtx {
    return cancelCtx{Context: parent}
}

其复杂就复杂在propagateCancel这个调用上:

// propagateCancel arranges for child to be canceled when parent is.
func propagateCancel(parent Context, child canceler) {
    done := parent.Done()
    if done == nil {
        return // parent is never canceled
    }

    select {
    case <-done:
        // parent is already canceled
        child.cancel(false, parent.Err())
        return
    default:
    }

    if p, ok := parentCancelCtx(parent); ok {
        p.mu.Lock()
        if p.err != nil {
            // parent has already been canceled
            child.cancel(false, p.err)
        } else {
            if p.children == nil {
                p.children = make(map[canceler]struct{})
            }
            p.children[child] = struct{}{}
        }
        p.mu.Unlock()
    } else {
        atomic.AddInt32(&goroutines, +1)
        go func() {
            select {
            case <-parent.Done():
                child.cancel(false, parent.Err())
            case <-child.Done():
            }
        }()
    }
}

propagateCancel通过parentCancelCtx向上顺着parent路径查找,之所以可以这样,是因为Value方法具备沿着parent路径查找的特性:

func parentCancelCtx(parent Context) (*cancelCtx, bool) {
    done := parent.Done()
    if done == closedchan || done == nil {
        return nil, false
    }
    p, ok := parent.Value(&cancelCtxKey).(*cancelCtx) // 沿着parent路径查找第一个cancelCtx
    if !ok {
        return nil, false
    }
    pdone, _ := p.done.Load().(chan struct{})
    if pdone != done {
        return nil, false
    }
    return p, true
}

如果找到一个cancelCtx,就将自己加入到该cancelCtx的child map中:

type cancelCtx struct {
    Context

    mu       sync.Mutex            // protects following fields
    done     atomic.Value          // of chan struct{}, created lazily, closed by first cancel call
    children map[canceler]struct{} // set to nil by the first cancel call
    err      error                 // set to non-nil by the first cancel call
}

注:接口类型值是支持比较的,如果两个接口类型值的动态类型相同且动态类型的值相同,那么两个接口类型值就相同。这也是children这个map用canceler接口作为key的原因。

这样当其parent cancelCtx的cancel Function被调用时,cancel function会调用cancelCtx的cancel方法,cancel方法会遍历所有children cancelCtx,然后调用child的cancel方法以达到关联取消的目的,同时该parent cancelCtx会与所有children cancelCtx解除关系!

func (c *cancelCtx) cancel(removeFromParent bool, err error) {
    if err == nil {
        panic("context: internal error: missing cancel error")
    }
    c.mu.Lock()
    if c.err != nil {
        c.mu.Unlock()
        return // already canceled
    }
    c.err = err
    d, _ := c.done.Load().(chan struct{})
    if d == nil {
        c.done.Store(closedchan)
    } else {
        close(d)
    }
    for child := range c.children { // 遍历children,调用cancel方法
        // NOTE: acquiring the child's lock while holding parent's lock.
        child.cancel(false, err)
    }
    c.children = nil // 解除与children的关系
    c.mu.Unlock()

    if removeFromParent {
        removeChild(c.Context, c)
    }
}

我们用一个例子来演示一下:

// github.com/bigwhite/experiments/tree/master/context-examples/with_cancel/cancelctx_map.go

package main

import (
    "context"
    "fmt"
    "time"
)

// 直接使用parent cancelCtx
func f1(ctx context.Context) {
    go func() {
        select {
        case <-ctx.Done():
            fmt.Println("goroutine created by f1 exit")
        }
    }()
}

// 基于parent cancelCtx创建新的cancelCtx
func f2(ctx context.Context) {
    ctx1, _ := context.WithCancel(ctx)
    go func() {
        select {
        case <-ctx1.Done():
            fmt.Println("goroutine created by f2 exit")
        }
    }()
}

// 使用基于parent cancelCtx创建的valueCtx
func f3(ctx context.Context) {
    ctx1 := context.WithValue(ctx, "key3", "value3")
    go func() {
        select {
        case <-ctx1.Done():
            fmt.Println("goroutine created by f3 exit")
        }
    }()
}

// 基于parent cancelCtx创建的valueCtx之上创建cancelCtx
func f4(ctx context.Context) {
    ctx1 := context.WithValue(ctx, "key4", "value4")
    ctx2, _ := context.WithCancel(ctx1)
    go func() {
        select {
        case <-ctx2.Done():
            fmt.Println("goroutine created by f4 exit")
        }
    }()
}

func main() {
    valueCtx := context.WithValue(context.Background(), "key0", "value0")
    cancelCtx, cf := context.WithCancel(valueCtx)
    f1(cancelCtx)
    f2(cancelCtx)
    f3(cancelCtx)
    f4(cancelCtx)

    time.Sleep(3 * time.Second)
    fmt.Println("cancel all by main")
    cf()
    time.Sleep(10 * time.Second) // wait for log output
}

上面这个示例演示了四种情况:

  • f1: 直接使用parent cancelCtx
  • f2: 基于parent cancelCtx创建新的cancelCtx
  • f3: 使用基于parent cancelCtx创建的valueCtx
  • f4: 使用基于parent cancelCtx创建的valueCtx之上创建的cancelCtx

运行这个示例,我们得到:

cancel all by main
goroutine created by f1 exit
goroutine created by f2 exit
goroutine created by f3 exit
goroutine created by f4 exit

我们看到,无论是直接使用parent cancelCtx,还是使用基于parent cancelCtx创建的其他各种Ctx,当parent cancelCtx的cancel Function被调用后,所有监听对应child Done channel的goroutine都能正确收到通知并退出。

当然这种“取消通知”只能由parent通知到下面的children,反过来则不行,parent cancelCtx不会因为child Context的cancel function被调用而被cancel掉。另外如果某个children cancelCtx的cancel Function被调用后,该children会与其parent cancelCtx解绑。

在前面贴出的propagateCancel函数的实现中,我们还看到了另外一个分支,即parentCancelCtx函数返回的ok为false时,propagateCancel函数会启动一个新的goroutine监听parent Done channel和自身的Done channel。什么情况下会走到这个执行分支下呢?这种情况似乎不多!我们来看一个自定义cancelCtx的情况:

package main

import (
    "context"
    "fmt"
    "runtime"
    "time"
)

func f1(ctx context.Context) {
    ctx1, _ := context.WithCancel(ctx)
    go func() {
        select {
        case <-ctx1.Done():
            fmt.Println("goroutine created by f1 exit")
        }
    }()
}

type myCancelCtx struct {
    context.Context
    done chan struct{}
    err  error
}

func (ctx *myCancelCtx) Done() <-chan struct{} {
    return ctx.done
}

func (ctx *myCancelCtx) Err() error {
    return ctx.err
}

func WithMyCancelCtx(parent context.Context) (context.Context, context.CancelFunc) {
    var myCtx = &myCancelCtx{
        Context: parent,
        done:    make(chan struct{}),
    }

    return myCtx, func() {
        myCtx.done <- struct{}{}
        myCtx.err = context.Canceled
    }
}

func main() {
    valueCtx := context.WithValue(context.Background(), "key0", "value0")
    fmt.Println("before f1:", runtime.NumGoroutine())

    myCtx, mycf := WithMyCancelCtx(valueCtx)
    f1(myCtx)
    fmt.Println("after f1:", runtime.NumGoroutine())

    time.Sleep(3 * time.Second)
    mycf()
    time.Sleep(10 * time.Second) // wait for log output
}

在这个例子中,我们“部分逃离”了context cancelCtx的体系并自定义了一个实现了Context接口的myCancelCtx,在这样的情况下,当f1函数基于myCancelCtx构建自己的child CancelCtx时,由于向上找不到*cancelCtx类型,所以它WithCancel启动了一个goroutine既监听自己的Done channel,也监听其parent Ctx(即myCancelCtx)的Done channel。

当myCancelCtx的cancel Function在main函数中被调用时(mycf()),新建的goroutine会调用child的cancel函数实现操作取消。运行上面示例,我们得到如下结果:

$go run custom_cancelctx.go
before f1: 1
after f1: 3  // 在context包中新创建了一个goroutine
goroutine created by f1 exit

由此,我们看到,除了“业务”层面可能导致的资源泄露之外,cancel Context的实现中也会有一些资源(比如上面这个新建的goroutine)需要及时释放,否则也会导致“泄露”。

一些小伙伴可能会问这样一个问题:在被调用函数(callee)中,到底是继续传递原cancelCtx给新建的goroutine,还是基于parent cancelCtx创建一个新的cancelCtx再传给goroutine用呢?这让我想起了装修时遇到的一个问题:是否在水管某些地方加阀门?

加上阀门,可以单独控制一路的关闭!同样在代码中,基于parent cancelCtx创建新的cancelCtx可以做单独取消操作,而不影响parentCtx,这就看业务层代码是否需要这么做了。

到这里,我们已经get到了context包提供的取消机制,但实际中,我们很难拿捏好cancel Function调用的时机。为此,context包提供了另外一个建构在cancelCtx之上的实用控制机制:timerCtx。接下来,我们就来看看timerCtx。

4) WithDeadline和WithTimeout函数

timerCtx基于cancelCtx提供了一种基于deadline的取消控制机制:

type timerCtx struct {
    cancelCtx
    timer *time.Timer // Under cancelCtx.mu.

    deadline time.Time
}

context包提供了两个创建timerCtx的API:WithDeadline和WithTimeout函数:

// $GOROOT/src/context/context.go

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
    if parent == nil {
        panic("cannot create context from nil parent")
    }
    if cur, ok := parent.Deadline(); ok && cur.Before(d) {
        // The current deadline is already sooner than the new one.
        return WithCancel(parent)
    }
    c := &timerCtx{
        cancelCtx: newCancelCtx(parent),
        deadline:  d,
    }
    propagateCancel(parent, c)
    dur := time.Until(d)
    if dur <= 0 {
        c.cancel(true, DeadlineExceeded) // deadline has already passed
        return c, func() { c.cancel(false, Canceled) }
    }
    c.mu.Lock()
    defer c.mu.Unlock()
    if c.err == nil {
        c.timer = time.AfterFunc(dur, func() {
            c.cancel(true, DeadlineExceeded)
        })
    }
    return c, func() { c.cancel(true, Canceled) }
}

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
    return WithDeadline(parent, time.Now().Add(timeout))
}

从实现来看,WithTimeout就是WithDeadline的再包装!我们弄懂WithDeadline即可。从WithDeadline的实现来看,该函数通过time.AfterFunc设置了一个定时器,定时器fire后的执行逻辑就是执行该ctx的cancel Function。也就是说timerCtx既支持手工cancel(原cancelCtx的机制),也支持定时cancel,并且通常由定时器来完成cancel。

有了cancelCtx的基础,timerCtx就不难理解了。不要要注意的一点时,即便有了定时器来cancel操作,我们也不要忘记显式调用WithDeadline和WithTimeout返回的cancel function,及早释放资源不是更好么!

4. 小结

本文对Go标准库context包要解决的问题、context包构成以及传值和传递控制的原理做了简要讲解,相信读完这些内容后,你再回头去看你写过的运用context包的代码肯定会有更为深刻的理解。

context包目前在Go生态内得到广泛应用,较为典型的是在http handler中传递值信息、在tracing框架中通过在上下文中的trace ID来整合tracing信息等。

Go社区对context包的声音也不全是正面,其中context.Context具有“病毒般”的传染性就是被集中诟病的方面。Go官方也有一个issue记录了Go社区对context包的反馈和优化建议,有兴趣的小伙伴可以去翻翻。

本文的context包源码来自Go 1.19.1版本,与老版本Go或Go的未来版本可能会有差别。

本文的源码在这里可以下载。

5. 参考资料

  • context包文档手册 – https://pkg.go.dev/context
  • Go Concurrency Patterns: Context – https://go.dev/blog/context

“Gopher部落”知识星球旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!2022年,Gopher部落全面改版,将持续分享Go语言与Go应用领域的知识、技巧与实践,并增加诸多互动形式。欢迎大家加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

我爱发短信:企业级短信平台定制开发专家 https://tonybai.com/。smspush : 可部署在企业内部的定制化短信平台,三网覆盖,不惧大并发接入,可定制扩展; 短信内容你来定,不再受约束, 接口丰富,支持长短信,签名可选。2020年4月8日,中国三大电信运营商联合发布《5G消息白皮书》,51短信平台也会全新升级到“51商用消息平台”,全面支持5G RCS消息。

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻)归档仓库 – https://github.com/bigwhite/gopherdaily

我的联系方式:

  • 微博:https://weibo.com/bigwhite20xx
  • 博客:tonybai.com
  • github: https://github.com/bigwhite

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

Go 1.19中值得关注的几个变化

本文永久链接 – https://tonybai.com/2022/08/22/some-changes-in-go-1-19

我们知道Go团队在2015年重新规定了团队发布版本的节奏,将Go大版本的发布频率确定为每年两次,发布窗口定为每年的2月与8月。而实现自举的Go 1.5版本是这一个节奏下发布的第一个版本。一般来说,Go团队都会在这两个窗口的中间位置发布版本,不过这几年也有意外,比如承载着泛型落地责任的Go 1.18版本就延迟了一个月发布。

就在我们以为Go 1.19版本不会很快发布的时候,美国时间2022年8月2日,Go核心团队正式发布了Go 1.19版本,这个时间不仅在发布窗口内而且相对于惯例还提前了。为什么呢?很简单,Go 1.19是一个“小”版本,当然这里的“小”是相对于Go 1.18那样的“大”而言的。Go 1.19版本开发周期仅有2个月左右(3~5月初),这样Go团队压缩了添加到Go 1.19版本中的feature数量。

不过尽管如此,Go 1.19中依然有几个值得我们重点关注的变化点,在这篇文章中我就和大家一起来看一下。

一. 综述

在6月份(那时Go 1.19版本已经Freeze),我曾写过一篇《Go 1.19新特性前瞻》,简要介绍了当时基本确定的Go 1.19版本的一些新特性,现在来看,和Go 1.19版本正式版差别不大。

  • 泛型方面

考虑到Go 1.18泛型刚刚落地,Go 1.18版本中的泛型并不是完全版。但Go 1.19版本也没有急于实现泛型设计文档)中那些尚未实现的功能特性,而是将主要精力放在了修复Go 1.18中发现的泛型实现问题上了,目的是夯实Go泛型的底座,为Go 1.20以及后续版本实现完全版泛型奠定基础(详细内容可查看《Go 1.19新特性前瞻》一文)。

  • 其他语法方面

无,无,无!重要的事情说三遍。

这样,Go 1.19依旧保持了Go1兼容性承诺。

  • 正式在linux上支持龙芯架构(GOOS=linux, GOARCH=loong64)

这一点不得不提,因为这一变化都是国内龙芯团队贡献的。不过目前龙芯支持的linux kernel版本最低也是5.19,意味着龙芯在老版本linux上还无法使用Go。

  • go env支持CGO_CFLAGS, CGO_CPPFLAGS, CGO_CXXFLAGS, CGO_FFLAGS, CGO_LDFLAGS和GOGCCFLAGS

当你想设置全局的而非包级的CGO构建选项时,可以通过这些新加入的CGO相关环境变量进行,这样就可以避免在每个使用Cgo的Go源文件中使用cgo指示符来分别设置了。

目前这些用于CGO的go环境变量的默认值如下(以我的macos上的默认值为例):

CGO_CFLAGS="-g -O2"
CGO_CPPFLAGS=""
CGO_CXXFLAGS="-g -O2"
CGO_FFLAGS="-g -O2"
CGO_LDFLAGS="-g -O2"
GOGCCFLAGS="-fPIC -arch x86_64 -m64 -pthread -fno-caret-diagnostics -Qunused-arguments -fmessage-length=0 -fdebug-prefix-map=/var/folders/cz/sbj5kg2d3m3c6j650z0qfm800000gn/T/go-build1672298076=/tmp/go-build -gno-record-gcc-switches -fno-common"

其他更具体的变化就不赘述了,大家可以移步《Go 1.19新特性前瞻》看看。

下面我们重点说说Go 1.19中的两个重要变化:新版Go内存模型文档与Go运行时引入Soft memory limit

二. 修订Go内存模型文档

记得当年初学Go的时候,所有Go官方文档中最难懂的一篇就属Go内存模型文档(如下图)这一篇了,相信很多gopher在初看这篇文档时一定有着和我相似的赶脚^_^。


图:老版Go内存模型文档

注:查看老版Go内存模型文档的方法:godoc -http=:6060 -goroot /Users/tonybai/.bin/go1.18.3,其中godoc已经不随着go安装包分发了,需要你单独安装,命令为:go install golang.org/x/tools/cmd/godoc。

那么,老版内存模型文档说的是啥呢?为什么要修订?搞清这两个问题,我们就大致知道新版内存模型文档的意义了。 我们先来看看什么是编程语言的内存模型。

1. 什么是内存模型?

提到内存模型,我们要从著名计算机科学家,2013年图灵奖得主Leslie Lamport在1979发表的名为《How to Make a Multiprocessor Computer That Correctly Executes Multiprocess Programs》的论文说起。

在这篇文章中,Lamport给出了多处理器计算机在共享内存的情况下并发程序正确运行的条件,即多处理器要满足顺序一致性(sequentially consistent)

文中提到:一个高速运行的处理器不一定按照程序指定的顺序(代码顺序)执行。如果一个处理器的执行结果(可能是乱序执行)与按照程序指定的顺序(代码顺序)执行的结果一致,那么说这个处理器是有序的(sequential)

而对于一个共享内存的多处理器而言,只有满足下面条件,才能被认定是满足顺序一致性的,即具备保证并发程序正确运行的条件:

  • 任何一次执行的结果,都和所有处理器的操作按照某个顺序执行的结果一致;
  • 在“某个顺序执行”中单独看每个处理器,每个处理器也都是按照程序指定的顺序(代码顺序)执行的。

顺序一致性就是一个典型的共享内存、多处理器的内存模型,这个模型保证了所有的内存访问都是以原子方式和按程序顺序进行的。下面是一个共享内存的顺序一致性的抽象机器模型示意图,图来自于《A Tutorial Introduction to the ARM and POWER Relaxed Memory Models》

根据顺序一致性,上面图中的抽象机器具有下面特点:

  • 没有本地的重新排序:每个硬件线程按照程序指定的顺序执行指令,完成每条指令(包括对共享内存的任何读或写)后再开始下一条。
  • 每条写入指令对所有线程(包括进行写入的线程)都是同时可见的。

从程序员角度来看,顺序一致性的内存模型是再理想不过了。所有读写操作直面内存,没有缓存,一个处理器(或硬件线程)写入内存的值,其他处理器(或硬件线程)便可以观察到。借助硬件提供的顺序一致性(SC),我们可以实现“所写即所得”。

但是这样的机器真的存在吗?并没有,至少在量产的机器中并没有。为什么呢?因为顺序一致性不利于硬件和软件的性能优化。真实世界的共享内存的多处理器计算机的常见机器模型是这样的,也称为Total Store Ordering,TSO模型(图来自《A Tutorial Introduction to the ARM and POWER Relaxed Memory Models》):

我们看到,在这种机器下,所有处理器仍连接到单个共享内存,但每个处理器的写内存操作从写入共享内存变为了先写入本处理器的写缓存队列(write buffer),这样处理器无需因要等待写完成(write complete)而被阻塞,并且一个处理器上的读内存操作也会先查阅本处理器的写缓存队列(但不会查询其他处理器的写缓存队列)。写缓存队列的存在极大提升了处理器写内存操作的速度。

但也正是由于写缓存的存在,TSO模型无法满足顺序一致性,比如:“每条写入指令对所有线程(包括进行写入的线程)都是同时可见的”这一特性就无法满足,因为写入本地写缓存队列的数据在未真正写入共享内存前只对自己可见,对其他处理器(硬件线程)并不可见。

根据Lamport的理论,在不满足SC的多处理器机器上程序员没法开发出可以正确运行的并发程序(Data Race Free, DRF),那么怎么办呢?处理器提供同步指令给开发者。对开发者而言,有了同步指令的非SC机器,具备了SC机器的属性。只是这一切对开发人员不是自动的/透明的了,需要开发人员熟悉同步指令,并在适当场合,比如涉及数据竞争Data Race的场景下正确使用,这大大增加了开发人员的心智负担。

开发人员通常不会直面硬件,这时就要求高级编程语言对硬件提供的同步指令进行封装并提供给开发人员,这就是编程语言的同步原语。而编程语言使用哪种硬件同步指令,封装出何种行为的同步原语,怎么应用这些原语,错误的应用示例等都是需要向编程语言的使用者进行说明的。而这些都将是编程语言内存模型文档的一部分。

如今主流的编程语言的内存模型都是顺序一致性(SC)模型,它为开发人员提供了一种理想的SC机器(虽然实际中的机器并非SC的),程序是建构在这一模型之上的。但就像前面说的,开发人员要想实现出正确的并发程序,还必须了解编程语言封装后的同步原语以及他们的语义。只要程序员遵循并发程序的同步要求合理使用这些同步原语,那么编写出来的并发程序就能在非SC机器上跑出顺序一致性的效果

知道了编程语言内存模型的含义后,接下来,我们再来看看老版Go内存模型文档究竟表述了什么。

2. Go内存模型文档

按照上面的说明,Go内存模型文档描述的应该是要用Go写出一个正确的并发程序所要具备的条件

再具体点,就像老版内存模型文档开篇所说的那样:Go内存模型规定了一些条件,一旦满足这些条件,当在一个goroutine中读取一个变量时,Go可以保证它可以观察到不同goroutine中对同一变量的写入所产生的新值

接下来,内存模型文档就基于常规的happens-before定义给出了Go提供的各种同步操作及其语义,包括:

  • 如果一个包p导入了包q,那么q的init函数的完成发生在p的任何函数的开始之前。
  • 函数main.main的开始发生在所有init函数完成之后。
  • 启动一个新的goroutine的go语句发生在goroutine的执行开始之前。
  • 一个channel上的发送操作发生在该channel的对应接收操作完成之前。
  • 一个channel的关闭发生在一个返回零值的接收之前(因为该channel已经关闭)。
  • 一个无缓冲的channel的接收发生在该channel的发送操作完成之前。
  • 一个容量为C的channel上的第k个接收操作发生在该channel第k+C个发送操作完成之前。
  • 对于任何sync.Mutex或sync.RWMutex变量l,当n<m时,第n次l.Unlock调用发生在第m次调用l.Lock()返回之前。
  • once.Do(f)中的f()调用发生在对once.Do(f)的任何一次调用返回之前。

接下来,内存模型文档还定义了一些误用同步原语的例子。

那么新内存模型文档究竟更新了哪些内容呢?我们继续往下看。

3. 修订后的内存模型文档都有哪些变化


图:修订后的Go内存模型文档

负责更新内存模型文档的Russ Cox首先增加了Go内存模型的总体方法(overall approach)

Go的总体方法在C/C++和Java/Js之间,既不像C/C++那样将存在Data race的程序定义为违法的,让编译器以未定义行为处置它,即运行时表现出任意可能的行为;又不完全像Java/Js那样尽量明确Data Race情况下各种语义,将Data race带来的影响限制在最小,使程序更为可靠。

Go对于一些存在data Race的情况会输出race报告并终止程序,比如多goroutine在未使用同步手段下对map的并发读写。除此之外,Go对其他存数据竞争的场景有明确的语义,这让程序更可靠,也更容易调试。

其次,新版Go内存模型文档增补了对这些年sync包新增的API的说明,比如: mutex.TryLock、mutex.TryRLock等。而对于sync.Cond、Map、Pool、WaitGroup等文档没有逐一描述,而是建议看API文档。

在老版内存模型文档中,没有对sync/atom包进行说明,新版文档增加了对atom包以及runtime.SetFinalizer的说明。

最后,文档除了提供不正确同步的例子,还增加了对不正确编译的例子的说明。

另外这里顺便提一下:Go 1.19在atomic包中引入了一些新的原子类型,包括: Bool, Int32, Int64, Uint32, Uint64, Uintptr和Pointer。这些新类型让开发人员在使用atomic包是更为方便,比如下面是Go 1.18和Go 1.19使用Uint64类型原子变量的代码对比:

对比Uint64的两种作法:

// Go 1.18

var i uint64
atomic.AddUint64(&i, 1)
_ = atomic.LoadUint64(&i)

vs.

// Go 1.19
var i atomic.Uint64 // 默认值为0
i.Store(17) // 也可以通过Store设置初始值
i.Add(1)
_ = i.Load()

atomic包新增的Pointer,避免了开发人员在使用原子指针时自己使用unsafe.Pointer进行转型的麻烦。同时atomic.Pointer是一个泛型类型,如果我没记错,它是Go 1.18加入comparable预定义泛型类型之后,第一次在Go中引入基于泛型的标准库类型:

// $GOROOT/src/sync/atomic/type.go

// A Pointer is an atomic pointer of type *T. The zero value is a nil *T.
type Pointer[T any] struct {
    _ noCopy
    v unsafe.Pointer
}

// Load atomically loads and returns the value stored in x.
func (x *Pointer[T]) Load() *T { return (*T)(LoadPointer(&x.v)) }

// Store atomically stores val into x.
func (x *Pointer[T]) Store(val *T) { StorePointer(&x.v, unsafe.Pointer(val)) }

// Swap atomically stores new into x and returns the previous value.
func (x *Pointer[T]) Swap(new *T) (old *T) { return (*T)(SwapPointer(&x.v, unsafe.Pointer(new))) }

// CompareAndSwap executes the compare-and-swap operation for x.
func (x *Pointer[T]) CompareAndSwap(old, new *T) (swapped bool) {
    return CompareAndSwapPointer(&x.v, unsafe.Pointer(old), unsafe.Pointer(new))
}

此外,atomic包新增的Int64和Uint64类型还有一个特质,那就是Go保证其地址可以自动对齐到8字节上(即地址可以被64整除),即便在32位平台上亦是如此,这可是连原生int64和uint64也尚无法做到的

go101在推特上分享了一个基于atomic Int64和Uint64的tip。利用go 1.19新增的atomic.Int64/Uint64,我们可以用下面方法保证结构体中某个字段一定是8 byte对齐的,即该字段的地址可以被64整除。

import "sync/atomic"

type T struct {
    _ [0]atomic.Int64
    x uint64 // 保证x是8字节对齐的
}

前面的代码中,为何不用_ atomic.Int64呢,为何用一个空数组呢,这是因为空数组在go中不占空间,大家可以试试输出上面结构体T的size,看看是不是8。

三. 引入Soft memory limit

1. 唯一GC调优选项:GOGC

近几个大版本,Go GC并没有什么大的改动/优化。和其他带GC的编程语言相比,Go GC算是一个奇葩的存在了:对于开发者而言,Go 1.19版本之前,Go GC的调优参数仅有一个:GOGC(也可以通过runtime/debug.SetGCPercent调整)。

GOGC默认值为100,通过调整它的值,我们可以调整GC触发的时机。计算下一次触发GC的堆内存size的公式如下:

// Go 1.18版本之前
目标堆大小 = (1+GOGC/100) * live heap // live heap为上一次GC标记后的堆上的live object的总size

// Go 1.18版本及之后
目标堆大小 = live heap + (live heap + GC roots) * GOGC / 100

注:Go 1.18以后将GC roots(包括goroutine栈大小和全局变量中的指针对象大小)纳入目标堆大小的计算

以Go 1.18之前的版本为例,当GOGC=100(默认值)时,如果某一次GC后的live heap为10M,那么下一次GC开启的目标堆heap size为20M,即在两次GC之间,应用程序可以分配10M的新堆对象。

可以说GOGC控制着GC的运行频率。当GOGC值设置的较小时,GC运行的就频繁一些,参与GC工作的cpu的比重就多一些;当GOGC的值设置的较大时,GC运行的就不那么频繁,相应的参与GC工作的cpu的比重就小一些,但要承担内存分配接近资源上限的风险。

这样一来,摆在开发者面前的问题就是:GOGC的值很难选,这唯一的调优选项也就成为了摆设。

同时,Go runtime是不关心资源limit的,只是会按照应用的需求持续分配内存,并在自身内存池不足的情况下向OS申请新的内存资源,直到内存耗尽(或到达平台给应用分配的memory limit)而被oom killed!

为什么有了GC,Go应用还是会因耗尽系统memory资源而被oom killed呢?我们继续往下看。

2. Pacer的问题

上面的触发GC的目标堆大小计算公式,在Go runtime内部被称为pacer算法,pacer中文有翻译成“起搏器”的,有译成“配速器”的。不管译成啥,总而言之它是用来控制GC触发节奏的

不过pacer目前的算法是无法保证你的应用不被OOM killed的,举个例子(见下图):

在这个例子中:

  • 一开始live heap始终平稳,净增的heap object保持0,即新分配的heap object与被清扫掉的heap object相互抵消。
  • 后续在(1)处出现一次target heap的跃升(从h/2->h),原因显然是live heap object变多了,都在用,即便触发GC也无法清除。不过此时target heap(h)是小于hard memory limit的;
  • 程序继续执行,在(2)处,又出现一次target heap的跃升(从h->2h),而live heap object也变多了,稳定在h,此时,target heap变为2h,高于hard memory limit了;
  • 后续程序继续执行,当live heap object到达(3)时,实际Go的堆内存(包括未清理的)超过了hard memory limit,但由于尚未到达target heap(2h),GC没有被执行,因此应用被oom killed。

我们看到这个例子中,并非Go应用真正需要那么多内存(如果有GC及时清理,live heap object就在(3)的高度),而是Pacer算法导致了没能及时触发GC

那么如何尽可能的避免oom killed呢?我们接下来看一下Go社区给出了两个“民间偏方”。

3. Go社区的GC调优方案

这两个“偏方”, 一个是twitch游戏公司给出的memory ballast(内存压舱石),另外一个则是像uber这样的大厂采用的自动GC动态调优方案。当然这两个方案不光是要避免oom,更是为了优化GC,提高程序的执行效率。

下面我们分别简单介绍一下。先来说说twitch公司的memory ballast。twitch的Go服务运行在具有64G物理内存的VM上,通过观察运维人员发现,服务常驻的物理内存消耗仅为400多M,但Go GC的启动却十分频繁,这导致其服务响应的时间较长。twitch的工程师考虑充分利用内存,降低GC的启动频率,从而降低服务的响应延迟。

于是他们想到了一种方法,他们在服务的main函数初始化环节像下面这样声明了一个10G容量的大切片,并保证这个切片在程序退出前不被GC释放掉:

func main() {
    // Create a large heap allocation of 10 GiB
    ballast := make([]byte, 10<<30)

    // Application execution continues
    // ...

    runtime.Keepalive(ballast)
    // ... ...
}

这个切片由于太大,将在堆上分配并被runtime跟踪,但这个切片并不会给应用带去实质上的物理内存消耗,这得益于os对应用进程内存的延迟簿记:只有读写的内存才会导致缺页中断并由OS为之分配物理内存。从类似top的工具来看,这10个G的字节仅会记录在VIRT/VSZ(虚拟内存)上,而不会记录在RES/RSS(常驻内存)上。

这样一来,根据前面Pacer算法的原理,触发GC的下一个目标堆大小就至少为20G,在Go服务分配堆内存到20G之前GC都不会被触发,所有cpu资源都会被用来处理业务,这也与twitch的实测结果一致(GC次数下降99%)。

一旦到了20G,由于之前观测的结果是服务仅需400多M物理内存,大量heap object会被回收,Go服务的live heap会回到400多M,但重新计算目标堆内存时,由于前面那个“压舱石”的存在,目标堆内存已经会在至少20G的水位上,就这样GC次数少了,GC少了,worker goroutine参加“劳役”的时间就少了,cpu利用率高了,服务响应的延迟也下来了。

注:“劳役”是指worker goroutine在mallocgc内存时被runtime强制“劳役”:停下自己手头的工作,去辅助GC做heap live object的mark。

不过使用该方案的前提是你对你的Go服务的内存消耗情况(忙闲时)有着精确的了解,这样才能结合硬件资源情况设定合理的ballast值。

按照Soft memory limit proposal的说法,该方案的弊端如下:

  • 不能跨平台移植,据说Windows上不适用(压舱石的值会直接反映为应用的物理内存占用);
  • 不能保证随着Go运行时的演进而继续正常工作(比如:一旦pacer算法发生了巨大变化);
  • 开发者需要进行复杂的计算并估计运行时内存开销以选择适合的ballast大小。

接下来我们再来看看自动GC动态调优方案。

去年12月,uber在其官方博客分享了uber内部使用的半自动化Go GC调优方案,按uber的说法,这种方案实施后帮助uber节省了70K cpu核的算力。其背后的原理依旧是从Pacer的算法公式出发,改变原先Go服务生命周期全程保持GOGC值静态不变的作法,在每次GC时,依据容器的内存限制以及当前的live heap size动态计算并设置GOGC值,从而实现对内存不足oom-killed的保护,同时最大程度利用内存,改善Gc对cpu的占用率。

显然这种方案更为复杂,需要有一个专家团队来保证这种自动调优的参数的设置与方案的实现。

4. 引入Soft memory limit

其实Go GC pacer的问题还有很多, Go核心团队开发者Michael Knyszek提了一个pacer问题综述的issue,将这些问题做了汇总。但问题还需一个一个解决,在Go 1.19这个版本中,Michael Knyszek就带来了他的Soft memory limit的解决方案

这个方案在runtime/debug包中添加了一个名为SetMemoryLimit的函数以及GOMEMLIMIT环境变量,通过他们任意一个都可以设定Go应用的Memory limit。

一旦设定了Memory limit,当Go堆大小达到“Memory limit减去非堆内存后的值”时,一轮GC会被触发。即便你手动关闭了GC(GOGC=off),GC亦是会被触发。

通过原理我们可以看到,这个特性最直接解决的就是oom-killed这个问题!就像前面pacer问题示意图中的那个例子,如果我们设定了一个比hard memory limit小一些的soft memory limit的值,那么在(3)那个点便不会出现oom-killed,因为在那之前soft memory limit就会触发一次GC,将一些无用的堆内存回收掉了。

但我们也要注意:soft memory limit不保证不会出现oom-killed,这个也很好理解。如果live heap object到达limit了,说明你的应用内存资源真的不够了,是时候扩内存条资源了,这个是GC无论如何都无法解决的问题。

但如果一个Go应用的live heap object超过了soft memory limit但还尚未被kill,那么此时GC会被持续触发,但为了保证在这种情况下业务依然能继续进行,soft memory limit方案保证GC最多只会使用50%的CPU算力,以保证业务处理依然能够得到cpu资源。

对于GC触发频率高,要降低GC频率的情况,soft memory limit的方案就是关闭GC(GOGC=off),这样GC只有当堆内存到达soft memory limit值时才会触发,可以提升cpu利用率。不过有一种情况,Go官方的GC guide中不建议你这么做,那就是当你的Go程序与其他程序共享一些有限的内存时。这时只需保留内存限制并将其设置为一个较小的合理值即可,因为它可能有助于抑制不良的瞬时行为。

那么多大的值是合理的soft memory limit值呢?在Go服务独占容器资源时,一个好的经验法则是留下额外的5-10%的空间,以考虑Go运行时不知道的内存来源。uber在其博客中设定的limit为资源上限的70%,也是一个不错的经验值。

四. 小结

也许Go 1.19因开发周期的压缩给大家带来的惊喜并不多。不过特性虽少,却都很实用,比如上面的soft memory limit,一旦用好,便可以帮助大家解决大问题。

而拥有正常开发周期的Go 1.20已经处于积极的开发中,从目前里程碑中规划的功能和改进来看,Go泛型语法将得到进一步的补全,向着完整版迈进,就这一点就值得大家期待了!

五. 参考资料

  • Russ Cox内存模型系列 – https://research.swtch.com/mm
  • 关于Go内存模型的讨论 – https://github.com/golang/go/discussions/47141
  • How to Make a Multiprocessor Computer That Correctly Executes Multiprocess Programs- https://www.microsoft.com/en-us/research/publication/make-multiprocessor-computer-correctly-executes-multiprocess-programs
  • A Tutorial Introduction to the ARM and POWER Relaxed Memory Models- https://www.cl.cam.ac.uk/~pes20/ppc-supplemental/test7.pdf
  • Weak Ordering – A New Definition- https://people.eecs.berkeley.edu/~kubitron/courses/cs258-S08/handouts/papers/adve-isca90.pdf
  • Foundations of the C++ Concurrency Memory Model – https://www.hpl.hp.com/techreports/2008/HPL-2008-56.pdf
  • Go GC pacer原理 – https://docs.google.com/document/d/1wmjrocXIWTr1JxU-3EQBI6BK6KgtiFArkG47XK73xIQ/edit

“Gopher部落”知识星球旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!2022年,Gopher部落全面改版,将持续分享Go语言与Go应用领域的知识、技巧与实践,并增加诸多互动形式。欢迎大家加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

我爱发短信:企业级短信平台定制开发专家 https://tonybai.com/。smspush : 可部署在企业内部的定制化短信平台,三网覆盖,不惧大并发接入,可定制扩展; 短信内容你来定,不再受约束, 接口丰富,支持长短信,签名可选。2020年4月8日,中国三大电信运营商联合发布《5G消息白皮书》,51短信平台也会全新升级到“51商用消息平台”,全面支持5G RCS消息。

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻)归档仓库 – https://github.com/bigwhite/gopherdaily

我的联系方式:

  • 微博:https://weibo.com/bigwhite20xx
  • 博客:tonybai.com
  • github: https://github.com/bigwhite

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats