在C/C++中,高并发场景多使用多线程来支持,而Go天然支持高并发,这是因为Go采取更上层的的goroutine来支持高并发场景。goroutine 只是由官方实现的用户态下的超级”线程池”,每个4~5KB的栈内存占用和由于实现机制而大幅减少的创建和销毁开销是go高并发的根本原因。

OS线程(操作系统线程)一般都有固定的栈内存(通常为2MB),一个goroutine的栈在其生命周期开始时只有很小的栈(典型情况下2KB),goroutine的栈不是固定的,他可以按需增大和缩小,goroutine的栈大小限制可以达到1GB,虽然极少会用到这个大。所以在Go语言中一次创建十万左右的goroutine也是可以的。

Goroutine

首先观察这段程序,我们使用了go关键字去启动一个协程跑Say函数,但是执行go run main.go后发现输出只有main say : hello world,而没有Say函数的输出。原因很简单,因为主函数mian首先执行完并return了,而go Say("func say : hello world!!!")需要为协程初始化消耗一些时间,因此晚于main的return,因此就没有输出打印。

  1. package main
  2. import (
  3. "fmt"
  4. )
  5. func Say(msg string) {
  6. fmt.Println(msg)
  7. }
  8. func main() {
  9. go Say("func say : hello world!!!")
  10. fmt.Println("main say : hello world")
  11. }

但是我们在main函数结束前休眠3秒,Say函数的打印就顺利输出了。

package main

import (
        "fmt"
        "time"
)

func Say(msg string) {

        fmt.Println(msg)
}


func main() {
        go Say("func say : hello world!!!")
        fmt.Println("main say : hello world")
        time.Sleep(time.Second * 3)

}
main say : hello world
func say : hello world!!!

在Go中使用goroutine进行并发编程是比较简单的,但是跟多线程编程一样,并发编程难点在于线程同步和线程安全,因此下文重点探究goroutine如何实现并发控制和并发安全。

并发控制

多线程会有线程安全问题,如何保证线程间通信和数据共享是多线程编程中的大难题。协程作为用户级线程同样也会面临一样的问题,Go的并发控制是通过这几种方式进行的:

并发控制方法主要有:

  • 全局变量
  • channel
  • WaitGroup
  • context
  • runtime

Sync.WaitGroup

当我们启动多个goroutine时,就涉及到并发控制。并发控制是个很大的主题,一句话概括就是我们想控制goroutine的生命周期,让goroutine按照我们的设定在某个时机执行某个动作。比如我们希望等所有协程完成自己的逻辑后,main才结束,这是并发控制的一个场景。c/c++中使用pthread_join完成线程的同步,而在go中,对应的功能可以由sync.WaitGroup来实现。

Sync.WaitGroup是一种实现并发控制方式,WaitGroup 对象内部有一个计数器,最初从0开始,它有三个方法:Add(), Done(), Wait() 用来控制计数器的数量。

  • Add(n) 把计数器设置为n 。
  • Done() 每次把计数器-1 。
  • wait() 会阻塞代码的运行,直到计数器地值减为0。
package main

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


var wg sync.WaitGroup

func Say(msg string) {
        defer wg.Done()
        fmt.Println(msg)
}


func main() {
        for i:=0; i<10; i++ {
            wg.Add(1)
            go Say(fmt.Sprintf("func %d say : hello world!!!", i))
        }

        wg.Wait()

        fmt.Println("main say : hello world")

}

输出

func 9 say : hello world!!!
func 0 say : hello world!!!
func 1 say : hello world!!!
func 2 say : hello world!!!
func 3 say : hello world!!!
func 4 say : hello world!!!
func 5 say : hello world!!!
func 6 say : hello world!!!
func 7 say : hello world!!!
func 8 say : hello world!!!
main say : hello world

runtime

当协程启动起来后,我们怎么控制这些协程的生命周期呢?比如我希望某些协程在特定条件下进行退出,或者让出调度时间片。此时我们可以使用runtime包来管理这些协程,这里我们主要掌握3个函数。

  1. runtime.Gosched() : 让出当前协程的时间片,让出CPU时间片给其他协程,等其他协程执行完再执行协程后面的逻辑。
  2. runtime.Goexit() : 使当前协程退出,而不影响协程的进行。
  3. runtime.GOMAXPROCS() : Go运行时的调度器使用GOMAXPROCS参数来确定需要使用多少个OS线程来同时执行Go代码,默认值是机器上的CPU核心数。goroutine和OS线程是多对多的关系,即m:n。

runtime.Gosched()的使用例子:

package main

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

func Say(msg string) {
        fmt.Println(msg)
        time.Sleep(time.Second * 3)
}

func main() {
        for i:=0; i<10; i++ {
            go Say(fmt.Sprintf("func %d say : hello world!!!", i))

        }

        runtime.Gosched()
        fmt.Println("main say : hello world")
}

从上面的例子可以知道,如果runtime.Gosched()这一句,main函数会最快执行完并结束进程,因此打印只会有一条;但加上runtime.Gosched()后,主协程因为让出了时间片,因此需要等到进程内其他协程执行完才轮到自己执行,因此会打印出所有日志,并且main say是最后一条日志,说明该协程最后执行。

func 9 say : hello world!!!
func 0 say : hello world!!!
func 1 say : hello world!!!
func 2 say : hello world!!!
func 3 say : hello world!!!
func 4 say : hello world!!!
func 5 say : hello world!!!
func 6 say : hello world!!!
func 7 say : hello world!!!
func 8 say : hello world!!!
main say : hello world

runtime.Goexit()的使用例子:
在子协程中调用runtime.Goexit()

package main

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

func Say(msg string) {

        runtime.Goexit()
        fmt.Println(msg)
        time.Sleep(time.Second * 3)
}

func main() {
        for i:=0; i<10; i++ {
            go Say(fmt.Sprintf("func %d say : hello world!!!", i))

        }

        fmt.Println("main say : hello world")
        for {
        }
}

打印输出

main say : hello world

如果在主协程调用runtime.Goexit()会发送什么呢?实验证明,会报死锁错误

fatal error: no goroutines (main called runtime.Goexit) - deadlock!

runtime.GOMAXPROCS()的使用例子:

在计算密集型的场景,使用多核并行计算能最大化其效率,如果是IO密集型场景,因为CPU时间都花在了CPU切换上,反而不值得。我们可以根据场景的不同,进而设置合适的runtime.GOMAXPROCS(),

package main

import (
        "fmt"
        "runtime"
)

func Say(msg string) {
        fmt.Println(msg)
}

func main() {
        for i:=0; i<10; i++ {
            go Say(fmt.Sprintf("func %d say : hello world!!!", i))

        }
        _ = runtime.GOMAXPROCS(4) //指定以4核运算
        fmt.Println("main say : hello world")
        for {
        }
}

channel

Go 语言中的通道(channel)是一种特殊的类型。通道像一个传送带或者队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序。channel跟Linux中的双向管道很像,也是用于进程/协程间通信。

channel的定义打开、发送、接收、关闭

chan T          // 可以接收和发送类型为 T 的数据
chan<- float64  // 只可以用来发送 float64 类型的数据
<-chan int      // 只可以用来接收 int 类型的数据

ch1 := make(chan int, 10) // 定义channel,里面可以塞的数据结构是int,缓冲长度为10
ch2 := make(chan []int) // 定义channel,里面可以塞的数据结构是[]int,缓冲长度为0(也就是无缓冲channel),往里面发了数据,会阻塞直到数据被接收


ch1 <- 10  //发送一个数据
x := <- ch1  // 接收一个数据

close(ch1) // 关闭channel

一个channel的案例,定义了多个数据结构的chan,实现了发送,接收以及协程同步和channel关闭操作。

package main


import (
    "fmt"
    "sync"
)

var wg sync.WaitGroup

func reciever(ch1 chan int, ch2 chan bool, ch3 chan []int, ch4 chan map[string]string) {
    defer wg.Done()
    x := <- ch1
    y := <- ch2
    m := <- ch3
    n := <- ch4
    fmt.Println("recieve msg:", x,y,m,n)
}


func main() {

    //发送,将一个数据发送到有缓冲通道中,进程内,协程间通信
    ch1 := make(chan int, 10) // 缓冲长度为10
    ch2 := make(chan bool, 10)
    ch3 := make(chan []int, 10)
    ch4 := make(chan map[string]string, 10)

    // 关闭channel
    defer func() {
        close(ch1)
        close(ch2)
        close(ch3)
        close(ch4)
    } ()

    ch1 <- 10
    ch2 <- true
    dataSlice := []int {1,23,45}
    ch3 <- dataSlice
    dataMap := map[string]string {
        "name": "James",
        "school": "SYSU",
    }
    ch4 <- dataMap
    wg.Add(1)
    go reciever(ch1, ch2, ch3, ch4)
    fmt.Println("send done")

    wg.Wait()
}

打印输出:

junshideMacBook-Pro:gogo junshili$ go run main.go 
send done
recieve msg: 10 true [1 23 45] map[name:James school:SYSU]

当channel很多时,使用上面的结构来处理读channel会显得流程混乱,因此我们需要使用select来优化流程。同样是定义了4个channel,select的写法如下:

select {
case <-chan1:
   // 如果chan1成功读到数据,则进行该case处理语句
case chan2 <- 1:
   // 如果成功向chan2写入数据,则进行该case处理语句
default:
   // 如果上面都没有成功,则进入default处理流程
}

channel使用不当会导致panic,注意以下特殊情况的处理

channel nil 非空 非满
接收 返回0值 接收值 阻塞 接收值 接收值
发送 panic 发送值 发送值 阻塞 发送值
关闭 panic 关闭成功 关闭成功 关闭成功 关闭成功

上图的nil一列是指channel关闭了。

注意到channel的读写操作会有阻塞的情形发生,因此如果不想我们的程序“卡住”不动的话,请为你的channel设置阻塞超时值,可以使用selct + <-time.After(time.Second * 1)定制超时时的处理。

select有很重要的一个应用就是超时处理。 如果没有case需要处理,select语句就会一直阻塞着。这时候我们可能就需要一个超时操作,用来处理超时的情况。

package main


import (
    "fmt"
    "time"
)


func main() {
    ch1 := make(chan string, 10)
    go func() {
        time.Sleep(time.Second * 2)
        ch1 <- "result test"
    } ()

    select {
    case res := <-ch1:
        fmt.Println(res)
    case <-time.After(time.Second * 1):
        fmt.Println("chan timeout 1s")
    }
}

输出

chan timeout 1s

正因为channel有协程间通信的机制,因此可以做并发控制,比如我们使用channel完成上面waitgroup的并发控制的工作。

package main

import (
        "fmt"
)

func Say(msg string, ch1 chan bool) {
        fmt.Println(msg)
        ch1 <- true
}

func main() {
        ch1 := make(chan bool)
        for i:=0; i<10; i++ {
            go Say(fmt.Sprintf("func %d say : hello world!!!", i), ch1)
        }
        done := 0
        for _ = range ch1 {
            done += 1
            if done == 10 {
                break
            }
        }

        close(ch1)
        fmt.Println("main say : hello world")

}

打印输出

func 9 say : hello world!!!
func 6 say : hello world!!!
func 7 say : hello world!!!
func 8 say : hello world!!!
func 3 say : hello world!!!
func 2 say : hello world!!!
func 4 say : hello world!!!
func 0 say : hello world!!!
func 1 say : hello world!!!
func 5 say : hello world!!!
main say : hello world

context

context意为上下文,Go的Context包只要就是用于在协程之间传递上下文信息,作为协程间通信的一种方式,是Go中并发控制和超时控制的一种常用方式。这里想首先弄清楚context包的出现究竟是为了解决什么问题。考虑这个一个场景:

http server在处理一个请求时,首先会会启动一个goroutine进行请求的处理,但是为了发挥多核机器的优势,往往会开启多个goroutine去执行多个执行逻辑,比如A协程去做反序列化工作,B协程去做解密工作,C协程去做数据检验工作,D协程去做身份校验工作,这就涉及到多个goroutine之间的调用,因此ABCD协程都是相互关联的。此外,如果某一时刻ABCD任意一个goroutine取消或者超时,ABCD这些goroutine都必须也同时退出,然后系统才可回收这次请求中占用的资源。
501250143-5c1654d795ca7_fix732.png

从上面的场景可以看出,如何管理这类相关关联的goroutine成为了一个问题,Go的一个解决方案是引入Context包,相互调用的goroutine之间通过传递context变量保持关联,这样在不用暴露各goroutine内部实现细节的前提下,有效地控制各goroutine的运行。
4049495135-5c1654e633d21_fix732.png

Context包提供了以下功能:

  1. 传递共享数据
  2. 取消耗时操作

并发场景下,一般是goroutine来处理一个任务,而它又会创建多个goroutine来负责不同子任务。虽然goroutine之间是平行的,没有继承关系,但是Context设计成是包含父子关系的,这样可以更好的描述goroutine调用之间的树型关系。因此context是一个树形结构,一个节点就是一个goroutine,该节点下的子节点就是他创建出来的子goroutine,父节点Context可以主动通过调用cancel方法取消子节点Context,而子节点Context只能被动等待。同时父节点Context自身一旦被取消(如其上级节点Cancel),其下的所有子节点Context均会自动被取消。

如下图所示,当goroutine ctx3 cancel时,他的子节点goroutine都会被结束,而他的父节点和兄弟节点gotoutine都不会受影响。

截屏2021-05-23 上午1.29.29.png

context实现的方法如下:

type Context interface {

    Deadline() (deadline time.Time, ok bool)

    Done() <-chan struct{}

    Err() error

    Value(key interface{}) interface{}
}

Go语言内置两个函数:Background() 和 TODO(),这两个函数分别返回一个实现了 Context 接口的 background 和 todo。

  • Background() 主要用于 main 函数、初始化以及测试代码中,作为 Context 这个树结构的最顶层的 Context,也就是根 Context。
  • TODO(),它目前还不知道具体的使用场景,在不知道该使用什么 Context 的时候,可以使用这个。
  • background 和 todo 本质上都是 emptyCtx 结构体类型,是一个不可取消,没有设置截止时间,没有携带任何值的 Context。

context提供了数据kv读写的方法

  1. context.WithValue 用于把数据设置进context,供相关联的协程读取;
  2. context.Value 用于从context中读取出数据;

context提供的三种context退出的方法

  1. WithCancel:带cancel返回值的Context,一旦cancel被调用,即取消该创建的context
  2. WithDeadline: 带有效期cancel返回值的Context,即必须到达指定时间点调用的cancel方法才会被执行
  3. WithTimeout:带超时时间cancel返回值的Context,类似Deadline,前者是时间点,后者为时间间隔
package main

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


type Options struct {
    Method string
    StartTime int64
    ReqId int
}

func worker(ctx context.Context, name string) {

    for {
        select {
        case <- ctx.Done(): //退出信号的捕捉
            fmt.Println("stop", name)
            return
        default:
            op := ctx.Value("options").(*Options)
            fmt.Printf("%s options is %v\n", name, op)
            time.Sleep(time.Second * 1)
        }
    }
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    now := time.Now().Unix()
    nctx := context.WithValue(ctx, "options", &Options{"http", now, 1002}) //写入kv
    go worker(nctx, "woker1")
    go worker(nctx, "woker2")

    time.Sleep(time.Second * 3)
    now = time.Now().Unix()
    cancel() // 通知协程进行退出
    time.Sleep(time.Second * 3)
}

输出:


woker2 options is &{http 1621649619 1002}
woker1 options is &{http 1621649619 1002}
woker1 options is &{http 1621649619 1002}
woker2 options is &{http 1621649619 1002}
woker1 options is &{http 1621649619 1002}
woker2 options is &{http 1621649619 1002}
stop woker2
stop woker1

上面的例子是直接控制协程的退出,context还有一个常用功能是超时退出,这在web后台中更为常见,比如A协程需要去请求数据库,如果发生慢查询或者网络波动导致请求超时未回复,因此可以使用context的WithTimeout()做超时监控和控制。

func worker(ctx context.Context, name string) {

    for {
        select {
        case <- ctx.Done(): //退出信号的捕捉
            fmt.Println("stop", name)
            return
        default:
            op := ctx.Value("options").(*Options)
            fmt.Printf("%s options is %v\n", name, op)
            time.Sleep(time.Second * 1)
        }
    }
}

func main() {
    ctx, _ := context.WithTimeout(context.Background(), 2*time.Second)
    now := time.Now().Unix()
    nctx := context.WithValue(ctx, "options", &Options{"http", now, 1002}) //写入kv
    go worker(nctx, "woker1")
    go worker(nctx, "woker2")

    time.Sleep(time.Second * 3)

}

输出

woker2 options is &{http 1621649843 1002}
woker1 options is &{http 1621649843 1002}
woker2 options is &{http 1621649843 1002}
woker1 options is &{http 1621649843 1002}
stop woker2
stop woker1

超时退出可以控制子协程的最长执行时间,而 context.WithDeadline() 则可以控制子协程的最迟退出时间,常用语用于设置截止时间。

package main

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


type Options struct {
    Method string
    StartTime int64
    ReqId int
}

func worker(ctx context.Context, name string) {

    for {
        select {
        case <- ctx.Done(): //退出信号的捕捉
            fmt.Println("stop", name)
            return
        default:
            op := ctx.Value("options").(*Options)
            fmt.Printf("%s options is %v\n", name, op)
            time.Sleep(time.Second * 1)
        }
    }
}

func main() {
    now := time.Now().Unix()
    ctx, _ := context.WithDeadline(context.Background(), time.Now().Add(time.Second * 1))
    nctx := context.WithValue(ctx, "options", &Options{"http", now, 1002}) //写入kv
    go worker(nctx, "woker1")
    go worker(nctx, "woker2")

    time.Sleep(time.Second * 3)

}

输出:

woker2 options is &{http 1621650258 1002}
woker1 options is &{http 1621650258 1002}
woker2 options is &{http 1621650258 1002}
woker1 options is &{http 1621650258 1002}
stop woker1
stop woker2

使用context需要注意的几个点:

  1. context.Background 只应用在最高等级,作为所有派生 context 的根。
  2. context.TODO 应用在不确定要使用什么的地方,或者当前函数以后会更新以便使用 context。
  3. 不要将 context 存储在结构中,在函数中显式传递它们,最好是作为第一个参数。
  4. 不能将请求的所有参数都使用 Context 进行传递,这是一种非常差的设计,比较常见的使用场景是传递请求对应用户的认证令牌以及用于进行分布式追踪的请求 ID。

并发安全

多线程编程下的并发安全问题永远是难以解决的问题,因此很多团队会为了少踩线程安全的坑而选择其他方式实现服务器高性能。但是在很多场景下多线程编程又没有很好的替代方案,因此也需要硬着头皮小心翼翼地进行编程。Go在处理线程安全问题时会用到下面几个技术:

  1. sync.Once
  2. 原子操作
  3. sync.Map

并发安全问题的实质是多个goroutine同时访问同一个公共资源,因此如何保证goroutine在同一时间内有序访问公共资源是首要思考问题。

资源的竞争是并发场景经常出现的情况,因此如何控制共享资源访问是需要解决的难点,而锁是解决资源竞争的普遍手段。

首先看一个例子,体验并发条件下数据被多个协程竞争修改的场景。我们有一个全局变量x,一共2个协程分别累加这个x值,我们预期这个x打印时值为100000。但是事实上这个x值小于100000,而且每次执行程序得到的x值都一样,这是因为并发导致的资源访问出错的问题。

package main

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

var x int
var lock sync.Mutex

func add(ch1 chan bool) {
    for i:= 0; i<50000; i++ {
        x += 1
    }
    ch1 <- true
}

func main() {
        ch1 := make(chan bool)
        for i:=0; i<2; i++ {
            go add(ch1)
        }
        start := time.Now()
        done := 0
        for _ = range ch1 {
            done += 1
            if done == 2 {
                break
            }
        }
        elapsed := time.Since(start)
        close(ch1)
        fmt.Println("main say : hello world, x=", x)
        fmt.Println("time_use=", elapsed)


}

打印输出

junshideMacBook-Pro:gogo junshili$ go run main.go 
main say : hello world, x= 50853
time_use= 125.446µs
junshideMacBook-Pro:gogo junshili$ go run main.go 
main say : hello world, x= 51476
time_use= 125.91µs
junshideMacBook-Pro:gogo junshili$ go run main.go 
main say : hello world, x= 65788
time_use= 174.12µs

因此如何处理高并发状态下资源有效访问的问题成了并发编程中需要重点关注的点,也就是我们常说的并发安全问题。我们可以使用互斥锁(Mutex)进行资源的访问控制,保证数据在被一个协程访问时,其他协程只能等待。

package main

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

var x int
var lock sync.Mutex

func add(ch1 chan bool) {
    for i:= 0; i<50000; i++ {
        lock.Lock()
        x += 1
        lock.Unlock()
    }
    ch1 <- true
}

func main() {
        ch1 := make(chan bool)
        for i:=0; i<2; i++ {
            go add(ch1)
        }
        start := time.Now()
        done := 0
        for _ = range ch1 {
            done += 1
            if done == 2 {
                break
            }
        }
        elapsed := time.Since(start)
        close(ch1)
        fmt.Println("main say : hello world, x=", x)
        fmt.Println("time_use=", elapsed)


}
main say : hello world, x= 100000
time_use= 1.80274ms

使用互斥锁能够保证同一时间有且只有一个goroutine进入临界区,其他的goroutine则在等待锁;当互斥锁释放后,等待的goroutine才可以获取锁进入临界区,多个goroutine同时等待一个锁时,唤醒的策略是随机的。

但是可以注意到程序运行时间已经达到了1.8毫秒,远大于无锁状态下的耗时125微妙,耗时相差了10倍。因此可以看出,加锁是一个很影响程序性能的操作,如果能避免数据加锁,就尽量避免。

互斥锁是完全互斥的,但是有很多实际的场景下是读多写少的,当我们并发的去读取一个资源不涉及资源修改的时候是没有必要加锁的,这种场景下使用读写锁是更好的一种选择。读写锁在Go语言中使用sync包中的RWMutex类型。

var rwlock sync.RWMutex

func add(ch1 chan bool) {
    for i:= 0; i<50000; i++ {
        rwlock.Lock()
        x += 1
        rwlock.Unlock()   
    }
    ch1 <- true
}

原子操作atomic

上面加锁的例子可以看到加锁操作因为涉及内核态的上下文切换会比较耗时,严重响程序性能,因此可以采取Go语言中的sync.atomic包来使用原子操作保证并发安全,并降低性能损耗。

截屏2021-05-23 上午1.31.08.png

原子操作共有5种,即:增或减、比较并交换、载入、存储和交换

具体使用文档参考:https://golang.org/pkg/sync/atomic/

我们针对上面并发修改x值的案例,使用原子操作保证了并发安全.

package main

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

)


var x int32

func add(ch1 chan bool) {
        for i:= 0; i<50000; i++ {
                atomic.AddInt32(&x, 1)
        }
        ch1 <- true
}

func main() {
        ch1 := make(chan bool)
        for i:=0; i<2; i++ {
            go add(ch1)
        }
        start := time.Now()
        done := 0
        for _ = range ch1 {
                done += 1
                if done == 2 {
                    break
                }
        }
        elapsed := time.Since(start)
        close(ch1)
        fmt.Println("main say : hello world, x=", x)
        fmt.Println("time_use=", elapsed)


}

sync.Once

在日常编写代码中,经常需要有某些函数只会执行一次,比如初始化函数,往往在进程启动时执行,而且我们需要这个函数在进程运行期间只能执行一次。但是,如果不采取任何措施来保证这个“一次执行”,那么这个会在高并发状态下失效,请看下面这个例子。

package main

import (
        "fmt"
        "time"
)

var gConfig map[string]string

func myInit(x int) {
    time.Sleep(time.Second * 5)
    if gConfig == nil {
        gConfig = map[string]string{
            "ip": "127.0.0.1",
            "port": "9090",
        }
        fmt.Printf("set gConfig, gConfig=%+v, x=%d\n", gConfig, x)
    }
}

func main() {
    for i:=0; i<100; i++ {
        go myInit(i)
    }

    for {

    }

}

从输出可以看出,原本期望只会初始化一次的逻辑,居然执行了两次,这是因为在高并发下,这个init的执行不是并发安全的,多个goroutine并发调用Icon函数时不是并发安全的,现代的编译器和CPU可能会在保证每个goroutine都满足串行一致的基础上自由地重排访问内存的顺序。.因此如果要保证这个init只会执行一次的话,要么自己使用加锁操作,要么可以选择使用sync.once来保证你的函数只会执行一次。

junshideMacBook-Pro:gogo junshili$ go run main.go 
set gConfig, gConfig=map[ip:127.0.0.1 port:9090], x=97
set gConfig, gConfig=map[ip:127.0.0.1 port:9090], x=88

Go的sync.Once很适合做这类场景,我们将sync.Once应用到上面这段程序中,试图解决并发安全问题。

package main

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

var gConfig map[string]string
var doOnce sync.Once

func myInit(x int) {
    time.Sleep(time.Second * 5)
    if gConfig == nil {
        gConfig = map[string]string{
            "ip": "127.0.0.1",
            "port": "9090",
        }
        fmt.Printf("set gConfig, gConfig=%+v, x=%d\n", gConfig, x)
    }
}

func main() {
    for i:=0; i<100; i++ {
        // 因为sync.Once只接受无参函数作为输入,因此我们调整了下函数结构,目的是把i传入myInit
        go doOnce.Do(func() {
            myInit(i)
        })
    }

    for {

    }
}

此时的输出就只有一条了,可以得出sync.Once保证了在高并发状态下myInit只执行了一次。
sync.Once其实内部包含一个互斥锁和一个布尔值,互斥锁保证布尔值和数据的安全,而布尔值用来记录初始化是否完成。这样设计就能保证初始化操作的时候是并发安全的并且初始化操作也不会被执行多次。

sync.Map

Go语言中内置的map不是并发安全的,当并发写map时就会报fatal error: concurrent map writes错误。这是因为内置map会有并发安全的检查,内置map是无锁的,内置map会有自己的并发检查,当并发写时会触发panic,导致程序异常退出。我们其他小组就因为Go的并发访问map导致了游戏服宕机,吃了二级事故。一开始看到map并发写会引发panic会比较奇怪,仅仅是并发访问数据(可能导致数据异常)就导致程序退出,感觉是不是有点过分了?但是仔细想想其实这种机制更为符合工程需求:数据异常发生时,我更倾向于系统快速失败,不然数据一致错误下去,后续的数据修复将非常困难(宕机比大量玩家数据异常反而没那么严重)。程序的及时退出,反而保证了数据的安全。

go是通过标志位实现的。在写之前,先吧标志位置为1,写之后,改为0。并且每次修改之前都会做判断,如果不符合预期,则会报错。

要使map支持并发安全访问,可以使用锁来保证,此外还可以使用sync.Map来替代Go内置map,sync.Map是并发安全的,可以直接使用。

package main

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

var gConfig = sync.Map{}

func setMap(val int) {
    key := strconv.Itoa(val)
    time.Sleep(time.Second * 3)
    gConfig.Store(key, val)  //写map
    value, _ := gConfig.Load(key)  // 读map
    fmt.Printf("gConfig:%+v\n", value)
}

func main() {
    for i:=0; i<100; i++ {
        go setMap(i)
    }

    for {

    }
}