进程,线程和协程详解

进程的Linux实现

an instance of a computer program that is being executed

进程是程序的一次执行,是一个程序及其数据,运行环境,在处理机上运行时所发生的活动。

与程序不同的是,进程具有动态性和生命周期,是系统进行资源分配和调度的独立单位。

和静态的程序相比,进程是一个运行态的实体,拥有各种各样的资源:内存空间和页表、打开的文件、、内核中数据实体(例如task_struct实体),内核栈等。针对进程,我们使用进程ID,也就是pid(process ID)。通过getpid和getppid可以获取当前进程的pid以及父进程的pid。

  1. 代码段:一个程序的机器码
  2. 数据段:已被初始化的变量值
  3. BSS:未初始化的变量

Linux进程结构主要涉及三个结构体task_struct、mm_struct、vm_area_struct

task_struct

在 Linux 中每一个进程都由 task_struct 数据结构来定义。task_struct 就是我们通常所说的 PCB,是对进程控制的唯一手段。

当我们调用 fork() 时,系统会为我们产生一个 task_struct 结构。然后从父进程,那里继承一些数据,并把新的进程插入到进程树中,以待进行进程管理。内核在为每个进程分配Task_struct结构的内存空间时,实际上一次性分配两个连续的内存页面(共8KB),其底部约1KB空间存放Task_struct结构,上面的7KB空间存放进程系统空间堆栈。

task struct详细结构

PID

进程标识值,实际上是一个int类型,默认最大值为32768

volatile long state

进程状态位,它会根据具体情况改变状态,是进程调度和对换的依据

  1. TASK_RUNNING:可运行,其包括两种:正在运行,由current所指向的进程。正准备运行,只要得到CPU就可以立即投入运行,CPU是唯一等待的系统资源
  2. TASK_INTERRUPTIBLE:可中断等待,因为等待某事件或其他资源而睡眠,内核发送信号给该进程表明资源到达或事件发生,进程状态则可变为TASK_RUNNING,只要调度器选中该进程即可恢复执行
  3. TASK_UNINTERRUPTIBLE:不可中断等待,处于该状态的进程因为正在等待某个事件或某个资源,而被放入系统中的某个等待队列(wait_queue),因为等待特定的系统资源而不可中断等待,只能用特定的方式来唤醒它,例如唤醒函数wake_up()等,它们不能由外部信号唤醒,只能由内核亲自唤醒
  4. TASK_ZOMBIE: 僵尸进程,分配的绝大部分资源将被回收,除了task_struct结构及少数资源外。此时的进程已经“死亡”,但task_struct结构还保存在进程列表中,半死不活,故称为“僵尸进程”
  5. TASK_STOPPED: 暂停,此时的进程暂时停止运行来接受某种特殊处理,比如接受调试。通常当进程接收到SIGSTOP、SIGTSTP、SIGTTIN或 SIGTTOU信号后就处于这种状态。
  6. TASK_TRACED:本质上来说,这属于TASK_STOPPED状态,用于从停止的进程中,将当前被调试的进程与常规的进程区分开来
  7. TASK_DEAD:进程wait系统调用发出后,当子进程退出时,父进程负责回收子进程的全部资源,子进程进入TASK_DEAD状态
  8. TASK_SWAPPING:换入/换出

stack

内核栈指针,进程内核栈是OS为进程开辟的一个栈帧空间。

但是这个栈帧空间不是用户的栈帧空间,因为用户的栈帧空间是不安全的,所以内核会专门为它开辟一个空间,就是内核栈

通过stack指针指向内核栈

进程通过alloc_thread_info函数分配它的内核栈,通过free_thread_info函数释放所分配的内核栈

1
2
3
4
5
//内核栈结构体
union thread_union {
struct thread_info thread_info;
unsigned long stack[THREAD_SIZE/sizeof(long)];
};
  1. thread_info:记录部分进程信息的结构体,其中包括了进程上下文信息,不同硬件平台的thread_info不一样,位于内核栈的尾端或顶端,内部有一个指针指向task_struct
  2. stack:一个数组,就是内核栈实体

由图可知,task_struct和 thread_info内部各有一个指针指向对方

因为内核中大部分处理进程的代码都是直接通过task_struct进行的,所以,通过current宏查找到当前正在运行的进程的task_struct的速度就显得尤其重要。硬件平台不同,这个宏的实现也会不同:

  1. 对于X86平台,因为其寄存器并不富裕,所以会在内核栈的底端创建thread_info,通过地址偏移找到task_struct
  2. 对于PowerPC,因为它有足够多的寄存器,所以它会直接将指针存在一个寄存器中,获取时可以直接返回

mm_struct

内存描述符指针,mm_struct结构描述了一个进程的整个虚拟地址空间

例如:物理页,代码段,数据段,堆等区域的起始地址和尾地址,当然还有一些内存配置信息,比如页表锁,分配的物理页数目,页目录等等

每一个进程都会有自己独立的mm_struct,即拥有自己独立的地址空间,这样才能互不干扰。

所有的mm_struct都会使用自身的mmlist指针链接到一个双向链表中

内核线程的mm_struct指向为null,这也正是内核线程的真正含义:它们不需要访问任何用户空间的内存,没有用户空间的上下文,在用户空间也没有页。

当一个进程被调度时,该进程的mm_struct内指向的所有地址空间就会被装载到内存(所以一个进程的调度是一个十分重量级的操作),当内核发现mm_struct指针为null时,就会保留前一个进程的地址空间,也就是说,内核线程会直接使用前一个进程的页表,因为不访问用户空间,所以只会使用前一个进程页表中于内核空间相关的信息。

vm_area_struct

简称为VMA,VMA指定了地址空间内的一块连续内存范围,内核将每个VMA作为一个单独的对象来进行管理,赋予其一致的属性,比如访问权限等。

也就是说,内核并不是直接操作物理页表来管理内存,而是又将大大小小的连续内存空间包装成对象来进行管理,每个对象都会有一些属性,以应对不同类型的内存区域,比如用户空间栈等。

vm_area_struct结构使用链表或树形结构链接,方便进程快速访问

在mm_struct中,如果指定聚簇方式为mm_rb,那么这些VMA会以红黑树的方式存储;如果为mmap,则会以链表的形式存储

由于VMA与mm_struct紧密相关,所以共享mm_struct的线程也会共享所有的VMA对象

线程的Linux实现

而在windows中,线程被抽象为一种比进程更轻量级的可以独立处理事件的单元,操作系统提供了线程控制块TCB,这是一种和PCB不同的数据结构

而在Linux中却不一样,从内核的角度来看,并没有线程这个概念,Linux把所有的线程都当作进程来处理,内核也并没有定义独特的调度算法和数据结构来实现线程,线程就是一个与父进程共享资源的进程而已,Linux在创建线程时,会直接创建进程并分配task_struct,同时指定共享资源,所以对于内核来说,它就是进程,线程在Linux中是一个实现进程共享资源的机制。

在 Linux 中每一个进程都由 task_struct 数据结构来定义。task_struct 就是我们通常所说的 PCB,当我们调用 fork() 时,系统会为我们产生一个 task_struct 结构。然后从父进程,那里继承一些数据,并将PCB插入任务队列中,以待进行进程管理。

对于线程来说,需要在clone()中指定共享资源

1
2
3
4
5
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND,0);
//VM:共享地址空间,这个是核心参数,必须指定
//FS:共享文件系统信息
//FILES:共享打开的文件
//SIGHAND:共享阻断信号

而一个普通的fork()就是

1
clone(SIGCHLD,0);

内核线程

内核需要经常在后台处理操作,这些任务可以交给内核线程来处理。内核线程就是独立运行在内核空间的标准进程。

内核线程没有独立的地址空间。其指向地址空间的mm指针设置为null,只存在于内核空间(task_struct结构中的mm指针:指向进程所拥有的内存描述符)

多线程的优点:并发性提高,占用资源比进程更少。

多线程缺点:存在大量临界资源,势必会造成各种互斥。编程难度提高,线程的调度和同步需要更多额外的开销。

线程的通信方式

通信方式 描述
管道 分为匿名管道和命名管道,实质是一个缓冲区,管道的作用正如其名,需要通信的两个进程在管道的两端,进程利用管道传递信息。管道对于管道两端的进程而言,就是一个文件,但是这个文件比较特殊,它不属于文件系统并且只存在于内存中。
信号signal 信号是软件层次上对中断机制的一种模拟,是一种异步通信方式,进程不必通过任何操作来等待信号的到达。信号可以在用户空间进程和内核之间直接交互,内核可以利用信号来通知用户空间的进程发生了哪些系统事件。
信号量Semaphore 信号量实质上就是一个标识可用资源数量的计数器,它的值总是非负整数。而只有0和1两种取值的信号量叫做二进制信号量(或二值信号量),可用用来标识某个资源是否可用。
共享内存 使得多个进程可以可以直接读写同一块内存空间,是针对其他通信机制运行效率较低而设计的。为了在多个进程间交换信息,内核专门留出了一块内存区,可以由需要访问的进程将其映射到自己的私有地址空间。进程就可以直接读写这一块内存而不需要进行数据的拷贝,从而大大提高效率
消息队列 消息队列是消息的链表,具有特定的格式,存放在内存中并由消息队列标识符标识,并且允许一个或多个进程向它写入与读取消息
套接字 不同客户端的进程间的通信方式,套接字上联应用进程,下联网络协议栈,是进程通过网络协议进行通信的接口。

用户空间和内核空间

操作系统为了支持多个应用同时运行,需要保证不同进程之间相对独立,一个进程的崩溃不会影响其他进程,恶意进程不能读取其他进程的数据。于是内存空间被划分为两部分,内核空间和用户空间,内核空间的代码和数据拥有更高的权限,而用户空间的代码不能访问高级别的空间,因此保护了操作系统自身的内存数据。

用户态:指进程运行在用户地址空间中的状态,被执行的代码要受到 CPU 的很多检查。进程只能访问地址空间中规定的页面的虚拟地址。

内核态:指进程运行在内核地址空间中的状态,此时 CPU 可以执行任何指令。运行的代码也不受任何的限制,可以自由地访问任何有效地址,也可以直接进行端口的访问。所有系统资源的管理都是在内核态去做的,比如创建一个线程需要分配资源,就需要进入内核态,来完成。

进程上下文

进程的上下文是由它的程序代码和程序运行所需要的数据结构以及硬件环境组成的,进程的运行环境主要包括:

  1. 进程空间中的代码和数据、进程堆栈和共享内存区等。
  2. 环境变量:提供进程运行所需的环境信息。
  3. 系统数据:进程空间中的对进程进行管理和控制所需的信息,包括进程任务结构体以及内核堆栈等。
  4. 进程访问设备或者文件的权限。
  5. 硬件寄存器。

在Linux中把系统提供给进程的的处于动态变化的运行环境总和称为进程上下文,系统中的每一个进程都有自己的上下文。

当前进程因时间片用完或者因等待某个事件而阻塞时,进程调度需要把处理器的使用权从当前进程交给另一个进程叫做进程切换。

此时,被调用进程成为当前进程。在进程切换时系统要把当前进程的上下文保存在指定的内存区域(该进程的任务状态段TSS中),然后把下一个使用处理器运行的进程的上下文设置成当前进程的上下文。当一个进程经过调度再次使用CPU运行时,系统要恢复该进程保存的上下文。所以,进程的切换也就是上下文切换

在系统内核为用户进程服务时,通常是通过系统调用来让内核接管,CPU会首先从TSS中取出内核栈的ss指针和sp指针信息并送入ss寄存器和sp寄存器,然后硬件和中断服务程序会将用户态的CPU状态和寄存器信息维护在内核栈中,即维护进程上下文,此时内核为用户进程服务,可以说内核在代替当前进程执行某种服务。

所以可以认为,内核态就是内核运行在进程上下文中的状态,也就是陷入内核态。

中断上下文:硬件通过触发信号,导致内核调用中断处理程序,进入内核空间。这个过程中,硬件的一些变量和参数也要传递给内核,内核通过这些参数进行中断处理。所谓的“中断上下文”,其实也可以看作就是硬件传递过来的这些参数和内核需要保存的一些其他环境(主要是当前被打断执行的进程环境)

从用户态进入内核态:中断

中断是CPU的一个功能:CPU停下工作,保留现场,自动的转去执行相应的处理程序,处理完该事件后再返回断点继续执行。避免了CPU的轮询检查,而是转换为事件驱动,向CPU发送中断事件,强制让CPU来执行中断处理程序。发生中断,CPU会立即进入内核态,针对不同的中断信号,采取不同的处理方式。**中断是CPU从用户态进入核心态的唯一途径(如系统调用)**。

硬中断

硬中断时由外部事件引起的,具有随机性和突发性,比如键盘,鼠标的输入,磁盘的读写,缺页。硬中断的中断号是由中断控制器提供的,硬中断是可以屏蔽掉的。流程如下:

  1. 外设 将中断请求发送给中断控制器;
  2. 中断控制器 根据中断优先级,有序地将中断号传递给 CPU;
  3. CPU 终止执行当前程序流,将 CPU 所有寄存器的数值保存到栈中;
  4. CPU 根据中断号,从中断向量表中查找中断处理程序的入口地址,执行中断处理程序;
  5. CPU 恢复寄存器中的数值,返回原程序流停止位置继续执行。

软中断(被动)

CPU的内部事件或者程序引起的中断,如程序故障,电压故障。

软中断(主动)

也称作系统调用,用户进程主动要求进入内核态。用户进程通过系统调用申请操作系统提供服务。

系统调用使用的是一个特别的中断实现的。具体是:调用 int $0x80的汇编指令,将产生向量为0x80的编程异常(软中断)

软中断模拟了硬中断的处理过程:

  1. CPU 终止执行当前程序流,将 CPU 所有寄存器的数值保存到栈中;
  2. CPU 根据中断向量,从中断向量表中查找中断处理程序的入口地址,执行中断处理程序;
  3. CPU 恢复寄存器中的数值,返回原程序流停止位置继续执行。

一个程序开多少线程合适

CPU密集型

一个完整的请求,IO操作可以在很短的时间内完成,CPU的运算时间占大部分,线程等待时间接近0

  1. 单核CPU:一个CPU对应一个线程,且IO时间短,所以不适合使用多线程。若使用多线程,会造成线程竞争,造成不必要的浪费
  2. 多核:如果是多核CPU,就可以最大化利用CPU的核心数,使用并发编程来提高效率。理论上的线程数量就等于CPU的核数,但是一般会设置为核数+1,这个额外的线程可以保证线程因为缺页中断或者其他原因暂停而不会导致CPU中断工作

IO密集型

一个完整请求,除了CPU的运算操作,还有许多IO操作要做,也就是说,IO操作占很大一部分,等待时间较长。

理论最佳线程数:CPU核心数 * (1/CPU利用率),CPU利用率=1+(IO耗时/CPU耗时)

如果几乎全是IO耗时,那么就可以说是2N,但是一般也有一个backup,也就是2N+1

线程的实现模型

线程的实现模型主要有 3 种:内核级线程模型、用户级线程模型和两级线程模型(也称混合型线程模型)

它们之间最大的差异就在于用户线程内核调度实体(KSE,Kernel Scheduling Entity)之间的对应关系

而所谓的内核调度实体 KSE 就是指可以被操作系统内核调度器调度的对象实体

简单来说 KSE 就是内核级线程,是操作系统内核的最小调度单元,也就是我们写代码的时候通俗理解上的线程了

用户级线程模型

用户线程与内核线程是多对一(N : 1)的映射模型,多个用户线程一般从属于单个进程并且多线程的调度是由用户自己的线程库来完成,线程的创建、销毁以及多线程之间的协调等操作都是由用户自己的线程库来负责而无须借助系统调用来实现。

一个进程中所有创建的线程都只和同一个 KSE 在运行时动态绑定,也就是说,操作系统只知道用户进程而对其中的线程是无感知的,内核的所有调度都是基于用户进程。许多语言实现的协程库基本上都属于这种方式(比如 python和c++)。

由于线程调度是在用户层面完成的,也就是相较于内核调度不需要让 CPU 在用户态和内核态之间切换,这种实现方式相比内核级线程可以做的很轻量级,对系统资源的消耗会小很多,因此上下文切换所花费的代价也会小得多。

但该模型有个原罪:并不能做到真正意义上的并发,假设在某个用户进程上的某个用户线程因为一个阻塞调用(比如 I/O 阻塞)而被 CPU 给中断(抢占式调度)了,那么该进程内的所有线程都被阻塞(因为单个用户进程内的线程自调度是没有 CPU 时钟中断的,从而没有轮转调度),整个进程被挂起。在用户级线程模型下,一个 CPU 关联运行的是整个用户进程,进程内的子线程绑定到 CPU 执行是完全由用户进程调度的,内部线程对 CPU 是不可见的。

所以很多的协程库会把自己一些阻塞的操作重新封装为完全的非阻塞形式,然后在以前要阻塞的点上,主动让出自己,并通过某种方式通知或唤醒其他待执行的用户线程在该 KSE上运行,从而避免了内核调度器由于KSE阻塞而做上下文切换,这样整个进程也不会被阻塞了。

内核级线程模型

用户线程与内核线程是一对一(1 : 1)的映射模型,也就是每一个用户线程绑定一个实际的内核线程,线程的调度则完全交付给操作系统内核去做,应用程序对线程的创建、终止以及同步都基于内核提供的系统调用来完成,大部分编程语言的线程库(比如 Java 的 java.lang.Thread、C++11 的 std::thread 等等)都是对操作系统的线程(内核级线程)的一层封装,创建出来的每个线程与一个独立的 KSE 静态唯一绑定,因此其调度完全由操作系统内核调度器去做。

优势是实现简单,直接借助操作系统内核的线程以及调度器,所以 CPU 可以快速切换调度线程,于是多个线程可以同时运行,因此相较于用户级线程模型它真正做到了并行处理;但它的劣势是,由于直接借助了操作系统内核来创建、销毁和以及多个线程之间的上下文切换和调度,因此资源成本大幅上涨,且对性能影响很大。

javaThread

两级线程模型

两级线程模型充分吸收了前两种线程模型的优点且尽量规避它们的缺点。

在此模型下,用户线程与内核KSE是多对多(N : M)的映射模型

区别于用户级线程模型,两级线程模型中的一个进程可以与多个内核线程 KSE 关联,也就是说一个进程内的多个线程可以分别绑定一个自己的 KSE,这点和内核级线程模型相似;

又区别于内核级线程模型,它的进程里的线程并不与 KSE 唯一绑定,而是可以多个用户线程映射到同一个 KSE,当某个 KSE 因为其绑定的线程的阻塞操作被内核调度出 CPU 时,其关联的进程中其余用户线程可以重新与其他 KSE 绑定运行。

所以,两级线程模型既不是用户级线程模型那种完全靠自己调度的也不是内核级线程模型完全靠操作系统调度的,而是中间态(自身调度与系统调度协同工作,Go 语言中的 runtime 调度器就是采用的这种实现方案,实现了 Goroutine 与 KSE 之间的动态关联。

该模型为何被称为两级?即用户调度器实现用户线程到 KSE 的『调度』,内核调度器实现 KSE 到 CPU 上的『调度』。

协程的C++实现:C++20 Coroutine TS

Coroutine是一个函数的泛化,它允许函数被挂起,稍后再恢复

也就是说,在一个线程内,比如main函数在执行到一半时可以停下来去执行另一个函数,另一个函数执行后又可以回到main函数,这就是可以挂起的函数,即协程

一个普通函数可以被认为有两个操作:调用返回

调用Call

当调用一个函数时,调用操作会创建一个栈帧,挂起调用函数的执行,并将执行转交到被调用函数的开始位置

这个“挂起”步骤通常包括将当前保存在CPU寄存器中的任何值保存到内存中,以便在函数恢复执行时,这些值可以在需要时恢复。根据函数的调用约定,调用方和被调用方可以协调谁保存这些寄存器值,但您仍然可以将它们视为调用操作的一部分。

返回Return

返回操作将返回值传递给调用方,并销毁函数的栈帧,然后在调用函数的位置恢复调用方的执行。恢复就是回到调用那个时刻的位置,比如设置寄存器重新指向调用者的栈帧。

协程泛化了函数且增加了两个额外的操作:挂起,恢复

协程的栈帧

协程可以在不销毁栈帧的情况下被挂起,这相当于打破了一个函数调用在虚拟机栈内的运作规则(压入-执行-返回-弹出-销毁)

这意味着我们需要一个额外的数据结构来保存协程的上下文信息,即堆内存上的协程帧

协程帧:用于保存寄存器上下文,挂起时的恢复点地址,相当于是保存了自身状态的快照。在编码中我们看见的是每个Coroutine对象内都设置了相应的变量来保存寄存器的值

挂起:Suspend

挂起操作在函数的当前点挂起协程的执行,并在不破坏栈帧帧的情况下将执行权转交给调用方或恢复调用方。

C++ Coroutines TS中,这些挂起点是通过co_awaitco_yield关键字来标识的,在挂起协程执行之后,挂起点上的任何对象都仍然是可用的。

记住,协程的切换不会破坏栈帧,这是协程实现回调的核心

当挂起时会发生以下操作:

  1. 确保当前协程寄存器上下文写入协程帧中
  2. 将恢复点的地址写入协程帧,以指示在哪个位置挂起,恢复操作就可以知道在哪里恢复协程的执行

恢复:Resume

就像普通函数调用一样,这个对resume()的调用将分配一个新的栈帧,并在将执行转交到该函数之前将调用者的返回地址存储在栈帧中。但是,它不是将执行转移到函数的开始,而是将执行转移到上次挂起的函数的点。

调用:Call

当一个协程A因为让出而将执行权让渡给协程B时,A会在保存自身寄存器上下文后将栈指针寄存器的值修改为B的栈地址,并弹出自身栈帧,执行retq指令跳转回调用者继续执行,这个过程不会影响指令寄存器IP的值。

销毁:Destroy

销毁操作销毁协程帧,而不恢复协程的执行

总结

协程常常被认为是轻量级的线程,实际上在C++中,协程不是进程也不是线程,而是一个特殊的函数,这个函数可以在某个地方挂起,并且可以重新在挂起处外继续运行。所以说,协程与进程、线程相比并不是一个维度的概念。

协程不是被操作系统内核所管理的,而是完全由协程之间互相协同调度,一个协程主动让出,下一个协程才能运行,也就是在用户态执行。这样带来的好处是性能大幅度的提升。

一个线程也可以包含多个协程。一个线程内可以有多个这样的特殊函数在运行,但是有一点必须明确的是,多个线程或者多个进程可以并行,但是一个线程内的多个协程绝对是串行的,因为它仍然是一个函数。

线程切换过程是由“用户态到内核态到用户态”, 而协程的切换过程只有用户态,即没有陷入内核态,因此切换效率高。

协程本质上是异步非阻塞技术,它是将事件回调进行了包装,让程序员看不到里面的事件循环。

协程的Golang实现:Goroutine & Scheduler

每一个 OS 线程都有一个固定大小的(一般会是 2MB)栈内存空间,这个栈会用来存储当前正在被调用或挂起(指在调用其它函数时)的函数的内部变量。这个固定大小的栈同时很大又很小。因为 2MB 的栈对于一个小小的 goroutine 来说是很大的内存浪费,而对于一些复杂的任务(如深度嵌套的递归)来说又显得太小。

因此,Go 语言做了它自己的『线程』。 在 Go 语言中,每一个 goroutine 是一个独立的执行单元,相较于每个 OS 线程固定分配 2M 内存的模式,goroutine 的栈采取了动态扩容方式, 初始时仅为 2KB,随着任务执行按需增长,最大可达 1GB(64 位机器最大是 1G,32 位机器最大是 256M),且完全由 golang 自己的调度器 Go Scheduler 来调度。此外,GC 还会周期性地进行内存回收,收缩栈空间。

因此,Go 程序可以同时并发成千上万个 goroutine 是得益于它强劲的调度器和高效的内存模型。golang 中的许多标准库的实现也都能见到 goroutine 的身影,比如 net/http 这个包,甚至语言本身的组件runtime和GC垃圾回收器都是运行在 goroutine 上的,作者对 goroutine 的厚望可见一斑。

G-P-M模型概述

G: 表示 Goroutine,每个Goroutine 对应一个G结构体,G存储Goroutine的运行堆栈、状态以及任务函数,可重用。G 并非执行体,每个 G 需要绑定到 P 才能被调度执行。

P: Processor,表示逻辑处理器, 对 G来说,P相当于CPU 核,G 只有绑定到 P(在P的 local runq 中)才能被调度。对 M 来说,P 提供了相关的执行环境(Context),如内存分配状态(mcache),任务队列(G)等,P 的数量决定了系统内最大可并行的 G 的数量(前提:物理 CPU 核数 >= P 的数量),P的数量由用户设置的GOMAXPROCS 决定,但是不论 GOMAXPROCS 设置为多大,P 的数量最大为 256。

M: Machine,OS线程抽象,一个M对应一个内核线程,代表着真正执行计算的资源,在绑定有效的 P 后,进入 schedule 循环;而 schedule 循环的机制大致是从 Global 队列、P 的 Local 队列以及 wait 队列中获取 G,切换到 G 的执行栈上并执行 G 的函数,调用 goexit 做清理工作并回到 M,如此反复。M 并不保留 G 状态,这是 G 可以跨 M 调度的基础,M 的数量是不定的,由 Go Runtime调整,为了防止创建过多 OS 线程导致系统调度不过来,目前默认最大限制为 10000 个。

在宏观上说,Goroutine 与 Machine 因为 Processor 的存在,形成了多对多(M:N)的关系。

G-P-M调度机制

GMP-scheduler

Go 调度器工作时会维护两种用来保存 G 的任务队列:一种是一个 Global 任务队列,一种是每个 P 维护的 Local 任务队列。

当通过 go关键字创建一个新的 goroutine 的时候,它会优先被放入 P 的本地队列。为了运行 goroutine,M 需要持有(绑定)一个 P,接着 M 会启动一个 OS 线程,循环从 P 的本地队列里取出一个 goroutine 并执行。

还有 work-stealing 调度算法:当 M 执行完了当前 P 的 Local 队列里的所有 G 后,P 也不会就啥都不干,它会先尝试从 Global 队列寻找 G 来执行,如果 Global 队列为空,它会随机挑选另外一个 P,从它的队列里中拿走一半的 G 到自己的队列中执行。

用户态阻塞/唤醒

当 goroutine 因为 channel 操作或者 network I/O 而阻塞时(实际上 golang 已经用 netpoller 实现了 goroutine 网络 I/O 阻塞不会导致 M 被阻塞,仅阻塞 G,这里仅仅是举个栗子),对应的 G 会被放置到某个 wait 队列(如 channel 的 waitq),该 G 的状态由 _Gruning 变为 _Gwaitting ,而 M 会跳过该 G 尝试获取并执行下一个 G,如果此时没有 runnable 的 G 供 M 运行,那么 M 将解绑 P,并进入 sleep 状态;当阻塞的 G 被另一端的 G2 唤醒时(比如 channel 的可读/写通知),G 被标记为 runnable,尝试加入 G2 所在 P 的 runnext,然后再是 P的Local 队列和 Global 队列。

系统调用阻塞

当 G 被阻塞在某个系统调用上时,此时 G 会阻塞在 _Gsyscall 状态,M 也处于 block on syscall 状态,此时的 M 可被抢占调度:执行该 G 的 M 会与 P 解绑,而 P 则尝试与其它 idle 的 M 绑定,继续执行其它 G。如果没有其它 idle 的 M,但 P 的 Local 队列中仍然有 G 需要执行,则创建一个新的 M;当系统调用完成后,G 会重新尝试获取一个 idle 的 P 进入它的 Local 队列恢复执行,如果没有 idle 的 P,G 会被标记为 runnable 加入到 Global 队列。

Goroutine会出现什么问题?

即便每个 goroutine 只分配 2KB 的内存,但如果聚少成多,内存暴涨,就会对 GC 造成极大的负担, JVM GC 的 STW(Stop The World)机制,也就是 GC 的时候会挂起用户程序直到垃圾回收完,虽然 Go1.8 之后的 GC 已经去掉了 STW 以及优化成了并行 GC,性能上有了不小的提升,但是,如果太过于频繁地进行 GC,依然会有性能瓶颈;

runtime 和 GC 也都是 goroutine ,如果 goroutine 规模太大,内存吃紧,runtime 调度和垃圾回收同样会出问题,虽然 G-P-M 模型足够优秀,但是没有内存,Go 调度器就会阻塞 goroutine,结果就是 P 的 Local 队列积压,又导致内存溢出,这就是个死循环…,甚至极有可能程序直接 Crash 掉。


进程,线程和协程详解
http://example.com/post/进程,线程,协程.html
作者
SamuelZhou
发布于
2022年10月20日
许可协议