并行并发进程线程协程 GIL 概念简明解释笔记

2021-10-13 15:55:26 +08:00
 Mark24

根据参考文章做了一些简要的笔记和概括 更多请参考引用部分

一、并发(Concurrency)和并行(Parallel)的区别

并发和并行是相近的概念。和并发所描述的情况一样,并行也是指两个或多个任务被同时执行。但是严格来讲,并发和并行的概念并是不等同的,两者存在很大的差别。

简单的一张图可以简单明了的理解 并行和并发

直观来讲,并发是两个等待队列中的人同时去竞争一台咖啡机。现实中可能是两队人轮流交替使用、也可能是争抢使用——这就是竞争。

而并行是每个队列拥有自己的咖啡机,两个队列之间并没有竞争的关系,队列中的某个排队者只需等待队列前面的人使用完咖啡机,然后再轮到自己使用咖啡机。

可以这样理解: 并发是一个处理器同时处理多个任务,而并行多个处理器或者是多核的处理器同时处理多个不同的任务。前者是逻辑上的同时发生( simultaneous ),而后者是物理上的同时发生。

二、进程

进程(英语:process ),是指计算机中已运行的程序。进程曾经是分时系统的基本运作单位。

它包括一些具体内容比如 独立的内存、系统中独立的 PID 等。

在时分复用系统中,操作系统在不同的进程之间进行上下文切换,来达到 “并发”的效果。

我们只需要简单的知道,他是一个基本单位,它的切换在操作系统之中,并且他的上下文切换相当的占用时间、和占据内存。

Ruby 的一些框架是通过 进程+fork 的方式工作的,比如 Sidekiq 。以 fork 进程工作的会存在一切缺点:

三、线程

线程(英语:thread )是操作系统能够进行运算调度的最小单位。大部分情况下,它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。

然后操作系统也会在进程之中,调度内部的线程,假设是多个线程,会在其中切换不同线程执行。

相对进程,线程弥补了进程切换的一切问题

3.1 多线程的意义

多线程的优点

多线程的意义和操作系统的时间切片其实是相当的。 如果没有多线程,我们的程序会是如何?

以小汽车移动举例子,如下两个颜色的小汽车,A 、B,如果我们想移动他们,只能顺序移动。

如果是支持多线程程序,A 、B 可以同时移动。比如一些游戏的坦克大战多个坦克运动;比如你可以在一个程序中既能聊天还能播放音乐比如浏览器等等。

3.2 线程存在的问题

就像前面,并行并发 提到的咖啡机模型,多个线程,他们在使用 CPU 会产生竞争问题,分配给谁?一般是交给操作系统来调度。但是当他们去访问同一个内存读写的时候,由于无法保证顺序,常常会出现问题——也就是线程不安全。

举个 Ruby 的例子

a = 0

threads = (1..10).map do |i|
  Thread.new(i) do |i|
    c = a
    sleep(rand(0..1))
    c += 10
    sleep(rand(0..1))
    a = c
  end
end

threads.each { |t| t.join }

puts a

这段代码要实现的功能很简单,只是把变量 a 累加 10 次,每次加 10,并且开了 10 个线程去完成这个任务。正常情况下我们期望的值是 a == 100,然而事实却是


> ruby a.rb
10

> ruby a.rb
10

> ruby a.rb
30

> ruby a.rb
20

出现这种情况的原因是,当我们的操作执行到一半的时候其他线程介入了,导致了数据混乱。这里为了突出问题,我们采用了 sleep 方法来把控制权让给其他线程,而在现实中,线程间的上下文切换是由操作系统来调度,我们很难分析出它的具体行为。

现实中为了解决问题,我们需要加锁


a = 0
mutex = Mutex.new

threads = (1..10).map do |i|
  Thread.new(i) do |i|
    # 加锁
    mutex.synchronize do
      c = a
      sleep(rand(0..1))
      c += 10
      sleep(rand(0..1))
      a = c
    end
  end
end

threads.each { |t| t.join }

puts a

这样可以保证结果

> ruby a.rb
100

> ruby a.rb
100

四、GIL

在 Python 、Ruby 中都存在一个东西叫做 GIL (全局解析器锁)。

GIL 起到什么作用呢,以 Ruby 的 MRI 解释器为例,存在 GIL 的 MRI 只能够实现并发,并无法充分利用 CPU 的多核特征,实现并行任务,进而减少程序运行时间。

在 MRI 里面线程只有在拿到 GIL 锁的时候才能够运行,即便我们创建了多个线程,本质上也就只有一个线程实例能够拿到 GIL,如此看来某一时刻便只能有一个线程在运行。

可以考虑下面这样的场景:

老师给小蓝安排了除草任务,小蓝为了加快速度呼唤了好友小张,然而除草任务需要有锄头才能进行。为此,即便有好友相助但锄头却只有一把所以两个人无法同时完成除草的任务,只有拿到锄头使用权的一方才能够进行除草。这把锄头就像是解析器中的 GIL,把小张跟小蓝想象成被创建的两个线程,当两个人的工作效率一样的时候,受限于锄头这个约束并无法同时进行除草任务,只能够交替使用锄头,本质上并不会减少工作时间,反而会在换人的时候(上下文切换)耗费掉一定的时间。

在一些场景下创建更多的线程并不能真正地减少程序的运行时间,反而有可能会随着进程数量的增加而增加切换上下文的开销,从而导致程序变得更慢。

五、协程

线程是由操作系统来控制切换的。而系统的切换是一种通用的策略,在一些场景下会没必要的浪费时间。

协程 就是一种让程序员去手动切换,这个过程就像 线程之间 可以互相协作 来完成任务,避免不必要的切换。具体如何切换、交给谁,这个根据实际的任务,程序员来判断。

比如 小明做 语文、数学、英语三节课。系统调度采用的是通用策略,他可能的选择是在三个任务之间均衡。 系统不停地在切换,实际上这种切换浪费了大量的时间。

我们现实中会这样做,因为这样是更优的选择。这样就减少了无意义的切换提高了效率。

比如 IO,当我们遇到 IO 的时候,有几种情况:

  1. 单线程就只能等待 IO 完成再继续。

  2. 系统无差别调度,工作线程和 IO 线程之间切换。

  3. 协程手动调度,遇到 IO 之后,直接转移控制权给其他代码。 这样可以有目标的去编写非阻塞、吞吐量大的程序。

六、解放 GIL 充分利用多核

以 Ruby 为例,想要去除 4 个人干活 共用一个锄头的 GIL 的尴尬事情。可以选择使用去除 GIL 的解释器。 除了 GIL 的实现,其中包括 Rubinius 以及 jruby 他们的底层分别用的是 C++以及 Java 实现的,除了去除 GIL 锁之外,他们还做了其他方面的优化。某些场景下他们都有着比 MRI 更好的性能。

这就需要你的程序里面避免出现竞争条件。

一些好的例子是 比如 Python 的 Flask 框架,他使用对不同线程 ID 建立起一个 map 保存 request 、response 上下文巧妙地实现了线程隔离,在处理 web 的这块可以做到线程安全。

还有一些专门解决多线程的模型

6.1 Actor 模型 Ractor (合成词 Ruby Actor )并发模型

Ractor 像 Go 、Erlang 的并发模型看齐

具体的实现

6.2 Guilds 并发模型

6.3 事件驱动模型

事件驱动这是一个像 JavaScript 的原理的一个实现,使用了单线程 eventloop 的方式进行工作,可以进行大量的 IO,而不需要担心线程问题。

具体实现

6.4 进程+fork, 让操作系统完成调度

这是一种补充,这种方案扎根于 Unix 、Linux 操作系统。 可以充分利用多核新。可以通过 标准库 实现。但是会比 上述线程方案要重。因为进程的内存是隔离的所以不会竞争。

原理可以参考

具体实现

大名鼎鼎的 unicorn

puma 也使用了这个模型。但是 puma 同样也拥有多线程

参考

并发并行部分

进程部分

线程部分

GIL 部分

协程部分

其他

Ruby China 上的讨论

我的 BLOG

2611 次点击
所在节点    Python
10 条回复
Mark24
2021-10-13 15:58:13 +08:00
最先发在 Ruby 节点,因为例子里主要举了 Ruby

由于 GIL 的问题,对于 Python 也有同样的意义,所以发在了 Python 节点,看的人多一点
suifengdang666
2021-10-13 16:26:52 +08:00
总结很到位,感谢分享
clino
2021-10-13 16:27:58 +08:00
所以 go 的协程最厉害,因为能利用的多核,这个是其他语言的协程没办法做到的。
Mark24
2021-10-13 16:41:28 +08:00
@clino 我理解其他语言 实现 actor 模型就可以了。至于具体实现,每个库会有自己侧重。

Go 可以理解为语言的层面上,自带了一个 actor 模型的实现。
Mark24
2021-10-13 16:50:28 +08:00
Actor 模型是 1973 定义, 由 Erlang 推广。 至于 Go 很成功,个人觉得是 Google 金主大力推广而为之。

其实不是 Go 的独有。

比如 Ruby3 的官方也在加入 Actor 模型

https://github.com/ruby/ruby/blob/master/doc/ractor.md
Mark24
2021-10-13 16:53:49 +08:00
Ruby3 的 Actor 效果可以参考 这篇 https://ruby-china.org/topics/40583

结论 :

4 线程比单线程提高 3 倍多(前提是计算密集型任务、核心数设置合理这取决于硬件)

理论上是 4x 这已经很接近理论了,实际上是 3 倍还多
clino
2021-10-14 14:57:33 +08:00
@Mark24 感谢科普,刚去了解了一下 actor 模型
不过像 Python 这样的即使具备了 actor 模型,受 GIL 限制要利用上多核还是比较困难
Mark24
2021-10-14 15:58:59 +08:00
@clino 可以换成 没有 GIL 的解释器实现 比如 Jython 、IronPython ( https://wiki.python.org/moin/GlobalInterpreterLock)
Mark24
2021-10-14 16:01:32 +08:00
官方的 CPython 在所有的 Python 实现中不算快的甚至很慢问题很多。

PS: 个人认为 Py 很可惜的问题在于 没有进行官方的标准化,以致于 另外实现的解释器都是凭着自己理解,或者看 CPython 实现的, 额外的实现可能存在坑。

------

这一点,Ruby 要稍微好一点,已经标准化了。保持兼容性。
Mark24
2021-10-14 16:02:47 +08:00
没有标准化,还意味着另一个问题,JIT 工作很难覆盖。 Python 的 JIT 社区实现的时候总是难产。Py 现在还没有官方的 JIT 。

这一点 Ruby 也稍微好一点,官方的 JIT 已经可以使用了。

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/807569

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX