并发和并行
并发性和并行性是两个概念。并发指在同一时刻只能有一条指令执行,但多个进程指令被快速轮换执行,使得在宏观上具有多个进程同时执行的效果;并行指在同一时刻,有多条指令在多个处理器上同时执行。
- 并发(Concurrency)
- 定义:并发是指系统能够处理多个任务同时存在的能力。在并发环境中,多个任务看起来是同时执行的,但实际上,可能是通过任务间的快速切换来实现的。这意味着在一个时间段内,只有一个任务在执行,但多个任务都在进行中。
- 实现方式:常见的并发实现方式包括多线程(Thread)和多进程(Process)。在多线程环境中,一个进程可以创建多个线程,这些线程可以并发执行。多进程则是操作系统级别的并发,每个进程都有自己的内存空间。
- 关键点:并发的核心在于任务切换和资源共享。操作系统通过时间分片(Time Slicing)来快速切换任务,使得每个任务都能获得处理器时间。
- 并行(Parallelism)
- 定义:并行是指系统能够同时执行多个任务的能力。与并发不同,并行涉及多个处理器或多核处理器同时工作,从而实现多个任务的真实并行执行。
- 实现方式:并行可以通过多处理器系统、多核处理器或分布式系统来实现。在并行计算中,每个处理器可以独立执行自己的任务,无需与其他处理器进行任务切换。
- 关键点:并行的关键在于同时执行和资源独立。每个处理器或核心可以独立完成自己的任务,不需要与其他处理器共享资源。
并发模型
并发模型是用于描述如何实现并发编程的一系列理论和实践方法。它们定义了如何在程序中管理多个同时发生的任务,以及这些任务如何交互和同步。并发模型的主要目标是提高程序的性能、响应能力和资源利用率。常见的并发模型:
- 线程与锁(Thread and Lock)
这是最基本的并发模型之一,其中程序被分解为多个线程,这些线程可以在单个处理器或多核处理器上并发执行。锁是用来保护共享资源不被多个线程同时修改的机制。这种模型容易引发死锁、优先级反转等问题,需要精细的设计和同步策略来确保正确性。
- 函数式编程(Functional Programming)
函数式编程(Functional Programming)是一种编程范式,它强调程序中的函数是一等公民,即函数可以作为参数传递给其他函数,也可以作为返回值返回。函数式编程的核心思想是避免副作用(Side Effects),即函数执行后不会对外部环境产生影响,也不会改变输入的参数。因此,函数式编程通常采用不可变数据结构,避免修改已有数据,而是通过创建新的数据结构来实现功能。因为没有可变状态,所以不需要复杂的锁机制就可以安全地并发执行。
- Clojure之道 - 分离标识与状态
Clojure是一种基于JVM的Lisp方言。Clojure之道是一套基于Clojure语言的编程思想和实践方法。它强调函数式编程和并发编程的重要性,并提倡使用Clojure提供的多种可变数据结构和并发工具(Software Transactional Memory, STM)来解决问题。Clojure之道的核心理念包括:使用函数式编程思想来编写简洁、清晰、易于理解和维护的代码;利用Clojure提供的并发工具和技术来实现高效的并发编程;使用可变数据结构和并发工具来分离标识与状态,从而避免共享可变状态带来的问题。
- Actor
Actor是一种并发编程模型,也是一种轻量级的线程。它的主要思想是将程序看作是由一系列独立的Actor组成,每个Actor都有自己的状态和行为,并且能够通过消息传递来进行交互。Actor之间的通信是基于消息传递的,每个Actor都会监听来自其他Actor的消息,并根据接收到的消息做出相应的响应。这种模型的优点是可以避免传统线程模型中的锁竞争等问题,同时也更加适合分布式系统的设计和开发。
- 通讯顺序进程(Communicating Sequential Processes)
通讯顺序进程(Communicating Sequential Processes,简称CSP)是一种并发编程模型,类似于actor模型,也是由独立的、并发执行的实体组成,实体之间通过发送消息进行通信。但是CSP模型更注重消息的传输通道,即channel,而不是发送消息的实体。Channel是一类对象,可以单独创建和读写,并在进程之间传递。CSP模型不关心发送消息的实体,而是关注发送消息时使用的channel。CSP模型也被称为“通信顺序进程”,因为它强调了消息的顺序和同步。Golang中的goroutine和channel就深受CSP影响,它鼓励通过channel进行同步而不是直接共享内存。
- 数据并行
数据并行是指在一个计算任务中同时处理多个数据集的方法。通常情况下,这些数据集会被分成若干个子集,然后分配给不同的处理器或者线程去处理。数据并行的优点是可以提高计算效率,减少计算时间,适用于大规模的数据处理任务。典型应用如图形处理器(GPU)上的并行计算。
- Lambda架构
Lambda架构是一种用于处理大数据的分布式计算框架。它包括三个层次:批处理层、速度层和在线层。其中,批处理层负责离线批量处理数据;速度层负责实时处理数据;在线层则提供实时查询和分析功能。Lambda架构的主要特点是具有高可用性和可扩展性,能够应对海量数据的处理需求。
并发模型中进程(Process)、内核线程(Kernel Thread)、轻量级进程(Lightweight Process)、用户线程(User Thread)、协程(Coroutine)的区别:
| 进程 | 内核线程 | 轻量级进程 | 用户线程 | 协程 | |
|---|---|---|---|---|---|
| 定义 | 操作系统中独立的资源分配和执行单元,拥有独立资源和执行上下文(进程的本质是代码区的指令不断执行,驱使动态数据区和静态数据区产生数据变化,它有进程程序、数据和进程控制块(PCB,processing control block)组成) | 在内核中实现(Windows、Solaris、Linux)。进程由内核通过系统调用实现的线程机制,由内核完成线程的创建、终止和管理(线程由栈、寄存器和线程控制块(TCB,thread control block)组成) | 建立在内核之上并由内核支持的用户线程,它是内核线程的高度抽象,每一个轻量级进程都与一个特定的内核线程关联 | 在用户空间实现(POSIX/Pthreads、Mach/C-threads、Solaris/threads)。由一组用户级的线程库函数来完成线程的管理,包线程的创建、终止、同步和调度等 | 在用户态下轻量级的线程,由程序自身控制其调度 |
| 资源 | 拥有独立的系统资源,如虚拟地址空间、打开的文件等 | 共享进程资源,每个内核线程拥有独立的执行上下文 | 共享进程资源,每个轻量级进程具有独立的执行上下文 | 共享进程资源,每个用户线程有独立的执行上下文 | 共享进程资源,每个协程仅包含少量运行时上下文信息(但与用户线程不同的是,协程通常拥有更少的独立资源) |
| 调度方式 | 由操作系统内核进行抢占式调度 | 由操作系统内核进行抢占式调度 | 由操作系统内核进行调度,与关联的内核线程同步调度 | 取决于线程模型:N:1(用户线程库调度,可能存在并发瓶颈),1:1(内核调度,与内核线程调度相同) | 由用户程序控制,采用非抢占式的协作式调度 |
| 并发性 | 可并发执行,每个进程在其独立资源环境下运行,互不影响 | 可并发执行,由内核调度,在同一进程的不同内核线程间互不影响 | 可并发执行,与内核线程一一对应,因此并发程度取决于内核线程的数量 | 可并发执行,但受制于用户线程与内核线程的映射模型(如多对一或多对多) | 可并发执行,通过协作式调度,一个协程挂起时主动让出CPU给其他协程 |
| 切换开销 | 进程切换涉及硬件上下文切换,开销较大 | 线程切换涉及内核态和用户态的切换,开销相对较小 | 轻量级进程切换开销接近内核线程切换,但由于与内核线程一对一的关系,开销相对较小 | 用户线程切换在用户态进行,理论上开销较小,但在多对一模型下,实际开销取决于系统调用次数 | 协程切换完全在用户态完成,无系统调用,只需交换少量上下文信息,开销极小 |
| 通信方式 | 通过进程间通信(IPC)机制,如管道、消息队列、共享内存、套接字等 | 通过共享内存、信号量、条件变量等机制在同一进程内通信 | 与内核线程通信方式相同,可通过共享内存、信号量等机制在同一进程内通信 | 通过线程同步机制(如互斥锁、条件变量、信号量等)在同一进程内通信 | 通过协程库提供的通道(Channel)或共享数据结构等方式进行通信 |
| 同步机制 | 使用进程间通信(IPC)机制进行同步,如管道、消息队列、共享内存、信号量、文件锁等。在某些情况下,也可以使用互斥锁、条件变量等线程同步机制,但这通常用于进程内部的线程同步 | 使用线程同步机制,如互斥锁、条件变量、信号量等 | 使用线程同步机制,如同内核线程 | 使用线程库提供的同步机制 | 使用协程库提供的协作式同步机制,如yield和channel |
| 阻塞影响 | 阻塞一个进程会阻塞其所有内核线程 | 阻塞一个内核线程不影响同一进程内的其他内核线程 | 阻塞一个轻量级进程意味着其关联的内核线程被阻塞,但不影响同一进程内关联不同内核线程的其他轻量级进程 | 阻塞一个用户线程可能会影响其他用户线程(在多对一模型下) | 阻塞一个协程通常不会阻塞其他协程,因为协程间是协作式调度 |
| 实现方式 | 由操作系统内核管理 | 由操作系统内核管理 | 由操作系统内核支持并在用户空间进行包装,每个轻量级进程与一个内核线程关联 | 由线程库在用户空间实现和管理 | 由协程库在用户空间实现和管理 |
| 操作系统支持 | 所有现代操作系统都支持 | 大多数现代操作系统都支持 | 部分操作系统提供轻量级进程支持 | 所有操作系统均支持用户线程实现 | 部分编程语言和环境支持协程实现 |
| 适用场景 | 需要强隔离和安全的任务 | 需要细粒度并行和内核直接调度的任务 | 适用于需要轻量级并发且利用内核线程支持的场景 | 高并发应用,尤其在用户空间内管理调度较方便的情况 | I/O密集型任务、高并发和协作式编程场景,追求低延迟和高吞吐量 |
Golang并发模型
Golang的并发模型是对CSP并发模型的实现——GPM调度模型。提到“调度”,我们首先想到的就是操作系统对进程、线程的调度。操作系统调度器会将系统中的多个线程按照一定算法调度到物理CPU上去运行。传统的编程语言比如C、C++等的并发实现实际上就是基于操作系统调度的,即程序负责创建线程(一般通过pthread等lib调用实现),操作系统负责调度。这种传统支持并发的方式有诸多不足:线程创建容易,退出难;线程间通信负责;创建和切换线程开销较大,而且不能大量创建线程,难以scaling。为此Golang采用了GPM调度模型。
GPM调度模型
Golang的调度模型,通常称为GPM模型,是Go运行时(runtime)的核心部分,负责有效地在多核处理器上调度并发执行的goroutines(G)。这个模型包括三个主要组件:Goroutine(G)、Processor(P)、Machine Thread(M)。涉及的主要源文件:
runtime/asm_amd64.s 进程启动时调用的一些汇编函数,可以理解为进程的入口。
runtime/runtime2.go 主要是g、m、p、schedt的数据结构和g、p的状态定义,还定义了一些全局变量。
runtime/proc.go 主要是调度器逻辑代码的实现。
- Goroutine
Goroutine是Golang并发执行的轻量级线程。它由Golang运行时管理,而不是操作系统内核。每个Goroutine对应一个G结构体,该结构体存储了Goroutine的状态、栈信息以及待执行的任务函数。Goroutine的栈空间动态伸缩,初始时2KB,随着需要可以增长到1GB。
- Processor
Processor是对处理器(CPU核心)的抽象,表示逻辑处理器。它的主要作用是作为Goroutine和Machine Thread之间的中介,负责调度Goroutines。每个P都维护了一个本地的Goroutine队列,存储着等待执行的Goroutine。每一个P都维护着M的执行上下文,包括内存分配缓存、一些同步原语等。每个M都会与一个P关联起来,并执行该P的任务队列中的Goroutine。Golang运行时在程序启动时可以创建多个P对象,数量通常默认为物理核心数,但可以通过环境变量GOMAXPROCS进行调整。这里的P虽然表示逻辑处理器,但P并不执行任何代码,对G来说,P相当于CPU核,G需要被分配一个P才能被调度。 对M来说,P提供了相关的执行环境(Context),如内存分配状态(mcache),任务队列(G)等,只有将P和M绑定才能让P中G得以真实运行起来。
- Machine Thread
Machine Thread是与操作系统线程直接对应的实体。在Linux上,它对应于pthread。它是实际执行Goroutine的实体。很多人认为GOMAXPROCS可以限制系统线程的数量,但这是错误的,M是按需创建的,和GOMAXPROCS没有直接关系。M在绑定有效的P后,进入调度循环,而且M并不保留G状态,这是G可以跨M调度的基础。当M因为系统调用或锁竞争而阻塞时,它会与P分离,运行时可能会创建新的M(如果系统资源允许)来继续执行其他P的Goroutine队列。
Goroutine调度器(Scheduler)
goroutine占用的资源非常小,goroutine调度的切换也不用陷入(trap)操作系统内核层完成,代价很低。因此,一个Go程序中可以创建成千上万个并发的goroutine。所有的Go代码都在goroutine中执行,哪怕是go的runtime也不例外。将这些goroutines按照一定算法放到“CPU”上执行的程序就称为goroutine调度器或goroutine scheduler。不过,一个Go程序对于操作系统来说只是一个用户层程序,对于操作系统而言,它的眼中只有thread,它甚至不知道有什么叫goroutine的东西的存在。goroutine的调度全要靠Go自己完成,实现Go程序内goroutine之间“公平”的竞争“CPU”资源,这个任务就落到了Go runtime头上,要知道在一个Go程序中,除了用户代码,剩下的就是go runtime了。于是goroutine的调度问题就演变为go runtime如何将程序内的众多goroutine按照一定算法调度到“CPU”资源上运行了。在操作系统层面,Thread竞争的“CPU”资源是真实的物理CPU,但在Go程序层面,各个goroutine要竞争的”CPU”资源是什么呢?Go程序是用户层程序,它本身整体是运行在一个或多个操作系统线程上的,因此goroutine们要竞争的所谓“CPU”资源就是操作系统线程(就是M)。这样Go scheduler的任务就明确了:将goroutines按照一定算法放到不同的操作系统线程(M)中去执行。这种在语言层面自带调度器的,我们称之为原生支持并发。
- go进程的启动
在golang中main包中的main函数并不是入口函数, 入口函数是在asm_amd64.s中定义的,而main包中的main函数是由runtime main函数启动的。go程序启动后,会调用runtime.rt0_go(SB)来执行程序的初始化和启动调度系统。启动流程如下:
- 调用
runtime.osinit来获取系统的cpu个数。 - 调用
runtime.schedinit来初始化调度系统,会进行p的初始化,也会把m0和某个p绑定。 - 调用
runtime.newproc新建一个goroutine,也叫main goroutine,它的任务函数是runtime.main函数,建好后插入m0绑定的p的本地队列。(在runtime.main函数中新建一个线程来执行sysmon的监控线程(sysmon的M无需绑定P即可运行),然后执行main 包中的main函数) - 调用
runtime.mstart来启动m,进入启动调度系统。(runtime.mstart函数调用mstart1函数,然后调用schedule()函数启动调度器,调度器只在g0上执行。每个m有一个g0,因为每个m都有一个系统堆栈,g0上的栈是系统分配的栈,在linux上栈大小默认固定8MB,不能扩展,也不能缩小。 而普通g一开始只有2KB大小,可扩展。在g0上也没有任何任务函数,也没有任何状态,并且它不能被调度程序抢占。因为调度就是在g0上跑的)
- 调度时机
schedule()函数在Go程序中并不是一直在运行。它的工作方式是基于事件驱动的,只在特定情况下被调用以执行调度任务。调度时机:
- 当一个新的goroutine被创建时。
- 当一个goroutine完成执行后。
- 当一个goroutine因为某些原因(如系统调用、I/O操作、锁竞争等)被阻塞时。
- 由系统监控器(sysmon)在检测到某些条件(如长时间运行的goroutine)时触发抢占式调度,防止其他goroutine被饿死。(和操作系统按时间片调度线程不同,Go并没有时间片的概念)
schedule()函数调用findRunnable()函数选择一个合适的goroutine来运行。
- GPM调度的基本流程:
- 当一个G被创建时,它会被放入P的本地队列,如果P的本地队列已满,则放入全局队列中。
- 调度器选择一个P,并将其与一个M关联(如果M因为阻塞操作而释放了P,运行时会创建新的M或者从M缓存中获取)。
- M执行P的本地队列中的G。如果P的本地队列为空,优先从全局队列获取G,如果全局队列为空时则通过work stealing机制从其他P的本地队列偷取G。
- 当一个G开始执行时,它会使用关联的M执行,直到它执行完成或被阻塞或被sysmon抢占。
- 如果G被阻塞在某个system call操作上,那么不光G会阻塞,执行该G的M也会解绑P(实质是被sysmon抢走了),与G一起进入sleep状态。如果此时有空闲的M,则P与其绑定继续执行其他G;如果没有空闲M,但仍然有其他G要去执行,那么就会创建一个新M。当阻塞在syscall上的G完成syscall调用后,G会尝试获取一个空闲的P执行,并放入到这个P的本地队列。如果获取不到P,那么这个线程M变成休眠状态,加入到空闲线程中,然后这个G会被放入全局队列中。
- 如果G被阻塞在某个channel操作或I/O操作上时,G会被放置到某个wait队列中,而M会尝试运行下一个runnable的G;如果此时没有runnable的G供M运行,那么M将解绑P,并进入sleep状态。当channel操作完成或I/O available,在wait队列中的G会被唤醒,标记为runnable,放入到某P的队列中,绑定一个M继续执行。
FYI
七周七并发模型