这是我参与「第五届青训营 」伴学笔记创作活动的第 2 天
首先是面试中也经常问的一个知识点:并发和并行的区别
并发:多线程程序在一个核的cpu上运行
并行:多线程程序在多个核的cpu上运行
Go 天生支持并发编程,可以充分发挥多核优势,高效运行。
Goroutine 协程
Go 语言主要向提供协程来实现并发。
协程:用户态,轻量级线程,栈MB级别。
线程:内核态,线程跑多个协程,栈KB级别。
这里的用户态是指协程是用户级别的概念,和操作系统内核无关,也就是说操作系统内核是感知不到协程的存在的。对协程的创建,撤销,同步与通信等功能都无需内核的支持。
而内核态就好理解了,内核态的线程是在操作系统内核的支持下运行的,操作系统内核能对线程进行调度。
这里的用户态和内核态的区别是,操作系统内核是否对并发任务的切换有所感知,是否能够干涉线程的切换。
两种状态其实各有好处,具体体现在程序中的特性也看语言的具体实现。
Go 语言可以使用 go 关键字来启动一个协程。
func main() {
for i := 0; i < 10; i++ {
go func(n int) {
fmt.Printf("%d ", n)
}(i)
}
// 9 4 1 0 6 7 8 2 5 3
// 0 2 1 5 9 3 6 7 4 8
// 等待三秒,等协程执行完
time.Sleep(3 * time.Second)
}
Chan 通道
Go 语言有自己特有的并发模型:CSP(Communicating Sequential Processes),提倡通过通信共享内存而不是通过共享内存来通信。
当然Go语言也保留了通过共享内存来通信的通信机制(通过加锁)。
Go 提供了通道(channel)来进行协程间的通信。
无缓冲通道也叫同步通道,顾名思义没有缓冲区。发送者执行到发送操作的时候,如果没有接收者接收,将会一直堵塞,直到有接收者来接收,接收者执行到接收操作的时候亦然。
有缓冲通道的内部持有一个元素队列。队列的最大容量是在调用 make() 函数创建通道时候时通过第二个参数指定的。
向有缓冲通道的发送操作就是向内部缓存队列的尾部插入元素,接收操作则是从队列的头部删除元素。如果内部缓存队列是满的,那么发送操作将阻塞直到因另一个 Goroutine 执行接收操作而释放了新的队列空间。相反,如果有缓冲通道是空的,接收操作将阻塞直到有另一个 Goroutine 执行发送操作而向队列插入元素。
通道(chan)使用Go语言内置的 make() 函数来申请内存。
c1 := make(chan int) // 无缓冲区的通道
c2 := make(chan int, 2) // 缓冲区为2
有缓存的通道其实也是一个比较经典的消费者&生产者模型。
func CalSquare() {
src := make(chan int)
dest := make(chan int, 3)
go func() {
defer close(src)
for i := 0; i < 10; i++ {
src <- i
}
}()
go func() {
defer close(dest)
for i := range src {
dest <- i * i
}
}()
for i := range dest {
//复杂操作
print(i, " ")
}
// 0 1 4 9 16 25 36 49 64 81 --- PASS: TestCalSquare (0.00s)
}
这里使用了 Go 原生提供的单元测试进行了简单运行,后文出现的代码同理。
func TestCalSquare(t *testing.T) {
CalSquare()
}
可以看到输出的结果是有序的,也就是说这里的并发安全是有保障的。
在实际开发中应该避免对共享内存做一些非并发的读写操作。
锁 Lock
Go 语言提供的通过共享内存实现通信的方式。
var (
x int64
lock sync.Mutex
)
func addWithLock() {
for i := 0; i < 2000; i++ {
lock.Lock()
x += 1
lock.Unlock()
}
}
func addWithoutLock() {
for i := 0; i < 2000; i++ {
x += 1
}
}
func Add() {
x = 0
for i := 0; i < 5; i++ {
go addWithoutLock()
}
time.Sleep(time.Second)
println("WithoutLock:", x)
x = 0
for i := 0; i < 5; i++ {
go addWithLock()
}
time.Sleep(time.Second)
println("WithLock:", x)
}
第一行是不加锁的代码的运行结果,这个结果是有问题的。第二行的才是正确结果。
WithoutLock: 8470
WithLock: 10000
在存在多个 Goroutine 同时操作临界区的资源的时候,需要加锁来保证程序执行的正确性。
线程同步 WaitGroup
之前的例子都是使用 time.Sleep(time.Second) 来实现的主协程阻塞,保证子协程执行完毕,这样的方式不够优雅也不够稳定。
Go 语言提供了 sync.WaitGroup 来实现当前协程的阻塞。
sync.WaitGroup 的机制其实有点像引用计数法,在计数器变为 0 之后才会放开主协程的阻塞。
func ManyGoWait() {
var wg sync.WaitGroup
wg.Add(5)
for i := 0; i < 5; i++ {
go func(j int) {
defer wg.Done()
hello(j)
}(i)
}
wg.Wait()
}
执行结果,可以看到所有的协程都执行完毕了。
hello goroutine : 4
hello goroutine : 0
hello goroutine : 1
hello goroutine : 3
hello goroutine : 2