概述
进程/线程
进程:是程序在操作系统中的一次执行过程,系统进行资源分配和调度的一个独立单位。
线程:是进程的一个执行实体,是 CPU 调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。
一个进程可以创建和撤销多个线程,同一个进程中的多个线程之间可以并发执行。
并发/并行
多线程程序在单核心的 cpu 上运行,称为并发;
多线程程序在多核心的 cpu 上运行,称为并行。
并发与并行并不相同,并发主要由切换时间片来实现“同时”运行,并行则是直接利用多核实现多线程的运行,Go程序可以设置使用核心数,以发挥多核计算机的能力。
协程/线程
协程:独立的栈空间,共享堆空间,调度由用户自己控制,本质上有点类似于用户级线程,这些用户级线程的调度也是自己实现的。
线程:一个线程上可以跑多个协程,协程是轻量级的线程。
优雅的并发编程范式,完善的并发支持,出色的并发性能是Go语言区别于其他语言的一大特色。使用Go语言开发服务器程序时,就需要对它的并发机制有深入的了解。
goroutine
goroutine(协程)是一种非常轻量级的实现,可在单个进程里执行成千上万的并发任务,它是Go语言并发设计的核心。
说到底 goroutine 其实就是线程,但是它比线程更小,十几个 goroutine 可能体现在底层就是五六个线程,而且Go语言内部也实现了 goroutine 之间的内存共享。
goroutine 的特点
-
有独立的栈空间
-
共享程序堆空间
-
调度由用户控制
-
协程是轻量级的线程
goroutine 的用法
//go 关键字放在方法调用前新建一个 goroutine 并执行方法体
go GetThingDone(param1, param2);
//新建一个匿名方法并执行
go func(param1, param2) {
}(val1, val2)
//直接新建一个 goroutine 并在 goroutine 中执行代码块
go {
//do someting...
}
测试案例:
package main
import (
"fmt"
"time"
)
func test() {
for i := 0; i < 10; i++ {
fmt.Println("test()", i)
time.Sleep(time.Second)
}
}
func main() {
// 开启了一个协程
go test()
for i := 0; i < 10; i++ {
fmt.Println("main()", i)
time.Sleep(time.Second)
}
}
输出结果:
main() 0
test() 0
main() 1
test() 1
test() 2
main() 2
main() 3
test() 3
test() 4
main() 4
main() 5
test() 5
test() 6
main() 6
main() 7
test() 7
main() 8
test() 8
test() 9
main() 9
主线程和协程执行流程图:
使用小结:
- 主线程是一个物理线程,直接作用在 cpu 上的。是重量级的,非常耗费 cpu 资源。
- 协程从主线程开启的,是轻量级的线程,是逻辑态。对资源消耗相对小。
- Golang 的协程机制是重要的特点,可以轻松的开启上万个协程。其它编程语言的并发机制是一般基于线程的,开启过多的线程,资源耗费大,这里就突显 Golang 在并发上的优势了。
goroutine 的调度模型
MPG 模式基本介绍:
M:操作系统的主协程(物理线程)
P:协程执行需要的上下文
G:协程
MPG 模式运行的状态1
- 当前程序有三个M,如果三个M都是在一个CPU运行,就是并发,如果在不同的CPU运行就是并行。
- M1,M2,M3正在执行一个G,M1的协程队列有3个,M2的协程队列有3个,M3的协程队列有2个。
- 从上图可以看到,Go 的协程是轻量级的线程,是逻辑态的, Go 可以容易的起上万个协程。
- 其他程序 C/Java 的多线程,往往是内核态的,比较重量级,几千个线程可能耗光CPU。
MPG 模式运行的状态2
- 分成两个部分来看。
- 原来的情况是 M0 主线程正在执行 Go线程,另外有3个协程在队列等待。
- 如果 Go 协程阻塞,比如读取文件获取数据库等。
- 这时就会创建 M1 主线程(也可能是从已有的线程池中取出 M1),并且将等待的3个协程挂到 M1 下开始执行,M0 的主线程下的 Go 仍然执行文件 io 的读写。
- 这样的MPG调度模式,可以既让 Go 执行,同时也不会让队列的其它协程一直阻塞,仍然可以并发/并行执行。
- 等到 Go 不阻塞了,M0 会被放到空闲的主线程继续执行(从已有的线程池中取),同时 Go 又会被唤醒。
设置运行的 CPU 数
Go 1.8后,默认让程序运行在多个核上,可以不用设置。
// 获取当前系统CPU的数量
num := runtime.NumCPU()
// 设置num个CPU运行Go程序
runtime.GOMAXPROCS(num)
channel
channel(通道)是Go语言在语言级别提供的 goroutine 间的通信方式。我们可以使用 channel 在两个或多个 goroutine 之间传递消息。
channel 是进程内的通信方式,因此通过 channel 传递对象的过程和调用函数时的参数传递行为比较一致,比如也可以传递指针等。如果需要跨进程通信,我们建议用分布式系统的方法来解决,比如使用 Socket 或者 HTTP 等通信协议。Go语言对于网络方面也有非常完善的支持。
channel 是类型相关的,也就是说,一个 channel 只能传递一种类型的值,这个类型需要在声明 channel 时指定。如果对 Unix 管道有所了解的话,就不难理解 channel,可以将其认为是一种类型安全的管道。
channel 的声明
ci := make(chan int)
cs := make(chan string)
cf := make(chan interface{})
测试案例:
package main
import (
"fmt"
)
func main() {
// 1. 创建一个可以存放3个int类型的管道
var intChan chan int
intChan = make(chan int, 3)
// 2. 查看intChan是什么
fmt.Printf("intChan的值=%v intChan本身的地址=%p\n", intChan, &intChan) // intChan的值=0xc00013e000 intChan本身的地址=0xc000136018
// 3. 向管道写入数据
intChan <- 10
num := 211
intChan <- num
intChan <- 50
// intChan<- 98// 注意点:当我们给管写入数据时,不能超过其容量
// 4. 查看管道的长度和cap(容量)
fmt.Println(len(intChan), cap(intChan)) // 3, 3
// 5. 从管道中读取数据
var num2 int
num2 = <-intChan
fmt.Println("num2=", num2)
fmt.Println(len(intChan), cap(intChan)) // 2, 3
// 6. 在没有使用协程的情况下,如果我们的管道数据已经全部取出,再取就会报告 deadlock
num3 := <-intChan
num4 := <-intChan
num5 := <-intChan
fmt.Println("num3=", num3, "num4=", num4, "num5=", num5)
}
使用小结:
- channel 中只能存放指定的数据类型。
- channle 的数据放满后,就不能再放入了。
- 如果从 channel 取出数据后,可以继续放入。
- 在没有使用协程的情况下,如果 channel 数据取完了,再取就会报 deadlock。
channel 的关闭
使用内置函数 close 可以关闭 channel,当 channel 关闭后,就不能再向 channel 写数据了,但是仍然可以从该 channel 读取数据。
package main
import "fmt"
func main() {
var intChan chan int
intChan = make(chan int, 3)
intChan <- 100
intChan <- 200
close(intChan) // 关闭管道
// 当管道关闭后,不能够再写入数据
// intChan <- 300 // error
// 当管道关闭后,读取数据是可以的
n1 := <-intChan
fmt.Println(n1) // 100
}
channel 的遍历
channel 支持 for–range 的方式进行遍历,注意两个细节:
- 在遍历时,如果 channel 没有关闭,则回出现 deadlock 的错误
- 在遍历时,如果 channel 已经关闭,则会正常遍历数据,遍历完后,就会退出遍历。
package main
import "fmt"
func main() {
var intChan chan int
intChan = make(chan int, 100)
for i := 0; i < 100; i++ {
intChan <- i
}
close(intChan)
// 1. 在遍历时,如果 channel 没有关闭,则回出现 deadlock 的错误
// 2. 在遍历时,如果 channel 已经关闭,则会正常遍历数据,遍历完后,就会退出遍历。
for v := range intChan {
fmt.Println(v)
}
}
应用实例
实例1
goroutine 和 channel 协同工作的案例,具体要求:
- 开启一个 writeData 协程,向管道 intChan 中写入 50 个整数。
- 开启一个 readData 协程,从管道 intChan 中读取 writeData 写入的数据。
- 注意:writeData 和 readData 操作的是同一个管道。
- 主线程需要等待 writeData 和 readData 协程都完成工作才能退出。
思路分析:
代码实现:
package main
import "fmt"
// 写入数据
func writeData(intChan chan int) {
for i := 0; i < 50; i++ {
intChan <- i
fmt.Println("writeData", i)
}
// 写入完数据后,关闭管道
close(intChan)
}
// 读取数据
func readData(intChan chan int, exitChan chan bool) {
for {
v, ok := <-intChan
if !ok {
break
}
fmt.Println("readData", v)
}
// 读取完数据后,即任务完成并关闭管道
exitChan <- true
close(exitChan)
}
func main() {
// 创建两个管道
intChan := make(chan int, 50)
exitChan := make(chan bool, 1)
go writeData(intChan)
go readData(intChan, exitChan)
for {
_, ok := <-exitChan
if !ok {
break
}
}
}
问题:如果注销掉 go readData(intChan, exitChan)
,程序会怎么样?
答:如果只是向管道写入数据,而没有读取,就会出现阻塞报错 fatal error: all goroutines are asleep - deadlock!
,原因是 intChan 容量是 10,而代码 writeData 会写入 50 个数据,因此会阻塞再 writeData 的 intChan <- i
。
实例2
统计 1-8000 的数字中,哪些是素数?
思路分析:
传统的方法,就是使用一个循环,循环的判断各个数是不是素数,完成任务时间长。
使用并发/并行的方式,将统计素数的任务分配给多个(4个) goroutine 去完成,完成任务时间短。
代码实现:
package main
import (
"fmt"
)
func putNum(intChan chan int) {
for i := 0; i < 8000; i++ {
intChan <- i
}
close(intChan)
}
func primeNum(intChan chan int, primeChan chan int, exitChan chan bool) {
var flag bool
for {
num, ok := <-intChan
if !ok {
break
}
// 假设是素数
flag = true
// 判断 num 是不是素数
for i := 2; i < num; i++ {
if num%i == 0 {
flag = false
break
}
}
if flag {
// 将这个数放入到 primeChan
primeChan <- num
}
}
exitChan <- true
}
func main() {
intChan := make(chan int, 1000)
primeChan := make(chan int, 2000)
exitChan := make(chan bool, 4)
go putNum(intChan)
for i := 0; i < 4; i++ {
go primeNum(intChan, primeChan, exitChan)
}
go func() {
for i := 0; i < 4; i++ {
<-exitChan
}
close(primeChan)
}()
for {
res, ok := <-primeChan
if !ok {
break
}
fmt.Println(res)
}
}
结论:使用 Go 协程后,执行的速度,比普通方法提高至少 4 倍。
实例3
channel 可以声明为只读,或者只写性质。
package main
import "fmt"
// ch chan<- int 声明只写操作
func send(ch chan<- int, exitChan chan struct{}) {
for i := 0; i < 10; i++ {
ch <- i
}
close(ch)
var a struct{}
exitChan <- a
}
func recv(ch <-chan int, exitChan chan struct{}) {
for {
v, ok := <-ch
if !ok {
break
}
fmt.Println(v)
}
var a struct{}
exitChan <- a
}
func main() {
var ch chan int
ch = make(chan int, 10)
exitChan := make(chan struct{}, 2)
go send(ch, exitChan)
go recv(ch, exitChan)
var total = 0
for range exitChan {
total++
if total == 2 {
break
}
}
}
实例4
goroutine 中使用 recover,解决协程中出现 panic,导致程序崩溃问题。
说明:如果我们起了一个协程,但是这个协程出现了 panic,如果我们没有捕获这个 panic,就会造成整个程序崩溃,这时我们可以在 goroutine 中使用 recover 来捕获 panic 进行处理,这样即使这个协程发生了问题,但是主线程仍然不受影响,可以继续执行。
package main
import (
"fmt"
"time"
)
func sayHello() {
for i := 0; i < 10; i++ {
time.Sleep(time.Second)
fmt.Println("hello,world!")
}
}
func test() {
defer func() {
// 捕获 test 抛出的 panic
if err := recover(); err != nil {
fmt.Println("test() 发生错误", err)
}
}()
var myMap map[int]string
myMap[0] = "golang" // error
}
func main() {
go sayHello()
go test()
for i := 0; i < 10; i++ {
fmt.Println("main() ok=", i)
time.Sleep(time.Second)
}
}