并发 vs 并行

单核

单进程

内部仅有一条代码执行流,不存在竞态,无需考虑同步问题

image-20231124082840830

  1. 每个单进程应用对应一个操作系统进程
  2. 操作系统的多个进程按照时间片大小,被轮流调度单核上执行
  3. 单核在某个时刻只能执行一个进程对应的程序代码,两个进程不存在并行执行的可能
    • 并行(Parallelism):在同一时刻,有两个或两个以上的进程的代码在处理器上执行
    • 因此,多处理器或者多核处理器是并行执行的必要条件

多进程

应用结构清晰,维护性更好

image-20231124083731320

  1. 应用通过 fork 等系统调用创建多个子进程,共同实现应用的功能
  2. App1 内部划分为多个模块,每个模块用一个进程来承载执行,每个模块都是一个单独的执行流
  3. 在单核下,多进程依然无法并行执行,只能按照时间片被操作系统调度到单核上执行

多核

多线程

image-20231124084628590

  1. 进程并不适合用于承载采用了并发设计的应用的模块执行流
    • 进程是操作系统中资源分配的基本单位:应用代码+应用数据、操作系统资源(文件描述符、内存地址空间等)
    • 因此,进程的创建、切换和撤销的代价都很大
  2. 线程是运行于进程上下文中的更轻量执行流
  3. 随着处理器技术的发展,多核处理器成为了主流,让真正的并行成为了可能
  4. 基于线程的应用通常采用单进程多线程的模型
    • 一个应用对应一个进程,应用通过并发设计将自身划分为多个模块,每个模块由一个线程独立承载执行
    • 多个线程共享该进程内的资源,而线程作为执行单元可以被独立调度处理器上执行
      • 线程的创建、切换和撤销的代价相对于进程要小得多
    • 当应用的多个线程同时被调度到不同的处理器核上执行时,该应用是并行

对比

并发关乎结构、而并行关乎执行

  1. 并发是应用设计实现阶段要考虑的问题
    • 考虑如何将应用划分为多个相互配合、可独立执行的模块
    • 采用并发设计的程序并不一定是并行执行的,如仅有一个单核 CPU
  2. 并发让并行变得容易
    • 采用并发设计的应用可以将负载自然扩展到各个 CPU 核上,从而提升处理器的利用效率

并发是一种能力,让程序可以由若干代码片段组合而成,并且每个片段都是独立运行

并行必要条件是具有多个处理器或者多核处理器

原生线程

  1. 在传统编程语言(C++、Java)中,基于多线程模型的应用设计,是一种典型的并发设计
  2. 但并非面向并发而生,多以操作系统线程作为承载分解后的代码片段的执行单元,由操作系统执行调度

不足

  1. 复杂
    • 在 C++ 中,线程创建容易退出难
    • 并发执行单元间的通信困难且易错
      • 一旦涉及到共享内存,会涉及到各种锁互斥机制,容易造成死锁
    • 需要设定线程栈大小
  2. 难以规模化
    • 线程的使用代价虽然远小于进程,但依然不能创建大量线程
      • 每个线程会占用不小的资源,且操作系统调度切换线程的代价也不小
    • 对于网络服务而言,只能基于少量线程做IO 多路复用

goroutine

goroutine - 由 Go Runtime 负责调度的轻量级用户线程,为并发设计提供了原生支持

优势

占用资源少 + 在用户层调度 + 支持 CSP 模型

  1. 占用资源小,每个 goroutine 的初始栈大小仅为 2K
  2. Go Runtime 而非操作系统调度,goroutine 上下文切换在用户层完成,开销更小
  3. 语言层面而不是通过标准库提供
    • goroutine 由 go 关键字创建,一退出就会被回收或者销毁,开发体验更佳
  4. 语言内置 channel 作为 goroutine 间的通信原语,为并发设计提供了强大支撑

用法

无论是 Go Runtime 代码,还是用户层代码,都运行在 goroutine 中

1
2
3
4
5
6
7
8
// 基于已有的具名函数
go fmt.Println("I am a goroutine")

// 基于匿名函数/闭包
var c = make(chan int)
go func(a, b int) {
c <- a + b
}(3, 4)
net/http/server.go
1
2
3
// 基于已有的具名方法
c := srv.newConn(rw)
go c.serve(connCtx)
  1. 一个应用内部启动的所有 goroutine 共享进程空间的资源
  2. 如果多个 goroutine 访问同一块内存数据,将会存在竞争,此时需要进行 goroutine 之间的同步

退出 goroutine

  1. goroutine 的使用代价很低,goroutine 的执行函数返回,意味着 goroutine 退出
  2. 如果 main goroutine 退出,意味着整个应用退出

goroutine 执行的函数或者方法即便有返回值,Go 也会忽略这些返回值

通信

传统

  1. 传统的编程语言(C++、Java 等)并非面向并发而生,所以面向并发的逻辑多是基于操作系统线程
    • 并发的执行单元(线程)之间的通信,利用的也是操作系统提供的线程或者进程间通信的原语
      • 如:共享内存、信号、管道、消息队列、套接字等
      • 使用最广泛的是结合了线程同步原语以及更为低级的原子操作)的共享内存方式
  2. 传统编程语言的并发模型是基于共享内存的 - 难用 + 易错

CSP

Communicating sequential processes,CSP 模型旨在简化并发程序的编写

  1. 输入输出为基本的编程原语,数据处理逻辑(P)调用输入原语获取数据,顺序处理数据,并将结果输出到输出原语
  2. 一个符合 CSP 模型的并发程序应该是一组通过输入输出原语连接起来的 P 集合
    • CSP 的组合思想与 Go 的设计哲学不谋而合

sequential processes
P 为一个抽象概念,代表任何顺序处理逻辑的封装,获得输入数据,并生产可以被其它 P 消费的输出数据

image-20231125103915703

  1. P 不一定就是操作系统的进程或者线程,在 Go 中,P 就是 goroutine
  2. 为了实现 CSP 并发模型中的输入输出原语,Go 引入了 goroutine 之间的通信原语 channel
  3. 通过 channel 将 goroutine 组合连接在一起
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func spawn(f func() error) <-chan error {
c := make(chan error)

go func() {
c <- f()
}()

return c
}

func main() {
c := spawn(func() error {
time.Sleep((1 << 1) * time.Second)
return errors.New("timeout")
})

fmt.Println(<-c) // timeout
}
  1. CSP 为 Go 支持的主流并发模型 - 推荐
  2. Go 也支持基于共享内存的并发模型
    • 提供基本的低级别同步原语,如 sync 包中互斥锁条件变量读写锁原子操作