Go并发
进程、线程、协程
进程:进程是操作系统资源分配的最小单位
进程有自己的虚拟地址空间,这个空间包括了各种资源,例如堆、栈,各种段,它们其实都是虚拟地址空间的一块区域。所以说进程是资源分配的最小单位。
线程:线程是操作系统任务调度和执行的最小单位。
线程包含在进程之中,是进程中实际运作单位
协程:线程中协作式调度的程序(函数)
协程运行在线程之上,当一个协程执行完成后,由开发者控制主动让出,让另一个协程运行在当前线程之上。
协程并没有增加线程数量,只是在线程的基础之上通过分时复用的方式运行多个协程,而且协程的切换在用户态完成,切换的代价比线程从用户态到内核态的代价小很多
协程在线程之上的运行是串行的
并发、并行
并发(Concurrency):是指在某个时间段内,多任务交替处理的能力。CPU把可执行时间均匀地分成若干份,每个进程执行一段时间后,记录当前的工作状态,
释放相关的执行资源并进入等待状态,让其他线程抢占CPU资源。
并行(Parallelism):是指同时处理多任务的能力
Go并发
Go 语言支持并发,我们只需要通过 go 关键字来开启 goroutine 即可。
goroutine 是轻量级线程,goroutine 的调度是由 Golang 运行时进行管理的。
goroutine 语法格式:
go 函数名( 参数列表 )
Go 允许使用 go 语句开启一个新的运行期线程, 即 goroutine,以一个不同的、新创建的 goroutine 来执行一个函数。 同一个程序中的所有 goroutine 共享同一个地址空间
goroutine
goroutine的概念类似于线程,但 goroutine是由Go的运行时(runtime)调度和管理的。
Go程序会智能地将 goroutine 中的任务合理地分配给每个CPU。Go语言之所以被称为现代化的编程语言,就是因为它在语言层面已经内置了调度和上下文切换的机制
在Go语言编程中你不需要去自己写进程、线程、协程,当需要让某个任务并发执行的时候,只需要把这个任务包装成一个函数,开启一个goroutine去执行这个函数就可以了
goroutine使用示例:
// api方法
go list.Sort()
// 自定义方法内部
func Announce(message string, delay time.Duration) {
go func() {
time.Sleep(delay)
fmt.println(message)
}()
}
Linux 内核中是以进程为单元来调度资源的,线程也是轻量级进程。所以说,进程、线程都是由内核来创建并调度。协程是由应用程序创建出来的任务执行单元,比如 Go 语言中的协程“goroutine”。协程本身是运行在线程上,由应用程序自己调度,它是比线程更轻量的执行单元。
在 Go 语言中,一个协程初始内存空间是 2KB(Linux 下线程栈大小默认是 8MB),相比线程和进程来说要小很多。协程的创建和销毁完全是在用户态执行的,不涉及用户态和内核态的切换。另外,协程完全由应用程序在用户态下调用,不涉及内核态的上下文切换。协程切换时由于不需要处理线程状态,需要保存的上下文也很少,速度很快。
Go语言中协程池的实现方法有两种:抢占式和调度式。
- 抢占式协程池,所有任务存放到一个共享的 channel 中,多个协程同时去消费 channel 中的任务,存在锁竞争。
- 调度式协程池,每个协程都有自己的 channel,每个协程只消费自己的 channel。下发任务的时候,采用负载均衡算法选择合适的协程来执行任务。比如选择排队中任务最少的协程,或者简单轮询。
channel
单纯地将函数并发执行是没有意义的。函数与函数间需要交换数据才能体现并发执行函数的意义
如果说goroutine是Go程序并发的执行体,channel就是它们之间的连接。channel是可以让一个goroutine发送特定值到另一个goroutine的通信机制。
Go 语言中的通道(channel)是一种特殊的类型。通道像一个传送带或者队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序。每一个通道都是一个具体类型的导管,也就是声明channel的时候需要为其指定元素类型
channel类型:
channel类型是一种引用类型,声明通道类型的格式如下:
var 变量 chan 元素类型
如: var ch1 chan int // 声明一个传递整型的通道
channel类型的空值是nil,声明的通道后需要使用make函数初始化之后才能使用。
创建channel的格式如下:
make(chan 元素类型, [缓冲大小])
创建channel示例:
// 创建 channel
a := make(chan int)
b := make(chan int, 10)
// 单向 channel
c := make(chan<- int)
d := make(<-chan int)
channel操作:
通道有发送(send)、接收(receive)和关闭(close)三种操作。
发送和接收都使用<-符号
// 声明并初始化通道 ch := make(chan int) // 发送数据到通道中 ch <- 10 // 从通道中接收数据 x := <-ch // 关闭通道 close(ch)
关闭通道需要注意,只有在通知接收方goroutine所有的数据都发送完毕的时候才需要关闭通道。通道是可以被垃圾回收机制回收的,它和关闭文件是不一样的,在结束操作之后关闭文件是必须要做的,但关闭通道不是必须的。
关闭后的通道有以下特点:
①:对一个关闭的通道再发送值就会导致panic
②:对一个关闭的通道进行接收会一直获取值直到通道为空
③:对一个关闭的并且没有值的通道执行接收操作会得到对应类型的零值
④:关闭一个已经关闭的通道会导致panic
操作 | nil channel | close channel | channel |
close | painc | panic | 成功 |
chan<- | 阻塞 | panic | 阻塞或成功发送 |
<-chan | 阻塞 | 永远不阻塞 | 阻塞或成功接收 |
无缓冲通道:
无缓冲的通道又称为阻塞的通道
使用ch := make(chan int)创建的是无缓冲的通道,无缓冲的通道只有在有人接收值的时候才能发送值。也就是说无缓冲的通道必须有接收才能发送。
无缓冲通道上的发送操作会阻塞,直到另一个goroutine在该通道上执行了接收操作,这时值才能发送成功,两个goroutine将继续执行。相反,如果接收操作先执行,接收方的goroutine将阻塞,直到另一个goroutine在该通道上发送一个值。
使用无缓冲通道进行通信将导致发送和接收的goroutine同步化。因此,无缓冲通道也被称为同步通道。
有缓冲通道:
在使用make函数初始化通道的时候为其指定通道的容量,如:
ch := make(chan int, 1) // 创建一个容量为1的有缓冲区通道
通道的容量表示通道中能存放元素的数量。只要通道的容量大于零,那么该通道就是有缓冲的通道。
通道的缓存满了之后,发送操作就会阻塞,直到通道中有元素被接收。
单向通道:
有的时候我们会将通道作为参数在多个任务函数间传递。
很多时候,我们在不同的任务函数中使用通道都会对其进行限制,比如限制通道在函数中只能发送或只能接收。
chan<- int是一个只能发送的通道,可以发送但是不能接收
<-chan int是一个只能接收的通道,可以接收但是不能发送
func squarer(out chan<- int, in <-chan int) { for i := range in { out <- i * i } close(out) }
channel使用技巧
等待一个事件,也可以通过close一个channel就足够了
chan := make(chan bool)
go func() {
// close 的 channel 会读到一个零值
close(chan)
}()
<-chan
select
select多路复用
在某些场景下我们需要从多个通道接收数据。通道在接收数据时,如果没有数据可以接收将会发生阻塞。
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
sync
1、WaitGroup:
Go语言中可以使用sync.WaitGroup来实现并发任务的同步(类似于Java中的CountDownLatch)。
sync.WaitGroup内部维护着一个计数器,计数器的值可以增加和减少。例如当我们启动了N 个并发任务时,就将计数器值增加N。每个任务完成时通过调用Done()方法将计数器减1。通过调用Wait()来等待并发任务执行完,当计数器值为0时,表示所有并发任务已经完成
sync.WaitGroup有以下几个方法:
方法名 | 功能 |
---|---|
(wg * WaitGroup) Add(delta int) | 计数器+delta |
(wg *WaitGroup) Done() | 计数器-1 |
(wg *WaitGroup) Wait() | 阻塞直到计数器变为0 |
示例:
func hello(wg *sync.WaitGroup) { // state 减 1 defer wg.Done() fmt.Println("Hello Goroutine!") } func main() { wg := &sync.WaitGroup{} // state 为 10 wg.Add(10) for i := 0; i < 10; i++ { go hello(wg) } // 等待state为0,才继续执行后续代码 wg.Wait() fmt.Println("main goroutine run over") }
2、sync.Once
在编程的很多场景下我们需要确保某些操作在高并发的场景下只执行一次,例如只加载一次配置文件、只关闭一次通道等
Go语言中的sync包中提供了一个针对只执行一次场景的解决方案–sync.Once。
3、sync.Map
线程安全的Map
// 线程安全的Map safeMap := &sync.Map{} // 插入键值对 safeMap.Store("name", "yangyongjie") safeMap.Store("age", "20") safeMap.Store("city", "nanjing") // 根据key获取value value1, ok := safeMap.Load("name") fmt.Println(value1) // 如果key存在,则返回value;如果不存在,则插入给定的value value2, ok := safeMap.LoadOrStore("age", 27) fmt.Println(value2) // 删除key safeMap.Delete("name") // 遍历 safeMap.Range(func(key, value interface{}) bool { fmt.Println(key) fmt.Println(value) return true })
并发安全和锁
有时候在Go代码中可能会存在多个goroutine同时操作一个资源(临界区),这种情况会发生竞态问题(数据竞态),也就是并发安全问题。
如:两个线程去累加变量x的值,结果不是10000,而是7946:
var x int32 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) // 7946 }
互斥锁(sync.Mutex):
sync.Mutex不支持可重入锁
互斥锁是一种常用的控制共享资源访问的方法,它能够保证同时只有一个goroutine可以访问共享资源。Go语言中使用sync包的Mutex类型来实现互斥锁
互斥锁的使用:
lock := sync.Mutex{} // 加锁 lock.Lock() // 释放锁 lock.Unlock()
使用互斥锁能够保证同一时间有且只有一个goroutine进入临界区,其他的goroutine则在等待锁;当互斥锁释放后,等待的goroutine才可以获取锁进入临界区,多个goroutine同时等待一个锁时,唤醒的策略是随机的
读写互斥锁(sync.RWMutex):
互斥锁是完全互斥的,但是有很多实际的场景下是读多写少的,当我们并发的去读取一个资源不涉及资源修改的时候是没有必要加锁的,这种场景下使用读写锁是更好的一种选择。读写锁在Go语言中使用sync包中的RWMutex类型
读写锁分为两种:读锁和写锁。当一个goroutine获取读锁之后,其他的goroutine如果是获取读锁会继续获得锁,如果是获取写锁就会等待;当一个goroutine获取写锁之后,其他的goroutine无论是获取读锁还是写锁都会等待
需要注意的是读写锁非常适合读多写少的场景,如果读和写的操作差别不大,读写锁的优势就发挥不出来
rwLock := sync.RWMutex{} // 加写锁 rwLock.Lock() // 释放写锁 rwLock.Unlock() // 加读锁 rwLock.RLock() // 释放读锁 rwLock.RUnlock()
原子操作 (atomic包)
代码中的加锁操作因为涉及内核态的上下文切换会比较耗时、代价比较高
针对基本数据类型我们还可以使用原子操作来保证并发安全,因为原子操作是Go语言提供的方法它在用户态就可以完成,因此性能比加锁操作更好。Go语言中原子操作由内置的标准库sync/atomic提供
import ( "fmt" "sync/atomic" ) var x int32 func main() { // 加1 atomic.AddInt32(&x, 1) // 读取 a := atomic.LoadInt32(&x) fmt.Println(a) // 1 // 写入 atomic.StoreInt32(&x, 10) fmt.Println(x) // 10 // 交换,旧值换新值:func SwapInt32(addr *int32, new int32) (old int32) atomic.SwapInt32(&x, 20) fmt.Println(x) // 20 // 比较并交换,CAS:func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool) atomic.CompareAndSwapInt32(&x, 20, 30) fmt.Println(x) // 30 }
END.