多进程的原理剖析(实操深刻理解!)

在开始之前先看一张图。
在这里插入图片描述

说一下这张图,顶部是操作系统,我们对计算机的操作都是通过操作系统来进行的。操作系统管理了一切计算机资源,如CPU、硬盘、内存等,这里只是写了内存,这是我们要重点关注的东西。从内存的角度看,内存里面保存了进程的信息,保存进程的内存里面保存了线程的信息。

进程是什么

先说说一个程序是怎么执行的,比如 Linux 下面有个 ls 命令,这实际上是一个二进制文件,保存在 /bin 目录下,当我们在终端执行 ls 的时候,终端会在硬盘里面找到这个 /bin/ls 文件,加载到内存中,为其创建一个进程,该进程的状态是就绪状态,然后在下一次的操作系统进程调度过程会运行这个进程。

总结一下,进程运行的几个步骤:
用户告知操作系统要执行的程序:终端输入 ls,然后按下回车。
操作系统从磁盘加载 ls 的可执行二进制指令到内存中
操作系统为 ls 创建一个进程,该进程的状态为就绪
下一次的操作系统进程调度会将该进程的指令加载到 CPU 进行执行
执行完毕之后(可能会经历多次系统进程调度),进程终止,释放进程占用的内存以及其他系统资源

我们可以看到,一个进程实际上相当于一个运行状态的二进制程序。

在实际实现上,进程除了包含了二进制程序的指令代码外,还包括了进程在运行过程中所占用的所有其他系统资源,如占用的端口、打开的文件等。

为什么需要线程

人们需要多线程的主要原因是,在许多应用中同时发生着多种活动。其中某些活动随着时间的推移会被阻塞。通过将这些应用程序分解成可以准并行运行的多个顺序线程,程序设计模型会变得更简单。(这一段来自《现代操作系统》)

进程有以下几种状态:就绪、阻塞、执行

图片

现在考虑没有线程的情况,当我们进程获得 CPU 时间的时候,得以执行,当需要进行 I/O 操作的时候,进程阻塞,发起系统调用,陷入到内核。这个过程可能会比较久,而等待 I/O 完成的这一段时间里,我们的进程由于处于阻塞的状态,在操作系统的进程调度程序中判断到进程处于阻塞状态的时候,并不会加载该进程到 CPU 来执行。这段时间其他就绪状态的进程可以获得 CPU 时间,但是如果系统中需要使用 CPU 的进程很少,就会造成 CPU 资源的浪费。

但是如果我们可以在进程发起 I/O 调用的时候,依然可以做其他可以使用 CPU 的操作,比如计算 1+1,这样我们就可以充分地利用 CPU 了,同时,我们的进程可能也会更快地完成,用户等待的时间也减少了。

也就是说,线程提供了一种能力,让我们的进程可以不阻塞在其中的某一个 I/O 操作上。这也是我们使用线程的一个非常普遍的场景。

另外一种利用多线程的目的是,充分利用多核 CPU。

线程的优点

线程和进程类似,如大多数人所说的那样,线程可以看作是轻量级的进程。线程轻在什么地方呢?操作系统的资源分配是按进程分配的,每一个进程拥有不同的系统资源,如地址空间(内存),而线程是依附于进程的(协程也是)。

这样一来,有两个很明显的好处:
操作系统不必为每个线程分配独立的资源
线程间可以共享地址空间,线程间通信很方便(相对于进程间通信)

同时,可以充分地利用 CPU,因为多个线程可以在不用的 CPU 核心上运行,也就是并行(当然,前提是你的电脑有多个核)。

最后一点就是上面说的,当遇到阻塞操作的时候,我们的进程依然可以不阻塞,可以使用其他线程来继续工作。

什么是协程

其实对操作系统来说,没有协程的概念。协程是某些现代编程语言上的概念,我们可以先把它理解为轻量级线程。相对于线程来说,开销更小。

说协程之前,有一个比较重要的知识点需要了解。CPU 上某一个时刻内只能运行一个进程,然后操作系统通过进程调度程序来让不同的进程获得使用 CPU 的权利。如果没有操作系统来进行干涉,一个进程就有可能永远地占用着 CPU,而其他进程完全没有运行的机会。

也就是说,不同进程之间对于 CPU 是有竞争关系的,每个进程都想自己独占 CPU。线程在这一方面有点类似于进程,都是希望获得尽量多的 CPU 时间,好让自己的任务快点完成。

而协程在这一方面和进程、线程都不一样,我们知道了进程、线程都是希望自己获得尽量多的 CPU 时间,而协程是依赖于某一个具体的线程的,由线程来管理的,所以从这一个角度看,其实协程也是希望自己获得尽量多的 CPU 时间。

协程从字面上看有 “协作” 的意味,事实上协程的初衷也是这样的。协程之所以叫协程,因为它提供了一种机制来在多个协程之间进行协调,PHP、Python 里面是 yield 关键字,当我们 yield 的时候,该协程会让出 CPU,这里的让出 CPU 不同于操作系统调度的 CPU 轮转,这里让出 CPU 的时候,并不代表协程所在的进程或线程让出了 CPU,协程 yield 的时候,不代表进程让出 CPU,只是让进程/线程有机会可以执行其他协程。

看看一个实际的例子(PHP):

<?php
function gen()
{
    foreach (range(1, 3) as $item) {
        echo "yield from gen: #{$item}\n";
        yield $item; // 让出 cpu,还能返回一个值
    }
}

$generator = gen();

// 做一些其他事情

foreach ($generator as $item) {
    // 每次 gen 让出 cpu 的间隙做一些其他操作
    echo "main #{$item}\n";
}

输出:

yield from gen: #1
main #1
yield from gen: #2
main #2
yield from gen: #3
main #3

正常情况下,我们的 foreach 会一次性运行完,但是我们看上面的输出,两个 foreach 交替执行。这就是协程的作用,让用户可以自己手动控制让出 CPU 给其他协程。

但这个例子并不能说明协程的好处。因为我们并没有从这个例子中看到有协程的任何优势,上面能实现的,不用 yield 也能实现。

Go 中的协程

Go 里面的协程算是一个很完美的实现了,底层通过多个线程来对不同协程进行调度,从而获得最好的性能。我们来看看 Go 中的协程(goroutine)。

package main

import (
  "fmt"
  "math/rand"
  "sync"
  "time"
)

func main() {
  var wg sync.WaitGroup
  wg.Add(4)

  rand.Seed(time.Now().UnixNano())
  for i := 0; i < 4; i++ {
    go func(index int) {
      // 模拟耗时操作,睡眠随机时间
      sleepTime := 100 * rand.Intn(20)
      time.Sleep(time.Millisecond * time.Duration(sleepTime))
      fmt.Printf("Goroutine #{%d} finished, sleep time: %dms.\n", index, sleepTime)
      wg.Done()
    }(i)
  }

  wg.Wait()
}

输出:

Goroutine #{0} finished, sleep time: 100ms.
Goroutine #{2} finished, sleep time: 800ms.
Goroutine #{3} finished, sleep time: 1100ms.
Goroutine #{1} finished, sleep time: 1600ms.

不了解 Go 没有关系,我们来分析一下,go 关键字启动了一个新的协程,协程运行的代码是 go 关键字后面的函数。当运行到 go 关键字的时候,底层的协程调度器将这个新启动的协程分配到某一个空闲的线程上运行,然后继续执行下一个循环,然后又启动一个新的协程,如此直到循环结束。

里面的 wg 是用来等待所有协程结束的,rand 用以生成随机数,不必过多关注。sleep 是一个阻塞操作,这里来模拟一些耗时的操作(一般是 I/O 操作)。

关于 Go 的协程,我们可以得到如下信息:
协程由 Go 进程的协程调度器来进行调度,协程调度器将协程分配给不同的线程进行执行
使用 go 关键字启动新协程的时候,不会阻塞进程(大家可以回想一下上面提到的为什么使用线程),因此进程依然可以启动新的协程。
上面已经说了,Go 的进程底层其实有多个线程在运行(这些线程的数量往往等于 CPU 核心数)。

非常重要的一点,在 Go 里面协程是使用底层的线程作为载体来运行的。

PHP 的协程

说实话,PHP 的 yield 实际用处不大,不过现在有 swoole 扩展对协程有比较好的支持。但是还是有一个局限就是,PHP 里的协程是单个进程/线程内的,无法利用多核的优势。当然,这只是针对单个进程运行的情况,现实中,往往是启动多个进程来处理,比如 HTTP 服务器,启动多个进程来监听请求,如果单次请求内同时有多个 I/O 请求,就可以利用协程来实现发起一个 I/O 请求之后,不阻塞进程,然后发起另一个 I/O 请求,然后等待这些 I/O 请求返回。

也就是说,虽然我们单个进程无法利用多核的优势,但我们依然可以选择使用多个进程来实现对 CPU 的更充分的利用。虽然不如 Go 那么高效,但是相对于传统的 php-fpm 模式算是一个颠覆性的改变了。性能也是传统的 php-fpm 很难比拟的。

总结

进程是程序的运行时的表现,线程可以看作是轻量级进程,而协程可以看作轻量级线程。线程、协程都共享进程的地址空间。
使用线程可以在产生 I/O 操作的时候不必阻塞整个进程。
进程、线程都希望一直占用 CPU,但是这个会被操作系统切换不同的进程、线程来运行,阻塞状态的进程不会被执行,就绪状态的进程会被运行,如果因为 I/O 阻塞了进程,那这个进程也不会继续被执行,直到 I/O 操作完成。
协程是编程语言中的概念,需要编程语言提供支持,由语言实现协程的调度(通常是通过多个线程,也有可能是单线程多协程)。PHP 和 Python 里面有 yield 关键字。Go 里面使用 go 关键字启动新的协程。
PHP 里面的协程模式是运行在单个进程上的,不会用到多个 CPU 核心。

参考资料

《现代操作系统》第2章 进程与线程
30+张图讲解:Golang调度器GMP原理与调度全分析
Python 协程,https://www.liaoxuefeng.com/wiki/897692888725344/923057403198272
PHP 协程,https://hyperf.wiki/#/zh-cn/coroutine

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值