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

Mark24 · 2021年10月13日 · 最后由 zzz6519003 回复于 2023年10月10日 · 1230 次阅读
本帖已被管理员设置为精华贴

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

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

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

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

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

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

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

二、进程

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

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

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

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

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

  • 切换上下文时间相对较长
  • 上下文的内存相对较大
  • 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 Puma 允许在每个进程中使用多线程,每个进程都有各自的线程池。大部分时候不会遇到上面说的竞争问题,因为每个 HTTP 请求都是在不同的线程处理。

  • Python 的 Flask 设计巧妙在于使用一个 map 做到了线程隔离,每个线程都有独立的 request、response 上下文也可以做到巧妙地不同现场处理。

  • Python 的 Tornado 使用了协程来写出非阻塞的吞吐量更高的 web server。

  • Ruby 中去除 GIL 的解释器:除了 GIL 的实现,其中包括 Rubinius 以及 jruby 他们的底层分别用的是 C++ 以及 Java 实现的,除了去除 GIL 锁之外,他们还做了其他方面的优化。某些场景下他们都有着比 MRI 更好的性能。

我的 BLOG

@Rei 这个不加精?

Rei 将本帖设为了精华贴。 10月10日 16:11
需要 登录 后方可回复, 如果你还没有账号请 注册新账号