Go 1.19.4 Goroutine(协程)-Day 18
1. Goroutine的本质
1.1 先看一段Python代码
def count():
c = 1
for i in range(5):
print(c)
c += 1
count()
print("@@@")
运行结果
1
2
3
4
5
@@@
很明显,上面这个代码,是count()函数执行完毕后,再运行的print("@@@")。
实际的执行过程,是count函数执行完毕后,隐式的执行一个return Non,count()函数才能彻底停止。
也就是说,一般情况下的函数调用,是同步且阻塞的,函数必须return Non了,才能结束调用(栈争消亡),继续执行后面的代码。
1.2 调整代码,加入yield
看执行结果,居然只执行了print函数,这是为什么呢?
python函数中,如果有yield语句,这个函数就从普通函数,变成了生成器函数。那么此时的函数调用,将立即返回一个生成器对象,而不是立即执行该函数。
所以上面的count()函数,就从普通函数,变成了生成器函数,调用它的时候,不在直接返回结果,而是返回一个名为“迭代器”的生成器对象。
迭代器的特点:
- 将元素一个一个的拿出来。
- 只能单向向后迭代,不可以回头迭代或者重新开始迭代。
- 使用next函数进行迭代,一次取一个元素,如果取完了再next就会报错。
- 惰性求值,不需要立即产生(有很多中间容器),可以解决短时间大量的内容产生。
- 相对于list、tuple等这样的立即容器,它们都需要将容器里面所有元素立即全部生成,这有可能会占用大量空间,甚至为了计算所有个数的元素而消耗大量的cpu资源来参与计算,且只能存放有限个元素。
- 迭代器,可以存放无限个元素,要一个就取出来一个。
1.3 使用next取出迭代器中的元素
1.3.1 单个next
可以看到,上面使用next后,无论我执行多少次,始终只打印1,这是为什么呢?
yield有一个关键的点,就是它把值抛出去后,就停下来了。
那么这里再加一个next试试。
1.3.2 多个next
可以看到,新增的next(t1),是可以接着上一个next(t1)继续向后执行的。
1.4 同时存在多个yield
import string
def count():
c = 1
for i in range(5):
print(c)
yield c
c += 1
def char():
s = string.ascii_lowercase
for c in s:
print(c)
yield c
t1 = count()
t2 = char()
next(t1)
next(t2)
next(t1)
print('@@@')
==========执行结果==========
1
a
2
@@@
可以看出代码在yield处暂停,通过next来驱动各个函数执行,可以由程序员在合适的地方通过yield来暂停一个函数执行,让另外一个函数执行。
默认情况下,多个普通函数的执行是同步阻塞的(上一个执行完且return Non后,下一个才能执行),想要实现多个普通函数同时执行,在同一个线程中是做不到的,必须把它们分别扔到不同的线程中。
线程的切换,正常只有内核态才能实现(无法人为操作),因为分配给线程的时间片,是无法人为控制的。但上述代码,通过使用yield和next实现了用户态的单线程函数切换,注意,这里就有点类似Golang的协程了。
1.5 让next反复执行(变相实现协程)
思路:
- 构建一个大循环
- 写一个任务列表,迭代该列表,拿出任务next
import string
import time
def count():
c = 1
while True:
print(c)
yield c
c += 1
def char():
s = string.ascii_lowercase
for c in s:
print(c)
yield c
t1 = count()
t2 = char()
tasks = [t1, t2]
while True:
pop_index = []
for i, task in enumerate(tasks):
print(i, task)
if next(task, None) is None:
pop_index.append(i)
time.sleep(0.5)
for i in reversed(pop_index):
tasks.pop(i)
print(len(tasks), tasks)
if len(tasks) == 0:
time.sleep(1)
print('@@@')
上述代码,在用户空间实现了不同任务在同一个线程中来回切换,
所以,Coroutine是可以在用户态通过控制,在适当的时机让出执行权的多任务切换技术。
注意:只要是代码就要在线程中执行,协程也不例外。
1.6 协程总结
1.6.1 协程的优点
协程是一种轻量级的线程,由Go语言的运行时进行管理。它允许你在程序中并发地执行多个任务,而不会像传统的线程那样消耗大量的内存和资源。你可以用协程来处理网络请求、文件读写、或者任何可以并行执行的任务。
1.6.2 协程的缺点
- 一但协程在线程中阻塞(相当于所在线程也被阻塞),那么该线程代码就不能继续向下执行。
- 协程必须主动让出,才能轮到该线程中另外一个协程运行。
能否让协程自由的在不同线程中移动,这样就不会阻塞某一个线程到导致线程中其他协程得不到执行?可以看后续介绍的GMP模型。
1.7 进程与线程
1.7.1 进程(Process)
进程是操作系统进行资源分配和调度的一个独立单位。它是应用程序运行的实例,拥有独立的内存空间。每个进程至少有一个线程,即主线程。
进程的特点包括:
- 独立性:进程是独立运行的,拥有自己的地址空间。
- 动态性:进程在其生命周期内会经历不同的状态,如创建、就绪、运行、等待和终止。
- 并发性:多个进程可以并发运行在多核处理器上。
- 拥有资源:每个进程都拥有自己的一套独立的地址空间,一般来说,进程间的资源是不共享的。
1.7.2 线程(Thread)
线程是进程中的一个实体,是被系统独立调度和分派的基本单位。线程自身不拥有系统资源,只拥有一点在运行中必不可少的资源(如执行栈),但它可以与同属一个进程的其他线程共享进程所拥有的全部资源。
线程的特点包括:
- 轻量级:线程的创建、销毁和管理的开销比进程小。
- 并行性:同一进程内的多个线程可以并行执行。
- 共享资源:同一进程内的线程共享进程的资源,如内存空间、文件句柄等。
- 独立调度:线程可以被操作系统独立调度。
1.7.3 进程和线程的区别
- 资源拥有:进程拥有独立的资源,而线程共享进程的资源。
- 开销:创建和销毁线程的开销比进程小。
- 方通信式:线程间可以直接读写进程数据段(如全局变量)来通信,而进程间通信需要使用特定的机制(如管道、信号、共享内存等)。
- 独立性:进程是独立运行的,一个进程崩溃不会直接影响到其他进程,而线程是进程的一部分,一个线程崩溃可能会影响到同进程的其他线程。
1.7.4 进程与线程的关系
- 一个进程至少有一个线程,即主线程。
- 线程是进程的一部分,进程是线程的容器。
- 线程的创建和管理都是在进程的上下文中进行的。
1.8 并发和并行
多线程程序在单核上运行,就是并发。
多线程程序在多核上运行,就是并行。
1.8.1 并发
并发是指在计算机系统中,多个任务在宏观上看起来是同时执行的。这并不意味着这些任务一定是在物理上同时进行的,它们可能在逻辑上交替执行,但给人的感觉是同时进行的。并发的关键点在于任务的交替执行和资源共享。
并发的特点:
- 时间共享:多个任务在时间上交替执行,每个任务获得CPU的一小段时间。
- 资源共享:任务之间可以共享资源,如内存、文件等。
- 上下文切换:操作系统需要保存和恢复任务的执行状态,以便在任务之间切换。
- 适用于多任务环境:可以在单核处理器上实现,并发任务通过时间片轮转来交替执行。
1.8.2 并行
并行是指在计算机系统中,多个任务在物理上同时执行。
这通常需要多核处理器或多处理器系统,每个任务可以由一个独立的处理器执行。
并行的特点:
- 同时执行:多个任务在不同的处理器上同时执行。
- 资源分配:每个任务通常拥有自己的资源,如独立的内存空间。
- 无上下文切换:由于任务是同时执行的,不需要像并发那样进行上下文切换。
- 适用于多核环境:并行任务可以充分利用多核处理器的计算能力。
1.8.3 并发与并行的区别
- 执行方式:并发是任务在逻辑上交替执行,而并行是任务在物理上同时执行。
- 资源利用:并发任务通常共享资源,而并行任务可能拥有独立的资源。
- 上下文切换:并发需要操作系统进行上下文切换,而并行不需要。
- 硬件要求:并行需要多核处理器或多处理器系统,而并发可以在单核处理器上实现。
1.8.4 并发与并行的关系
- 行是并发的一种特殊形式:并行可以看作是并发的一种,它要求物理上的同时执行。
- 并发可以提高并行性:通过合理的并发控制,可以提高系统的并行性,从而提高性能。
2. GPM调度模型
Go语言协程中,非常重要的就是协程调度器scheduler和网络轮询器netpoller。
2.1 基本介绍
- G:表示Goroutine,每执行一次go xxx()就创建一个 G,包含要执行的函数和上下文信息。
- P:Processor,逻辑处理器(虚拟处理器),也表示goroutine 执行所需的资源,最多有GOMAXPROCS个。可以把P想象成一个虚拟机,G是运行在虚拟机上的。
- 可以通过环境变量GOMAXPROCS或runtime.GOMAXPROCS()设置当前程序并发时占用的 CPU逻辑核心数,Go1.5版本之后,默认使用全部的CPU逻辑核心数(实际同时能跑多少个,取决于真正的物理核心数)。
- P的数量决定着最大可并行的G的数量。
- P有自己的队列(长度256),里面放着待执行的G。
- M和P需要绑定在一起,这样P队列中的G才能真正在线程上执行。- M:Machine,指物理服务器OS中的线程,真正干活的都是线程(代码中的目标函数)。M从P中获取G运行。
2.2 Goroutine调度
2.2.1 正常情况(用户态)
2.2.2 阻塞情况(用户态)
g1中进行channel、互斥锁等操作进入阻塞态,g1和m1解绑,执行第3步的获取下一个可执行的 g。
如果阻塞态的g1被其他协程g唤醒后,就尝试加入到唤醒者的LRQ中,如果LRQ满了,就连同g和LRQ 中一半转移到GRQ中。
2.2.3 系统调用(内核态)
2.2.3.1 同步系统调用
(1)如果G1在执行的过程中,陷入到了内核态且阻塞了,这个时候G1和M1是没有办法解绑的(陷入了深度绑定),只能把M1、G1和P1解绑。
(2)此时空闲的P1会向“休眠队列”申请一个新的空闲M(简称M2)进行绑定,然后M2就会从p1的队列中获取G来执行。
(3)如果空闲队列中没有空闲的M(空闲队列为空),那么此时GO运行时会创建一个新的M和P1进行绑定。
(4)后续被解绑的M1和G1从阻塞态恢复成就绪态后(阻塞结束),还是需要和P绑定,但优先和原来的P1进行绑定,此时如果P1已经和其他M绑定了,那么M1和G1解绑,G1加入到全局队列,M1加入到休眠队列。
2.2.3.2 异步网络IO调用
网络IO代码会被Go在底层变成非阻塞IO,这样就可以使用IO多路复用了。
m1执行g1,执行过程中发生了非阻塞IO调用(读/写)时,g1和m1解绑,g1会被网络轮询器Netpoller接手。m1再从p1的LRQ中获取下一个Goroutine g2执行。注意,m1和p1不解绑。
g1等待的IO就绪后,g1从网络轮询器移回P的LRQ(本地运行队列)或全局GRQ中,重新进入可执行状态。
就大致相当于网络轮询器Netpoller内部就是使用了IO多路复用和非阻塞IO,类似我们课件代码中的select的循环。GO对不同操作系统MAC(kqueue)、Linux(epoll)、Windows(iocp)提供了支持。
3. Goroutine
3.1 创建协程
使用go关键字就可以把一个函数定义为一个协程,非常方便。
3.1.1 无协程
package main
import "fmt"
func add(x, y int) int {
var c int
defer func() { fmt.Println("c =", c) }()
defer fmt.Println("c =", c)
fmt.Printf("调用add函数:x=%d, y=%d\n", x, y)
c = x + y
return c
}
func main() {
fmt.Println("main start")
add(4, 5)
fmt.Println("main end")
}
========调试结果========
main start
调用add函数:x=4, y=5
c = 0
c = 9
main end
3.1.2 加入协程
package main
import "fmt"
func add(x, y int) int {
var c int
defer func() { fmt.Println("c =", c) }()
defer fmt.Println("c =", c)
fmt.Printf("调用add函数:x=%d, y=%d\n", x, y)
c = x + y
return c
}
func main() {
fmt.Println("main start")
go add(4, 5) // 加入go关键字即可
fmt.Println("main end")
}
=========调试结果=========
main start
main end
为啥加入协程后,add函数不执行了?
在go中,当mian函数无事可做时(运行完毕),会结束当前运行的main主线程,也就是进程结束了。
那为什么“main end”都打印了,add函数没打印呢?
创建协程很快,但创建完毕后需要调度,这个调度的过程(加入队列、m和p绑定等过程)是需要时间的。
由于协程的创建就是一瞬间的事,所以紧接着“main end”就开始执行了,最后进程结束,但实际上协程还在调度中,所以就看不到add函数的结果。
3.1.3 让主线程(main)等待协程
package main
import (
"fmt"
"runtime"
"time"
)
func add(x, y int) int {
var c int
defer func() { fmt.Println("c =", c) }()
defer fmt.Println("c =", c)
fmt.Printf("调用add函数:x=%d, y=%d\n", x, y)
c = x + y
return c
}
func main() {
fmt.Println("main start")
fmt.Printf("当前存在的协程数量=%v\n", runtime.NumGoroutine()) // 显示当前存在的协程数量。
go add(4, 5)
time.Sleep(2 * time.Second) // 等待2秒,让主线程(main函数)阻塞2秒。
fmt.Println("main end")
fmt.Printf("当前存在的协程数量=%v\n", runtime.NumGoroutine()) // 显示当前存在的协程数量。
}
=======调试结果=======
main start
当前存在的协程数量=1
调用add函数:x=4, y=5
c = 0
c = 9
main end
当前存在的协程数量=1
3.2 等待组(WaitGroup
)
除了使用time.Sleep(2 * time.Second)的方式外,还能使用等待组让主协程等待协程。
sync.WaitGroup
:
sync.WaitGroup
是 Go 语言标准库sync
包中提供的一个并发原语,用于等待一组并发操作的完成。它允许主线程等待多个并发运行的 Go 协程(goroutine)完成,从而协调主线程和多个协程之间的执行流。可用方法:
- Add:这个方法用来增加sync.WaitGroup的计数器。通常,每次调用Add(1)表示有一个并发任务即将开始。参数可以是任何整数,表示对计数器增加或减少的量。
- Done:这个方法用来减少sync.WaitGroup的计数器。每次并发任务完成时调用Done(),表示任务已经结束。
- Wait:这个方法会使调用它的goroutine(轻量级线程)阻塞,直到sync.WaitGroup的计数器归零。这意味着,主程序会等待所有并发任务调用了Done()之后,才会继续执行。
package main
import (
"fmt"
"runtime"
"sync"
)
func add(x, y int, wg *sync.WaitGroup) int { //(4)把定义好的wg传递进来
var c int
defer wg.Done() //(5)add函数执行完毕时,WaitGroup计数器减1
defer func() { fmt.Println("c =", c) }()
defer fmt.Println("c =", c)
fmt.Printf("调用add函数:x=%d, y=%d\n", x, y)
c = x + y
return c
}
func main() {
var wg sync.WaitGroup //(1)新增等待组,默认为0值
wg.Add(1) //(2)下面只用到了1个协程,所以Add(1)
fmt.Println("main start")
fmt.Printf("当前存在的协程数量=%v\n", runtime.NumGoroutine())
go add(4, 5, &wg) //(6)传参时,把定义好的等待组也传进去
wg.Wait() //(3)阻塞main协程,等到sync.WaitGroup计数器为0结束,它上面必须要有个Done,否则会一直阻塞(变成了死锁,无解)。
fmt.Println("main end")
fmt.Printf("当前存在的协程数量=%v\n", runtime.NumGoroutine())
}
==============调试结果==============
main start
当前存在的协程数量=1
调用add函数:x=4, y=5
c = 0
c = 9
main end
当前存在的协程数量=1
原文地址:https://blog.csdn.net/qq_42515722/article/details/143058239
免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!