一、使用协程
go 的协程使用上十分的简单,只需要在方法前添加 go 关键字,就可以开启一个新的用户态线程去异步执行。
package main
import (
"fmt"
"time"
)
func main() {
go doSomething("mclink")
doSomething("study")
}
func doSomething(str string) {
for i:=0 ;i < 5 ; i++ {
time.Sleep(time.Second)
fmt.Println(str)
}
}
打印的结果如下:
study
mclink
mclink
study
study
mclink
mclink
study
mclink
study
我们都知道,一个进程中至少会有一个线程,我们称之为主线程,在代码中,我们通过 go 关键词起了另一个线程去执行 doSomething 方法,此时,通过 go 的调度器,两个线程会乱序的打印出相应的字符串。
在 go 语言中,同一个程序中的所有 goroutine 共享同一个地址空间, 与Java的多线程不同,goroutne 是基于用户态的,实际上一个 goroutine 只是一个 go 语言定义的结构体,因此你可以在一个进程内大量的创建,并且只会占用极少的资源,而 Java 的多线程是基于内核态的,创建的成本相对较高。
协程和线程类似,共享堆,但不共享栈,一般来说,协程的转换一般由程序员在代码中显式的控制,它避免了上下文切换的额外耗费,兼顾了多线程的优点,简化了高并发程序的复杂。
二、 通道的使用
由于同一个进程的线程共享该进程的资源,因此线程间的通信成本远比进程间要小,在 go 语言中, channel 则是沟通多个 goroutine 之间的桥梁。
我们可以把 channel 类似成 Unix系统的通道。
2.1 如何创建通道
在 go 语言中,我们通过make关键字来创建通道,通道的类型一般是确定的,也就是说 string 类型的通道只能放进string 类型的变量。例如:
package main
import "fmt"
func main() {
ch := make(chan int) // 非缓冲通道
ch2 := make(chan string, 2) // 缓冲通道
fmt.Println(ch, ch2)
}
2.2通道的类型
通道的类型只有两种,缓冲通道和非缓冲通道
// Channel: The channel's buffer is initialized with the specified
// buffer capacity. If zero, or the size is omitted, the channel is
// unbuffered.
func make(t Type, size ...IntegerType) Type
我们通过 make 函数的注释说明就可以知道,当 size 参数为 0 或者是没有传,则为非缓冲队列,否则是缓冲队列
-
缓冲通道
- 即使通道元素没有被接收,也可以继续往里面发送元素,直到超过缓冲值,通过设置这个缓冲值可以提高通道的操作效率。
-
非缓冲通道
- 表示往通道中发送一个元素后,只有该元素被接收后才能存入下一个元素
通道实际上就是一个队列,队列是先进先出的数据结构,如果你对队列还不熟悉,可以看看我之前写过的队列文章传送门。
在这里我们需要注意的是,一旦通道满了,再写入数据时,该协程将会被阻塞,直到有其他协程把数据取出。
我们可以举个例子看看:
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int, 1)
go func() {
fmt.Println("这里是子协程")
ch <- 1 // 塞进去
fmt.Println("塞进了一个1")
ch <- 2 //当前协程可能阻塞
fmt.Println("塞进了一个2")
time.Sleep(time.Second * 3)
ch <-3
fmt.Println("塞进了一个3")
}()
fmt.Println("主协程")
time.Sleep(time.Second * 3) // 这里注意,阻塞 3s 后,主协程取出通道数据后,上面 2 才会塞进去
fmt.Println("取出通道数据:" , <-ch)
time.Sleep(time.Second *3)
fmt.Println("取出通道数据:", <-ch)
fmt.Println("取出通道数据:", <-ch) // 空通道取数据会有阻塞作用
fmt.Println("over")
}
输出如下:
主协程
这里是子协程
塞进了一个1
取出通道数据: 1
塞进了一个2
取出通道数据: 2
塞进了一个3
取出通道数据: 3
over
我们可以发现,在主协程取出通道数据前, 2 是塞不进通道里的。利用这种队列阻塞的原理,我们可以很轻松的实现多端异步消费的功能
2.3 关闭通道
一般涉及到资源类的,我们一般都需要手动去回收资源,尤其是常驻进程类的服务,如果没有回收资源的意识,很容易造成内存泄漏。
// The close built-in function closes a channel, which must be either
// bidirectional or send-only. It should be executed only by the sender,
// never the receiver, and has the effect of shutting down the channel after
// the last sent value is received. After the last value has been received
// from a closed channel c, any receive from c will succeed without
// blocking, returning the zero value for the channel element. The form
// x, ok := <-c
// will also set ok to false for a closed channel.
func close(c chan<- Type)
我们只需要关注最后一句话:一个封闭的通道c,任何接收c的操作将被阻塞,返回channel元素的零值。
x,ok:= <-c
关闭了通道,ok的结果也将为false。
关闭通道的操作只能执行一次,试图关闭已关闭的通道会引发 panic。此外,关闭通道的操作只能在发送数据的一方关闭,如果在接收一方关闭,会导致 panic,因为接收方不知道发送方什么时候执行完毕,向一个已经关闭的通道发送数据会导致 panic。
简单总结如下:
- 关闭一个未初始化(nil) 的 channel 会产生 panic
重复关闭同一个 channel 会产生 panic - 向一个已关闭的 channel 中发送消息会产生 panic
- 从已关闭的 channel 读取消息不会产生 panic,且能读出 channel 中还未被读取的消息,若消息均已读出,则会读到类型的零值。
- 从一个已关闭的 channel 中读取消息永远不会阻塞,并且会返回一个为 false 的 ok-idiom,可以用它来判断 channel 是否关闭
- 关闭 channel 会产生一个广播机制,所有向 channel 读取消息的 goroutine 都会收到消息
最后一个关闭通道的广播我们可以举个例子:
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int)
for i := 0; i < 5; i++ { // 创建五个协程
go func(i int, ch chan int) {
for {
if isCancel(ch) { // 死循环监听,能消费到就会退出
break
}
time.Sleep(time.Microsecond * 5)
}
fmt.Println(i, "cancel")
}(i, ch)
}
cancel(ch)
time.Sleep(time.Second)
}
func cancel(ch chan int) {
// 关闭有通道广播的作用,所有消费者都能收到通知,即 <-ch, 拿到对应的类型的零值
close(ch)
}
func isCancel(ch chan int) bool {
select {
case <-ch:
return true
default:
return false
}
}
输出如下:
4 cancel
3 cancel
0 cancel
1 cancel
2 cancel
2.4 获取通道的数据
在上面我们可以通过 <-
操作符来后来获取通道的一个元素,我们还可以通过
for range
语法来获取。
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int, 1)
go func() {
for i:= 0 ; i < 10; i++ {
ch <- i
time.Sleep(time.Second * 1)
}
close(ch) // 这个不能省,否则主协程不知道子协程什么时候执行完毕,从一个空的通道接收数据会报如下运行时错误(死锁):fatal error: all goroutines are asleep - deadlock!
}()
// 接收数据
for i := range ch {
fmt.Println("receive data: ", i)
}
fmt.Println("over")
}
输出如下:
receive data: 0
receive data: 1
receive data: 2
receive data: 3
receive data: 4
receive data: 5
receive data: 6
receive data: 7
receive data: 8
receive data: 9
over
三、锁
go 的协程并不是线程安全的,对于一些场景,我们希望可以进行资源的控制,否则很容易造成数据的错误。
比如说 加N操作, A 和 B 都看到当前的数字为 10, A想加100,B想加 200 ,于是他们同时发出了修改请求,对于A 、B来说,正确的结果应该分别为 110和210, 但是最终的结果却是 310 。 为了保证正确的结果,在对其进行操作时,应该限制同时只能由单个人去处理。
资源的共享,必然会引起竞争,为了避免竞争所带来的的数据错误影响,我们应该保证共享的资源同一时刻只有一个人可以操作,这就是并发编程的弊端,通过锁的方式保证的数据一致性,却牺牲了部分性能。
在 go 语言中,锁主要分为两种:互斥锁和读写锁
3.1 互斥锁 (mutex)
我们先举一个没有锁机制的并发代码
package main
import (
"fmt"
"time"
)
func main() {
count := 0
for r := 0; r < 1000; r++ {
go func() {
count += 1
}()
}
time.Sleep(time.Second * 3)
fmt.Println("the count is : ", count)
}
此时count 的值一般是低于 1000 的,因为部分协程拿到的值是同一个,并且 count+=1 操作并不是原子操作。
//互斥锁是一个互斥锁。
//互斥锁的零值是未锁定的互斥锁。
//互斥锁不能在第一次使用后被复制。
type Mutex struct {
state int32
sema uint32
}
//Locker接口只有两个方法,一个加锁,一个解锁。
type Locker interface {
Lock()
Unlock()
}
//锁定当前的互斥量
//如果锁已被使用,则调用的 goroutine 将阻塞直到互斥锁可用。
func (m *Mutex) Lock()
//对当前互斥量进行解锁
//如果在进入解锁时未锁定m,则为运行时错误。
//锁定的互斥锁与特定的 goroutine 无关。
//允许一个goroutine锁定Mutex然后安排另一个goroutine来解锁它。
func (m *Mutex) Unlock()
我们将上面的栗子进行简单的改写:
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var mutex sync.Mutex
count := 0
for r := 0; r < 1000; r++ {
go func() {
mutex.Lock()
count += 1
mutex.Unlock()
}()
}
time.Sleep(time.Second * 3)
fmt.Println("the count is : ", count)
}
就可以解决并发导致的问题。
我们可以发现这种方式,是对某个资源的上锁,一旦这个资源被上锁,该资源则无法被抢占。
但是大多数情况,我们是希望可并发读不可并发写的。
3.2 读写锁 (RWMutex)
读写锁是针对读写操作的互斥锁,可以分别针对读操作与写操作进行锁定和解锁操作 。
其访问控制规则如下:
- 多个写操作之间是互斥的
- 写操作与读操作之间也是互斥的
- 多个读操作之间不是互斥的
在这样的控制规则下,读写锁可以大大降低性能损耗。
// RWMutex是一个读/写互斥锁,可以由任意数量的读操作或单个写操作持有。
// RWMutex的零值是未锁定的互斥锁。
//首次使用后,不得复制RWMutex。
//如果goroutine持有RWMutex进行读取而另一个goroutine可能会调用Lock,那么在释放初始读锁之前,goroutine不应该期望能够获取读锁定。
//特别是,这种禁止递归读锁定。 这是为了确保锁最终变得可用; 阻止的锁定会阻止新读操作获取锁定。
type RWMutex struct {
w Mutex //如果有待处理的写操作就持有
writerSem uint32 // 写操作等待读操作完成的信号量
readerSem uint32 //读操作等待写操作完成的信号量
readerCount int32 // 待处理的读操作数量
readerWait int32 // number of departing readers
}
//读操作锁定
func (rw *RWMutex) RLock()
//读操作解锁
func (rw *RWMutex) RUnlock()
//写操作锁定
func (rw *RWMutex) Lock()
//写操作解锁
func (rw *RWMutex) Unlock()
//返回一个实现了sync.Locker接口类型的值
func (rw *RWMutex) RLocker() Locker
因为读写锁控制的多个读操作并不是互斥的,所以对于同一个读写锁,添加了多少个读锁定,必须要有等量的读解锁,否则其他协程将没有机会进行资源的操作。
- Unlock方法会试图唤醒所有因为进行读锁定而被阻塞的协程
- RUnlock 只会在已无任何读锁定的情况下,试图唤醒一个因欲进行写锁定而被阻塞的协程。
- 若对一个未被写锁定的读写锁进行写解锁,就会引发一个不可恢复的panic,同理对一个未被读锁定的读写锁进行读解锁也会如此。
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var rwm sync.RWMutex
for i := 0; i < 3; i++ {
go func(i int) {
fmt.Println("尝试进行读锁定", i)
rwm.RLock()
fmt.Println("读锁定完成 ", i)
time.Sleep(time.Second)
fmt.Println("尝试进行读解锁 ", i)
rwm.RUnlock()
fmt.Println("读解锁完成", i)
}(i)
}
time.Sleep(time.Microsecond * 300)
fmt.Println("尝试执行写锁定")
rwm.Lock() // 当所有的读解锁都完成,此句才能正常执行,否则将被阻塞
fmt.Println("写锁定完成")
}
输出如下:
尝试进行读锁定 0
尝试进行读锁定 2
读锁定完成 2
读锁定完成 0
尝试进行读锁定 1
读锁定完成 1
尝试执行写锁定
尝试进行读解锁 2
读解锁完成 2
尝试进行读解锁 0
读解锁完成 0
尝试进行读解锁 1
读解锁完成 1
写锁定完成
我们可以发现当所有的读解锁都完成后,写锁定才能正常执行。