Golang学习笔记:协程

本文深入探讨了Go语言中的协程(goroutine)及其工作原理,包括同步与异步、协程框架、状态切换、与线程进程的区别以及Go中的调度器。通过Linux例子阐述了协程的IO操作和上下文切换,并分析了Go的协程调度器如何在多状态间高效切换。最后,讨论了Go中协程(goroutine)与系统调用、上下文切换的关系,以及其在高并发场景下的优势。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

Golang学习笔记
参考文档一链接:https

一.协程用在哪里?协程需要解决什么问题?

对于开发人员而言,客户端和服务器是熟知的对象,在这两个对象上都可以运用到协程。
客户端向服务器端请求数据,如果是用线程来实现这个过程的话,就会引出一个同步和异步的概念
【同步和异步:
  客户端方面(大量的请求):如果请求数据和响应数据在同一个流程里面,发出请求后要等待响应数据就是同步,而异步就是不在同一个流程里,只管请求数据,不等待结果继续请求;
  服务器(I/O)方面:所有的服务器都有一个事件驱动,就是持续检测是否有数据传入服务器(例如linux检测数据用epoll_wait()),而检测到有数据后就recv(fd)和send(),前面的检测和后面的接收反馈如果在同一个流程里,就是同步操作,如果检测到有数据后就将其抛到线程池中,由线程池对数据进行处理,而这边的线程只管接收,这就是异步操作。(对于Linux来说,异步IO是下面有个AIO的子系统,一旦有数据来了会从内核中调用一个回调函数)】
  同步操作的优点就是程序逻辑清晰,可读性很强,缺点就是程序性能低;异步操作的缺点就是会出现多个线程共用一个网络IO(一个链接先发送一个head文件给服务器,服务器会开一个线程,然后同一个链接在线程还未执行完前面的操作时又发了一个head文件,这样服务器又开了一个线程,这就出现了多个线程共用一个网络接口的现象),就会导致竞争,这就线程需要加锁,管理好子线程的网络接口。协程就是解决这个问题的:协程就是同步的编程方式和异步的性能。

二.协程的框架(Linux的例子)

	在进行IO操作(recv,send)之前,先执行了 epoll_ctl的del操作,将相应的sockfd从epfd中删除掉,在执行完IO操作(recv,send)再进行epoll_ctl的add的动作。这段代码似乎没有什么作用。如果是在多个上下文中,这样的做法就很有意义了。能够保证sockfd只在一个上下文中能够操作IO的。不会出现在多个上下文同时对一个IO进行操作的。协程的IO异步操作正式是采用此模式进行的。

主要依靠yelid()(让出CPU执行权)和resume()(重新恢复运行),进程之间是如何实现切换的呢?在Windows中有15个寄存器,一个线程由这几个寄存器进行处理,当需要切换到另一个进程时,就将当前寄存器的值保存到内存,然后加载另一个线程的寄存器值,寄存器组就是上下文。
在这里插入图片描述
1.将sockfd 添加到epoll管理中;
2.进行上下文环境切换,由协程上下文转移CPU执行权给到到调度器的上下文;
3.调度器获取下一个协程上下文,Resume新的协程。
CPU有一个非常重要的寄存器叫做EIP,用来存储CPU运行下一条指令的地址。我们可以把回调函数的地址存储到EIP中,将相应的参数存储到相应的参数寄存器中。
原语create:创建一个协程。调度器是否存在,不存在也创建。调度器作为全局的单例。将调度器的实例存储在线程的私有空间pthread_setspecific。分配一个coroutine的内存空间,分别设置coroutine的数据项,栈空间,栈大小,初始状态,创建时间,子过程回调函数,子过程的调用参数。将新分配协程添加到就绪队列 ready_queue中。
原语yield:让出CPU。调用后该函数不会立即返回,而是切换到最近执行resume的上下文。该函数返回是在执行resume的时候,会有调度器统一选择resume的,然后再次调用yield的。resume与yield是两个可逆过程的原子操作。
原语resume:恢复协程的运行权。调用后该函数也不会立即返回,而是切换到运行协程实例的yield的位置。返回是在等协程相应事务处理完成后,主动yield会返回到resume的地方。

三.如何在多种状态高效切换?

新创建的协程,创建完成后,加入到就绪集合,等待调度器的调度;协程在运行完成后,进行IO操作,此时IO并未准备好,进入等待状态集合;IO准备就绪,协程开始运行,后续进行sleep操作,此时进入到睡眠状态集合。
  就绪(ready)集合并不没有设置优先级的选型,所有在协程优先级一致,所以可以使用队列来存储就绪的协程,简称为就绪队列(ready_queue)。睡眠(sleep)集合需要按照睡眠时长进行排序,采用红黑树来存储,简称睡眠树(sleep_tree)红黑树在工程实用为<key, value>, key为睡眠时长,value为对应的协程结点。等待(wait)集合,其功能是在等待IO准备就绪,等待IO也是有时长的,所以等待(wait)集合采用红黑树的来存储,简称等待树(wait_tree),此处借鉴nginx的设计。
  调度器的属性,需要有保存CPU的寄存器上下文 ctx,可以从协程运行状态yield到调度器运行的。从协程到调度器用yield,从调度器到协程用resume。

四.进程、线程和协程之间的联系

进程和线程都是由内核进行调度的,有CPU时间片的概念,都是抢占式调度,系统是不知道协程的存在的,协程对于内核来说就是透明的,协程的调度由我们用户程序进行控制,并且协程需要自己主动将控制权转让出去,别的协程才可以被执行。
  协程常与进程、线程被一并提起,好像他们是同一种概念的不同层级,抽象来看确实如此。协程与一个函数类似,不过函数调用总从一个入口进入,从一个出口返回,而协程则不同,在执行过程中可以中断,被调度去执行其他协程或普通函数,然后在未来某个时刻调度回来继续执行。在运行时它看起来就是一个独立调度的单位,但与进程、线程这种独立调度不同的是,协程挂起和恢复的位置总是固定的,而且是用户开发者显式指定的。
  线程是操作系统的内核对象,多线程编程时,如果线程数过多,就会导致频繁的上下文切换,这些 cpu 时间是一个额外的耗费。所以在一些高并发的网络服务器编程中,使用一个线程服务一个 socket 连接是很不明智的。协程是在应用层模拟的线程,避免了上下文切换的额外耗费,兼顾了多线程的优点,简化了高并发程序的复杂度。

五.协程是如何工作的?

OS 的并发问题就来源于 OS 不知道进程或线程内部的抽象结构,因为一个高级语言中的本该是原子操作的代码被编译到机器码后,就不再是一个原子操作了,它可能包含多条机器指令,这就意味着机器码丢失了高级语言的这些抽象信息,从而可能在连续的多条机器指令间隙被调度,造成并发环境下的逻辑混乱。
  如果 OS 知道哪个位置的代码应该是原子的,那么就可以解决并发问题,而这个解决方案就是 基于信号量的进程同步(锁),但这又与协程完全不同,OS 仅仅是执行锁的原子操作,而协程将可以指定几个调度点位,告诉调度器可以在哪个点位进行调度。协程真正方便的地方是调度和异步 I/O 库的配合使用,我们可以让协程在 I/O 时允许被调度,从而充分利用 I/O 时间来运行其他 CPU 密集的代码。
  golang里的关于管理协程的调度器:支撑整个调度器的主要有4个重要结构,分别是M、G、P、Sched,前三个定义在runtime.h中,Sched定义在proc.c中,Sched结构就是调度器,它维护有存储M和G的队列以及调度器的一些状态信息等。M代表内核级线程,一个M就是一个线程,goroutine就是跑在M之上的;P是Processor(处理器),它的主要用途就是用来执行goroutine的,所以它也维护了一个goroutine队列,里面存储了所有需要它来执行的goroutine;G就是goroutine实现的核心结构了,G维护了goroutine需要的栈、程序计数器以及它所在的M等信息。**地鼠(gopher)用小车运着一堆待加工的砖。M就可以看作图中的地鼠,P就是小车,G就是小车里装的砖。**接下来介绍一下这个搬砖过程。
  1.启动过程做了调度器初始化runtime·schedinit后,调用runtime·newproc创建出第一个goroutine,这个goroutine将执行的函数是runtime.main,这第一个goroutine也就是所谓的主goroutine。我们写的最简单的Go程序”hello,world”就是完全跑在这个goroutine里,当然任何一个Go程序的入口都是从这个goroutine开始的。最后调用的runtime·mstart就是真正的执行上一步创建的主goroutine。
  启动过程中的调度器初始化runtime·schedinit函数主要根据用户设置的GOMAXPROCS值来创建一批小车§,不管GOMAXPROCS设置为多大,最多也只能创建256个小车§。这些小车§初始创建好后都是闲置状态,也就是还没开始使用,所以它们都放置在调度器结构(Sched)的pidle字段维护的链表中存储起来了,以备后续之需。查看runtime.main函数可以了解到主goroutine开始执行后,做的第一件事情是创建了一个新的内核线程(地鼠M),不过这个线程是一个特殊线程,它在整个运行期专门负责做特定的事情——系统监控(sysmon)。接下来就是进入Go程序的main函数开始Go程序的执行。至此,Go程序就被启动起来开始运行了。一个真正干活的Go程序,一定创建有不少的goroutine,所以在Go程序开始运行后,就会向调度器添加goroutine,调度器就要负责维护好这些goroutine的正常执行。
  2.go关键字对应到调度器的接口就是runtime·newproc。runtime·newproc干的事情很简单,就负责制造一块砖(G),然后将这块砖(G)放入当前这个地鼠(M)的小车§中。每个新的goroutine都需要有一个自己的栈,G结构的sched字段维护了栈地址以及程序计数器等信息,这是最基本的调度信息,也就是说这个goroutine放弃cpu的时候需要保存这些信息,待下次重新获得cpu的时候,需要将这些信息装载到对应的cpu寄存器中。假设这个时候已经创建了大量的goroutne,就轮到调度器去维护这些goroutine了。
  3.Go程序中没有语言级的关键字让你去创建一个内核线程,你只能创建goroutine,内核线程只能由runtime根据实际情况去创建。runtime什么时候创建线程?以地鼠运砖图来讲,砖(G)太多了,地鼠(M)又太少了,刚好还有空闲的小车§没有使用,那就从别处再借些地鼠(M)过来直到把小车§用完为止。这里有一个地鼠(M)不够用,从别处借地鼠(M)的过程,这个过程就是创建一个内核线程(M)。
  4.newm接口只是给新创建的M分配了一个空闲的P,也就是相当于告诉借来的地鼠(M)——“接下来你将使用1号小车搬砖,待会自己到停车场拿车。”,地鼠(M)去拿小车§这个过程就是acquirep。runtime.mstart在进入schedule之前会给当前M装配上P,runtime.mstart函数中的代码:

else if(m != &runtime.m0) {
	acquirep(m->nextp);
	m->nextp = nil;
}
schedule();

if分支的内容就是为当前M装配上P,next p就是new m分配的空闲小车§,只是到这个时候才真正拿到手罢了。没有P,M是无法执行goroutine的。对应acquire p的动作是release p,把M装配的P给载掉;活干完了,地鼠需要休息了,就把小车还到停车场,然后睡觉去。
地鼠(M)拿到属于自己的小车§后,就进入工场开始干活了,也就是上面的schedule调用。简化schedule的代码如下:

static void
schedule(void)
{
	G *gp;

	gp = runqget(m->p);
	if(gp == nil)
		gp = findrunnable();

	if (m->p->runqhead != m->p->runqtail &&
		runtime·atomicload(&runtime·sched.nmspinning) == 0 &&
		runtime·atomicload(&runtime·sched.npidle) > 0)  // TODO: fast atomic
		wakep();

	execute(gp);
}

这里涉及到4大步逻辑:
  runqget, 地鼠(M)试图从自己的小车§取出一块砖(G),当然结果可能失败,也就是这个地鼠的小车已经空了,没有砖了。
  findrunnable, 如果地鼠自己的小车中没有砖,那也不能闲着不干活是吧,所以地鼠就会试图跑去工场仓库取一块砖来处理;工场仓库也可能没砖啊,出现这种情况的时候,这个地鼠也没有偷懒停下干活,而是悄悄跑出去,随机盯上一个小伙伴(地鼠),然后从它的车里试图偷一半砖到自己车里。如果多次尝试偷砖都失败了,那说明实在没有砖可搬了,这个时候地鼠就会把小车还回停车场,然后睡觉休息了。如果地鼠睡觉了,下面的过程当然都停止了,地鼠睡觉也就是线程sleep了。
  wakep, 到这个过程的时候,可怜的地鼠发现自己小车里有好多砖啊,自己根本处理不过来;再回头一看停车场居然有闲置的小车,立马跑到宿舍找睡觉的地鼠,小伙伴醒后拿上自己的小车就去干活去。有时候地鼠跑到宿舍却发现没有在睡觉的地鼠,会很失望向工场老板说——”停车场还有闲置的车啊,我快干不动了,赶紧从别的工场借个地鼠来帮忙吧。”,最后工场老板就搞来一个新的地鼠干活了。
  execute,地鼠拿着砖放入火种欢快的烧练起来。
  5.到这里,还有一个疑点没解决啊,假设地鼠的车里有很多砖,它把一块砖放入火炉中后,何时把它取出来,放入第二块砖呢?难道要一直把第一块砖烧练好,才取出来吗?那估计后面的砖真的是等得花儿都要谢了。这里就是要真正解决goroutine的调度,上下文切换问题。
  从channel的实现代码可以发现,对channel读写操作的时候会触发调用runtime.park函数。goroutine调用park后,这个goroutine就会被设置位waiting状态,放弃cpu。被park的goroutine处于waiting状态,并且这个goroutine不在小车§中,如果不对其调用runtime.ready,它是永远不会再被执行的。除了channel操作外,定时器中,网络poll等都有可能park goroutine。除了park可以放弃cpu外,调用runtime.gosched函数也可以让当前goroutine放弃cpu,但和park完全不同;gosched是将goroutine设置为runnable状态,然后放入到调度器全局等待队列(也就是上面提到的工场仓库,这下就明白为何工场仓库会有砖块(G)了吧)。
  除此之外,就轮到系统调用了,有些系统调用也会触发重新调度。Go语言完全是自己封装的系统调用,所以在封装系统调用的时候,可以做不少手脚,也就是进入系统调用的时候执行entersyscall,退出后又执行exitsyscall函数。 也只有封装了entersyscall的系统调用才有可能触发重新调度,它将改变小车§的状态为syscall。还记一开始提到的sysmon线程吗?这个系统监控线程会扫描所有的小车§,发现一个小车§处于了syscall的状态,就知道这个小车§遇到了goroutine在做系统调用,于是系统监控线程就会创建一个新的地鼠(M)去把这个处于syscall的小车给抢过来,开始干活,这样这个小车中的所有砖块(G)就可以绕过之前系统调用的等待了。被抢走小车的地鼠等系统调用返回后,发现自己的车没,不能继续干活了,于是只能把执行系统调用的goroutine放回到工场仓库,自己睡觉去了。
  6. goroutine在cpu上换入换出,不断上下文切换的时候,必须要保证的事情就是保存现场和恢复现场,保存现场就是在goroutine放弃cpu的时候,将相关寄存器的值给保存到内存中;恢复现场就是在goroutine重新获得cpu的时候,需要从内存把之前的寄存器信息全部放回到相应寄存器中去。goroutine在主动放弃cpu的时候(park/gosched),都会涉及到调用runtime.mcall函数,此函数也是汇编实现,主要将goroutine的栈地址和程序计数器保存到G结构的sched字段中,mcall就完成了现场保存。恢复现场的函数是runtime.gogocall,这个函数主要在execute中调用,就是在执行goroutine前,需要重新装载相应的寄存器。

六.协程与golang的关系

在golang里协程就是goroutine,golang在runtime、系统调用等多方面对 goroutine 调度进行了封装和处理,在进行系统调用或者长时间执行时,会主动将当前goroutine的执行权让给下一个goroutine。golang最大的特色就是在语言上就支持协程,只需用go关键字就可以创建一个协程,非常便利。

2022.06.19复习补充:
1.一个程序进入内存,称之为进程,而一个程序可以有多个进程,每个进程在内存中的区别是地址不同,但是它们对应的都是同一个程序;
2.在同一个进程内部会有多个任务并发执行的需求,线程就解决这个问题,接下来说明一下线程的特点:在一个进程中会有多个线程,每个线程共享进程的空间,但是不共享计算。
进程是静态的概念:程序进入内存,分配对应的资源:内存空间,进程进入内存,同时产生一个主线程
线程是动态的概念:是可执行的计算单元(通俗一点就是线程是一条条的指令,而数据是在进程里面的)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

夜以冀北

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值