13-语句执行规则

Don't communicate by sharing memory; share memory by communicating.

不要通过共享数据来通讯,以通讯的方式来共享数据。

channel类型的值,被用来以通讯的方式共享数据。一般被用来在不同的goroutine(代表并发编程模型中的用户级线程)之间传递数据。

0.1. 进程与线程

  • 进程:【资源分配单位】描述的就是程序的执行过程,是运行着的程序的代表,一个进程其实就是某个程序运行的一个产物。(静静躺在那里的代码是程序、奔跑着、正在发挥功能的代码就是进程)。

  • 线程:【CPU调度单位】总是在进程之内,被视为进程中运行着的控制流(代码执行流程)。

每个进程中的内容每个线程中的内容
地址空间程序计数器
全局变量寄存器
文件句柄
子进程状态
即将发送的定时器
信号与信号处理程序
账户信息
  • 一个进程至少包含一个线程,如果只包含一个线程,那么所有代码会被串行执行。每个进程的第一个线程都会随着该进程的启动而被创建,称它为所属进程的主线程
  • 如果一个进程包含多个线程,那么代码可以被并发(单个CPU)地执行,除了主线程,其他线程都是进程中已存在的线程创建出来的主线程之外的其他线程,只能由代码显式地创建和销毁,各个线程之间可以共享地址空间和文件等资源。但是,当进程中的一个线程奔溃时,会导致其所属进程的所有线程奔溃。

0.1.1. 进程

0.1.1.1. 进程的状态

  • 运行状态(Runing):该时刻进程占用 CPU;
  • 就绪状态(Ready):可运行,但因为其他进程正在运行而暂停停止;
  • 阻塞状态(Blocked):该进程正在等待某一事件发生(如等待输入/输出操作的完成)而暂时停止运行,这时,即使给它CPU控制权,它也无法运行;
  • 创建状态(new):进程正在被创建时的状态;
  • 结束状态(Exit):进程正在从系统中消失时的状态;

image

  • NULL -> 创建状态:一个新进程被创建时的第一个状态;
  • 创建状态 -> 就绪状态:当进程被创建完成并初始化后,一切就绪准备运行时,变为就绪状态,这个过程是很快的;
  • 就绪态 -> 运行状态:处于就绪状态的进程被操作系统的进程调度器选中后,就分配给 CPU 正式运行该进程;
  • 运行状态 -> 结束状态:当进程已经运行完成或出错时,会被操作系统作结束状态处理;
  • 运行状态 -> 就绪状态:处于运行状态的进程在运行过程中,由于分配给它的运行时间片用完,操作系统会把该进程变为就绪态,接着从就绪态选中另外一个进程运行;
  • 运行状态 -> 阻塞状态:当进程请求某个事件且必须等待时,例如请求 I/O 事件;
  • 阻塞状态 -> 就绪状态:当进程要等待的事件完成时,它从阻塞状态变到就绪状态;

特殊状态:挂起,表示进程没有占有物理内存空间。

由于虚拟内存管理原因,进程的所使用的空间可能并没有映射到物理内存,而是在硬盘上,这时进程就会出现挂起状态,另外调用 sleep 也会被挂起。

挂起状态可以分为两种:

  • 阻塞挂起状态:进程在外存(硬盘)并等待某个事件的出现;
  • 就绪挂起状态:进程在外存(硬盘),但只要进入内存,即刻立刻运行;

image

0.1.1.2. 进程的控制结构

在操作系统中,是用进程控制块(process control block,PCB)数据结构来描述进程的。

PCB 是进程存在的唯一标识,这意味着一个进程的存在,必然会有一个 PCB,如果进程消失了,那么 PCB 也会随之消失。

PCB包含的内容:

  • 进程描述信息:
    • 进程标识符:标识各个进程,每个进程都有一个并且唯一的标识符
    • 用户标识符:进程归属的用户,用户标识符主要为共享和保护服务
  • 进程控制和管理信息:
    • 进程当前状态,如 new、ready、running、waiting 或 blocked 等
    • 进程优先级:进程抢占 CPU 时的优先级
  • 资源分配清单:有关内存地址空间或虚拟地址空间的信息,所打开文件的列表和所使用的 I/O 设备信息
  • CPU 相关信息:CPU 中各个寄存器的值,当进程被切换时,CPU 的状态信息都会被保存在相应的 PCB 中,以便进程重新执行时,能从断点处继续执行

PCB通常是通过链表的方式进行组织,把具有相同状态的进程链在一起,组成各种队列。比如:

  • 将所有处于就绪状态的进程链在一起,称为就绪队列
  • 把所有因等待某事件而处于等待状态的进程链在一起就组成各种阻塞队列
  • 对于运行队列在单核 CPU 系统中则只有一个运行指针了,因为单核 CPU 在某个时间,只能运行一个程序

0.1.1.3. 进程的上下文切换

各个进程之间是共享 CPU 资源的,在不同的时候进程之间需要切换,让不同的进程可以在 CPU 执行,那么这个一个进程切换到另一个进程运行,称为进程的上下文切换。

CPU 寄存器和程序计数是 CPU 在运行任何任务前,所必须依赖的环境,这些环境就叫做 CPU 上下文

CPU 上下文切换就是先把前一个任务的 CPU 上下文(CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。

系统内核会存储保持下来的上下文信息,当此任务再次被分配给 CPU 运行时,CPU 会重新加载这些上下文,这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。

上面说到所谓的「任务」,主要包含进程、线程和中断。所以,可以根据任务的不同,把 CPU 上下文切换分成:进程上下文切换、线程上下文切换和中断上下文切换。

进程是由内核管理和调度的,所以进程的切换只能发生在内核态。

所以,进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。

0.1.1.4. 发生进程上下文切换的场景

  • 为了保证所有进程可以得到公平调度,CPU 时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程。这样,当某个进程的时间片耗尽了,就会被系统挂起,切换到其它正在等待 CPU 的进程运行;
  • 进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行;
  • 当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度;
  • 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行;
  • 发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序;

进程间通信

由于多进程地址空间不同,数据不能共享,一个进程内创建的变量在另一个进程是无法访问。于是操作系统提供了各种系统调用,搭建起各个进程间通信的桥梁,这些方法统称为进程间通信 IPC (IPC InterProcess Communication)

常见进程间通信方式:

  • 匿名管道 Pipe:实质是一个内核缓冲区,进程以先进先出 FIFO 的方式从缓冲区存取数据。是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系(父子进程间)的进程间通信。
  • 命名管道 FIFO:提供了一个路径名与之关联,以文件形式存在于文件系统中,这样即使不存在亲缘关系的进程,只要可以访问该路径也能相互通信,支持同一台计算机的不同进程之间,可靠的、单向或双向的数据通信。
  • 信号Signal:用于进程间互相通信或者操作的一种机制,可以在任何时候发给某一进程,无需知道该进程的状态。如果该进程当前不是执行态,内核会暂时保存信号,当进程恢复执行后传递给它。如果一个信号被进程设置为阻塞,则该信号的传递被延迟,直到其阻塞被取消时才被传递给进程。信号在用户空间进程和内核之间直接交互,内核可以利用信号来通知用户空间的进程发生了哪些系统事件,信号事件主要有两个来源:
    • 硬件来源:用户按键输入Ctrl+C退出、硬件异常如无效的存储访问等。
    • 软件终止:终止进程信号、其他进程调用 kill 函数、软件异常产生信号。
  • 消息队列 Message Queue:消息队列是存放在内核中的消息链表,每个消息队列由消息队列标识符表示,只有在内核重启或主动删除时,该消息队列才会被删除。
  • 共享内存 Shared memory:一个进程把地址空间的一段,映射到能被其他进程所访问的内存,一个进程创建、多个进程可访问,进程就可以直接读写这一块内存而不需要进行数据的拷贝,从而大大提高效率。(是最快的可用 IPC 形式,是针对其他通信机制运行效率较低而设计的)。
  • 套接字 Socket:( TCP/IP 协议栈,也是建立在 socket 通信之上),它是一种通信机制,凭借这种机制,既可以在本机进程间通信,也可以跨网络通过,因为,套接字通过网络接口将数据发送到本机的不同进程或远程计算机的进程。

0.1.2. 线程

0.1.2.1. 线程分类

  • 系统级线程(Kernal Thread):在Go语言的运行时中,系统会帮助我们自动地创建和销毁系统级的线程(操作系统提供的线程)。
  • 用户级线程(User Thread):架设在系统级线程之上,由用户(我们编写的程序)完全控制的代码执行流程,用户级线程的创建、销毁、调度、状态变更以及其中的代码和数据都完全需要程序自己去实现和处理。
    • 优势:用户级线程的创建和销毁不通过操作系统,速度快,不用等待操作系统去调度它们运行,所以容易控制且灵活。
    • 劣势:复杂,如果只是用系统级线程,那么只要指明需要新线程执行的代码片段,并且下达创建或销毁线程的指令就好,其他的具体实现由操作系统代劳。我们必须全权负责与用户级线程相关的所有具体的实现,并且需要和操作系统争取对接,否则可能无法正确运行。
  • 轻量级进程(Lightweight Process):在内核中来支持用户线程

用户线程和内核线程的对应关系:

  • 多个用户线程对应同一个内核线程
  • 一个用户线程对应一个内核线程
  • 多个用户线程对应到多个内核线程
0.1.2.1.1. 用户线程

用户线程是基于用户态的线程管理库来实现的,那么线程控制块(Thread Control Block, TCB) 也是在库里面来实现的,对于操作系统而言是看不到这个 TCB 的,它只能看到整个进程的 PCB。

所以,用户线程的整个线程管理和调度,操作系统是不直接参与的,而是由用户级线程库函数来完成线程的管理,包括线程的创建、终止、同步和调度等

用户线程的优点:

  • 每个进程都需要有它私有的线程控制块(TCB)列表,用来跟踪记录它各个线程状态信息(PC、栈指针、寄存器),TCB 由用户级线程库函数来维护,可用于不支持线程技术的操作系统;
  • 用户线程的切换也是由线程库函数来完成的,无需用户态与内核态的切换,所以速度特别快;

用户线程的缺点:

  • 由于操作系统不参与线程的调度,如果一个线程发起了系统调用而阻塞,那进程所包含的用户线程都不能执行了。
  • 当一个线程开始运行后,除非它主动地交出 CPU 的使用权,否则它所在的进程当中的其他线程无法运行,因为用户态的线程没法打断当前运行中的线程,它没有这个特权,只有操作系统才有,但是用户线程不是由操作系统管理的。
  • 由于时间片分配给进程,故与其他进程比,在多线程执行时,每个线程得到的时间片较少,执行会比较慢;
0.1.2.1.2. 内核线程

内核线程是由操作系统管理的,线程对应的 TCB 自然是放在操作系统里的,这样线程的创建、终止和管理都是由操作系统负责

内核线程的优点:

  • 在一个进程当中,如果某个内核线程发起系统调用而被阻塞,并不会影响其他内核线程的运行;
  • 分配给线程,多线程的进程获得更多的 CPU 运行时间;

内核线程的缺点:

  • 在支持内核线程的操作系统中,由内核来维护进程和线程的上下问信息,如 PCB 和 TCB;
  • 线程的创建、终止和切换都是通过系统调用的方式来进行,因此对于系统来说,系统开销比较大;
0.1.2.1.3. 轻量级进程

轻量级进程(Light-weight process,LWP)是内核支持的用户线程,一个进程可有一个或多个 LWP,每个 LWP 是跟内核线程一对一映射的,也就是 LWP 都是由一个内核线程支持。

image

  • 1 : 1 模式: 一个线程对应到一个 LWP 再对应到一个内核线程,如上图的进程 4,属于此模型。
    • 优点:实现并行,当一个 LWP 阻塞,不会影响其他 LWP;
    • 缺点:每一个用户线程,就产生一个内核线程,创建线程的开销较大。
  • N : 1 模式:多个用户线程对应一个 LWP 再对应一个内核线程,如上图的进程 2,线程管理是在用户空间完成的,此模式中用户的线程对操作系统不可见。
    • 优点:用户线程要开几个都没问题,且上下文切换发生用户空间,切换的效率较高;
    • 缺点:一个用户线程如果阻塞了,则整个进程都将会阻塞,另外在多核 CPU 中,是没办法充分利用 CPU 的。
  • M : N 模式:根据前面的两个模型混搭一起,就形成 M:N 模型,该模型提供了两级控制,首先多个用户线程对应到多个 LWP,LWP 再一一对应到内核线程,如上图的进程 3。
    • 优点:综合了前两种优点,大部分的线程上下文发生在用户空间,且多个线程又可以充分利用多核 CPU 的资源。
  • 组合模式:如上图的进程 5,此进程结合 1:1 模型和 M:N 模型。可以针对不同的应用特点调节内核线程的数目来达到物理并行性和逻辑并行性的最佳方案。

0.1.2.2. 线程与进程的比较

  • 进程是资源(包括内存、打开的文件等)分配的单位,线程是 CPU 调度的单位;
  • 进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈;
  • 线程同样具有就绪、阻塞、执行三种基本状态,同样具有状态之间的转换关系;
  • 线程能减少并发执行的时间和空间开销;

对于,线程相比进程能减少开销,体现在:

  • 线程的创建时间比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们;
  • 线程的终止时间比进程快,因为线程释放的资源相比进程少很多;
  • 同一个进程内的线程切换比进程切换快,因为线程具有相同的地址空间(虚拟内存共享),这意味着同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表。而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的;
  • 由于同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,这就使得线程之间的数据交互效率更高了;

所以,线程比进程不管是时间效率,还是空间效率都要高。

所谓操作系统的任务调度,实际上的调度对象是线程,而进程只是给线程提供了虚拟内存、全局变量等资源。

对于线程和进程,我们可以这么理解:

  • 当进程只有一个线程时,可以认为进程就等于线程;
  • 当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源,这些资源在上下文切换时是不需要修改的;

另外,线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要保存的。

0.1.3. 硬件设备访问速度的差异

CPU、内存和I/O设备之间速度差异:

访问时间设备类型容量
1ns寄存器1KB
2ns高速缓存4MB
10ns主存16GB
10ms磁盘4TB

解决方式:

  1. CPU 使用缓存来中和和内存的访问速度差异
  2. 操作系统提供进程和线程调度,让 CPU 在执行指令的同时分时复用线程,让内存和磁盘不断交互,不同的 CPU 时间片能够执行不同的任务,从而均衡这三者的差异
  3. 编译程序提供优化指令的执行顺序,让缓存能够合理的使用

0.1.4. 并行与并发

  • 并发:CPU分时执行多个任务(指令),切换任务前把没完成的当前任务的状态暂存起来
  • 并行:多个CPU同时处理多个任务(指令)

合理使用线程是一门艺术,合理编写一道准确无误的多线程程序更是一门艺术。

0.1.5. 多线程安全问题

在没有采用同步机制的情况下,多个线程中的执行操作往往是不可预测的。

0.1.5.1. 可见性问题

  1. 单核时代,所有的线程共用一个 CPU,CPU 缓存和内存的一致性问题容易解决。
  2. 多核时代,每个核都独立的运行一个线程,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。

0.1.5.2. 原子性问题

如果对一个变量的操作分为多个步骤,多线程执行的过程中,无论是并发还是并行,由于可见性问题,线程单独操作变量,并将结果写回内存,将会导致变量结果与预期不一致。

因此在多线程中,需要保证对变量的操作是原子性的,即这个操作是一个原子操作(要么全部执行,要么全部不执行)。

原子性操作是完全独立于任何其他进程运行的操作,原子操作多用于现代操作系统和并行处理系统中。原子操作通常在内核中使用,因为内核是操作系统的主要组件。但是,大多数计算机硬件,编译器和库也提供原子性操作。

0.1.5.3. 有序性问题

编译器有时候确实是 「好心办坏事」,它为了优化系统性能,往往更换指令的执行顺序。

0.1.5.4. 活跃性问题

死锁:每个线程都在等待其他线程释放资源,而其他资源也在等待每个线程释放资源,这样没有线程抢先释放自己的资源,产生死锁无线等待下去。

四个必要条件,破坏一个就不会死锁:

  • 互斥:资源排他性使用
  • 请求和保持:已持有资源的线程请求新资源(新资源处于占用状态),请求阻塞
  • 不剥夺:已占用的资源只能由占用者释放
  • 循环等待

活锁:两个并行线程尝试获取另一个锁失败后,释放自己持有的锁,过程一直重复,虽然没有线程阻塞,但任务没有向下执行。

0.1.6. 多线程安全问题解决方案

当多个线程共享资源,即同时对一共享数据进行修改,从而影响程序运行的正确性时,就称为竞态条件。

线程安全的核心是对状态访问操作进行管理,只要共享变量或变量状态可变就会出现问题。

  1. 采用同步机制
  2. 不在多线程中共享变量且将变量设置为不可变

0.1.6.1. 同步机制

  1. 解决原子性:互斥锁、读写锁、自旋锁、条件变量、信号量
  2. 解决可见性:volatile:

0.1.7. 多线程性能问题

在并发场景下,线程切换(上下文切换)这个操作开销很大,把大量的时间消耗在线程切换上而不是线程运行上。

线程上下文切换时,要看它们是不是属于同一个进程:

  • 当两个线程不是属于同一个进程,则切换的过程就跟进程上下文切换一样;
  • 当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据;

context:上下文切换的资源,寄存器的状态,程序计数器,栈等。

切换过程包括:

  1. 暂停当前线程
  2. 保存当前状态
  3. 选择合适线程
  4. 加载新的状态
  5. 执行线程代码

引起切换的原因:

  1. 当前正在执行的任务完成,系统的 CPU 正常调度下一个需要运行的线程
  2. 当前正在执行的任务遇到 I/O 等阻塞操作,线程调度器挂起此任务,继续调度下一个任务
  3. 多个任务并发抢占锁资源,当前任务没有获得锁资源,被线程调度器挂起,继续调度下一个任务
  4. 用户的代码挂起当前任务,比如线程执行 sleep() 方法,让出CPU
  5. 使用硬件中断的方式引起上下文切换

0.2. 调度

0.2.1. 调度时机

  • 就绪态 -> 运行态:当进程被创建时,会进入到就绪队列,操作系统会从就绪队列选择一个进程运行;
  • 运行态 -> 阻塞态:当进程发生 I/O 事件而阻塞时,操作系统必须另外一个进程运行;
  • 运行态 -> 结束态:当进程退出结束后,操作系统得从就绪队列选择另外一个进程运行;

0.2.2. 调度原则

  • CPU 利用率:调度程序应确保 CPU 是始终匆忙的状态,这可提高 CPU 的利用率;
  • 系统吞吐量:吞吐量表示的是单位时间内 CPU 完成进程的数量,长作业的进程会占用较长的 CPU 资源,因此会降低吞吐量,相反,短作业的进程会提升系统吞吐量;
  • 周转时间:周转时间是进程运行和阻塞时间总和,一个进程的周转时间越小越好;
  • 等待时间:这个等待时间不是阻塞状态的时间,而是进程处于就绪队列的时间,等待的时间越长,用户越不满意;
  • 响应时间:用户提交请求到系统第一次产生响应所花费的时间,在交互式系统中,响应时间是衡量调度算法好坏的主要标准。

0.2.3. 调度算法

  • 抢占式
  • 非抢占式

0.2.3.1. 单核系统

  • 先来先服务调度算法
  • 最短作业优先调度算法
  • 高响应比优先调度算法
  • 时间片轮转调度算法
  • 最高优先级调度算法
  • 多级反馈队列调度算法

0.3. 并发模型

并发模型其实和分布式系统模型非常相似,在并发模型中是线程彼此进行通信,而在分布式系统模型中是 进程 彼此进行通信。然而本质上,进程和线程也非常相似。

  • 分布式系统通常要比并发系统面临更多的挑战和问题比如进程通信、网络可能出现异常,或者远程机器挂掉等
  • 并发模型同样面临着比如 CPU 故障、网卡出现问题、硬盘出现问题等

0.3.1. 并行worker

image

这些共享状态可能会使用一些工作队列来保存业务数据、数据缓存、数据库的连接池等。

在线程通信中,线程需要确保共享状态是否能够让其他线程共享,而不是仅仅停留在 CPU 缓存中让自己可用,当然这些都是程序员在设计时就需要考虑的问题。

多线程在访问共享数据时,会丢失并发性,因为操作系统要保证只有一个线程能够访问数据,这会导致共享数据的争用和抢占。未抢占到资源的线程会阻塞

0.3.2. 流水线(事件驱动系统)

image

0.3.2.1. Actor模型

在 Actor 模型中,每一个 Actor 其实就是一个 Worker, 每一个 Actor 都能够处理任务。

Actor 模型是一个并发模型,它定义了一系列系统组件应该如何动作和交互的通用规则。一个参与者Actor对接收到的消息做出响应,然后可以创建出更多的 Actor 或发送更多的消息,同时准备接收下一条消息。

image

Actor 模型重在参与交流的实体(即进程),而 CSP 重在交流的通道,如 Go 中的 channel。

0.3.2.2. Channels模型

也叫CSP(Communicating sequential processes)。

在 Channel 模型中,worker 通常不会直接通信,与此相对的,他们通常将事件发送到不同的 通道(Channel)上,然后其他 worker 可以在这些通道上获取消息。

image

有的时候 worker 不需要明确知道接下来的 worker 是谁,他们只需要将作者写入通道中,监听 Channel 的 worker 可以订阅或者取消订阅,这种方式降低了 worker 和 worker 之间的耦合性。

与 Actor 相比,CSP 最大的优点是灵活性。Actor 模型,负责通信的媒介和执行单元是耦合的。而 CSP 中,channel 是第一类对象,可以被独立创造、写入、读出数据,也可以在不同执行单元中传递。

CSP 模型也易受死锁影响,且没有提供直接的并行支持。并行需要建立在并发基础上,引入了不确定性。

CSP 模型不关注发送消息的进程,而是关注发送消息时使用的 channel,而 channel 不像 Actor 模型那样进程与队列紧耦合。而是可以单独创建和读写,并在进程 (goroutine) 之间传递。

0.4. Golang Runtime

调度器

Go语言拥有:

  • 独特的并发编程模型
  • 用户级线程goroutine
  • 强大的用于调度goroutine、对接操作系统的调度器

这个调度器是Go语言运行时系统的重要组成部分,主要负责统筹调配Go并发编程模型中的三个主要元素:

  • G(goroutine):用户级线程
  • P(processor):可以承载若干个goroutine,且能够使这些goroutine适时地与系统级线程对接,并得到真正运行的中介
  • M(machine):系统级线程

宏观上讲,由于P的存在,用户级线程和系统级线程可以呈现多对多的关系。

例如:

  • 当一个正在与某个系统级线程对接并运行的用户级线程,需要因某个事件(等待IO或锁的解除)而暂停运行的时候,调度器总会及时发现,并把这个用户级线程和系统级线程分离,以释放计算资源供其他等待运行的用户级线程使用。
  • 当一个用户级线程需要恢复运行的时候,调度器又会尽快为它寻找空闲的计算资源(包括系统级线程)并安排运行。
  • 当系统级线程不够用的时候,调度器会帮我们向操作系统申请新的系统级线程。
  • 当某一个系统级线程已经无用时,调度器会负责把它及时销毁掉。

因为调度器帮我们做了很多事,所以Go程序才能高效地利用操作系统和计算机资源。程序中所有的用户级线程都会被充分地调度,其中的代码也都会并发地运行,即使用户级线程有数十万计。

image

image

  • 全局队列(Global Queue):存放等待运行的G。
  • P的本地队列:同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。新建G'时,G'优先加入到P的本地队列,如果队列满了,则会把本地队列中一半的G移动到全局队列。
  • P列表:所有的P都在程序启动时创建,并保存在数组中,最多有GOMAXPROCS(可配置)个。
  • M:线程想运行任务就得获取P,从P的本地队列获取G,P队列为空时,M也会尝试从全局队列拿一批G放到P的本地队列,或从其他P的本地队列偷一半放到自己P的本地队列。M运行G,G执行之后,M会从P获取下一个G,不断重复下去。

P和M的数量

  • P的数量:由启动时环境变量$GOMAXPROCS或者是由runtime的方法GOMAXPROCS()决定。这意味着在程序执行的任意时刻都只有$GOMAXPROCS个goroutine在同时运行。
  • M的数量:
    • (1) go语言本身的限制:go程序启动时,会设置M的最大数量,默认10000.但是内核很难支持这么多的线程数,所以这个限制可以忽略
    • (2)runtime/debug中的SetMaxThreads函数,设置M的最大数量
    • (3)一个M阻塞了,会创建新的M

并发模型

Go 是采用 CSP 的思想的,channel 是 go 在并发编程通信的推荐手段,Go 语言推荐使用通信来进行进程间同步消息。这样做有三点好处:

  • 首先,使用发送消息来同步信息相比于直接使用共享内存和互斥锁是一种更高级的抽象,使用更高级的抽象能够为我们在程序设计上提供更好的封装,让程序的逻辑更加清晰;

  • 其次,消息发送在解耦方面与共享内存相比也有一定优势,我们可以将线程的职责分成生产者和消费者,并通过消息传递的方式将它们解耦,不需要再依赖共享内存;

  • 最后,Go 语言选择消息发送的方式,通过保证同一时间只有一个活跃的线程能够访问数据,能够从设计上天然地避免线程竞争和数据冲突的问题;

并发控制

  • sync.WaitGroup:某任务需要多 goroutine 协同工作,每个 goroutine 只能做该任务的一部分,只有全部的 goroutine 都完成,任务才算是完成
  • channel+select:比较优雅的通知一个 goroutine 结束;多groutine中数据传递
  • context:多层级groutine之间的信号传播(包括元数据传播,取消信号传播、超时控制等),优雅的解决了 goroutine 启动后不可控的问题

0.5. goroutine最佳实践

package main

import "fmt"

func main() {
    for i := 0; i < 10; i++ {
        go func() {
            fmt.Println(i)
        }()
    }
}

代码执行后,不会有任何内容输出。

与一个进程总会有一个主线程类似,每一个独立的Go程序运行起来总会有一个主用户线程(goroutine)。这个主goroutine会在Go程序的运行准备工作完成后被自动地启用,并不需要任何手动操作。

每条go语句一般都会携带一个函数调用,这个被调用的函数被称为go函数,主用户线程(goroutine)的go函数,就是那个程序入口的main函数。

0.5.1. 已经存在的用户级线程会被优先复用

go函数被真正执行的时间,总会与其所属的go语句被执行的时间不同。当程序执行到一条go语句,Go语言运行时系统,会先试图从某个存放空闲的用户级线程的队列中获取某个用户级线程,它只有找不到空闲的用户级线程的情况们才会去创建一个新的用户级线程。

0.5.2. 用户级线程的创建成本很低

创建一个新的用户级线程并不会像创建一个进程或者一个系统级线程那样,必须通过操作系统的系统调用来完成,在Go语言的运行时系统内部就可以完成了,一个用户级线程就相当于需要并发执行代码片段的上下文环境。

在拿到空闲的用户级线程之后,Go语言运行时系统会用这个用户级线程去包装那个go函数(函数中的代码),然后再把这个用户级线程追加到某个可运行的用户级线程队列(先进先出)中。虽然在队列中被安排运行的时间很快,上述的准备工作也不可避免,因此存在一定时间消耗。所以go函数的执行时间,总是会明显滞后(相对于CPU和Go程序)于go语句的执行时间

只要go语句本身执行完毕,Go程序完全不用等待go函数的执行,它会立刻去执行后面的语句,这就是异步并发执行

注意:一旦主用户级线程(main函数中的那些代码)执行完毕,当前的Go程序就会结束运行。如果在Go程序结束的那一刻,还有用户级线程没有运行,那就没有机会运行了。

严格的说,Go语言并不会保证用户级线程会以怎样的顺序运行,因为主用户级线程会与手动启动的其他用户级线程一起接受调度,又因为调度器很可能会在用户级线程中的代码只执行了一部分的时候暂停,以期所有的用户级线程有更公平的运行机会。所以哪个用户级线程先执行完,是不可预知的,除非使用了某种Go语言提供的方式进行人为干预。

0.6. 主用户级线程等待其他用户级线程

  1. 让主用户级线程Sleep()一会:但是时间难以把握
  2. 其他用户级线程运行完毕之后发出通知:创建一个通道,长度与手动启动的用户级线程一致,每个用户级线程运行完毕的时候向通道中发送一个值(在go函数的最后发送),在main函数的最后接收通道中的值,接收次数与手动启动的用户级线程数量一直
  3. sync包中的sync.WaitGroup类型
sign := make(chan struct{}, num)        // 结构体类型的通道

sign <- struct{}{}

<- sign

struct{}类似于空接口interface{},代表既不包含任何字段也不拥有任何方法的空结构体类型。

struct{}类型的值的表示方法只有一个:struct{}{},它占用的内存空间是0字节。这个值在整个Go程序中永远都只会存在一份。无数次的试用这个值的字面量,但是用到的却是同一个值。

0.7. 用户级线程按顺序执行

package main

import (
            "fmt"
            "sync/atomic"
            "time"
        )

func main() {
    // 用户级线程随机执行
    for i := 0; i < 10; i++ {
        go func() {
            fmt.Println(i)
        }()
    }

    // 用户级线程按顺序执行
    for i := 0; i < 10; i++ {
        go func(i int) {            // 让go函数接收一个int型参数,在调用它的时候,把变量传进去
            fmt.Println(i)          // 这样Go语言包装每个用户级线程都可以拿到一个唯一的整数
        }(i)
    }

    // 在go语句被执行时,传给go函数的参数`i`会被先求值,如此就得到了当次迭代的序号,
    // 之后,无论go函数会在什么时候执行,这个参数值都不会变,
    // 也就是go函数中调用`fmt.Prinrln`函数打印的一定是那个当次迭代的序号。

    var count uint32

    for i := uint32(0); i < 10; i++ {
        go func(i uint32) {
            fn := func() {
                fmt.Println(i)
            }
            trigger(i, fn)
        }(i)
    }

    trigger := func(i uint32, fn func()) {
        for {
            if n := atomic.LoadUint32(&count); n == i {     // 原子操作
                fn()
                atomic.AddUint32(&count, 1)                 // 原子操作
                break
            }
            time.Sleep(time.Nanosecond)
        }
    }

    trigger(10, func(){})
}

0.8. 系统调用

Go 会优化系统调用(无论阻塞与否),通过运行时封装它们。封装的那一层会把 P 和线程 M 分离,并且可以让另一个用户线程在它上面运行。下面以文件读取举例:

func main() {
   buf := make([]byte, 0, 2)

   fd, _ := os.Open("number.txt")
   fd.Read(buf)
   fd.Close()

   println(string(buf)) // 42
}

文件读取的流程如下:

image

P0 现在在空闲 list 中,有可能被唤醒。当系统调用 exit 时,Go 会遵守下面的规则,直到有一个命中了。

  • 尝试去捕获相同的 P,在我们的例子中就是 P0,然后 resume 执行过程
  • 尝试从空闲 list 中捕获一个 P,然后 resume 执行过程
  • 把 G 放到全局队列里,把与之相关联的 M 放回空闲 list 去

然而,在像 http 请求等 non-blocking I/O 情形下,Go 在资源没有准备好时也会处理请求。在这种情形下,第一个系统调用 — 遵循上述流程图 — 由于资源还没有准备好所以不会成功,(这样就)迫使 Go 使用 network poller 并使协程停驻,如下示例。

func main() {
   http.Get(`https://httpstat.us/200`)
}

当第一个系统调用完成且显式地声明了资源还没有准备好,G 会在 network poller 通知它资源准备就绪之前一直处于停驻状态。在这种情形下,线程 M 不会阻塞:

image

在 Go 调度器在等待信息时 G 会再次运行。调度器在获取到等待的信息后会询问 network poller 是否有 G 在等待被运行。

image

如果多个协程都准备好了,只有一个会被运行,其他的会被加到全局的可运行队列中,以备后续的调度。

0.9. 系统线程方面的限制

在系统调用中,Go 不会限制可阻塞的 OS 线程数。

GOMAXPROCS 变量表示可同时运行用户级线程的操作系统线程的最大数量。系统调用中可被阻塞的最大线程数并没有限制;可被阻塞的线程数对 GOMAXPROCS 没有影响。这个包的 GOMAXPROCS 函数查询和修改这个最大数限制。

如下示例:

func main() {
   var wg sync.WaitGroup

   for i := 0;i < 100 ;i++  {
      wg.Add(1)

      go func() {
         http.Get(`https://httpstat.us/200?sleep=10000`)

         wg.Done()
      }()
   }

   wg.Wait()
}

利用追踪工具得到的线程数如下:

image

由于 Go 优化了系统线程使用,所以当 G 阻塞时,它仍可复用,这就解释了为什么图中的数跟示例代码循环中的数不一致。

上次修改: 25 November 2019