Go 语言之并发

并发

并发是指同时进行多个任务的程序。
go语言有两种并发编程的风格:第一种是goroutine和管道(channel),他们支持通信顺序进程(communicating sequential processes)或被简称为CSP,CSP是一个并发的模式,在不同的执行体(goroutine)之间传递值。第二种是共享内存多线程的传统模型。go语言提倡通信共享内存而不是通过共享内存而实现通信

Goroutines

在go语言中,每一个并发的执行单元叫作一个goroutine。类似于其他语言的线程,但goroutine是由go的运行时(runtime)调度和管理的。程序运行的main函数就是一个goroutine,我们叫它main goroutine

创建goroutine

go语言中只需要在调用的函数前面加上go关键字,就可以为一个函数创建一个goroutine

func f() {
	fmt.Println("goroutine!!!!")
}
func main() {
	go f()
	fmt.Println("main goroutine")
}
// main goroutine
// goroutine!!!!

运行结果goroutine!!!!打印在main goroutine之后,原因是goroutine的执行需要耗时;有时goroutine!!!!会打印不出,原因是当main()函数执行之后,就会结束所有在main goroutine中执行的goroutine

启动多个goroutine

我们还可以启动多个goroutine。为了保证每个goroutine都可以执行结束,这里引用了sync.WaitGroup来实现goroutine同步。

var wg sync.WaitGroup

func f(i int) {
	defer wg.Done() //消费掉一个goroutine 
	fmt.Printf("goroutine %d\n", i)
}
func main() {
	for i := 0; i < 5; i++ {
		wg.Add(1) //每启动一个goroutine就计数+1
		go f(i)
	}
	wg.Wait() //等待所有的goroutine运行结束
}
/*
goroutine 4
goroutine 1
goroutine 3
goroutine 2
goroutine 0
*/

多次运行上面的代码,每次运行结果都不同,这是由于并发导致的,goroutine的调度是随机的。

goroutine 与线程

OS线程(操作系统线程)一般都有固定的栈内存(通常为2MB),goroutine的栈不是固定的,一个goroutine的栈在其生命周期开始时只有很小的栈(典型情况下2KB),它可以按需增大和缩小,其中栈的大小限制可以达到1GB。

goroutine调度

GPM是go语言运行时(runtime)层面的实现,是go语言自己实现的一套调度系统。

  • G就是goroutine,里面除了存放本goroutine信息外,还有与所在P的绑定等信息。
  • P管理着一组goroutine队列,P里面会存储当前goroutine运行的上下文环境(函数指针,堆栈地址及地址边界)。
  • M(machine)是go运行时(runtime)对操作系统内核线程的虚拟,M与内核线程一般是一一映射的关系,一个goroutine最终是要放到M上执行的。

P与M一般也是一一对应的。它们关系是:P管理着一组G挂在到M上运行。P的个数是通过runtime.GOMAXPROCS设定(最大256),go1.5版本之后默认为物理线程数。groutine的调度是在用户态下完成的,不涉及到内核态与用户态之间的频繁切换。

go语言中可以通过runtime.GOMAXPROCS()函数设置当前程序并发时占用的CPU逻辑核心数。

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

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

Channels

如果说goroutine是go语言程序的并发体,那么channel则是它们之间的通信机制。一个channel是一个通信机制,它可以让一个goroutine通过它给另一个goroutine发送值信息。一个可以发送int类型数据的channel一般写为chan int

声明一个channel

var 变量 chan 元素类型
var ch chan int

使用make函数来创建,格式如下

make(chan 元素类型,[缓冲大小])
ch := make(chan int) //ch 的格式是 `chan int`

和map类似,channel也对应一个make创建的底层数据结构的引用。channel和其他引用类型一样,channel的零值也是nil。

channel之间可以使用==进行比较,如果引用同一个对象,那么比较的结果为真。

一个channel有发送和接受两个操作,都是通信行为。 一个发送语句将一个值从一个goroutine通过channel发送到另一个执行接收操作的goroutine。发送和接收两个操作都是用<-运算符。

发送和接收的语法如下:

ch <- x // 发送x到channel
x := <- ch //接收一个从channel中取出的值,赋值给x
<- ch  //接收一个从channel中取出的值,忽略结果

channel还支持close操作,用于关闭channel,随后对基于该channel的任何发送操作都将导致panic异常。对一个已经被close过的channel进行接收操作依然可以接收到之前已经成功发送到数据;如果channel中没有数据了,将产生一个零值。

close(ch)
无缓冲的通道

无缓冲通道上的发送将会阻塞,直到另一个goroutine在对应的通道上执行接收操作,这时值传送完成。两个goroutine都可以继续执行。如果接收操作先发生,那么接收者goroutine也将阻塞,直到有另一个goroutine在相同的channel上执行发送操作。

当通过一个无缓冲通道发送数据时,接收者收到数据发生在再次唤醒发送者goroutine之前。

如果没有接收者,只有一个发送者,那么程序在运行时会报错

ch := make(chan int)
ch <- 10
fmt.Println("发送成功")
fatal error: all goroutines are asleep - deadlock!

这段代码会在ch <- 10这一行形成死锁。解决的方法就是启动一个goroutine去接受值。

func recv(c chan int) {
	x := <-c
	fmt.Printf("接收到值:%d\n", x)
}
func main(){
    ch := make(chan int)
	go recv(ch)
	ch <- 10
	fmt.Println("发送成功")
}

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

有缓冲的通道

我们可以在使用make函数初始化channel时,指定其通道的容量,容量代表通道中存放元素的数量

func main(){
    ch := make(chan int ,1) //创建一个容量为1的有缓冲区的通道   
}

只要通道的容量大于零,那么就是有缓冲的通道。

select 多路复用

在有些时候我们需要同时从多个通道中接收数据。在循环迭代中,不能保证多个通道都能顺利接收数据,比如第一个channel中没有事件发送过来那么程序会被阻塞,这样也就无法接收第二个channel的事件了。为了能够多路复用,go语言提供了select语句

select{
	case <- ch1:
    	//...
	case x:=<- ch2:
    	//...
    case ch3 <- y:
    	//...
	default:
    	//...
}

每一个case代表一个通信操作(在某个channel上进行发送和接收)

select会等待case中有能够执行的case时去执行。当条件满足时,select才会去通信并执行case之后的语句;这时候其他通信是不执行的。空的select{}会永远的等待下去。

创建一个buff为1的channel,所以会出现交替执行case的情况,因此打印出来的值为0 2 4 6 8

ch := make(chan int, 1)
for i := 0; i < 10; i++ {
    select {
        case x := <-ch:
        	fmt.Println(x)
        case ch <- i:
        }
}

如果多个case同时就绪,那么select会随机地选择一个执行,这样来保证每一个channel都有平等的被select的机会。

将上面例子中的buff更改为2,这个时候出现的结果就是不固定的随机的。

ch := make(chan int, 2)
for i := 0; i < 10; i++ {
    select {
        case x := <-ch:
        fmt.Println(x)
        case ch <- i:
        }
}

select中的default来设置当其他的操作都不能够马上被处理时程序需要执行的逻辑。

数据竞争

数据竞争会在两个以上的goroutine并发访问相同的变量且至少其中一个为写操作时发生。比较常见的例子是银行转账或者计数。

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

当有2个goroutine同时访问同一个变量x,就会造成数据竞争,结果达不到预期。

解决数据竞争的方式

  1. 不要去写变量:如果我们在创建goroutine之前的初始化阶段,就初始化了map中的所有条目并且再也不去修改它们,那么任意数量的goroutine并发访问Icon都是安全的,因为每一个goroutine都只是去读取而已。
  2. 避免从多个goroutine访问变量:将访问的变量限定在一个单独的goroutine中。
  3. 允许很多goroutine去访问变量,但同一个时刻最多只有一个goroutine在访问,这种方式称为“互斥”。
sync.Mutex互斥锁

go语言使用sync包的Mutex类型来实现互斥锁。针对上面的代码我们加入互斥锁

var (
	x  int64
	mu sync.Mutex
)

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

加锁后发现可以得到预期的结果。每次一个goroutine访问x变量时,它都会调用mutex的Lock方法来获取一个互斥锁,如果其他goroutine已经获取这个锁了,这个操作会被阻塞直到其他的goroutine调用了Unlock使该锁变回可用状态。

多个goroutine同时等待一个锁时,唤醒的策略是随机的。

sync.RWMutex读写锁

互斥锁是完全互斥的,但好多业务场景是读多写少,我们并发的读取资源不设计到资源修改的时候是没有必要加锁的,针对这种情况读写锁是更好的选择。go语言提供这样的锁是sync.RWMutex

我们模拟一个读写操作,测试使用互斥锁所用的时间

var mu  sync.Mutex
func write() {
	//加写锁
	mu.Lock()
	defer mu.Unlock()
	x++
	time.Sleep(10 * time.Millisecond)
	wg.Done()
}
func read() {
	//加读锁
	mu.Lock()
	defer mu.Unlock()
	time.Sleep(time.Millisecond)
	wg.Done()
}
func main(){
    start := time.Now()
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go write()
    }
    for i := 0; i < 10000; i++ {
        wg.Add(1)
        go read()
    }
    wg.Wait()
    end := time.Now()
    fmt.Println(end.Sub(start))    
}

我们会发现时间基本在10+秒,因为读的时候也会加锁。

然后将上述改为读写锁,我们在测试下时间

var rwlock sync.RWMutex
func write() {
	//加写锁
	rwlock.Lock()
	defer rwlock.Unlock()
	x++
	time.Sleep(10 * time.Millisecond)
	wg.Done()
}
func read() {
	//加读锁
	rwlock.RLock()
	defer rwlock.RUnlock()
	time.Sleep(time.Millisecond)
	wg.Done()
}
func main(){
    start := time.Now()
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go write()
    }
    for i := 0; i < 10000; i++ {
        wg.Add(1)
        go read()
    }
    wg.Wait()
    end := time.Now()
    fmt.Println(end.Sub(start))    
}

发现使用读写锁后,时间仅需要120ms。如果当读写次数相当时,读写锁的效果就会下降甚至不如互斥锁。

sync.Once惰性初始化

如果初始化成本比较大的话,那么将初始化延迟到需要的时候再去做就是一个比较好的选择。有时我们需要某些操作在高并发操作中只执行一次,例如加载配置文件。

func loadIcons() {
	icons = map[string]image.Image{
        "spades.png":   loadIcon("spades.png"),
        "hearts.png":   loadIcon("hearts.png"),
        "diamonds.png": loadIcon("diamonds.png"),
        "clubs.png":    loadIcon("clubs.png"),
	}
}
// NOTE: not concurrency-safe!
func Icon(name string) image.Image {
    if icons == nil {
        loadIcons() // one-time initialization
    }
    return icons[name]
}

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

func loadIcons() {
    icons = make(map[string]image.Image)
    icons["spades.png"] = loadIcon("spades.png")
    icons["hearts.png"] = loadIcon("hearts.png")
    icons["diamonds.png"] = loadIcon("diamonds.png")
    icons["clubs.png"] = loadIcon("clubs.png")
}

因此,一个goroutine在检查icons是非空时,也并不能确定这个变量初始化完成了,可能只初始化了一个空的map。这个时候我们可以使用互斥锁来完成,但使用互斥锁就无法对变量进行并发访问。

var mu sync.Mutex // guards icons
var icons map[string]image.Image

// 并发安全的
func Icon(name string) image.Image {
    mu.Lock()
    defer mu.Unlock()
    if icons == nil {
        loadIcons()
    }
    return icons[name]
}

我们可以通过引用一个允许多读的锁来完成

var mu sync.RWMutex
var icons map[string] image.Image
func loadIcons() {
	icons = map[string]image.Image{
        "spades.png":   loadIcon("spades.png"),
        "hearts.png":   loadIcon("hearts.png"),
        "diamonds.png": loadIcon("diamonds.png"),
        "clubs.png":    loadIcon("clubs.png"),
	}
}
func Icon(name string) image.Image{
    mu.RLock()
    if icons != nil{
        icon := icons[name]
        mu.RUnlock()
        return icon
    }
    mu.RUnlock()
    mu.Lock()
    if icons == nil{
        loadIcons()
    }
    icon := icons[name]
    mu.Unlock()
    return icon
}

首先goroutine会获得一个读锁,查询map,然后释放锁。如果没有查到map,那么就获得一个写锁,因为内存同步机制的存在,我们无法观察到某个goroutine内变量初始化的情况,所以此时需要对icons进行判空,以防止其他goroutine初始化过了。经过上述代码就可以实现资源初始化了,但太复杂容易出错,好在go语言sync.Once为我们提供了这种支持。

其签名如下

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

如果要执行的函数f()有参数的话 ,需要闭包来实现。

var loadIconsOnce sync.Once
func loadIcons() {
	icons = map[string]image.Image{
        "spades.png":   loadIcon("spades.png"),
        "hearts.png":   loadIcon("hearts.png"),
        "diamonds.png": loadIcon("diamonds.png"),
        "clubs.png":    loadIcon("clubs.png"),
	}
}
func Icon(name string)image.Image{
    loadIconsOnce.Do(loadIcons)
    return icons[name]
}

一次性的初始化需要一个互斥量mutex和一个boolean变量来记录初始化是不是已经完成了。

go语言会先判断boolean变量是否为1,只有不为1才锁定mutex。在第一次调用时,boolean变量的值为false,Do会调用loadIcons 并将boolean更改为true。随后调用什么都不会做,但是mutex同步会保证icons变量对所有的goroutine可见。使用这种方式,我们能够避免在变量被构建完成之前和其他的goroutine共享该变量。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值