Go语言基础之goroutine | 青训营笔记

62 阅读6分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 4 天,今天整理了goroutine相关的学习笔记

基本概念

并发与并行

image.png

并发:多线程程序在一个核的cpu上运行

image.png

并行:多线程程序在多个核的cpu上运行

进程、线程与协程

进程(process):程序在操作系统中的一次执行过程,系统进行资源分配和调度的一个独立单位。

线程(thread):操作系统基于进程开启的轻量级进程,是操作系统调度执行的最小单位。

协程(coroutine):非操作系统提供而是由用户自行创建和控制的用户态‘线程’,比线程更轻量级。

Goroutine

Goroutine 是 Go 语言支持并发的核心,在一个Go程序中同时创建成百上千个goroutine是非常普遍的,一个goroutine会以一个很小的栈开始其生命周期,一般只需要2KB。区别于操作系统线程由系统内核进行调度, goroutine 是由Go运行时(runtime)负责调度。例如Go运行时会智能地将 m个goroutine 合理地分配给n个操作系统线程,实现类似m:n的调度机制,不再需要Go开发者自行在代码层面维护一个线程池。

Goroutine 是 Go 程序中最基本的并发执行单元。每一个 Go 程序都至少包含一个 goroutine——main goroutine,当 Go 程序启动时它会自动创建。

在Go语言编程中你不需要去自己写进程、线程、协程,你的技能包里只有一个技能——goroutine,当你需要让某个任务并发执行的时候,你只需要把这个任务包装成一个函数,开启一个 goroutine 去执行这个函数就可以了。

使用goroutine

只需在调用函数的时候在前面加上一个go关键字就可以为一个函数创建一个goroutine

一个goroutine必定对应一个函数,可以创建多个goroutine去执行相同的函数。

启动单个goroutine

package main

import (
	"fmt"
)

func hello() {
	fmt.Println("hello")
}

func main() {
	hello()
	fmt.Println("你好")
}

得到的编译结果如下:

hello
你好

因为代码中的hello()函数和后面的打印语句是串行的。 接下来我们再调用 hello 函数前面加上关键字go,也就是启动一个 goroutine 去执行 hello 这个函数。

package main

import (
	"fmt"
)

func hello() {
	fmt.Println("hello")
}

func main() {
	go hello()
	fmt.Println("你好")
}

执行结果如下:

你好

这一次只在终端打印了“你好”,而没有打印hello,这是为什么?

其实在 Go 程序启动时,Go 程序就会为 main 函数创建一个默认的 goroutine 。在上面的代码中我们在 main 函数中使用 go 关键字创建了另外一个 goroutine 去执行 hello 函数,而此时 main goroutine 还在继续往下执行,我们的程序中此时存在两个并发执行的 goroutine。当 main 函数结束时整个程序也就结束了,同时 main goroutine 也结束了,所有由 main goroutine 创建的 goroutine 也会一同退出。也就是说我们的 main 函数退出太快,另外一个 goroutine 中的函数还未执行完程序就退出了,导致未打印出“hello”。

所以我们要想办法让 main 函数‘“等一等”将在另一个 goroutine 中运行的 hello 函数。其中最简单粗暴的方式就是在 main 函数中“time.Sleep”一秒钟了(这里的1秒钟只是我们为了保证新的 goroutine 能够被正常创建和执行而设置的一个值)。

修改代码如下:

package main

import (
	"fmt"
	"time"
)

func hello() {
	fmt.Println("hello")
}

func main() {
	go hello()
	fmt.Println("你好")
	time.Sleep(time.Second)
}

执行结果如下:

你好
hello

为什么会先打印“你好”呢? 这是因为在程序中创建goroutine执行函数需要一定的开销,而与此同时,main函数所在的goroutine是继续执行的。

image.png

在上面的程序中使用time.Sleep让main goroutine等待hello goroutine执行结束是不优雅的,也是不准确的。

Go语言中sync包为我们提供了一些常用的并发原语。在下面的代码中,我在main goroutine中使用sync.WaitGroup来等待hello goroutine完成后再退出。

package main

import (
	"fmt"
	"sync"
)

// 声明全局等待组变量
var wg sync.WaitGroup

func hello() {
	fmt.Println("hello")
	wg.Done() // 告知当前goroutine完成
}

func main() {
	wg.Add(1) // 登记1个goroutine
	go hello()
	fmt.Println("你好")
	wg.Wait() // 阻塞等待登记的goroutine完成
}

代码输出结果与上述使用time.Sleep一致,但是这一次程序不会再有多余的停顿,hello goroutine执行完毕后直接退出。

启动多个goroutine

package main

import (
   "fmt"
   "sync"
)

var wg sync.WaitGroup

func hello(i int) {
   defer wg.Done() // goroutine结束就登记-1
   fmt.Println("hello", i)
}
func main() {
   for i := 0; i < 10; i++ {
      wg.Add(1) // 启动一个goroutine就登记+1
      go hello(i)
   }
   fmt.Println("你好")
   wg.Wait() // 等待所有登记的goroutine都结束
}

执行结果如下:

hello 0
hello 1
hello 5
hello 3
hello 4
你好
hello 7
hello 6

多次执行上面的代码会发现每次终端上打印数字的顺序都不一致。这是因为10个 goroutine 是并发执行的,而 goroutine 的调度是随机的。

动态栈

操作系统的线程一般都有固定的栈内存(通常为2MB),而 Go 语言中的 goroutine 非常轻量级,一个 goroutine 的初始栈空间很小(一般为2KB),所以在 Go 语言中一次创建数万个 goroutine 也是可能的。并且 goroutine 的栈不是固定的,可以根据需要动态地增大或缩小, Go 的 runtime 会自动为 goroutine 分配合适的栈空间。

GOMAXPROCS

Go运行时的调度器使用GOMAXPROCS参数来确定需要使用多少个 OS 线程来同时执行 Go 代码。默认值是机器上的 CPU 核心数。例如在一个 8 核心的机器上,GOMAXPROCS 默认为 8。Go语言中可以通过runtime.GOMAXPROCS函数设置当前程序并发时占用的 CPU逻辑核心数。(Go1.5版本之前,默认使用的是单核心执行。Go1.5 版本之后,默认使用全部的CPU 逻辑核心数。)

package main

import (
   "fmt"
   "runtime"
   "sync"
)

var wg sync.WaitGroup

func a() {
   for i := 0; i < 10; i++ {
      fmt.Println("A:", i)
      //wg.Done()
   }
   wg.Done()
}

func b() {
   for i := 0; i < 10; i++ {
      fmt.Println("B:", i)
      //wg.Done()
   }
   wg.Done()
}

func main() {
   runtime.GOMAXPROCS(1) //占用一个核心
   wg.Add(2)
   go a()
   go b()
   wg.Wait()
}

执行结果如下:

B: 0
B: 1
B: 2
B: 3
B: 4
B: 5
B: 6
B: 7
B: 8
B: 9
A: 0
A: 1
A: 2
A: 3
A: 4
A: 5
A: 6
A: 7
A: 8
A: 9

两个任务只有一个逻辑核心,此时是做完一个任务再做另一个任务。将逻辑核心数设置为10,此时两个任务并行执行,代码如下:

package main

import (
   "fmt"
   "runtime"
   "sync"
)

var wg sync.WaitGroup

func a() {
   for i := 0; i < 10; i++ {
      fmt.Println("A:", i)
      //wg.Done()
   }
   wg.Done()
}

func b() {
   for i := 0; i < 10; i++ {
      fmt.Println("B:", i)
      //wg.Done()
   }
   wg.Done()
}

func main() {
   runtime.GOMAXPROCS(10) 
   wg.Add(2)
   go a()
   go b()
   wg.Wait()
}

执行结果如下:

B: 0
B: 1
B: 2
B: 3
B: 4
A: 0
A: 1
A: 2
A: 3
A: 4
A: 5
B: 5
B: 6
B: 7
B: 8
B: 9
A: 6
A: 7
A: 8
A: 9

Go语言中操作系统线程和goroutine的关系:

  1. 一个操作系统线程对应用户态多个goroutine
  2. go程序可以同时使用多个操作系统线程
  3. goroutine和OS线程是多对多的关系