图源:php.net
正如Python关于协程的PEP所讲,异步编程和并发已经是编程的一个热门领域,所以无论是老派语言如Python,或者是新语言Go,要么是添加新特性以支持协程,要么是天生就对协程和并发有完整支持。
但在这方面php就相当落(la)后(kua)了。
或许这和语言的应用领域和使用方式有一些关系,php作为一个和Apache等web service紧密结合的Web开发语言,绝大部分php项目都是依托于web service处理和转发请求的,php本身并不需要花大力气去管理并发和进程,至少开发者不需要。这也就意味着协程和并发对传统的php项目可有可无。
但这也不完全没有用途,否则Swoole也就不会有商业价值。在某些追求高并发高性能而抛弃web service直接监听TCP套接字进行服务的场景(比如游戏或即时聊天服务器等),就需要协程了,而Swoole的价值正在于此。
幸运的是php8.1正式引入了一个核心的协程机制:内置的Fliber
类。
虽然围绕该提案有很多讨(si)论(bi),但至少必须要引入协程这是绝大多数人的观点,至于是仅引入一个最小核心类Fliber
还是完整的包含了“事件循环”、“线程调度”等的完整框架,这些问题很难在短期内社区达成一致。就我看来,有至少比没有强。
如果你还不知道什么是协程以及协程的基本概念,可以阅读Python学习笔记33:协程,虽然那篇文章讨论的是Python的协程,但其实和php的协程概念是几乎一致的,毕竟本质上Python和php一样是单线程的。
这里我会提供一个使用协程的案例,分别用php、Go lang、Python实现,以对比它们之间的差异。
$create_number = new Fiber(function (): void {
Fiber::suspend();
for ($i = 0; $i <= 10; $i++) {
Fiber::suspend($i);
}
Fiber::suspend(null);
});
$double_number = new Fiber(function (Fiber $create_number): void {
Fiber::suspend();
while (true) {
$number = $create_number->resume();
if ($number === null) {
$create_number->resume();
Fiber::suspend(null);
break;
}
Fiber::suspend($number * 2);
}
});;
$print_number = new Fiber(function (Fiber $double_number) {
while (true) {
$number = $double_number->resume();
if (null === $number) {
$double_number->resume();
break;
}
echo $number . " ";
}
});
$create_number->start();
$double_number->start($create_number);
$print_number->start($double_number);
// 0 2 4 6 8 10 12 14 16 18 20
这里创建了三个协程:
$create_number
负责产出数据,示例中是1...10
$double_number
将$create_number
产出的数据*2
,结果是2...20
$print_number
将$double_number
处理过的数据输出到屏幕php的协程是新引入的Fiber
类的实例,该类的构造方法接受一个callable
类型的参数。这个参数可以是匿名函数、函数变量或者实现了__invoke
的对象。callable
类型可以接收参数,该参数在调用Fiber
实例的start
方法时传入。
php的协程由start
方法激活。激活后会进入协程绑定的callable
的代码执行,直到遇到Fiber::suspend()
挂起,该静态方法会将当前正在运行的协程(也就是代码所在callable
绑定的协程)挂起。如果suspend
没有参数,会向外部传递一个null
值,如果有参数,会向外传递给激活或让它恢复执行的调用方。
这也是为什么$create_number
和$double_number
两个协程需要在首行添加Fiber::suspend()
,因为需要它们在激活后挂起,直到第三个协程$print_number
激活后来间接恢复前两个协程来执行“产出数据”的工作。
在这个示例中协程$print_number
依赖于$double_number
,而$double_number
依赖于$create_number
,所以在最里层的协程$create_number
结束的时候,必须通知外部的$double_number
,而$double_number
也必须通知最外层的$print_number
。
我这里使用向外传递一个null
值的方式通知,外部协程在检测到null
后,调用$innerCoroutine->resume()
来让内侧协程恢复执行,以正常退出。然后外部协程也就可以退出了。
我这里讲的还是很笼统,建议阅读Python学习笔记33:协程,我使用了时序图来说明协程的调用机制。
from typing import Coroutine
def create_number():
for num in range(11):
yield num
def double_num(cn: Coroutine):
while True:
try:
num = next(cn)
except StopIteration:
break
num = num*2
yield num
def print_num(dn: Coroutine):
while True:
try:
num = next(dn)
except StopIteration:
break
print("{:d} ".format(num), sep=' ', end='')
print_num(double_num(create_number()))
Python和php的协程非常相似,不过Python并没有选择使用新的类型或者关键字,而是直接让生成器演化为协程。此外Python用next()
来驱动协程,并且协程在执行完毕退出时,会抛出一个StopIteration
的异常。外层协程可以通过捕获该异常来判断内层协程是否执行完毕。
package main
import (
"fmt"
"sync"
)
func create_num(out_chan chan<- int, swg *sync.WaitGroup) {
defer swg.Done()
for i := 0; i <= 10; i++ {
out_chan <- i
}
close(out_chan)
}
func double_num(in_chan <-chan int, out_chan chan<- int, swg *sync.WaitGroup) {
defer swg.Done()
for {
num, ok := <-in_chan
if !ok {
close(out_chan)
break
}
num = num * 2
out_chan <- num
}
}
func print_num(in_chan <-chan int, swg *sync.WaitGroup) {
defer swg.Done()
for {
num, ok := <-in_chan
if !ok {
break
}
fmt.Printf("%d ", num)
}
}
func main() {
chan1 := make(chan int)
chan2 := make(chan int)
var swg sync.WaitGroup
swg.Add(1)
go create_num(chan1, &swg)
swg.Add(1)
go double_num(chan1, chan2, &swg)
swg.Add(1)
go print_num(chan2, &swg)
swg.Wait()
}
Go语言和前两者差别有点大,因为Go是支持多线程的,Go的协程其实叫做“Goroutine”而非"Coroutine"。goroutine是真正的多线程,只不过可以通过非缓冲通道来同步,这样就表现得像是普通协程。在使用非缓冲通道的时候,上面的异步代码大致执行过程和前两者是类似的。只不过Go需要添加一个sync.WaitGroup
来实现“线程计数”,以阻塞主goroutine,等待三个子goroutine执行完毕后再退出。