golang 解析 --- 进程,线程,协程
一.背景
在并发编程中进程和线程是不可忽略的两个概念,他们很好的完成了操作系统或者服务对于高并发的需求,然而随着时代的进步,协程的概念应运而生,本文旨在解释协程相对于进程和线程在高并发环境下的优势,所以会先介绍进程,线程,最后讲解协程的调度方式。
二.详细介绍
2.1 进程
2.1.1 概念
进程基本上是一个正在执行的程序,它是操作系统中最小的资源分配单位。
2.1.2 结构
当一个程序被加载到内存中并成为一个进程时,它可以分为四个部分——堆栈、堆、文本和数据。下图显示了主内存中进程的简化布局:
堆栈
进程堆栈包含临时数据,例如方法/函数参数、返回地址和局部变量。
堆
这是在进程运行时动态分配给进程的内存。
数据
包含全局变量和静态变量。
文本
包括由程序计数器的值和处理器寄存器的内容表示的当前活动。
2.1.3 进程上下文切换
进程的上下文切换是指 cpu 从一个进程切换到另一个进程。
进程上下文切换主要包含两个主要过程:进程地址空间切换和处理器状态切换
进程地址空间切换
切换原因:进程地址空间指的是进程所拥有的虚拟地址空间,而这个地址空间是假的,是 linux 内核通过数据结构来描述出来的,从而使得每一个进程都感觉到自己拥有整个内存的假象,cpu 访问的指令和数据最终会落实到实际的物理地址,对用进程而言通过缺页异常来分配和建立页表映射。进程地址空间内有进程运行的指令和数据,因此到调度器从其他进程重新切换到我的时候,为了保证当前进程访问的虚拟地址是自己的必须切换地址空间。
切换方式:将当前进程的 pgd 虚拟地址转换为物理地址存放在用户控件的页表基址寄存器,当访问用户空间地址的时候 mmu 会通过这个寄存器做遍历页表,获得物理地址。
原理是进程想要访问一个用户空间虚拟地址,cpu 的 mmu 所做的工作,就是从页表基址寄存器拿到页全局目录的物理基地址,然后和虚拟地址配合来查查找页表,最终找到物理地址进行访问(当然如果 tlb 命中就不需要遍历页表),每次用户虚拟地址访问的时候(内核空间共享不考虑),由于页表基地址寄存器内存放的是当前执行进程的页全局目录的物理地址,所以访问自己的一套页表,拿到的是属于自己的物理地址(实际上,进程是访问虚拟地址空间的指令数据的时候不断发生缺页异常,然后缺页异常处理程序为进程分配实际的物理页,然后将页帧号和页表属性填入自己的页表条目中),就不会访问其他进程的指令和数据,这也是为何多个进程可以访问相同的虚拟地址而不会出现差错的原因。
ps:地址空间切换过程中,还会清空 tlb(页表缓存:用于存放虚拟地址映射至物理地址的标签页表条目),防止当前进程虚拟地址转化过程中命中上一个进程的 tlb 表项,一般会将所有的 tlb 无效,但是这会导致很大的性能损失,因为新进程被切换进来的时候面对的是全新的空的 tlb,造成很大概率的 tlb miss,需要重新遍历多级页表
处理器状态切换
切换原因:需要将进程的内核栈和执行流进行切换。
切换方式:处理器状态切换就是将前一个进程的 sp,pc 等寄存器的值保存到一块内存上,然后将即将执行的进程的 sp,pc 等寄存器的值从另一块内存中恢复到相应寄存器中,恢复 sp 完成了进程内核栈的切换,恢复 pc 完成了指令执行流的切换。
sp 寄存器在任意时刻会保存我们栈顶的地址.
pc 寄存器也称为程序寄存器,用于存储指向下一条指令的地址,也即将将要执行的指令代码。
2.2 线程
2.2.1 概念
线程是进程的子集,也称为轻量级进程。一个进程可以有多个线程,这些线程由调度器独立管理。一个进程内的所有线程都是相互关联的。线程是操作系统中最小的调度单位。
2.2.2 结构
线程有一些公共信息,例如数据段、代码段、文件等,这些信息共享给它们的对等线程。但包含自己的寄存器、堆栈和程序计数器。
堆栈:函数在被执行的时候产生的数据包括 函数参数、 局部变量、 返回地址等信息,这些信息是保存在栈中的,线程相当于进程中的一个执行流,为了保存执行流的信息,我们需要给线程创建独属堆栈
寄存器:函数运行需要额外的寄存器来保留一些信息,所以线程的寄存器也是私有的。
程序计数器:CPU 执行指令的信息保存在一个叫做程序计数器的寄存器中,通过这个寄存器我们就知道接下来要执行哪一条指令。所以线程也有自己的计数器用于告诉我们线程执行的工作顺序。
2.2.3 线程上下文切换
根据线程的结构可知,线程没有自己的地址空间,同一进程的线程之间切换,他们共享同一进程的地址空间,所以只需要切换处理器状态;不同进程的线程之间切换,会引起进程切换
由于同一进程下的线程上下文切换不引起虚拟地址空间切换,所以它们上下文切换的花销要比进程小很多。
2.3 协程
2.3.1 概念
可以看作轻量级线程,他的内存占用少只要 2k,且上下文切换成本低,是一个独立执行的函数,由 go 语言启动,由 Go 运行时(runtime)管理。Go 程序会智能地将 goroutine 中的任务合理地分配给每个 CPU。
2.3.2 结构
在 Go 中,goroutine 只不过是一个 Go 结构,包含有关正在运行的程序的信息,例如堆栈、程序计数器或其当前的 OS 线程。
2.3.3 协程上下文切换
goroutine 调度概念介绍
当你的 go 程序启动,他会根据你的主机分配逻辑处理器(P),每个物理核心可能有多个硬件线程,以我的电脑为例子,显示是 6 核但是可以出实话出来 12 个逻辑处理器,他们可用于并行执行 OS 线程。
每个逻辑处理器都会分配一个 OS 线程(M),该线程由操作系统管理,当 go 执行,有 12 个线程可用于执行工作,每个线程连接到一个 P。
每个 go 程序都会有一个初始的协程(G:goroutine),他可以被看作是程序级别线程,所有的 goroutine 在 M 上进行上下文切换。
最后还需要有运行队列,全局运行队列(GRQ)和本地运行队列(LRQ)。每个 P 都有一个 LRQ,用于管理分配在 P 上下文中执行的 Goroutine。这些 Goroutine 轮流在分配给该 P 的 M 上进行上下文切换。GRQ 用于管理尚未分配给 P 的 Goroutine。有一个将 Goroutines 从 GRQ 移动到 LRQ 的过程,我们将在后面讨论。
进程,线程的切换,都是操作系统进行调度的,go 调度是 go 语音的一部分,它运行在内核之上的用户空间中。它不是抢占式,而是协作调度。作为协作调度程序意味着调度程序需要在代码中的安全点发生的明确定义的用户空间事件来做出调度决策。
以上是 goroutine 的定义,从上文可知,goroutine 调度与进程线程最大的区别就在于它是运行在用户空间中的协作调度方式的上下文切换。
会触发调度程序调度决策的场景
1.go 关键字使用
2.垃圾回收
3.系统调用
4.mutex,channel 调用导致 goroutine 阻塞
切换开销
Goroutine 上下文切换只涉及修改三个寄存器(PC[程序寄存器]/SP[堆栈指针]/DX)的值,而比较线程的上下文切换需要包括模式切换(从用户态切换到内核态)和 16 个寄存器,PC、SP 等寄存器刷新
三.总结
进程上下文切换开销:
1.地址空间
2.硬件上下文
线程上下文切换开销:
1.硬件上下文
2.同一进程下不切换地址空间
goroutine 切换开销:
1.用户态,不用象线程和进程一样多进行一次内核用户态切换
2.只需要保存/恢复三个寄存器的值,开销远远小于线程
其余优点:goroutine 的栈空间为 2k,线程为 2m,进程是 10m
由进程,线程,goroutine 的上下文切换可以明显看出是一个逐步减负的过程,这个过程可以结合它们的结构来理解,coverco 故而自带 goroutine 的 go 语言在高并发开发中有着得天独厚的优势。
参考文章
https://www.toptal.com/software/introduction-to-concurrent-programming
进程:
https://www.tutorialspoint.com/operating_system/os_processes.htm
https://www.guru99.com/process-management-pcb.html
https://blog.csdn.net/21cnbao/article/details/108860584
线程
https://www.javatpoint.com/process-vs-thread
https://blog.csdn.net/weixin_39630048/article/details/113328415
协程
https://www.youtube.com/watch?v=f6kdp27TYZs
https://talks.golang.org/2012/concurrency.slide#13(关于并发演讲)
https://www.ardanlabs.com/blog/2018/08/scheduling-in-go-part2.html
版权声明: 本文为 InfoQ 作者【en】的原创文章。
原文链接:【http://xie.infoq.cn/article/6f306c7113545609a2cb75231】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论