golang并发编程

go的并发编程

go 在语言层面支持并发,go 标准库提供的所有系统调用操作(包括所有同步IO操作),都会分配 CPU 给其他 goroutine ,这使得轻量级线程的切换管理不依赖于系统的线程和进程,也不需要依赖于 CPU 的核心数量。

有人把 go 比作21世纪的C语言,第一是因为 go 具有 C语言的简洁和性能,第二就是 go 的并发编程了,go 在语言层面就支持并发,操作更简单,性能更好,同时,go 还自带自动垃圾回收机制,方便内存管理。

go 语言为并发编程而内置的上层 API 基于顺序通信进程模型 CSP(communicating sequential processes)。这就意味着显式锁都是可以避免的,因为go 通过相对安全的通道发送和接受数据以实现同步(channel),这大大地简化了并发程序的编写,“不要通过共享内存来通信,而应该通过通信来共享内存”,这是风靡 go 社区的流行语。

go 语言中的并发程序主要使用两种手段来实现,goroutine 和 channel。

并发介绍

进程、线程和协程

进程

进程是程序在操作系统中的一次执行过程,系统进行资源分配和调度的一个独立单位。

进程一般由程序、数据集、进程控制块三部分组成。我们编写的程序用来描述进程要完成哪些功能以及如何完成。

数据集则是程序在执行过程中所需要使用的资源;进程控制块用来记录进程的外部特征,描述进程的执行变化过程,系统可以利用它来控制和管理进程,它是系统感知进程存在的唯一标志。 进程的局限是创建、撤销和切换的开销比较大。

线程

线程是进程的一个执行实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。

线程由线程ID、 程序计数器、寄存器集合和堆栈共同组成,一个进程可以包含一个或多个线程

协程

协程是一种轻量级的线程,也被称为用户级线程或微线程,可以轻松创建上百万个而不会导致系统资源衰竭,它是一种可以暂停和恢复执行的函数,能够在特定的位置暂停执行,然后在需要时恢复执行,一个线程上可以跑多个协程。

与线程相比,协程不依赖于操作系统的线程调度机制,而是由程序员自行控制协程的切换和调度。在协程中,可以通过挂起和恢复操作来实现协程之间的切换,而无需进行上下文切换和线程间的通信。

协程的优势在于其轻量级和高效性。由于协程的切换操作由程序员手动控制,因此可以减少上下文切换的开销,并且在切换时不需要保存和恢复整个线程的上下文,而只需要保存和恢复协程的状态。

协程可以用于处理需要高并发和高效性能的任务,特别是在IO密集型的场景下,如网络通信、数据库访问等。协程的代码结构也更加清晰和简洁,可以提高代码的可读性和可维护性。

Go语言对于并发的实现是靠协程,Goroutine

并发和并行

并发:多线程的程序在一核 CPU 上运行

并行:多线程的程序在多核 CPU 上运行

并发:

golang并发编程_第1张图片

并行:

golang并发编程_第2张图片

并发主要由切换时间片来实现"同时"运行,并行则是直接利用多核实现多线程的运行,go可以设置使用核数,以发挥多核计算机的能力。

并行一定比并发快吗?

不一定,在实际并行运行的组件,我们需要考虑到多个线程之间的通信问题,这种跨 CPU 的通信问题的性能开销比较高,所以并行并不一定快。

goruntine

go 使用 goruntine 来实现并发

goroutine 是与其他函数或方法同时运行的函数或方法。goroutines 可以被认为是轻量级的线程。与线程相比,创建 goroutine 的成本很小,它就是一段代码,一个函数入口。以及在堆上为其分配的一个堆栈(初始大小为4K,会随着程序的执行自动增长删除)。因此它非常廉价,go 应用程序可以轻松并发运行数千个 goroutines

代码实现

启动 goroutine 的方式非常简单,只需要在调用的函数(普通函数和匿名函数)前面加上一个 go 关键字即可。

package main

import "fmt"

func main() {
   // goroutine : 和普通方法调用完全不同,它是并发执行的,快速交替。
   go hello()
   for i := 0; i < 1000; i++ {
      fmt.Println("main - ", i)
   }
}

// hello函数
func hello() {
   for i := 0; i < 1000; i++ {
      fmt.Println("hello - ", i)
   }
}
  • 当新的Goroutine开始时, Goroutine调用立即返回。与函数不同,go不等待Goroutine执行结束

  • 当Goroutine调用,并且Goroutine的任何返回值被忽略之后,go立即执行到下一行代码

  • main的Goroutine应该为其他的Goroutines执行。如果main的Goroutine终止了,程序将被终止,而其他Goroutine将不会运行

封装 main 函数的 goroutine 称为主 goroutine。

主 goroutine 所做的事情并不是执行 main 函数那么简单。它首先要做的是:设定每一个 goroutine 所能申请的栈空间的最大尺寸。在32位的计算机系统中此最大尺寸为 250MB,而在64位的计算机系统中此尺寸为 1GB。如果有某个 goroutine 的栈空间尺寸大于这个限制,那么运行时系统就会引发一个栈溢出(stack overflow)的运行时恐慌。随后,这个 go 程序的运行也会终止。

此后,主 goroutine 会进行一系列的初始化工作,涉及的工作内容大致如下:

  • 创建一个特殊的 defer 语句,用于在主 goroutine 退出时做必要的善后处理。因为主 goroutine 也可能非正常的结束
  • 启动专用于在后台清扫内存垃圾的 goroutine ,并设置 GC 可用的标识.
  • 执行 main 包中所引用包下的 init 函数
  • 执行 main 函数

执行完 main 函数后,它还会检查主 goroutine 是否引发了运行时恐慌,并进行必要的处理。

程序运行完毕后,主 goroutine 会结束自己以及当前进程的运行。

runtime包

runtime.Gosched()

让出 CPU 时间片,重新等待安排任务(大概意思就是本来计划的好好的周末出去烧烤,但是你妈让你去相亲,两种情况第一就是你相亲速度非常快,见面就黄不耽误你继续烧烤,第二种情况就是你相亲速度特别慢,见面就是你侬我侬的,耽误了烧烤,但是还馋就是耽误了烧烤你还得去烧烤)

package main

import (
    "fmt"
    "runtime"
)

func main() {
    go func(s string) {
        for i := 0; i < 2; i++ {
            fmt.Println(s)
        }
    }("world")
    // 主协程
    for i := 0; i < 2; i++ {
        // 切一下,再次分配任务
        runtime.Gosched()
        fmt.Println("hello")
    }
}

runtime.Goexit()

退出当前协程(一边烧烤一边相亲,突然发现相亲对象太丑影响烧烤,果断让她滚蛋,然后也就没有然后了)

package main

import (
    "fmt"
    "runtime"
)

func main() {
    go func() {
        defer fmt.Println("A.defer")
        func() {
            defer fmt.Println("B.defer")
            // 结束协程
            runtime.Goexit()
            defer fmt.Println("C.defer")
            fmt.Println("B")
        }()
        fmt.Println("A")
    }()
    for {
    }
}

go 中操作系统线程和 goroutine 的关系:

  • 一个操作系统线程对应用户态多个 goroutine。
  • go 程序可以同时使用多个操作系统线程。
  • goroutine 和 OS 线程是多对多的关系,即 m:n。

channel

不要通过共享内存来通信,而应该通过通信来共享内存(chan)” 这是一句风靡 golang 社区的经典语

单纯地将函数并发执行是没有意义的。函数与函数间需要交换数据才能体现并发执行函数的意义。

虽然可以使用共享内存进行数据交换,但是共享内存在不同的 goroutine 中容易发生竞态问题。为了保证数据交换的正确性,必须使用互斥量对内存进行加锁,这种做法势必造成性能问题。

go 的并发模型是 CSP(Communicating Sequential Processes),提倡通过通信共享内存而不是通过共享内存而实现通信。

如果说 goroutine 是 go 程序并发的执行体,channel 就是它们之间的连接。channel 是可以让一个 goroutine 发送特定值到另一个 goroutine 的通信机制。

go 中的通道(channel)是一种特殊的类型。通道像一个传送带或者队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序。每一个通道都是一个具体类型的导管,也就是声明 channel 的时候需要为其指定元素类型。

channel类型

channel 是一种引用类型。声明通道类型的格式如下:

var 变量名 chan 类型
var ch1 chan int   // 声明一个传递整型的通道
var ch2 chan bool  // 声明一个传递布尔型的通道
var ch3 chan []int // 声明一个传递int切片的通道

由于 channel 是引用类型,声明的初始值是 nil,需要使用 make() 创建才能使用

make(chan 元素类型, [缓冲大小])
ch1 := make(chan int)
ch2 := make(chan bool)
ch3 := make(chan []int)
ch4 := make(chan int, 3)
ch5 := make(chan bool, 3)
ch6 := make(chan []int, 3)

channel操作

通道有发送(send)、接收(receive)和关闭(close)三种操作。

发送和接收都使用 <- 符号。

现在我们先使用以下语句定义一个通道:

ch := make(chan int)

发送

将一个值发送到通道中。

ch <- 10 // 把10发送到ch中

接收

从一个通道中接收值。

x := <- ch // 从ch中接收值并赋值给变量x
<-ch       // 从ch中接收值,忽略结果

关闭

我们通过调用内置的 close 函数来关闭通道。

close(ch)

关于关闭通道需要注意的事情是,只有在通知接收方 goroutine 所有的数据都发送完毕的时候才需要关闭通道。通道是可以被垃圾回收机制回收的,它和关闭文件是不一样的,在结束操作之后关闭文件是必须要做的,但关闭通道不是必须的。

关闭后的通道有以下特点:

  • 对一个关闭的通道再发送值就会导致 panic。
  • 对一个关闭的通道进行接收会一直获取值直到通道为空。
  • 对一个关闭的并且没有值的通道执行接收操作会得到对应类型的零值。
  • 关闭一个已经关闭的通道会导致 panic。

无缓冲的通道

golang并发编程_第3张图片

无缓冲的通道又称为阻塞的通道。我们来看一下下面的代码:

func main() {
    ch := make(chan int)
    ch <- 10
    fmt.Println("发送成功")
}

上面这段代码能够通过编译,但是执行的时候会出现以下错误:

fatal error: all goroutines are asleep - deadlock!

为什么会出现 deadlock 错误呢?

因为我们使用 ch := make(chan int) 创建的是无缓冲的通道,无缓冲的通道只有在有人接收值的时候才能发送值。就像你住的小区没有快递柜和代收点,快递员给你打电话必须要把这个物品送到你的手中,简单来说就是无缓冲的通道必须有接收才能发送。

上面的代码会阻塞在 ch <- 10 这一行代码形成死锁,那如何解决这个问题呢?

一种方法是启用一个 goroutine 去接收值,例如:

func recv(c chan int) {
    ret := <-c
    fmt.Println("接收成功", ret)
}
func main() {
    ch := make(chan int)
    go recv(ch) // 启用goroutine从通道接收值
    ch <- 10
    fmt.Println("发送成功")
}

无缓冲通道上的发送操作会阻塞,直到另一个 goroutine 在该通道上执行接收操作,这时值才能发送成功,两个 goroutine 将继续执行。相反,如果接收操作先执行,接收方的 goroutine 将阻塞,直到另一个 goroutine 在该通道上发送一个值。

使用无缓冲通道进行通信将导致发送和接收的 goroutine 同步化。因此,无缓冲通道也被称为同步通道。

有缓冲的通道

解决上面问题的方法还有一种就是使用有缓冲区的通道。

golang并发编程_第4张图片

我们可以在使用 make() 初始化通道的时候为其指定通道的容量,例如:

func main() {
    ch := make(chan int, 1) // 创建一个容量为1的有缓冲区通道
    ch <- 10
    fmt.Println("发送成功")
}

只要通道的容量大于零,那么该通道就是有缓冲的通道,通道的容量表示通道中能存放元素的数量。就像你小区的快递柜只有那么个多格子,格子满了就装不下了,就阻塞了,等到别人取走一个快递员就能往里面放一个。

我们可以使用内置的 len() 获取通道内元素的数量,使用 cap() 获取通道的容量,虽然我们很少会这么做。

package main

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

func main() {
	ch := make(chan int)
	fmt.Println(len(ch), cap(ch))
	ch = make(chan int, 5)
	fmt.Println(len(ch), cap(ch))
	ch <- 1
	fmt.Println(len(ch), cap(ch))
	ch <- 2
	ch <- 3
	ch <- 4
	ch <- 5
	fmt.Println(len(ch), cap(ch))
	
	chs := make(chan string, 5)
	
	go func(ch chan string) {
		for i := 0; i < 10; i++ {
			ch <- "test" + strconv.Itoa(i)
			fmt.Println("通道写入数据:", "test"+strconv.Itoa(i))
		}
		close(ch)
	}(chs)
	for v := range chs {
		time.Sleep(time.Second)
		fmt.Println("通道的数据:", v)
	}
}

单向通道

只能读数据或写数据的通道,通常用做函数的参数

package main

import (
   "fmt"
   "time"
)

// 单向通道使用场景
func main() {

   ch1 := make(chan int) // 可读可写
   go writeOnly(ch1)
   go readOnly(ch1)

   time.Sleep(time.Second * 3)
}

// 作为函数的参数或者返回值之类的。
// 指定函数去写,不让他读取,防止通道滥用
func writeOnly(ch chan<- int) {
   // 函数的内部,处理一些写数据的操作
   ch <- 100
}

// 指定函数去读,不让他写,防止通道滥用
func readOnly(ch <-chan int) int {
   // 取出通道的值,做一些操作,不可写的。
   data := <-ch
   fmt.Println(data)
   return data
}

在函数传参及任何赋值操作中将双向通道转换为单向通道是可以的,但反过来是不可以的。

异常

channel 常见的异常总结

注意:关闭已经关闭的channel也会引发panic。

goroutine池

  • 本质上是生产者消费者模型
  • 可以有效控制goroutine数量,防止暴涨
  • 需求:
    • 计算一个数字的各个位数之和,例如数字123,结果为1+2+3=6
    • 随机生成数字进行计算
  • 控制台输出结果如下:
job id:4451641 randnum:2540985352668496627 result:97
job id:4451642 randnum:4376210369283797057 result:89
job id:4451643 randnum:3979224207991695909 result:102
job id:4451644 randnum:7738467937097130802 result:91
job id:4451645 randnum:7774489285234103460 result:84
job id:4451646 randnum:8153909031394906197 result:87
job id:4451647 randnum:3271823444020638000 result:57
job id:4451648 randnum:7908124528232179269 result:87
job id:4451649 randnum:3578801845990856442 result:96
job id:4451650 randnum:4966381665914656514 result:95
job id:4451651 randnum:707077446563636895 result:93
job id:4451652 randnum:3328166925182126797 result:88
job id:4451653 randnum:1006832089061410691 result:65
job id:4451654 randnum:4452148470433171239 result:72
job id:4451655 randnum:2138597202631520610 result:63
job id:4451656 randnum:2555040877733225394 result:81
job id:4451657 randnum:7102164356070709635 result:72
package main

import (
    "fmt"
    "math/rand"
)

type Job struct {
    // id
    Id int
    // 需要计算的随机数
    RandNum int
}

type Result struct {
    // 这里必须传对象实例
    job *Job
    // 求和
    sum int
}

func main() {
    // 需要2个管道
    // 1.job管道
    jobChan := make(chan *Job, 128)
    // 2.结果管道
    resultChan := make(chan *Result, 128)
    // 3.创建工作池
    createPool(64, jobChan, resultChan)
    // 4.开个打印的协程
    go func(resultChan chan *Result) {
        // 遍历结果管道打印
        for result := range resultChan {
            fmt.Printf("job id:%v randnum:%v result:%d\n", result.job.Id,
                result.job.RandNum, result.sum)
        }
    }(resultChan)
    var id int
    // 循环创建job,输入到管道
    for {
        id++
        // 生成随机数
        r_num := rand.Int()
        job := &Job{
            Id:      id,
            RandNum: r_num,
        }
        jobChan <- job
    }
}

// 创建工作池
// 参数1:开几个协程
func createPool(num int, jobChan chan *Job, resultChan chan *Result) {
    // 根据开协程个数,去跑运行
    for i := 0; i < num; i++ {
        go func(jobChan chan *Job, resultChan chan *Result) {
            // 执行运算
            // 遍历job管道所有数据,进行相加
            for job := range jobChan {
                // 随机数接过来
                r_num := job.RandNum
                // 随机数每一位相加
                // 定义返回值
                var sum int
                for r_num != 0 {
                    tmp := r_num % 10
                    sum += tmp
                    r_num /= 10
                }
                // 想要的结果是Result
                r := &Result{
                    job: job,
                    sum: sum,
                }
                //运算结果扔到管道
                resultChan <- r
            }
        }(jobChan, resultChan)
    }
}

定时器

Timer

时间到了,执行只执行1次

package main

import (
	"fmt"
	"time"
)

func main() {
	// 1.timer基本使用
	timer1 := time.NewTimer(2 * time.Second)
	t1 := time.Now()
	fmt.Printf("t1:%v\n", t1)
	t2 := <-timer1.C
	fmt.Printf("t2:%v\n", t2)

	// 2.验证timer只能响应1次
	timer2 := time.NewTimer(time.Second)
	for {
		<-timer2.C
		fmt.Println("时间到")
	}

	// 3.timer实现延时的功能
	time.Sleep(time.Second)

	timer3 := time.NewTimer(2 * time.Second)
	<-timer3.C
	fmt.Println("2秒到")

	<-time.After(2 * time.Second)
	fmt.Println("2秒到")

	// 4.停止定时器
	timer4 := time.NewTimer(2 * time.Second)
	go func() {
		<-timer4.C
		fmt.Println("定时器执行了")
	}()
	b := timer4.Stop()
	if b {
		fmt.Println("timer4已经关闭")
	}

	// 5.重置定时器
	timer5 := time.NewTimer(3 * time.Second)
	timer5.Reset(1 * time.Second)
	fmt.Println(time.Now())
	fmt.Println(<-timer5.C)
}

Ticker

时间到了,多次执行

package main

import (
    "fmt"
    "time"
)

func main() {
    // 1.获取ticker对象
    ticker := time.NewTicker(1 * time.Second)
    i := 0
    // 子协程
    go func() {
        for {
            //<-ticker.C
            i++
            fmt.Println(<-ticker.C)
            if i == 5 {
                //停止
                ticker.Stop()
            }
        }
    }()
}

select

在某些场景下我们需要同时从多个通道接收数据。通道在接收数据时,如果没有数据可以接收将会发生阻塞。你也许会写出如下代码使用遍历的方式来实现:

for{
    // 尝试从ch1接收值
    data, ok := <-ch1
    // 尝试从ch2接收值
    data, ok := <-ch2
}

这种方式虽然可以实现从多个通道接收值的需求,但是运行性能会差很多。为了应对这种场景,go 内置了 select 关键字,可以同时响应多个通道的操作。

select 的使用类似于 switch 语句,它有一系列 case 分支和一个默认的分支。每个 case 会对应一个通道的通信(接收或发送)过程。select 会一直等待,直到某个 case 的通信操作完成时,就会执行 case 分支对应的语句。具体格式如下:

select {
case <-chan1:
// 如果chan1成功读到数据,则进行该case处理语句
case chan2 <- 1:
// 如果成功向chan2写入数据,则进行该case处理语句
default:
// 如果上面都没有成功,则进入default处理流程
}
  • select 可以同时监听一个或多个 channel,直到其中一个 channel ready
package main

import (
   "fmt"
   "time"
)

func test1(ch chan string) {
   time.Sleep(time.Second * 5)
   ch <- "test1"
}
func test2(ch chan string) {
   time.Sleep(time.Second * 2)
   ch <- "test2"
}

func main() {
   // 2个管道
   output1 := make(chan string)
   output2 := make(chan string)
   // 跑2个子协程,写数据
   go test1(output1)
   go test2(output2)
   // 用select监控
   select {
   case s1 := <-output1:
      fmt.Println("s1=", s1)
   case s2 := <-output2:
      fmt.Println("s2=", s2)
   }
}
  • 如果多个 channel 同时 ready,则随机选择一个执行
package main

import (
   "fmt"
)

func main() {
   // 创建2个管道
   int_chan := make(chan int, 1)
   string_chan := make(chan string, 1)
   go func() {
      //time.Sleep(2 * time.Second)
      int_chan <- 1
   }()
   go func() {
      string_chan <- "hello"
   }()
   select {
   case value := <-int_chan:
      fmt.Println("int:", value)
   case value := <-string_chan:
      fmt.Println("string:", value)
   }
   fmt.Println("main结束")
}
  • 可以用于判断管道是否存满
package main

import (
   "fmt"
   "time"
)

// 判断管道有没有存满
func main() {
   // 创建管道
   output1 := make(chan string, 10)
   // 子协程写数据
   go write(output1)
   // 取数据
   for s := range output1 {
      fmt.Println("res:", s)
      time.Sleep(time.Second)
   }
}

func write(ch chan string) {
   for {
      select {
      // 写数据
      case ch <- "hello":
         fmt.Println("write hello")
      default:
         fmt.Println("channel full")
      }
      time.Sleep(time.Millisecond * 500)
   }
}

并发安全和锁

有时候在 go 代码中可能会存在多个 goroutine 同时操作一个资源(临界区),这种情况会发生竞态问题(数据竞态)。类比现实生活中的例子有十字路口被各个方向的的汽车竞争;还有火车上的卫生间被车厢里的人竞争。

var x int64
var wg sync.WaitGroup

func add() {
    for i := 0; i < 5000; i++ {
        x = x + 1
    }
    wg.Done()
}
func main() {
    wg.Add(2)
    go add()
    go add()
    wg.Wait()
    fmt.Println(x)
}

上面的代码中我们开启了两个 goroutine 去累加变量 x 的值,这两个 goroutine 在访问和修改 x 变量的时候就会存在数据竞争,导致最后的结果与期待的不符。

互斥锁

互斥锁是一种常用的控制共享资源访问的方法,它能够保证同时只有一个 goroutine 可以访问共享资源。go 中使用 sync 包的 Mutex 类型来实现互斥锁。 使用互斥锁来修复上面代码的问题:

var x int64
var wg sync.WaitGroup
var lock sync.Mutex

func add() {
    for i := 0; i < 5000; i++ {
        lock.Lock() // 加锁
        x = x + 1
        lock.Unlock() // 解锁
    }
    wg.Done()
}
func main() {
    wg.Add(2)
    go add()
    go add()
    wg.Wait()
    fmt.Println(x)
}

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

读写互斥锁

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

读写锁分为两种:读锁和写锁。当一个 goroutine 获取读锁之后,其他的 goroutine 如果是获取读锁会继续获得锁,如果是获取写锁就会等待;当一个goroutine 获取写锁之后,其他的 goroutine 无论是获取读锁还是写锁都会等待。

var (
    x      int64
    wg     sync.WaitGroup
    lock   sync.Mutex
    rwlock sync.RWMutex
)

func write() {
    // lock.Lock()   // 加互斥锁
    rwlock.Lock() // 加写锁
    x = x + 1
    time.Sleep(10 * time.Millisecond) // 假设读操作耗时10毫秒
    rwlock.Unlock()                   // 解写锁
    // lock.Unlock()                     // 解互斥锁
    wg.Done()
}

func read() {
    // lock.Lock()                  // 加互斥锁
    rwlock.RLock()               // 加读锁
    time.Sleep(time.Millisecond) // 假设读操作耗时1毫秒
    rwlock.RUnlock()             // 解读锁
    // lock.Unlock()                // 解互斥锁
    wg.Done()
}

func main() {
    start := time.Now()
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go write()
    }

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go read()
    }

    wg.Wait()
    end := time.Now()
    fmt.Println(end.Sub(start))
}

需要注意的是读写锁非常适合读多写少的场景,如果读和写的操作差别不大,读写锁的优势就发挥不出来。

Sync

sync.WaitGroup

在代码中生硬的使用 time.Sleep 肯定是不合适的,go 中可以使用 sync.WaitGroup 来实现并发任务的同步。sync.WaitGroup 有以下几个方法:

方法名 功能
(wg * WaitGroup) Add(delta int) 计数器+delta
(wg *WaitGroup) Done() 计数器-1
(wg *WaitGroup) Wait() 阻塞直到计数器变为0

sync.WaitGroup 内部维护着一个计数器,计数器的值可以增加和减少。例如当我们启动了 N 个并发任务时,就将计数器值增加 N。每个任务完成时通过调用 Done() 将计数器减1。通过调用 Wait() 来等待并发任务执行完,当计数器值为0时,表示所有并发任务已经完成。

我们利用 sync.WaitGroup 将上面的代码优化一下:

var wg sync.WaitGroup

func hello() {
    defer wg.Done()
    fmt.Println("Hello Goroutine!")
}
func main() {
    wg.Add(1)
    go hello() // 启动另外一个goroutine去执行hello函数
    fmt.Println("main goroutine done!")
    wg.Wait()
}

需要注意 sync.WaitGroup 是一个结构体,传递的时候要传递指针。

sync.Once

这是一个进阶知识点。

在编程的很多场景下我们需要确保某些操作在高并发的场景下只执行一次,例如只加载一次配置文件、只关闭一次通道等。

go 中的 sync 包中提供了一个针对只执行一次场景的解决方案—sync.Once

sync.Once 只有一个 Do 方法,其签名如下:

func (o *Once) Do(f func()) {}

注意:如果要执行的函数f需要传递参数就需要搭配闭包来使用。

加载配置文件示例

延迟一个开销很大的初始化操作到真正用到它的时候再执行是一个很好的实践。因为预先初始化一个变量(比如在 init 函数中完成初始化)会增加程序的启动耗时,而且有可能实际执行过程中这个变量没有用上,那么这个初始化操作就不是必须要做的。我们来看一个例子:

var icons map[string]image.Image

func loadIcons() {
    icons = map[string]image.Image{
        "left":  loadIcon("left.png"),
        "up":    loadIcon("up.png"),
        "right": loadIcon("right.png"),
        "down":  loadIcon("down.png"),
    }
}

// Icon 被多个goroutine调用时不是并发安全的
func Icon(name string) image.Image {
    if icons == nil {
        loadIcons()
    }
    return icons[name]
}

多个 goroutine 并发调用 Icon 函数时不是并发安全的,现代的编译器和 CPU 可能会在保证每个 goroutine 都满足串行一致的基础上自由地重排访问内存的顺序。loadIcons 函数可能会被重排为以下结果:

func loadIcons() {
    icons = make(map[string]image.Image)
    icons["left"] = loadIcon("left.png")
    icons["up"] = loadIcon("up.png")
    icons["right"] = loadIcon("right.png")
    icons["down"] = loadIcon("down.png")
}

在这种情况下就会出现即使判断了 icons 不是 nil 也不意味着变量初始化完成了。考虑到这种情况,我们能想到的办法就是添加互斥锁,保证初始化icons 的时候不会被其他的 goroutine 操作,但是这样做又会引发性能问题。

var icons map[string]image.Image

var loadIconsOnce sync.Once

func loadIcons() {
    icons = map[string]image.Image{
        "left":  loadIcon("left.png"),
        "up":    loadIcon("up.png"),
        "right": loadIcon("right.png"),
        "down":  loadIcon("down.png"),
    }
}

// Icon 是并发安全的
func Icon(name string) image.Image {
    loadIconsOnce.Do(loadIcons)
    return icons[name]
}

sync.Once 其实内部包含一个互斥锁和一个布尔值,互斥锁保证布尔值和数据的安全,而布尔值用来记录初始化是否完成。这样设计就能保证初始化操作的时候是并发安全的并且初始化操作也不会被执行多次。

sync.Map

go 中内置的 map 不是并发安全的

var m = make(map[string]int)

func get(key string) int {
    return m[key]
}

func set(key string, value int) {
    m[key] = value
}

func main() {
    wg := sync.WaitGroup{}
    for i := 0; i < 20; i++ {
        wg.Add(1)
        go func(n int) {
            key := strconv.Itoa(n)
            set(key, n)
            fmt.Printf("k=:%v,v:=%v\n", key, get(key))
            wg.Done()
        }(i)
    }
    wg.Wait()
}

上面的代码开启少量几个 goroutine 的时候可能没什么问题,当并发多了之后执行上面的代码就会报 fatal error: concurrent map writes 错误。

像这种场景下就需要为 map 加锁来保证并发的安全性了,go 的 sync 包中提供了一个开箱即用的并发安全版 map–sync.Map。开箱即用表示不用像内置的 map 一样使用 make() 初始化就能直接使用。同时 sync.Map 内置了诸如 Store、Load、LoadOrStore、Delete、Range 等操作方法。

var m = sync.Map{}

func main() {
    wg := sync.WaitGroup{}
    for i := 0; i < 20; i++ {
        wg.Add(1)
        go func(n int) {
            key := strconv.Itoa(n)
            m.Store(key, n)
            value, _ := m.Load(key)
            fmt.Printf("k=:%v,v:=%v\n", key, value)
            wg.Done()
        }(i)
    }
    wg.Wait()
}

原子操作(atomic包)

原子操作

代码中的加锁操作因为涉及内核态的上下文切换会比较耗时、代价比较高。针对基本数据类型我们还可以使用原子操作来保证并发安全,因为原子操作是 go 提供的方法它在用户态就可以完成,因此性能比加锁操作更好。go 中原子操作由内置的标准库 sync/atomic 提供。

atomic包

方法 解释
func LoadInt32(addr *int32) (val int32)
func LoadInt64(addr *int64) (val int64)
func LoadUint32(addr *uint32) (val uint32)
func LoadUint64(addr *uint64) (val uint64)
func LoadUintptr(addr *uintptr) (val uintptr)
func LoadPointer(addr *unsafe.Pointer) (val unsafe.Pointer)
读取操作
func StoreInt32(addr *int32, val int32)
func StoreInt64(addr *int64, val int64)
func StoreUint32(addr *uint32, val uint32)
func StoreUint64(addr *uint64, val uint64)
func StoreUintptr(addr *uintpt, val uintptr)
func StorePointer(addr *unsafe.Pointer, val unsafe.Pointer)
写入操作
func AddInt32(addr *int32, delta int32) (new int32)
func AddInt64(addr *int64, delta int64) (new int64)
func AddUint32(addr *uint32, delta uint32) (new uint32)
func AddUint64(addr *uint6, delta uint64) (new uint64)
func AddUintptr(addr *uintptr, delta uintptr) (new uintptr)
修改操作
func SwapInt32(addr *int3, new int32) (old int32)
func SwapInt64(addr *int6, new int64) (old int64)
func SwapUint32(addr *uint3, new uint32) (old uint32)
func SwapUint64(addr *uint6, new uint64) (old uint64)
func SwapUintptr(addr *uintpt, new uintptr) (old uintptr)
func SwapPointer(addr *unsafe.Pointe, new unsafe.Pointer) (old unsafe.Pointer)
交换操作
func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)
func CompareAndSwapInt64(addr *int64, old, new int64) (swapped bool)
func CompareAndSwapUint32(addr *uint32, old, new uint32) (swapped bool)
func CompareAndSwapUint64(addr *uint6, old, new uint64) (swapped bool)
func CompareAndSwapUintptr(addr *uintptr, old, new uintptr) (swapped bool)
func CompareAndSwapPointer(addr *unsafe.Pointer, old, new unsafe.Pointer) (swapped bool)
比较并交换操作

示例

我们填写一个示例来比较下互斥锁和原子操作的性能。

var x int64
var l sync.Mutex
var wg sync.WaitGroup

// 普通版加函数
func add() {
    // x = x + 1
    x++ // 等价于上面的操作
    wg.Done()
}

// 互斥锁版加函数
func mutexAdd() {
    l.Lock()
    x++
    l.Unlock()
    wg.Done()
}

// 原子操作版加函数
func atomicAdd() {
    atomic.AddInt64(&x, 1)
    wg.Done()
}

func main() {
    start := time.Now()
    for i := 0; i < 10000; i++ {
        wg.Add(1)
        // go add()       // 普通版add函数 不是并发安全的
        // go mutexAdd()  // 加锁版add函数 是并发安全的,但是加锁性能开销大
        go atomicAdd() // 原子操作版add函数 是并发安全,性能优于加锁版
    }
    wg.Wait()
    end := time.Now()
    fmt.Println(x)
    fmt.Println(end.Sub(start))
}

atomic 包提供了底层的原子级内存操作,对于同步算法的实现很有用。这些函数必须谨慎地保证正确使用。除了某些特殊的底层应用,使用通道或者sync 包的函数/类型实现同步更好。

学习文档:go并发编程

你可能感兴趣的:(go基础学习,golang)