Golang语言goroutine协程篇

                                              作者:尹正杰

版权声明:原创作品,谢绝转载!否则将追究法律责任。

一.并发编程常见术语

1.串行、并发与并行

串行:
	我们都是先读小学,小学毕业后再读初中,读完初中再读高中。

并发:
	同一时间段内执行多个任务(你在用微信和两个女朋友聊天)。
	多个现场程序在一个核的CPU上运行,就是并发,并发的本质还是串行。
	举个例子:
		- 1.食堂窗口一个大妈同一时间只能给一个人打饭;

并行:
	同一时刻执行多个任务(你和你朋友都在用微信和女朋友聊天)。
	多线程程序在多个核的CPU上运行,就是并行。说白了,任务分布在不同CPU上,同一时间点同时执行。
	举个例子:
		- 2.食堂窗口有多个大妈,同时给不同人打饭;
	

2.程序、进程、线程

程序(program):
	是为完成特定任务,用某种语言编写的一组指令的集合,是一段静态的代码,程序是静态的。

进程(process):
	程序在操作系统中的一次执行过程,指的是正在运行的程序(program),进场作为资源分配的单位,在内存中会为每个进程分配不同的内存区域。
	进程是一个动态的过程,进程的生命周期有它自身的产生,存在和消亡的过程。

线程(thread):
	进程可进一步细化为线程,是一个程序内存的一条执行路径。
	若一个进程同一时间并行执行多个线程,就是支持多线程的。  

3.协程(coroutine)

协程(coroutine):
	又称为"微线程",协程是一种用户态的轻量级现场。
	举个例子:
		在执行A函数的时候,可以随时中断,去执行B函数,然后中断继续执行A函数(可以自动切换)。
		注意这一切换过程并不是函数调用(没有调用语句),过程很像多线程,然而协程中只有一个线程在执行(协程的本质就是单线程)。

对于单线程下,我们不可避免程序中出现I/O操作,但如果我们能在自己的程序中(即用户程序级别,而非操作系统级别)控制单线程下的多个任务能在一个任务中遇到I/O阻塞时就将寄存器上下文和栈保存到其它地方,然后切换到另外一个任务中去计算。在任务切回来的时候,恢复先前保存的寄存器上下文和栈,这样就保证了该线程能够最大限度地处于就绪状态,即随时都可以被CPU执行的状态,相当于我们在用户程序级别将自己的IO操作最大限度地隐藏起来,从而可以迷惑操作系统,让其看到: 该线程好像是一直在计算,I/O比较少,从而更多的将CPU的执行权限分配给我们的线程(注意,线程是CPU控制的,而协程是程序自身控制的,属于程序级别的切换,操作系统完全感知不到,因而更加轻量级)。

4.动态栈

操作系统的线程一般都有固定的栈内存(通常为2MB),而Go语言中的goroutine非常轻量级,一个goroutine的初始栈空间很小(一般为2KB),所以在Go语言中一次创建数万个goroutine也是可能的。

并且goroutine的栈不是固定的,可以根据需要动态地增大或缩小, Go的runtime会自动为goroutine分配合适的栈空间。

5.协程和线程谁的效率高

这个没法比较,我们所知道的是协程相比线程占用资源要少,但是效率高低取决于书写代码的人的逻辑。
	- 开启一个线程大概需要2M空间,而且需要cpu调度才能执行,线程会抢CPU时钟周期;
  - 开启一个协程大概需要2K空间,而且是由go解释器自己实现的GPM调度,主动退出;
  - 所以我们可以同时启动成千上万个goroutine而不会过大的占用内存;
  - 相反如果我们开启成千上万个线程,第一,会占用内存,甚至导致机器崩溃,第二,操作系统调度线程,本身也需要耗费大量时间;
    
协程如果需要用CPU才会占用cpu时钟周期,如果没有使用cpu的需求,他就会主动把cpu让其它协程执行。

线程在时间片内,即使不使用cpu,比如当前正在从磁盘读数据,他也不会让出cpu的时钟周期;
  
协程在操作系统眼里看就是一个线程,只不过我们人为将这种轻量级线程给他起了一个高大上的名字叫做"协程";

6.goroutine调度

操作系统内核在调度时会挂起当前正在执行的线程并将寄存器中的内容保存到内存中,然后选出接下来要执行的线程并从内存中恢复该线程的寄存器信息,然后恢复执行该线程的现场并开始执行线程。从一个线程切换到另一个线程需要完整的上下文切换。因为可能需要多次内存访问,所以这个切换上下文的操作开销较大,会增加运行的cpu周期。

区别于操作系统内核调度操作系统线程,goroutine的调度是Go语言运行时(runtime)层面的实现,是完全由Go语言本身实现的一套调度系统——go scheduler。它的作用是按照一定的规则将所有的goroutine调度到操作系统线程上执行。

如上图所示,在经历数个版本的迭代之后,目前Go语言的调度器采用的是GPM调度模型。
	- G:
		表示goroutine,每执行一次"go f()"就创建一个 G,包含要执行的函数和上下文信息。

	- 全局队列(Global Queue):
		存放等待运行的G。

	- P:
		表示goroutine执行所需的资源,最多有GOMAXPROCS个。

	- P的本地队列:
		同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。
		新建G时,G优先加入到P的本地队列,如果本地队列满了会批量移动部分G到全局队列。

	- M:
		线程想运行任务就得获取P,从P的本地队列获取G,当P的本地队列为空时,M也会尝试从全局队列或其他P的本地队列获取G。
		M运行G,G执行之后,M会从P获取下一个G,不断重复下去。

	- Goroutine调度器和操作系统调度器是通过M结合起来的:
		每个M都代表了1个内核线程,操作系统调度器负责把内核线程分配到CPU的核上执行。
		
单从线程调度讲,Go语言相比起其他语言的优势在于OS线程是由OS内核来调度的,goroutine则是由Go运行时(runtime)自己的调度器调度的,完全是在用户态下完成的,不涉及内核态与用户态之间的频繁切换,包括内存的分配与释放,都是在用户态维护着一块大的内存池, 不直接调用系统的malloc函数(除非内存池需要改变),成本比调度OS线程低很多。 另一方面充分利用了多核的硬件资源,近似的把若干goroutine均分在物理线程上, 再加上本身goroutine的超轻量级,以上种种特性保证了goroutine 调度方面的性能。

7.GOMAXPROCS

7.1 GOMAXPROCS概述

Go运行时的调度器使用GOMAXPROCS参数来确定需要使用多少个OS线程来同时执行Go代码。默认值是机器上的CPU核心数。

例如在一个8核心的机器上,GOMAXPROCS默认为 8。

Go语言中可以通过runtime.GOMAXPROCS函数设置当前程序并发时占用的CPU逻辑核心数。

Go1.5版本之前,默认使用的是单核心执行。Go1.5版本之后,默认使用全部的CPU逻辑核心数。

7.2 测试GOMAXPROCS有效性

package main

import (
	"math"
	"sync"
)

var wg sync.WaitGroup

func demo() {
	defer wg.Done()

	var i int64
	for i < math.MaxInt64 {
		i++
	}
}

func main() {
	// 设置我的Go程序只是用两个逻辑核心,若不设置,从go 1.5之后的版本默认使用所有的核心。
	// runtime.GOMAXPROCS(2)
	wg.Add(20)

	for i := 0; i <= 20; i++ {
		go demo()
	}

	wg.Wait()
}

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

- 一个操作系统线程对应用户态多个goroutine。

- go程序可以同时使用多个操作系统线程。

- goroutine和OS线程是多对多的关系,即"m:n"(调度m个goroutine到n个OS线程)。

- goroutine的调度不许哟啊切换内核态,而是在用户态完成的,所以调用一个goroutine比调度一个线程成本低很多。

8.并发模型

业界将如何实现并发编程总结归纳为各式各样的并发模型,常见的并发模型有以下几种:
	- 线程&锁模型
	- Actor模型
	- CSP模型
	- Fork&Join模型
	
Go语言中的并发程序主要是通过基于CSP(communicating sequential processes)的goroutine和channel来实现,当然也支持使用传统的多线程共享内存的并发方式。

二.goroutine

1.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去执行这个函数就可以了,就是这么简单粗暴。

2.go关键字

Go语言中使用goroutine非常简单,只需要在函数或方法调用前加上go关键字就可以创建一个goroutine ,从而让该函数或方法在新创建的goroutine中执行。

创建一个新的 goroutine 运行函数f,语法格式为: "go f()"

匿名函数也支持使用go关键字创建goroutine去执行,如下所示:
        go func(){
          // ...
        }()

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

3.启动单个goroutine

package main

import (
	"fmt"
	"strconv"
	"time"
)

func hello() {
	for i := 1; i <= 20; i++ {
		fmt.Printf("hello golang ---> %s\n", strconv.Itoa(i))

		// 阻塞程序1s
		time.Sleep(time.Second)
	}

}

// 主线程
func main() {


	/*
	主死从随:
		- 1.如果主线程退出了,即使协程还没有执行完毕,程序也会退出;
		- 2.当然协程也可以在主线程没有退出前,就自己结束了,比如完成了自己的任务;
		
	主线程和协程执行流程如上图所示。
	*/

	// 使用go关键字开启一个协程调用hello函数
	go hello()

	// 在主线程也执行同样的代码
	for i := 1; i <= 10; i++ {
		fmt.Printf("main ---> %s\n", strconv.Itoa(i))

		// 阻塞程序1s
		time.Sleep(time.Second)
	}

}

4.启动多个goroutine

package main

import (
	"fmt"
	"time"
)

func main() {
	// 一次性启动5个协程
	for i := 1; i <= 5; i++ {
		// 匿名函数 + 外部变量 = 闭包
		go func(x int) {
			fmt.Printf("x = %d\n", x)
		}(i)
	}

	// 让主线程阻塞1s,等待5个协程执行完毕后再退出。
	time.Sleep(time.Second * 1)
}

5.多线程异常捕获

package main

import (
	"fmt"
	"time"
)

// printNumber 输出数字
func printNumber() {
	for i := 1; i <= 10; i++ {
		fmt.Println(i)
	}
}

// devide 做除法操作
func devide(a, b int) {
	defer func() {
		/*
		多个协程中,其中一个协程出现painc,会导致整个程序崩溃。

		解决方案:
			利用defer和recover捕获panic进行处理,即使协程出现问题,主线程仍然不受影响可以继续执行。
		*/
		err := recover()

		if err != nil {
			fmt.Printf("devide()出现错误: %v\n", err)
		}
	}()

	fmt.Printf("%d ➗ %d = %d\n", a, b, a/b)

}

func main() {

	// 启动两个协程
	go printNumber()
	go devide(100, 0)


	// 让主线程不退出
	time.Sleep(time.Second * 10)

}

三.使用WaitGroup控制协程退出

1 sync.WaitGroup概述

方法名 功能
func (wg * WaitGroup) Add(delta int) 计数器+delta
(wg *WaitGroup) Done() 计数器-1
(wg *WaitGroup) Wait() 阻塞直到计数器变为0
在代码中生硬的使用time.Sleep肯定是不合适的,Go语言中可以使用sync.WaitGroup来实现并发任务的同步。 

sync.WaitGroup有如上表所示的几个方法。

sync.WaitGroup内部维护着一个计数器,计数器的值可以增加和减少。例如当我们启动N个并发任务时,就将计数器值增加N。

每个任务完成时通过调用Done方法将计数器减1。通过调用Wait来等待并发任务执行完,当计数器值为0时,表示所有并发任务已经完成。

2.一次加入单个goroutine案例

package main

import (
	"fmt"
	"sync"
)

// 声明全局等待组变量,只需要定义无需赋值
var wg sync.WaitGroup

func hello(id int) {

	// 协程执行完成后减1,告知当前goroutine完成。为了防止计数器减1操作,结合defer关键字使用。
	defer wg.Done()

	fmt.Printf("goroutine ID = %d\n", id)

}

func main() {

	// 启动5个协程(goroutine)
	for i := 1; i <= 5; i++ {
		// Add方法用于登记1个goroutine,协程开始的时候加1操作。
		wg.Add(1)

		// 使用go关键字可以启动一个goroutine去执行hello函数。
		go hello(i)
	}

	/*
		Go 语言中通过sync包为我们提供了一些常用的并发原语。

		当你并不关心并发操作的结果或者有其它方式收集并发操作的结果时,WaitGroup是实现等待一组并发操作完成的好方法。

		Wait()方法用于阻塞等待登记的goroutine完成,在此期间,主线程一直在阻塞,什么时候wg减为0了就停止。

	*/
	wg.Wait()

}

3.一次加入多个goroutine案例

package main

import (
	"fmt"
	"sync"
)

var wg sync.WaitGroup

func hello(id int) {

	defer wg.Done()

	fmt.Printf("goroutine ID = %d\n", id)

}

func main() {

	// 如果一开始就知道启动的协程个数的情况下,可以先执行Add操作。Add中加入的数字和协程的个数一定要保持一致。
	wg.Add(5)

	// 启动5个协程
	for i := 1; i <= 5; i++ {
		go hello(i)
	}

	wg.Wait()

}

posted @ 2024-08-02 01:17  尹正杰  阅读(286)  评论(0编辑  收藏  举报