乐趣区

关于后端:你真的了解-syncOnce-吗

是什么

援用官网形容的一段话,Once is a object that will perform exactly one action,即它是一个对象,它提供了保障某个动作只被执行一次的性能。最典型的场景当然就是单例对象的初始化操作。

咋么做

Once 的代码很简洁,从头到尾加正文不超过 70 行代码。对外裸露了一个惟一接口 Do(f func()),应用起来也是非常简单。

package main

import (
  "fmt"
  "sync"
)

func main() {
  var once sync.Once
  fun1 := func() {fmt.Println("第一次打印")
  }
  once.Do(fun1)
  
  fun2 := func() {fmt.Println("第二次打印")
  }

  once.Do(fun2)
}

在运行下面这段代码之后,从后果中你会发现只运行了 fun1。这样看如同没什么问题,然而这段代码并不是并发的调用 Do(),那就略微调整一下代码:

package main

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

func main() {
  var once sync.Once
  for i := 0; i < 5; i++ {go func(i int) {fun1 := func() {fmt.Printf("i:=%d\\n", i)
      }
      once.Do(fun1)
    }(i)
  }
  // 为了避免主 goroutine 间接运行完了,啥都看不到
  time.Sleep(50 \* time.Millisecond)
}

咱们开启了 5 个并发的 goroutine,不论你咋么运行,始终只打印一次,至于 i 是多少,就看先执行的是哪个 g 了。Once 保障只有第一次调用 Do() 办法时,传递的 f ( 无参数无返回值的函数) 才会执行,并且之后不论调用的参数是否扭转了,也不再执行。

咋么实现

在看一个性能的同时,其实咱们自身也能够站在技术的角度上来思考,如果是你,你会咋么实现这个 Once。我感觉这是件很有意思的事件。

第一工夫想到的就是 go 中开箱即用的 sync.Mutex 的  Lock() 办法的第一段:

// Lock locks m.
// If the lock is already in use, the calling goroutine
// blocks until the mutex is available.
func (m \*Mutex) Lock() {
  // Fast path: grab unlocked mutex.
  if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
      ......
        return
  }
   ......

}

利用 atomic 的原子操作来实现这个需要。这的确能够保障只执行一次。然而也存在一个微小的坑,咱们来验证下:

package main

import (
  "fmt"
  "net"
  "sync/atomic"
  "time"
)

type OnceA struct {done uint32}

func (o \*OnceA) Do(f func()) {if !atomic.CompareAndSwapUint32(&o.done, 0, 1) {return}
  f()}

func main() {
  var once OnceA 
  var conn net.Conn
  go func() {fun1 := func() {time.Sleep(5 \* time.Second) // 模仿初始化的速度很慢
      conn, \_ = net.DialTimeout("tcp", "baidu.com:80", time.Second)
    }
    once.Do(fun1)
  }()
  time.Sleep(500 \* time.Millisecond)
  fun2 := func() {fmt.Println("执行 fun2")
    conn, \_ = net.DialTimeout("tcp", "baidu.com:80", time.Second)
  }
  // 再调用 do 曾经查看到 done 为 1 了
  once.Do(fun2)
  \_, err := conn.Write(\[\]byte("\\"GET / HTTP/1.1\\\\r\\\\nHost: baidu.com\\\\r\\\\n Accept: \*/\*\\\\r\\\\n\\\\r\\\\n\\""))
  if err != nil {fmt.Println("err:", err)
  }
}

conn 是一个 net.Conn 的接口类型变量,这里为了达到成果,通过 sleep 模仿了初始化资源的耗时,当 fun2() 想要进行初始化的时候,未然发现 done 的值是 1 了,然而 fun1 初始化速度很慢,导致接下来操作 conn.Write 的时候,因为此时 conn 还是一个空资源,最终运行时抛出空指针的 panic 了。

这个问题的起因在于真正应用资源的时候,资源初始化还没到位,真是难堪😅。

那么 Go 是如何防止这种问题的呢?

// Copyright 2009 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package sync

import ("sync/atomic")

// Once is an object that will perform exactly one action.
type Once struct {
  done uint32
  m    Mutex
}
func (o \*Once) Do(f func()) {
  // Note: Here is an incorrect implementation of Do:
  //
  //  if atomic.CompareAndSwapUint32(&o.done, 0, 1) {//    f()
  //  }
  //
  // Do guarantees that when it returns, f has finished.
  // This implementation would not implement that guarantee:
  // given two simultaneous calls, the winner of the cas would
  // call f, and the second would return immediately, without
  // waiting for the first's call to f to complete.
  // This is why the slow path falls back to a mutex, and why
  // the atomic.StoreUint32 must be delayed until after f returns.

  if atomic.LoadUint32(&o.done) == 0 {
    // Outlined slow-path to allow inlining of the fast-path.
    o.doSlow(f)
  }
}

func (o \*Once) doSlow(f func()) {o.m.Lock()
  defer o.m.Unlock()
  if o.done == 0 {defer atomic.StoreUint32(&o.done, 1)
    f()}
}

你看大佬都间接正文贴心的通知你 if atomic.CompareAndSwapUint32(&o.done, 0, 1) 这个不是正确的实现。并发的状况下,胜者取得调用 f,然而第二个会间接返回,没有期待第一个初始化完结。

所以 Once 实现应用了一个互斥锁,互斥锁保障了只有一个 g 初始化,同时采取的是双查看的机制,再次判断 Once.done 是否为 0,如果为 0,代表第一次初始化,等到初始化完结之后,再开释锁。并发状况下,其余的 g 就会被阻塞在 o.m.Lock()。

如何避坑

说是避坑,然而绝大多数的坑都是因为程序员本身代码问题所导致的,尽管有点难堪,但的确如此。Once 的“坑”还算少的,不像 sync.Mutex 和 Channel 那样,略微姿态不留神点就 panic 了。这一块后续再写文章介绍下。除了下面须要留神的应用资源的时候资源还未初始化实现的问题,在 Once 中还须要防止的是死锁问题

// 因为嵌套调用 Do 外面的 lock 导致死锁
func ErrOne() {
  var o sync.Once
  o.Do(func() {o.Do(func() {fmt.Println("初始化")
    })
  })
}

Do 调用了 f,f 外面又调用了 Do,最终导致死锁。我把下面的代码简化成上面这样

package main

import "sync"

func main() {
  var mu sync.Mutex
  mu.Lock()
  mu.Lock()}

防止这种谬误也很简略,不要在 f 函数中再次调用以后的 Once 即可。

延长

下面有提到过,Once.Do 因为某些起因导致初始化失败,然而原生的问题在于,后续再也没有机会执行同一个 Once.Do 了,产生这样的状况,现实的解决是,只有真正初始化胜利,才设置 Done 的值,并且如果初始化失败,理当告诉到上游服务,这样上游服务能够做一些重试机制或者异样解决等操作。

package main

import (
  "fmt"
  "io"
  "net"
  "os"
  "sync"
  "sync/atomic"
  "time"
)

type Once struct {
  done uint32
  m    sync.Mutex
}
// 传入的 f 有返回值,如果初始化失败,返回对应 error,
// Do 办法再把这个 err 返回给上游服务
func (o \*Once) Do(f func() error) error {if atomic.LoadUint32(&o.done) == 1 { //fast path
    return nil
  }
  return o.doSlow(f)
}

func (o \*Once) doSlow(f func() error) error {o.m.Lock()
  defer o.m.Unlock()
  var err error
  if o.done == 0 { // 双查看,还没有初始化
    err = f()
    if err == nil { // 只有真正初始化胜利才把 done 的值改成 1
      atomic.StoreUint32(&o.done, 1)
    }
  }
  return err
}

咱们扭转了 f 函数,减少了一个返回值,在初始化失败之后返回给 Do 函数,由 Do 函数再把谬误返回给上游的调用方,把控制权交还给调用方做失败的解决。另外改变的一点是,只有真正初始化胜利之后才把 Done 的值改成 1。那么咱们能够简略的把下面的业务代码革新一下:

package main

import (
  "fmt"
  "io"
  "net"
  "os"
  "sync"
  "sync/atomic"
  "time"
)

type Once struct {
  done uint32
  m    sync.Mutex
}
// 传入的 f 有返回值,如果初始化失败,返回对应 error,
// Do 办法再把这个 err 返回给上游服务
func (o \*Once) Do(fn func() error) error {if atomic.LoadUint32(&o.done) == 1 {return nil}
  return o.doSlow(fn)
}

func (o \*Once) doSlow(fn func() error) error {o.m.Lock()
  defer o.m.Unlock()
  var err error
  if o.done == 0 { / 双查看,还没有初始化
    err = fn()
    if err == nil { // 只有真正初始化胜利才把 done 的值改成 1
      atomic.StoreUint32(&o.done, 1)
    }
  }
  return err
}

func main() {urls := \[\]string{
    "127.0.0.1:3453",
    "127.0.0.1:9002",
    "127.0.0.1:9003",
    "baidu.com:80",
  }
  var conn net.Conn
  var o Once
  count := 0
  var err error
  for \_, url := range urls {err := o.Do(func() error {
      count++
      fmt.Printf("初始化 %d 次 \\n", count)
      conn, err = net.DialTimeout("tcp", url, time.Second)
      fmt.Println(err)
      return err
    })
    if err == nil {break}
    if count == 3 {fmt.Println("初始化失败,不再重试")
      break
    }
  }

  if conn != nil {\_, \_ = conn.Write(\[\]byte("GET / HTTP/1.1\\r\\nHost: google.com\\r\\n Accept: \*/\*\\r\\n\\r\\n"))
    \_, \_ = io.Copy(os.Stdout, conn)
  }

}

当咱们在应用一些开源工具时,只有业务须要,你能够革新各种你想要的货色。有时候,阻塞住你的,往往就是一身空想罢了。共勉


举荐浏览

  • 有意思!Go 源代码中的那些机密:为什么 time.minWall 是 1885?
退出移动版