关于 协程 概念的一些疑惑

查表仔

问题描述

作为一个php开发,平时接触最多的就是传统fpm框架(tp、laravel等),以及守护进程框架(webman等)。

关于协程的概念,目前看到 swoole、golang 中可以实现。对 协程 的概念有点模糊。

为此你搜索到了哪些方案及不适用的原因

关于 进程 的概念,无论是fpm,还是守护进程 workerman,都是一个进程处理一个请求,当 进程数量 处理不过来很多的请求的时候,会阻塞。

想知道协程这一块是怎么处理的?我有以下猜想:

举个例子,业务逻辑是这样的:

一个请求过来后,首先,需要 4 秒钟调用第三方接口A,需要 4 秒钟调用第三方接口B,拿到A和B接口返回的数据后,需要2秒钟进行A和B接口返回数据的组装。

我用同步处理这个场景,需要 4+4+2 = 10秒。如果我用协程,4+2=6秒 就可以完成。

在使用协程的情况下,如果我有5个进程,同时来了5个请求,单个进程里处理单个请求需要 6 秒钟。是不是这 5个进程可以同时生成5个协程来处理呢?还是说 5个进程,同一时间内,只能有一个协程在处理?

1558 4 15
4个回答

chaz6chez

协程本身不具备并发能力,只是一种上下文(请求/响应/执行等)的编排方案,类似于队列;协程还区分有栈协程(PHP中的fiber)和无栈协程(PHP中的yield);协程一般需要结合线程或者异步执行能力才可以达到并发/并行效果。
你可以理解为,把代码段碎片化,按照协程调度器的执行方案进行执行。

协程 + 协程调度器 + 协程执行单元
消息 + 消息队列 + 消费者

看起来和队列很像是不是,本质上是一样的

  • 查表仔 2023-11-14

    您上面回复的我看明白了,您回答的角度是从它本身以及和其它相关技术结合的 大面 来看的。我是有点纠结于他的实际流程是怎么执行的。是想知道在请求到达进程后,协程是如何处理以及挂起的,是单个进程之内只有一个协程在处理,还是所有进程之内只有一个协程在处理。

  • 查表仔 2023-11-14

    如果按照您上面说的,是不是 golang 和 swoole的协程调度机制也不一样,那我上面的提问是不是得建立在某个技术栈上才能进行下一步的探讨

  • chaz6chez 2023-11-14

    单个进程管理自己的协程,进程与进程之间是相互隔离的,所以Golang用的是多线程执行单元

  • chaz6chez 2023-11-14

    是的,swoole是单个执行单元,golang是多个执行单元,PHP-fiber只能利用eventloop在主线程上进行切换调度

  • chaz6chez 2023-11-14

    通常来说,和linux内核下面,进程切换的方案差不多,比如时间分片法,也就是某个执行体如果执行完了可以主动出让当前CPU,或者执行超时了以后被强制暂停,等到分配下一个CPU时间;

    也就是说,协程也可以这样

  • 查表仔 2023-11-14

    感谢您的回答~

  • chaz6chez 2023-11-14

    这个具体调度的实现,每种协程调度的具体细节可能都不同,取决于具体实现,但思路大差不差,可以关注一下;
    如果没记错的话,golang的协程和执行单元是m:n,java21和swoole的协程和执行单元是m:1;而php python等都是运用事件循环在当前主线程内进行调度的,与其他的用额外线程来执行的不一样。

  • 查表仔 2023-11-14

    m:n 和 m:1 也是相当于单个进程内的协程调度规则把。在单个进程内,swoole的 m:1,有多个协程,但是每次只调度一个。例如我是多核cpu,一个核心能处理两个进程,但是这一个进程内,也就只能有1个协程在调度,虽然在很大程度上提高了请求的执行速度,但是其余空闲的核心就利用不上了。不如 golang的 m:n ,有多个协程,又能同时调度多个,从而充分利用了cpu的核心吧?
    我对操作系统进程线程调度这一块的知识掌握的太少了,得补习补习了

  • 查表仔 2023-11-14

    我测试了swoole的协程,通过它提供的 WaitGroup 和 Barrier 这种方法,对于开发难度来说,感觉是比较好上手业务逻辑的。golang这种属于性能好,但是对于实际开发,如果不了解他的调度机制及实际顺序流程,感觉很容易写bug。

  • chaz6chez 2023-11-14

    你可以这样理解,通常来说golang下是用线程比较多,一般一个服务启动一个进程即可,GPM模型最后会把碎片化的任务调度交给不同的线程来进行运行,这样可以利用多核;
    (如果没记错的话,你可以具体查看一下swoole文档或者源码)swoole/swow下除了php主线程之外还会有一个协程执行线程,会将主线程的任务碎片化调度交给执行线程进行执行,通常来说会在上层加上多进程模型来利用多核,也就是多个进程一托一;
    workerman如果使用fiber的话,就是主进程要处理碎片化调度和执行协程,从始至终只有一个线程进行执行,但是整体是多进程模型。
    进程与进程之间相互隔离,各是各的,整体看来都是可以利用多核的,只是利用的方式不一样;整体并没有说m:n就性能更好,因为线程和进程在内核中的调度也存在性能损耗,也会有内核态和用户态的切换等,各有优劣。

  • chaz6chez 2023-11-14

    golang其实还好,也分systemcall和netpoll,不同场景下有的会在固定线程上执行,有的会有执行线程的转移切换,这个在调度器底层实现了,其实也不会有什么bug或者问题,对于开发者来说用起来跟m:1没什么区别;为什么实现m:1而不是m:n,可能更多的层面是因为php和java都存在虚拟机,另外生态上面的复用上,为了不对虚拟机进行大规模的重写,并利用之前的生态和模式(进程模型、bio等),从而用m:1的方式实现。
    多进程的资源占用上肯定是比多线程要多的,多方面考量吧

  • chaz6chez 2023-11-14

    多进程模型的话,相当于自己的服务除了系统会对服务进行主进程的管理,自己的服务还需要在内部进行子进程管理;而多线程模型就仅仅只需要在进程内对线程管理而已,很多资源都可以复用,在界限上更符合“规范”或者“理念”;

  • chaz6chez 2023-11-14

    进程之间肯定是没有线程之间那么方便快捷,存在一定的难度,为了简化这些内容,方便管理,把一些工作交给系统本身的能力,这样会更健壮,开发起来也不需要一些过分的奇淫技巧。但实际上来说,都能通过一些方法实现想要的功能。用一句比较通俗的话来说就是线程比进程更具有“边界感”。

  • 查表仔 2023-11-14

    上面说的都很有道理,但我水平不够,对你上面说的部分知识点还是有点一知半解,再就是长期的开发思维都是对进程的一些操作,忽然理解起来线程的调度还是难懂。我边学边测试这边的知识点,在回头看看你上面说的,可能就好理解了,感谢您的耐心回答~

  • ikun 2023-11-15

    好文 ,@chaz6chez 在论坛写个专栏吧 ORZ

  • chaz6chez 2023-11-15

    @ikun 之后有空出一个吧,我最近在研究mmap和apcu,准备完善一下这个插件 https://www.workerman.net/plugin/133 ,让它支持更多的功能,因为我现在在计划做一个轻调度的插件,纯用内存和sqlite来支撑小型服务。

  • tomlibao 2023-11-16

    @chaz6chez 厉害,你是主开发什么语言的?

  • chaz6chez 2023-11-16

    @tomlibao 主PHP吧算是

  • 邬綵唔惪 2023-11-17

    协程还区分有栈协程(PHP中的fiber)和无栈协程(PHP中的yield),这两种协程有啥区别呢?为啥流行不起来呢?

  • nitron 2023-11-17

    有栈协程要保留函数调用栈用于挂起恢复,会需要更多的内存空间
    无栈协程在不改变函数调用栈的情况下,采用类似生成器的思路实现了上下文切换

    理论上无栈比有栈性能好,但实际使用中不需要扣资源的时候,两者没多大区别,有栈用起来方便点

  • 邬綵唔惪 2023-11-17

    这两种协程为啥流行不起来呢?是因为需要改造生态的问题吗?

  • chaz6chez 2023-11-18

    @邬綵唔惪 yield一直都流行,只不过圈子很小,reactphp、amphp这些都是利用yield + eventloop实现的类似async/await;这类组件或者框架有个缺点,不能利用php历史积攒下来的大部分组件包,因为整个思想是NIO的也就是no-blocking I/O,而PHP整个生态主要是围绕FPM,然后整体思想是blocking I/O的;同样,因为yield由于无栈,在框架层面实现时候很多东西没办法实现,所以这个圈子引入了fiber。

    还是那句话,协程本质上不具备并发能力,本质上是代码执行片段碎片化并编排的方案的一环,协程+协程调度器+协程执行单元才能实现具备高并发能力的方案;有的用线程,有的用事件驱动。

  • chaz6chez 2023-11-18

    只不过很多人习惯把这种方案笼统的称之为“协程”

  • 邬綵唔惪 2023-11-20

    fiber一样不能利用php历史积攒下来的大部分组件包吧?那样和用yield实现的有啥区别呢?引入的意思是啥呢?

  • 小飞鼠 2024-04-10

    难搞哦,光是要读懂你们的评论的意思 我就感觉很吃力了 怎么办?

he426100
  1. 同一时间内,只能有一个协程在处理?
    这句话指的是在一个进程内同一时间只有一个协程在处理,单个进程是可以创建无数个协程的,我试过在服务器上单进程创建1000万个定时器;

  2. 我用同步处理这个场景,需要 4+4+2 = 10秒。如果我用协程,4+2=6秒 就可以完成。
    用协程也是同步,只不过不会阻塞了,开N个Curl,效果是“并发”N个请求,实际上还是一个个去执行,只是发出请求后不会堵在那里等返回,跟队列是挺像的,把任务抛给队列,对当前业务流程来说就是秒完成;

  3. 同时来了5个请求
    协程的话单进程就可以处理了,不需要5个进程,fpm下一执行curl进程就堵在那里等返回,无法处理下一个请求,协程不存在的。

以上说的是swoole/swow

  • chaz6chez 2023-11-18

    @he426100 swoole/swow有额外一条线程来专门执行协程的内容,不阻塞当前主线程,也就是有两条,你在主线程上创建的协程会被协程线程接管

  • chaz6chez 2023-11-18

    你创建可以创建多个协程,但协程执行单元同一时间只有一个,通过合理的调度和主线程进行配合进行执行,从而达到高效的处理能力

贾仁

Mark

  • 小飞鼠 2024-04-10

    难搞哦,光是要读懂你们的评论的意思 我就感觉很吃力了 怎么办?

  • rbb 2024-04-10

    除了天分,那就努力,慢慢来懂

pader

首先你要搞清楚的是为什么像 php-fpm 这种东西一个进程一个线程只能处理一个请求,如果一个线程在处理一个请求时卡住了不能再处理别的请求了,那么它到底是卡在什么地方?

程序在运行的时候,笼统的可以分为两个概述,一个是运算,一个是 IO。

运算

运算就是你需要强依赖 CPU 完成的事,比如在本地计算 N 个 1+1,执行大量的 if 判断逻辑,运行各种各样的本地代码,这类工作、需要程序占用本地 CPU 和内存,在单个线程中当它们在执行时,这些硬件资源就是被占用着的,没有更多的 CPU 时间来给到程序,所以在运算类任务时,线程一定是阻塞的。

就好比你个人在吃饭,在写字,在读书时需要你自己来干这些事,这时你是腾不出手来处理别的事,你就是被占用的。

IO

而另一种就是 IO,IO 就是程序调用外部的东西,然后就等外部的东西返回结果,在等的期间线程本身是闲着在那里的。比如你的程序调用 MySQL,程序就是通过协议(网络、或者 Socket)将要执行的语句发送给 MySQL 服务器,在 MySQL 返回数据前线程本身就是一直在等结果,自己并没干什么。包括调用 Redis,或者发送 HTTP 请求等等,甚至包括向系统要求读写磁盘,都存在一定的等待,这个时间可能非常短,也可能非常长。想一想,如果你的程序执行时间是 50ms,但在等待 MySQL 返回数据时就耗费了 30ms,这 30ms 的时间线程是闲着的,却不能执行其它的工作,是不是一种浪费。

和前面你自己吃饭,写字的例子相反,你点了个外卖,外卖预估 30 分钟送到你的手中,你要在等外卖送来的这 30 分钟内什么都不干,完全等在这里吗?你对象喊你帮忙拿一下东西,你不回应,为什么,因为你在等外卖,你的时间被占用了?

异步

如前所述,你在等待 IO 响应时浪费了大量的时间,甚至不对外部作出响应。也许你也想到了,在做等待的时候完全可以把闲置的时间用来干别的事,这对人来说是很正常的,你肯定不会在那里傻等。

程序的事情是有上下依赖关系的,就好比你的计划是等到外卖后开始吃饭,外卖点的餐还没来,你就暂还没法吃饭。但是你虽然暂不能吃饭,但是做与吃饭以外的事情是可以干的,比如给对象拿一下东西。

但是别忘了,程序是线性执行的,按代码的意思就是从上到下的执行,下面的东西依赖上面的东西。那么有没有一种办法,当程序去读取 MySQL 数据时,数据没返回前程序先去干别的呢,当 MySQL 返回后,再接着执行原来要往下执行的逻辑?有,这就是异步的概念。

异步最开始都是先给程序设定一个所谓的“回调函数”,然后就去干别的了,因为这里顺序存在不确定性,有的先跑然后接下来的事情进回调了,在回调前又有些东西在跑,总之顺序是乱乱的,所以就叫作了异步。

事件循环

我们再举例子,你等外卖来后要吃饭,你还在等你的乐高玩具快递到了后要拼乐高,你还在等这等那,如果你在等一万件事,你还记得这些事在有结果后该干什么吗?这么多事情你怎么去有条理的检查它们是否有回应了,有些事情你可能等着等着都忘记了,下一次有人给你个东西,你可能在想“这是什么?我什么时候要这个了?我接下来该干嘛??”

对于异步,这里有个重要的核心,那就是事件循环。

事件循环里面记录了所有在等待的 IO 以及接下来的回调。这就好比你有一万件事在等,每件事当你要等的时候,你拿个小本子把它记下来,你在等什么,有结果后该干什么。当你有事的时候你就做事,你一闲下来你就会去检查小本子上的事有没有结果了,有结果就把它要干的事情干掉,然后将这它从小本子上划掉。还没结果就继续留在小本子上,下一次还会再检查它。

这个小本子,就是一个队列,有那么多事件在一个队列里,有一个循环在线程一闲下来的时候就检查它,事件循环的名字就是从此而来。

这样一来,你所有的闲置时间都利用上来,你不会浪费每一分一秒,有一万件事都可以在你的手上有条不紊的并发执行着。你的效率真高!对于人来说,这可能有点不人道,太累了,人会崩溃,但是对于计算机,我们当然是要充分的利用 CPU。

协程

好现在我们就要脱离举生活中例子的概念,重新回到代码中,在程序里异步开始时是一大堆回调,这带来了两个大问题:

一是编程习惯被完全打乱,回调在程序中往往是一个函数或者一个闭包,导致我们的代码写起来极不舒服,每当要等一个调用返回结果时,总要把接下来的代码写进另一个函数或者闭包中。不再是以前的从上到下直观的顺序,看着不舒服,维护也极为困难。

以前的代码:

$data = $db->query("SELECT * FROM data LIMIT 1");
print_r($data);

现在的代码:

$db->query("SELECT * FROM data LIMIT 1", function($data) {
    print_r($data);
})

二是可怕的回调地狱问题,当你一套流程走下来需要等很多 IO,写很多回调时,因为有先后顺序,这些回调一层套一层,里里外外 N 多层,代码看起来极为可怕,一眼看去,无法呼吸,无法思考。

以前的代码:

$data = $db->query("SELECT * FROM data LIMIT 1");

$result = $cache->set("data_cache", $data);

if ($result) {
    echo "写入数据缓存成功";
}

现在的代码:

$db->query("SELECT * FROM data LIMIT 1", function($data) use ($redis) {
    $redis->set("data_cache", function($result) {
        if ($result) {
            echo "写入数据缓存成功";
        }
    });
})

下面就轮到协程出场了,协程帮你从层层回调函数中解脱出来,再次回到以前的同步编程方式中来。以下面的伪代码为例:

//调用 A
$cortinue->async(function() {
    $data = $db->query("SELECT * FROM data LIMIT 1");

    $result = $cache->set("data_cache", $data);

    if ($result) {
        echo "写入数据缓存成功";
    }
});

//调用 B
$cortinue->async(function() {
    $data = $db->query("SELECT * FROM data LIMIT 1,1");

    $result = $cache->set("data_cache", $data);

    if ($result) {
        echo "写入数据缓存成功";
    }
});

以上代码中,当调用 A的代码执行到 query() 在等待时,调用 A 的整个调用堆栈就暂停了等待 query() 返回结果,但是调用 B 中的代码仍然在继续跑,如果调用 B 的代码停在某处等待 IO 返回时,调用 A 也不会受影响。这就实现了并发,对于调用 A 和调用 B 中的代码来讲,它们自己就是同步的。

PHP

协程那么美好,它需要什么?

如你所见,上面的代码要能够实现等待时停止在当前调用堆栈的某处,却不阻塞同一个线程中其它地方的调用,然后在调用完成后再恢复到那个地方继续执行,这个特性是需要语言支持的,从用户代码层面是无法实现这种东西的。如果语言层面没有提供类似的支持,那么协程就无法实现,比如 Swoole 就是通过扩展模块来实现,而 PHP 在 8.1 之前的版本中有一个叫生成器的东西,通过配置 yield 语法也可以实现(但是有点丑),而 PHP 8.1 之后增加了一个叫作 Fiber 的东西,可以做到无需添加关键字,隐式的有栈中断和恢复,是实现协程的基础。

还有一点,如果底层的调用,比如向 MySQL 发请求走的 TCP 或者 Socket 调用,在语言层面本来就是阻塞的,也就是说语言不支持异步 IO,那么即使是异步或协程编程,程序在等待时也会完全暂停,无法并发。这是现在 PHP 的缺点,PHP 的网络 Socket 可以通过设定阻塞参数实现无阻塞 IO,但是文件读写 IO 还无法实现异步,除了像 Swoole 这种扩展层面提供异步文件 IO 支持,其它的比如 Workerman, Amp 要么在读写文件时阻塞当前进程,要么在其它进程中写,要么还是需要依赖扩展。

而有些语言,比如 Node,Go,Java,C,C++ 等等他们要么设计之初就把 IO 设计成异步的(Node、Go)、要么支持多线程,通过线程提供异步 IO 而不阻塞业务主线程。

这就是异步、协程。

异步、协程、多线程是现代语言几个重要的概念和特性,PHP 在这方面有很大的不足,即使是语言层面提供了支持,异步和协程对整个生态也是一个很大的考验,PHP 任重而道远。

Pader,2024年4月16日头脑一热发表于 Workerman 问答社区,希望能解答你的疑问。

🔝