介绍
并发指在同一时间内可以执行多个任务。并发编程含义比较广泛,包含多线程编程、多进程编程及分布式程序等。本章讲解的并发含义属于多线程编程。
Go 语言通过编译器运行时(runtime),从语言上支持了并发的特性。Go 语言的并发通过 goroutine 特性完成。goroutine 类似于线程,但是可以根据需要创建多个 goroutine 并发工作。goroutine 是由 Go 语言的运行时调度完成,而线程是由操作系统调度完成。
Go 语言还提供 channel 在多个 goroutine 间进行通信。goroutine 和 channel 是 Go 语言秉承的 CSP(Communicating Sequential Process)并发模式的重要实现基础。本章中,将详细为大家讲解 goroutine 和 channel 及相关特性。
进程时代
现代化的计算机有了操作系统,每个程序都是一个进程,但是操作系统在一段时间只能运行一个进程,直到这个进程运行完,才能运行下一个进程,这个时期可以成为单进程时代。但由于其速度还是太慢了,比如进程要读数据阻塞了,CPU就在哪浪费着,于是操作系统就具有了最早的并发能力:多进程并发,当一个进程堵塞的时候,就切换到另外等待执行的进程,这样就尽量把CPU利用了起来。
线程时代
但后来人们发现进程拥有太多资源,在创建、切换和销毁的时候,都会占用很长的时间,CPU虽然利用起来了,但CPU有很大的一部分都被用来进行进程调度了。
后来操作系统支持了线程,线程在进程里面,运行起来所需要的资源比进程少很多。一个进程可以有多个线程,CPU在执行调度的时候切换的是线程,这很快就能完成。但线程的设计本身有点复杂,而且由于需要考虑很多底层细节,比如锁和冲突检测。
协程
但是在当今互联网高并发场景下,因为会消耗大量的内存(每个线程的内存占用级别为MB),线程多了之后调度也会消耗大量的CPU。
线程分为内核态线程和用户态线程,用户态线程需要绑定内核态线程,CPU并不能感知用户态线程的存在,它只知道它在运行1个线程,这个线程实际是内核态线程。用户态线程实际有个名字叫协程(co-routine),为了容易区分,我们使用协程指用户态线程,使用线程指内核态线程。
goroutine
Go为了提供更容易使用的并发方法,使用了goroutine和channel,goroutine是一个轻量级的线程,执行时只需要4-5k的内存,比线程更易用,更高效,更轻便,调度开销比线程小,可同时运行上千万个并发。
go语言中开启一个goroutine非常简单,go函数名(),就开启了个线程。默认情况下,调度器仅使用单线程,要想发挥多核处理器的并行处理能力,必须调用runtine.GOMAXPROCS(n)来设置可并发的线程数,也可以通过设置环境变量GOMAXPROCS打到相同的目的。
runtime 包下提供的与goroutine相关的函数
Gosched()函数
这个函数的作用是让当前goroutine让出CPU,好让其它的goroutine获得执行的机会。同时,当前的goroutine也会在未来的某个时间点继续运行。请看下面的例子:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29package main
import (
"runtime"
"fmt"
)
func main(){
go sayHello() //启动线程1
go sayWorld() // 启动线程2
var str string // 等待输入,在这里用来阻止主线程关闭
fmt.Scan(&str)
}
// 线程1打印 hello
func sayHello(){
for a:= 0; a < 5; a++ {
fmt.Print("hello ")
runtime.Gosched()
}
}
// 线程1打印 hello
func sayWorld(){
for a:= 0; a < 5; a++ {
fmt.Println("world ")
runtime.Gosched()
}
}
运行结果如图所示
从以上我们能够看出来,一个线程输出一句后调用Gosched函数,放弃了CPU执行权限,另一个线程获取权限,然后输出内容。就这样两个线程交替获得权限,才输入以上结果。
1 | var str string // 等待输入,在这里用来阻止主线程关闭 |
这两句代码是等待输入的意思,在这里用来阻止主线程关闭的。如果没有这两句的话,会发现我们的程序瞬间就结束了,而且什么都没有输出。这是因为主线程关闭之后,所有开启的goroutine都会强制关闭,他还没有来得及输出,就结束了。
- runtime.NumCPU()
runtime.NumCPU()返回了cpu核数,runtime.NumGoroutine()返回当前进程的goroutine线程数。即便我们没有开启新的goroutine运行结果如下1
2
3
4
5
6
7
8
9
10package main
import (
"runtime"
"fmt"
)
func main(){
fmt.Println(runtime.NumCPU())
fmt.Println(runtime.NumGoroutine())
}
- Goexit()函数
runtime.Goexit()函数用于终止当前的goroutine,单defer函数将会继续被调用运行结果如下1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24package main
import (
"runtime"
"fmt"
)
func test(){
defer func(){
fmt.Println(" in defer")
}()
for i := 0; i < 10; i++{
fmt.Print(i)
if i > 5{
runtime.Goexit()
}
}
}
func main(){
go test()
var str string
fmt.Scan(&str)
}
channel
如果有一种机制,在子线程结束的时候通知一下主线程,然后主线程再关闭,岂不是更好,这样就不用无休止的等待了,于是就有了channel。
goroutine之间通过channel来通讯,可以认为channel是一个管道或者先进先出的队列。你可以从一个goroutine中向channel发送数据,在另一个goroutine中取出这个值。通俗点儿解释就是channel可以在两个或者多个goroutine之间传递消息。在Go中,goroutine和channel是并发编程的两大基石,goroutine用来执行并发任务,channel用来在goroutine之间来传递消息。
引入一个简单的例子
1 |
|
这是一段很简单的程序,初始化了一个 非缓冲的channel,然后并发一个协程去接受channel中的数据,然后往channel中连续发送两个值。
输出结果如下图所示:
使用 make 创建 channel
首先大家先理解一组概念,什么是非缓冲型channel和缓冲型channel?对,其实很简单,make时如果channel空间不为0,就是缓冲型的channel。
1 | // 不带缓冲区 |
引入一个较为复杂的例子
1 | package main |
这个例子首先并发出一个dealsign方法,用来接收关闭信号,如果接收到关闭信号后往exit channel发送一条消息,然后并发运行channel1,channel1中定了一个ticker,正常情况下channel1每秒打印第一个case语句,如果接收到exit的信号,进入第二个case,然后关闭传入的exited channel,那么main中的Loop,接收到exited关闭的信号后,打印“main exit begin”, 然后退出循环,进程成功退出。这个例子演示了channel在goroutine中起到的传递消息的作用。这个例子是为了向大家展示channel在多个goroutine之间进行通信。
生产者/消费者案例
生产者goroutine负责将数据放入channel,消费者goroutine从channel中取出数据进行处理
1 | package main |
运行结果如下