Go语言区别于其它语言最核心的优势就是:Go对并发编程的大力支持。
- 一来Go是通过协程goroutine作为执行体实现并发的,而一个CPU一秒钟可以很轻松地调度上百万个协程而不会使系统资源枯竭,所以Go的并发性能是不容置疑的,Go目前的定位就是开发高并发的服务端程序。
- 二来Go只需要轻轻一写
go
关键字就可以发起一个goroutine,实现起来非常简单,同时Go采用的消息传递来实现执行体之间的通信,所以并发编程也更加安全。
目录
一、并发的一些基础知识
二、Go并发编程关键词之:协程goroutine和go
关键字
三、Go并发编程关键词之:消息传递实现执行体之间的通信和channel作为消息通道
一、并发的一些基础知识
1、为什么需要并发编程?
-
如果我们的程序只是串行执行,一个操作就会阻塞程序的执行,导致其它的操作无法及时地执行,程序的执行效率低,而并发编程就可以很大程度地提高程序的执行效率;具体比如说:
- 一方面我们需要及时地响应用户界面,另一方面我们还需要进行大量的运算操作,而我们需要界面响应和运算同时进行;
- 当我们的服务器面对大量的用户请求时,就需要更多的“服务器工作单元”来分别响应这些请求;
-
计算机的CPU从单核向多核发展,如果我们的程序只是串行程序,那么计算机的能力得不到发挥,浪费资源,而并发编程就可以很好地利用CPU多核的特点,充分利用硬件资源。
2、有哪些方式可以实现并发编程(即并发编程的执行体有哪些)?
我们可以通过以下四种执行体来实现并发编程:
-
多进程:多进程是操作系统层面实现并发的模式,这种方式简单,但是系统开销极大。
-
多线程:多线程是我们目前为止使用最多也是最有效的并发编程模式,系统开销要比多进程小得多,但是在高并发的模式下,大量地开辟多线程会很快地消耗完系统的内存和CPU资源,系统开销也非常大,而且效率也会受到影响。
-
基于回调的异步IO:这种方式就是为了解决多线程在高并发下的极大开销而出现的,它可以尽可能地帮助我们少开线程,使用异步IO来使得服务器持续运转,但是编程比较复杂。
-
协程:协程是寄存于线程中的,它不需要操作系统来进行抢占式地调度,所以它的系统开销极小,可以有效避免高并发场景下多线程的缺点。使用协程实现并发非常简单,缺点是使用协程需要语言级别的支持,否则用户就需要在程序中自己实现调度器,非常麻烦,目前在语言级别支持协程的还很少,说了这么多,其实只是想说Go支持协程。协程相比线程的最大优势就在于一颗CPU每秒可以轻松调度上百万个协程而不会导致资源衰竭,而进程和协程最多也就不超过一万个,所以协程在高并发的场景下性能是非常牛逼的。
3、提到并发还不得不提执行体之间的通信:
执行体之间的通信,有两种方式:
-
执行体通过共享数据来通信:这种方式需要考虑执行体的同步与互斥:当多个执行体之间存在共享同一份数据的时候,为保证数据的准确性,我们就需要对访问这个共享数据的多个执行体进行互斥。当多个执行体之间存在逻辑上的时序关系时,我们就需要需要对执行体进行同步。当然提到执行体的互斥,我们就会想到有锁和死锁这两个关键字。
-
执行体通过消息传递来通信:当执行体之间进行消息传递的时候,通常需要基于一个消息队列或者进程邮箱这样的设施来进行通信,Go语言中就内置了一个这样的消息队列,叫作通道(channel),两个goroutine之间就可以很方便地通过channel来进行消息的传递,达到通信的目的。
前者是大多数语言采用的执行体间通信的方式,尽管Go也保留了传统的执行体通过共享资源来通信的方式,允许适度地使用,但是Go更推荐使用执行体通过消息传递来通信。
二、Go并发编程关键词之:协程goroutine和go
关键字
goroutine是Go语言中的协程,它是Go语言中实现并发编程的执行体。一个CPU一秒钟可以很轻松地调度上百万个协程而不会使系统资源枯竭,所以goroutine使得Go的并发性能不容置疑。
如果我们想开辟一个协程,让一个函数在一个协程中并发执行,那我们只需要在这个函数前面加上go
关键字就可以了,当这个函数结束时,这条goroutine也就结束了,注意如果这个函数有返回值,那么这个返回值会被自动丢弃。非常简单吧。
如没有并发的情况下:
(清单2.1)
// goroutine
package main
import (
"fmt"
)
func main() {
for i := 0; i < 10; i++ {
z := Add(i, i)
fmt.Println(z)
}
}
func Add(x, y int) (z int) {
z = x + y
return z
}
打印:
0
2
4
6
8
10
12
14
16
18
成功: 进程退出代码 0.
我们可以正常地拿到函数的返回值z,并打印。
当让Add()函数并发执行的时候:
(清单2.2)
func main() {
for i := 0; i < 10; i++ {
z := go Add(i, i) // 在函数前加上go关键字
fmt.Println(z)
}
}
这样写是会编译报错的,因为Go里并发执行的函数会自动丢弃函数的返回值。所以我们把代码改成这样:
(清单2.3)
// goroutine
package main
import (
"fmt"
)
func main() {
for i := 0; i < 10; i++ {
go Add(i, i)
}
}
func Add(x, y int) (z int) {
z = x + y
fmt.Println(z)
return z
}
编译不报错了,这段代码的意思是:我们开辟了是个协程,每个协程执行一个Add()函数。
但是,运行,我们看不到控制台打印东西啊,什么情况?要回答这个问题我们需要了解下Go语言的执行机制了:
每个Go程序都是从main包的main()函数开始执行,等main()函数执行结束后,整个程序也就执行结束了,并不会等待每个goroutine都执行结束。
那么,上面的例子中,我们在主函数中开辟了十个协程,主函数就返回了,因此程序已经执行结束了,这是个协程还没来得及执行,所以不会有打印输出。
那么要想正常打印输出,我们就必须得等十个协程都执行完才能让主函数返回,那我们如何才能知道是个协程执行完了呢?这就引出了下一节的内容--Go执行体间通过消息传递来通信,消息传递的通道被称为channel。
不过在开始下一节前,我们可以先使用共享数据的执行体通信方式来解决一下这个问题,然后和下一节的消息传递的执行体通信方式来对比一下。代码如下:
(清单2.4)
// goroutine
package main
import (
"fmt"
"runtime"
"sync"
)
var counter int = 0 // 创建一个变量来记录有几个协程执行完毕,十个协程会共享这个变量
func main() {
lock := &sync.Mutex{} // 创建一把锁
for i := 0; i < 10; i++ {
go Add(i, i, lock)
}
for { // 给主函数一个死循环,保证主函数不会立即退出,而是由我们来掌控主函数的退出时间
runtime.Gosched()
if counter >= 10 {
break
}
}
}
func Add(x, y int, lock *sync.Mutex) (z int) { // 该函数新增一个参数为互斥锁,来保证同一时间只有一个协程能访问锁之间的操作
lock.Lock() // 加锁
counter++
z = x + y
fmt.Println(z)
lock.Unlock() // 解锁
return z
}
打印:
2
18
4
6
8
10
12
14
16
0
成功: 进程退出代码 0.
可见,通过共享数据的通信方式我们解决了这个问题,但这才是这么简单的一个例子,那么当我们遇到高并发的场景时,就需要无数的共享数据、无数的锁,再加上业务逻辑,无疑是个噩梦。那并发编程作为Go语言的招牌,它会提倡怎么解决呢?下一节,Go执行体间通过消息传递来通信,消息传递的通道被称为channel。
三、Go并发编程关键词之:消息传递实现执行体之间的通信和channel作为消息通道
Go语言推荐使用消息传递的方式来实现执行体间的通信,通信通道为channel。当然消息传递只是一种方式,具体实现是靠channel来实现的,所以接下来我们会学习channel。
在具体讲解之前,我们先用消息传递和channel实现上面的例子,对比一下,方便我们对消息传递和channel有个直观的认识。
(清单3.1)
// goroutine
package main
import (
"fmt"
)
func main() {
chs := make([]chan int, 10) // 创建一个数组切片,用来存放是个channel
for i := 0; i < 10; i++ {
chs[i] = make(chan int) // 创建channel
go Add(i, i, chs[i]) // 为每一个协程添加一个channel
}
for _, ch := range chs {
<-ch // 这里是读取channel里的数据,同样的,一个channel在写入数据之前,读这个操作也是阻塞的,也就是说如果一个channel没有写入数据,那它的读取操作会一直挂在那阻塞,因此这就保证了channel必须是写入了数据,即该routine执行完了Add()
}
}
func Add(x, y int, ch chan int) (z int) { // 该函数新增一个参数为channel,以便给每个协程一个通信的消息通道
z = x + y
fmt.Println(z)
ch <- 1 // 每有一个协程执行Add()函数,就像这个协程对应的channel里写一个1,当然这个channel在读取前,写这个操作是是被阻塞的,也就是说如果一个channel没被读取,那它的写入操作会一直挂在那阻塞,注意要写在代码的后面,保证代码执行完了才写入
return z
}
打印:
18
10
12
14
16
0
4
6
8
2
成功: 进程退出代码 0.
可见,这种方式消息传递要比共享数据的通信方式简洁多了,而且不必再去考虑锁的问题,因此实现并发编程也更安全。
1、channel的定义
channel是类型相关的,也就是说channel只能传递某一种类型的值,所以我们在声明channel的时候,就指定好它可以传递哪种类型的值。如:
声明一个可以传递int类型的channel
var ch chan int
我们其实可以把chan int
看做是一个channel的类型,即整型channel。
又如,我们创建了一个字典,其value的类型为bool型channel
var dict map[string] chan bool
2、channel的创建与赋值
var ch chan int // 声明channel
ch = make(chan int) // 创建channel并赋值
ch1 := make(chan int)
3、channel的操作
- 写入
ch <- value
这个就是channel的写入操作。一个channel在读取前,写这个操作是是被阻塞的,也就是说如果一个channel没被读取,那它的写入操作会一直挂在那阻塞。
- 读取
value <- ch
这个就是channel的读取操作。同样的,一个channel在写入数据之前,读这个操作也是阻塞的,也就是说如果一个channel没有写入数据,那它的读取操作会一直挂在那阻塞,因此这就保证了channel必须是写入了数据。
- 关闭channel
close(ch) // 关闭channel
_, ok := <-ch // 判断channel是否关闭
4、带缓冲的channel
上面的例子中,我们演示的是不带缓冲的channel,即只能写入单个数据,但有的场景是需要持续输入大量数据的,因此就需要用到带缓冲的channel。如:
ch := make(chan int, 1024)
上面的代码就创建了一个整型channel,这个整型channel可以写入1024个字节大小的整型数据。当然这种带缓冲的channel,就是连续不断地写入数据的,所以在写入数据写完之前是不会被阻塞的,整体的写入和读取的阻塞关系还是和单个数据的是一样的,只不过这个数据长一点而已嘛。
Go的并发编程还有很多别的知识,如果需要,逐步深入。
网友评论