简介

Go语言天生支持并发编程,通过goroutine开启协程并发,通过channel实现协程通信,然后通过sync等库保证并发的性能和安全。所以这里我们关注三个问题:

  1. 怎么使用goroutine?协程是怎么执行的?
  2. 协程通信有哪些方式,怎么通过channel实现协程通信?
  3. 如何保证协程安全?

Goroutine

Goroutine本身是一个函数,直接调用就是一个函数,通过go关键字调用就是一个goroutine协程。main函数对应的是main goroutine,Go程序启动入口在runtime.main,会创建main goroutine,执行初始化逻辑,然后在main goroutine中执行我们编写的main函数,遇到go关键字时,再创建新的goroutine。main goroutine执行完我们的main函数之后,会执行runtime剩余的Defer和Panic处理逻辑,或者直接exit。

func helloWorld(){
    fmt.Print("hello,world")
}
//goroutine调用方式
go helloWorld(){}

也可以直接调用一个匿名函数:

go func(){
    fmt.Print("hello,world")
}()
### Goroutine的优雅退出

在main goroutine中如果执行完了我们编写的main.main还有其它的goroutine没有执行而且没有使用WaitGroupchannel同步机制的话runtime会带着整个程序一起退出不会管那些没有执行完毕的goroutine例如下面这段代码不会有任何输出因为main在创建完协程之后马上退出了

```golang
package main

import "fmt"

func helloWorld() {
	fmt.Print("hello,world")
}

func main() {
	go helloWorld()
}

在go helloWorld()后面加上time.Sleep(600),可以看到有输出。但是这种方式算不上优雅,且不能保证在这个时间之内,所有的goroutine都执行完了。这里先总结一些优雅退出的方法,可以先看后面的部分,再回头看这里的优雅退出方法。我们可以通过信道,从其它的goroutine向主协程发送完成消息,告诉主协程可以,但是这种方式不适合很多goroutine的情况。

package main

import "fmt"

func main() {
	done := make(chan bool)
	go func() {
		fmt.Println("Hello World")
		done <- true
	}()
	<-done
}

使用sync.WaitGroup优雅退出 这是一个只要实例化了就可以调用的类型,有三个方法:

  • Add():添加本协程要等待的协程数目。通常在调用协程前使用。
  • Done():子协程完成之后,调用done(),对应的计数器减一。
  • Wait():阻塞当前协程,只到实例计数器归零。
package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	wg.Add(1)
	go func() {
		fmt.Println("Hello World")
		wg.Done()
	}()
	wg.Wait()
}

Channel

一个让一个goroutine和另外的goroutine传输信息的通道,又叫做信道/通道。它是线程/协程安全的。

channel创建方式

信道必须通过make初始化才能够使用,否则是一个nil。

var 实例 chan 信道类型
var chan1 chan int
pipeline = make(chan int)

pipeline:=make(chan int)

channel是可以有容量的,make函数的第二个参数可以指定channel的容量,默认为0。后面的缓冲/无缓冲信道会讲到这个容量的作用。

pipeline:=make(chan int,2)

channel的操作

channel有三种操作:发送数据、读取数据、关闭。

pipeline<-200//发送数据
data:=<-pipeline//读取数据
close(pipeline)//关闭channel

接收方从已经关闭的channel中读出的数据永远是0,发送方二次关闭channel也是会报错的,所以要学会怎么判断信道是否关闭。从信道读取数据,实际会有两个返回值,第二个表示channel是否还在正常工作,没有被关闭。

x,ok:=<-pipeline

无缓冲信道和缓冲信道

无缓冲信道是容量为0的信道,缓冲信道的容量不为0,可以分别通过cap()和len()获取信道的容量和长度。

c:=cap(pipeline)
l:=len(pipeline)

这里要注意两点,发送端和接收端都是可能出现阻塞的。

  • 如果无法从信道中获取新的数据,发送端会阻塞,只到获取到数据,无论信道容量是多少。
  • 如果信道已满,发送端也会阻塞。对于无缓冲信道,如果没有接收端在等着接收数据,发送端会阻塞。对于缓冲信道,如果长度等于容量,发送端同样会阻塞。

如果发送端和接收端在不同的协程,阻塞一般没什么影响,等另一端准备好了就可以。但是如果二者在同一个协程,一端阻塞,另外一端可能也没办法解决,可能会导致死锁。例如下面这个发送端阻塞了主线程,接收端等发送方完成才能创建并接受数据,而发送端在等待一个接收端,整个main进入了死锁的状态。

package main

import (
	"fmt"
	"time"
)

func main() {
	pipeline := make(chan int, 0)

	pipeline <- 200
	fmt.Printf("ok")
	close(pipeline)

	go func() {
		data, ok := <-pipeline
		fmt.Println(data, ok)
	}()

	time.Sleep(2 * time.Second)
}

双向信道和单向信道

一般定义的chan都是双向信道。但是有些时候,我们希望对信道的数据流做一些控制,例如希望某个信道只读或者只写。golang的单向信道需要从双向信道通过别名类型来定义,因为只有只读或者只写没有意义。

package main

import (
	"fmt"
	"time"
)

type Sender chan<- int
type Receiver <-chan int

func main() {
	pipeline := make(chan int, 0)

	go func() {
		var sender Sender = pipeline
		sender <- 1
	}()

	go func() {
		var receiver Receiver = pipeline
		num := <-receiver
		fmt.Println(num)
	}()
	time.Sleep(time.Second)
}

select语法

select专门用于处理多个channel的操作,类似于switch语句,但是专注于channel通信。

select {
case <-chan1:
    // 从chan1接收到数据时执行
case chan2 <- value:
    // 成功向chan2发送数据时执行
case val := <-chan3:
    // 从chan3接收数据并赋值给val时执行
default:
    // 当上述case都阻塞时执行
}

多个case准备就绪时,会选择一个随机执行,如果所有case都阻塞,而且没有default分支,select会被阻塞。

sync提供的并发支持

sync包提供的是同步原语,协调goroutine的并发关系和对共享资源的访问,避免数据竞争和死锁问题,比较常见功能包括互斥锁、读写互斥锁、等待组、只执行一次、并发安全的map。

Mutex

实现协程之间,对于共享资源的互斥访问。实例化即可访问。

package main

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

func main() {
	var mu sync.Mutex
	x := 9
	go func() {
		mu.Lock()
		x = 7
		mu.Unlock()
	}()
	fmt.Println(x)
	time.Sleep(time.Second)
}

RWMutex

允许多个读操作并发,但是写操作互斥。

package main

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

func main() {
	var mu sync.RWMutex
	x := 9
	go func() {
		mu.RLock()
		fmt.Println(x)
		mu.RUnlock()
	}()
	go func() {
		mu.Lock()
		x = 6
		mu.Unlock()
	}()
	time.Sleep(time.Second)
}

WaitGroup

用于优雅等待子协程退出。

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	wg.Add(1)
	go func() {
		fmt.Println("Hello World")
		wg.Done()
	}()
	wg.Wait()
}

Once

多个协程可能需要执行同一段代码,Once可以保证只执行一次。例如我们使用多个协程来创建单例模式。


package main

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

type Singleton struct {
	val int
}

var (
	instance *Singleton // 单例实例
	one      sync.Once  // 确保单例实例只被创建一次
)

// GetInstance 返回单例实例
func GetInstance(v int) *Singleton {
	if instance == nil { // 如果实例不存在,则创建
		time.Sleep(1 * time.Millisecond) // 模拟耗时操作
		fmt.Println("GetInstance v:", v)
		one.Do(func() {
			instance = &Singleton{val: v}
		}) //只会执行一次
	}
	return instance
}

func main() {
	for i := 0; i < 10; i++ {
		go func(i int) {
			singleton := GetInstance(i)
			fmt.Printf("Singleton instance: %p, value: %d\n", singleton, singleton.val)
		}(i)
	}
	time.Sleep(1000 * time.Second)

Map

线程安全的Map。标准的map加互斥锁的方案存在严重性能问题,会粗粒度锁定整个map。sync.Map中使用了两个mao,一个专门用于读,一个专门用于写。sync.Map同样不需要初始化,sync.Map内部使用interface{}存储所有键值,值没有类型要求,但是键是需要能够比较的类型,有点类似Redis的意思。

var m sync.Map

m.Store("name","hong")//写
m.Store("age",24)
nameok:=m.Load("name")//读
m.Delete("name")//删

还有些高级操作

valloaded:=m.LoadOrStore("age",25)//不存在则存储,返回存储后的值
m.Range(func(key,value interface())bool{
    fmt.Printf("  %v: %v\n", key, value)
    return true
})

它的键值都是interface{},所以我们需要类型断言来获取正确类型的值。

value, ok := m.Load("name")
if !ok {
    // 键不存在
    return
}

// 类型断言
if name, ok := value.(string); ok {
    fmt.Println("名字是:", name)
} else {
    fmt.Println("值类型不是字符串")
}

总结

重点是三部分:

  • goroutine:Golang并发基础。
  • channel:协程通信基础,Golang一等公民。
  • sync:高效管理并发,避免数据竞争和死锁问题。