分类 实战 下的文章

这篇文章转载自好未来学而思技术团队,分享老师:李乐,本文基于Swoole-4.3.2和PHP-7.1.0版本

Swoole协程简介

Swoole4为PHP语言提供了强大的CSP协程编程模式,用户可以通过go函数创建一个协程,以达到并发执行的效果,如下面代码所示:

//Co::sleep() 是 Swoole 提供的 API,并不会阻塞当前进程,只会阻塞协程触发协程切换
go(function() {
    Co::sleep(1);
    echo "a";
});

go(function() {
    Co::sleep(2);
    echo "b";
});

echo "c";

//输出结果:cab
//程序总执行时间 2 秒

其实在Swoole4之前就实现了多协程编程模式,在协程创建、切换以及结束的时候,相应的操作php栈即可(创建、切换以及回收php栈)。

此时的协程实现无法完美的支持php语法,其根本原因在于没有保存c栈信息。(vm内部或者某些扩展提供的API是通过c函数实现的,调用这些函数时如果发生协程切换,c栈该如何处理?)

Swoole4新增了c栈的管理,在协程创建、切换以及结束的同时会伴随着c栈的创建、切换以及回收。

Swoole4协程实现方案如下图所示:

2023-03-25T15:37:46.png

其中:

  • API层是提供给用户使用的协程相关函数,比如go()函数用于创建协程;Co::yield()使得当前协程让出CPU;Co::resume()可恢复某个协程执行;
  • Swoole4协程需要同时管理c栈与php栈,Coroutine用于管理c栈,PHPCoroutine用于管理php栈;其中Coroutine(),yield(),resume()实现了c栈的创建以及换入换出;create_func(),on_yield(),on_resume()实现了php栈的创建以及换入换出;
  • Swoole4在管理c栈时,用到了boost.context库,make_fcontext()和jump_fcontext()函数均使用汇编语言编写,实现了c栈上下文的创建以及切换;
  • Swoole4对boost.context进行了简单封装,即Context层,Context(),SwapIn()以及SwapOut()对应c栈的创建以及换入换出。

深入理解C栈

函数是对代码的封装,对外暴露的只是一组指定的参数和一个可选的返回值;假设函数P调用函数Q,Q执行后返回函数P,实现该函数调用需要考虑以下三点:

  • 指令跳转:进入函数Q的时候,程序计数器必须被设置为Q的代码的起始地址;在返回时,程序计数器需要设置为P中调用Q后面那条指令的地址;
  • 数据传递:P能够向Q提供一个或多个参数,Q能够向P返回一个值;
  • 内存分配与释放:Q开始执行时,可能需要为局部变量分配内存空间,而在返回前,又需要释放这些内存空间;

大多数语言的函数调用都采用了栈结构实现,函数的调用与返回即对应的是一系列的入栈与出栈操作,我们通常称之为函数栈帧(stack frame)。示意图如下:

2023-03-25T15:38:42.png

上面提到的程序计数器即寄存器%rip,另外还有两个寄存器需要重点关注:%rbp指向栈帧底部,%rsp指向栈帧顶部。

下面将通过具体的代码事例,为读者讲解函数栈帧。c代码与汇编代码如下:

2023-03-25T15:39:14.png

2023-03-25T15:39:25.png

分析汇编代码:

  • main函数与add函数入口,首先将寄存器%rbp压入栈中用于保存其值,其次移动%rbp指向当前栈顶部(此时%rbp,%rsp都指向栈顶,开始新的函数栈帧);
  • main函数"subq $16, %rsp",是为main函数栈帧预留16个字节的内存空间;
  • 调用add函数时,第一个参数和第二个参数分别保存在寄存器%edi和%esi,返回值保存在寄存器%eax;
  • call指令用于函数调用,实现了两个功能:寄存器%rip压入栈中,跳转到新的代码位置;
  • ret指令用于函数返回,弹出栈顶内容到寄存器%rip,依次实现代码跳转;
  • leave指令等同于两条指令:movq %rsp,%rbp和popq %rbp,用于释放main函数栈帧,恢复前一个函数栈帧;
  • 注意add函数栈帧,并没有为其预留空间,寄存器%rsp和%rbp都指向栈帧底部;根本原因是add函数没有调用其他函数。

该程序的栈结构示意图如下:

2023-03-25T15:40:05.png

问题:观察上面的汇编代码,输入参数分别使用的是寄存器%edi和%esi,返回值使用的是寄存器%eax,输入输出参数不应该保存在栈上吗?寄存器比内存访问要快的多,现代处理器寄存器数目也比较多,因此倾向于将参数优先保存在寄存器。比如%rdi, %rsi, %rdx, %rcx, %r8d, %r9d 六个寄存器用于存储函数调用时的前6个参数,那么当输入参数数目超过6个时,如何处理?这些输入参数只能存储在栈上了。
(%rdi等表示64位寄存器,%edi等表示32位寄存器)

2023-03-25T15:40:26.png

Swoole C栈管理

通过学习c栈基本知识,我们知道最主要有三个寄存器:%rip程序计数器指向下一条需要执行的指令,%rbp指向函数栈帧底部,%rsp指向函数栈帧顶部。这三个寄存器可以确定一个c栈执行上下文,c栈的管理其实就是这些寄存器的管理。

第一节我们提到Swoole在管理c栈时,用到了boost.context库,其中make_fcontext()和jump_fcontext()函数均使用汇编语言编写,实现了c栈执行上下文的创建以及切换;函声明命如下:

fcontext_t make_fcontext(void *sp, size_t size, void (*fn)(intptr_t));
intptr_t jump_fcontext(fcontext_t *ofc, fcontext_t nfc, intptr_t vp, bool preserve_fpu = false);

make_fcontext函数用于创建一个执行上下文,其中参数sp指向内存最高地址处(在堆中分配一块内存作为该执行上下文的c栈),参数size为栈大小,参数fn是一个函数指针,指向该执行上下文的入口函数;代码主要逻辑如下:

2023-03-25T15:40:54.png

make_fcontext函数创建的执行上下文示意图如下(可以看到预留了若干字节用于保存上下文信息):

2023-03-25T15:42:12.png

Swoole协程实现的Context层封装了上下文的创建,创建上下文函数实现如下:

2023-03-25T15:42:27.png

可以看到c栈执行上下文是通过sw_malloc函数在堆上分配的一块内存,默认大小为2M字节;参数sp指向的是内存最高地址处;执行上下文的入口函数为Context::context_func()。

jump_fcontext函数用于切换c栈上下文:

  1. 函数会将当前上下文(寄存器)保存在当前栈顶(push),同时将%rsp寄存器内容保存在ofc地址;
  2. 函数从nfc地址处恢复%rsp寄存器内容,同时从栈顶恢复上下文信息(pop)。

代码主要逻辑如下:

2023-03-25T15:43:50.png

观察jump_fcontext函数的汇编代码,可以看到保存上下文与恢复上下文的代码基本是对称的。恢复上下文时"popq %r8"用于弹出上一次保存的程序计数器%rip的内容,然而并没有看到保存寄存器%rip的代码。这是因为调用jump_fcontext函数时,底层call指令已经将%rip入栈了。

Swoole协程实现的Context层封装了上下文的换入换出,可以在上下文swap_ctx_和ctx_之间随时换入换出,代码实现如下:

2023-03-25T15:44:28.png

上下文示意图如下所示:

2023-03-25T15:45:07.png

Swoole PHP栈管理

php代码在执行时,同样存在函数栈帧的分配与回收。php将此抽象为两个结构,php栈zend_vm_stack,与执行数据(函数栈帧)zend_execute_data。

php栈结构与c栈结构基本类似,定义如下:

struct _zend_vm_stack {
    zval *top;
    zval *end;
    zend_vm_stack_prev;
}

其中top字段指向栈顶位置,end字段指向栈底位置;prev指向上一个栈,形成链表,当栈空间不够时,可以进行扩容。php虚拟机申请栈空间时默认大小为256K,Swoole创建栈空间时默认大小为8K。

执行数据结构体,我们需要重点关注这几个字段:当前函数编译后的指令集(opline指向指令集数组中的某一个元素,虚拟机只需要遍历该数组并执行所有指令即可),函数返回值,以及调用该函数的执行数据;结构定义如下:

2023-03-25T15:46:47.png

php栈初始化函数为zend_vm_stack_init;当执行用户函数调用时,虚拟机通过函数zend_vm_stack_push_call_frame在php栈上分配新的执行数据,并执行该函数代码;函数执行完成后,释放该执行数据。代码逻辑如下:

2023-03-25T15:46:59.png

php栈帧结构示意图如下:

2023-03-25T15:47:25.png

Swoole协程实现,需要自己管理php栈,在发生协程创建以及切换时,对应的创建新的php栈,切换php栈,同时保存和恢复php栈上下文信息。这里涉及到一个很重要的结构体php_coro_task:

2023-03-25T15:47:40.png

这里列出了php_coro_task结构体的若干关键字段,这些字段用于保存和恢复php上下文信息。

协程创建时,底层通过函数PHPCoroutine::create_func实现了php栈的创建:

2023-03-25T15:53:51.png

从代码中可以看到结构php_coro_task是直接存储在php栈的底部。

当通过yield函数让出CPU时,底层会调用函数 PHPCoroutine::on_yield 切换 php 栈:

2023-03-25T15:54:19.png

Swoole协程实现

前面我们简单介绍了Swoole协程的实现方案,以及Swoole对c栈与php栈的管理,接下来将结合前面的知识,系统性的介绍Swoole协程的实现原理。

Swoole协程数据模型

话不多说,先看一张图:

2023-03-25T15:54:43.png

  • 每个协程都需要管理自己的c栈与php栈;
  • Context封装了c栈的管理操作;ctx_字段保存的是寄存器%rsp的内容(指向c栈栈顶位置);swap_ctx_字段保存的是将被换出的协程寄存器%rsp内容(即,将被换出的协程的c栈栈顶位置);SwapIn()对应协程换入操作;SwapOut()对应协程换出操作;
  • 参考jump_fcontext实现,协程在换出时,会将寄存器%rip,%rbp等暂存在c栈栈顶;协程在换入时,相应的会从栈顶恢复这些寄存器的内容;
  • Coroutine管理着协程所有内容;cid字段表示当前协程的ID;task字段指向当前协程的php_coro_task结构,该结构中保存的是当前协程的php栈信息(vm_stack_top,execute_data等);ctx字段指向的是当前协程的Context对象;origin字段指向的是另一个协程Coroutine对象;yield()和resume()对应的是协程的换出换入操作;
  • 注意到php_coro_task结构的co字段指向其对应的协程对象Coroutine;
  • Coroutine还有一些静态属性,静态属性的属于类属性,所有协程共享的;last_cid字段存储的是当前最大的协程ID,创建协程时可用于生成协程ID;current字段指向的是当前正在运行的协程Coroutine对象;on_yield和on_resume是两个函数指针,用于实现php栈的切换操作,实际指向的是方法PHPCoroutine::on_yield和PHPCoroutine::on_resume;

Swoole协程创建与切换

协程创建

Swoole创建协程可以使用go()函数,底层实现对应的是PHP_FUNCTION(swoole_coroutine_create),其函数实现如下:

2023-03-25T15:55:43.png

  • 注意Coroutine::create函数第一个参数伟create_func,该函数后续用于创建php栈,并开始协程代码的执行;
  • 可以看到PHPCoroutine::create在调用Coroutine::create创建创建协程之前,保存了当前php栈信息到php_coro_task结构中。
  • 注意主程序的php栈是虚拟机创建的,结构与上面画的协程php栈不同,主程序的php_coro_task结构并没有存储在php栈上,而是一个静态变量PHPCoroutine::main_task,从get_task方法可以看出,主程序中get_current_task()返回的是null,因此最后获得的php_coro_task结构是PHPCoroutine::main_task。

2023-03-25T15:56:09.png

  • 在Coroutine的构造函数中完成了协程对象Coroutine的创建与初始化,以及Context对象的创建与初始化(创建了c栈);run()函数执行了协程的换入,从而开始协程的运行;

2023-03-25T15:56:28.png

  • 可以看到创建协程对象Coroutine时,通过last_cid来计算当前协程的ID,同时将该协程对象加入到全局map中;代码ctx(stack_size,fn, private_data)创建并初始化了Context对象;
  • run()函数将该协程换入执行时,赋值origin为当前协程(主程序中current为null),同时设置current为当前协程对象Coroutine;调用SwapIn()函数完成协程的换入执行;最后如果协程执行完毕,则关闭并释放该协程对象Coroutine;
  • 初始化Context对象时,可以看到其构造函数Context::Context(size_tstack_size, coroutine_func_t fn, void* private_data),其中参数fn为协程入口函数(PHPCoroutine::create_func),可以看到其赋值给ontext对象的字段fn_,但是在创建c栈上下文时,其传入的入口函数为context_func;

2023-03-25T15:57:07.png

  • 函数context_func内部其实调用的就是方法PHPCoroutine::create_func;当协程执行结束时,会标记end字段为true,同时将该协程换出;

2023-03-25T15:57:28.png

问题:参数arg为什么是Context对象呢,是如何传递的呢?这就涉及到jump_fcontext汇编实现,以及jump_fcontext的调用了

2023-03-25T15:57:47.png

调用jump_fcontext函数时,第三个参数传递的是this,即当前Context对象;而函数jump_fcontext汇编实现时,将第三个参数的内容拷贝到%rdi寄存器中,当协程换入执行函数context_func时,寄存器%rdi存储的就是第一个参数,即Context对象。

方法PHPCoroutine::create_func就是创建并初始化php栈,执行协程代码;这里不做过多介绍。

问题:Coroutine的静态属性on_yield和on_resume时什么时候赋值的?

在Swoole模块初始化时,会调用函数swoole_coroutine_util_init(该函数同时声明了"Co"等短名称),该函数进一步的调用PHPCoroutine::init()方法,该方法完成了静态属性的赋值操作。

2023-03-25T15:59:00.png

协程切换

用户可以通过Co::yield()和Co::resume()实现协程的让出和恢复,Co::yield()的底层实现函数为PHP_METHOD(swoole_coroutine_util, yield),Co::resume()的底层实现函数为PHP_METHOD(swoole_coroutine_util, resume)。本节将为读者讲述协程切换的实现原理。

2023-03-25T15:59:18.png

  • 调用Co::resume()恢复某个协程之前,该协程必然已经调用Co::yield()让出CPU;因此在Co::yield()时,会将该协程对象添加到全局map中;Co::resume()时做相应校验,如果校验通过则恢复协程,并从map种删除该协程对象;
  • co->yield()实现了协程的让出操作;

    1. 设置协程状态为SW_CORO_WAITING;
    2. 回调on_yield方法,即PHPCoroutine::on_yield,保存当前协程(task代表协程)的php栈上下文,恢复另一个协程的php栈上下文(origin代表另一个协程对象);
    3. 设置当前协程对象为origin;
    4. 换出该协程;

2023-03-25T15:59:46.png

  • co->resume()实现了协程的恢复操作:

    1. 设置协程状态为SW_CORO_RUNNING;
    2. 回调on_resume方法,即PHPCoroutine::on_resume,保存当前协程(current协程)的php栈上下文,恢复另一个协程(task代表协程)的php栈上下文;
    3. 设置origin为当前协程对象,current为即将要换入的协程对象;
    4. 换入协程;

2023-03-25T16:00:41.png

Swoole协程有四种状态:初始化,运行中,等待运行,运行结束;定义如下:

2023-03-25T16:01:00.png

协程之间可以通过Coroutine对象的origin字段形成一个类似链表的结构;
Co::yield()换出当前协程时,会换入origin协程;
在A协程种调用Co::resume()恢复B协程时,会换出A协程,换入B协程,同时标记A协程为B的origin协程;

协程切换过程比较简单,这里不做过多详述。

Swoole协程调度

Swoole的协程调度是基于事件驱动的,下面将介绍socket读写事件以及sleep定时器事件触发的协程调度。

socket读写事件

Swoole的socket读写使用的成熟的IO多路复用模型:epoll/kqueue/select/poll等,并且将其封装在结构体_swReactor中,其定义如下:

2023-03-25T16:01:37.png

在调用函数PHPCoroutine::create创建协程时,会校验是否已经初始化_swReactor对象,如果没有则会调用php_swoole_reactor_init函数创建并初始化main_reactor对象;

2023-03-25T16:02:46.png

我们以epoll为例,main_reactor各回调函数如下:

2023-03-25T16:03:05.png

注意:这里注册了一个函数swoole_event_wait,在生命周期register_shutdown阶段会执行该函数,开始Swoole的事件循环,阻挡了php生命周期的结束。

类Socket封装了socket读写相关的所有操作以及数据结构,其定义如下:

2023-03-25T16:03:26.png

  • socket字段类型为swConnection,代表传输层连接;
  • reactor字段指向结构体swReactor对象,用于fd事件的注册、修改、删除以及wait;
  • 当调用recv()函数接收数据,阻塞了该协程时,read_co字段指向该协程对象Coroutine;
  • 当调用send()函数接收数据,阻塞了该协程时,write_co字段指向该协程对象Coroutine;
  • 类Socket初始化函数为Socket::init_sock:

2023-03-25T16:03:55.png

当我们调用CoroutineSocket->recv接收数据时,底层实现如下:

2023-03-25T16:04:13.png

类timeout_setter会设置socket的接收数据超时时间read_timeout为timeout。

函数socket->recv_all会循环读取数据,直到读取到指定长度的数据,或者底层返回等待标识阻塞当前协程:

2023-03-25T16:04:29.png

  • 函数首先创建timer_controller对象,设置其超时时间为read_timeout,以及超时回调函数为timer_callback;
  • while(true)死循环读取fd数据,当读取数据量等于__n时,读取操作结束,break该循环;如果读取操作swConnection_recv返回值小于0,并且错误标识为SW_WAIT,说明需要等待数据到来,此时阻塞当前协程等待数据到来(函数wait_event会换出当前协程),阻塞超时时间为read_timeout(函数timer.start()用于设置超时时间)。

2023-03-25T16:04:53.png

函数swTimer_add用于添加一个定时器;Swoole底层定时任务是通过最小堆实现的,堆顶元素的超时时间最近;结构体_swTimer维护着Swoole内部所有的定时任务:

2023-03-25T16:05:30.png

当调用swTimer_add向_swTimer结构中添加定时任务时,需要更新_swTimer中最早的定时任务触发时间_next_msec,同时更新main_reactor对象的超时时间:

2023-03-25T16:06:10.png

函数wait_event负责将当前协程换出,直到注册的事件发生

2023-03-25T16:17:07.png

  • 函数add_event用于添加事件,底层调用reactor->add添加fd的监听事件;
  • read_co = co或者write_co = co,用于记录当前哪个协程阻塞在该socket对象上,当该socket对象的读写事件被触发时,可以恢复该协程执行;
  • 函数yield()将该协程换出;

上面提到,创建协程时,注册了一个函数swoole_event_wait,在生命周期register_shutdown阶段会执行该函数,开始Swoole的事件循环,阻挡了php生命周期的结束。函数swoole_event_wait底层就是调用main_reactor->wait等待fd读写事件的产生;我们以epoll为例讲述事件循环的逻辑:

2023-03-25T16:17:44.png

swReactorEpoll_wait是对函数epoll_wait的封装;当有读写事件发生时,执行相应的handle,根据上面的讲解我们知道读写事件的handle分别为readable_event_callback和writable_event_callback;

2023-03-25T16:17:57.png

可以看到函数readable_event_callback只是简单的恢复read_co协程即可;

当epoll_wait发生超时,最终调用的是函数swReactor_onTimeout,该函数会从Swoole维护的一系列定时任务swTimer中查找已经超时的定时任务,同时执行其callback回调;

2023-03-25T15:49:11.png

该callback回调函数即为上面设置的timer_callback:

2023-03-25T15:48:46.png

同样的,timer_callback函数只是简单的恢复read_co或者write_co协程即可

sleep定时器事件

Co::sleep()的实现函数为PHP_METHOD(swoole_coroutine_util, sleep),该函数通过调用Coroutine::sleep实现了协程休眠的功能:

2023-03-25T15:48:23.png

可以看到,与socket读写事件超时处理相同,sleep内部实现时通过swTimer_add添加定时任务,同时换出当前协程实现的。该定时任务会导致main_reactor对象的超时时间的改变,即修改了epoll_wait的超时时间。

sleep的超时处理函数为sleep_timeout,只需要换入该阻塞协程对象即可,实现如下:

2023-03-25T15:48:00.png

本文转载于学而思网校技术团队,2020.10月我加入了好未来励步事业部,我们事业部开始大面积应用 swoole,所以对 swoole 进行了深入学习。本文作者:陈曹奇昊

初次接触Swoole的PHP开发者多少都会有点雾里看花的感觉,看不清本质。一部分PHP开发者并不清楚Swoole是什么,只是觉得很牛掰就想用了,这种行为无异于写作文的时候总想堆砌一些华丽的辞藻或是引经据典来提升文章逼格,却背离了文章的主题,本末倒置,每一种技术的诞生都有它的原因,异步或是协程不是万能的银弹,你需要它的时候再去用它,而不是想用它而用它,毕竟编程世界的惯性是巨大的,这天下还是同步阻塞的天下。还有一部分开发者是对Swoole有了一些自己的见解,但对错参半,写出来的程序能跑,甚至也能上生产,但不是最优的,其中大部分问题都源于开发者无法将惯有的思维方式灵活转变。

协程

首先协程的最简定义是用户态线程,它不由操作系统而是由用户创建,跑在单个线程(核心)上,比进程或是线程都更加轻量化,通常创建它只有内存消耗:假如你的配置允许你开几千个进程或线程,那么开几万个几十万个协程也是很轻松的事情,只要内存足够大,你可以几乎无止境地创建新的协程。在Swoole下,协程的切换实现是依靠双栈切换,即C栈和PHP栈同时切换,由于有栈协程的上下文总是足够的小,且在用户态便能完成切换,它的切换速度也总是远快于进程、线程,一般只需要纳秒级的CPU时间,对于实际运行的逻辑代码来说这点开销总是可以忽略不计(尤其是在一个重IO的程序中,通过调用分析可以发现协程切换所占的CPU时间非常之低)。

对于Swoole这样的有栈协程,你完全可以简单地将其看做是一个栈切换器,你可以在运行的子程序中随意切换到另一个子程序,底层会保存好被切走的协程的执行位置,回来时可以从原先的位置继续往下运行。

2023-03-25T15:29:53.png

Swoole多进程模型下的进程、线程、协程关系图
但这篇文章我们要谈的并不只是单单「协程」这一个概念,还隐含了关于异步网络IO一系列的东西,光有协程是什么也做不了的,因为Swoole的协程永远运行在一个线程中,想用它做并行计算是不可能的,运行速度只会因为创建开销而更慢,没有异步网络IO支持,你只能在不同协程间切来切去玩。

实际上PHP早就实现了协程,yield关键字就是允许你从一个函数中让出执行权,需要的时候能重新回到让出的位置继续往下执行,但它没有流行起来也有多种原因,一个是它的传染性,每一层调用都需要加关键字,另一个就是PHP没有高效可靠的异步IO支持,让其食之无味。

异步

注:本文中提到的异步IO并非全为严格定义上的异步IO,更多的是日常化的表达
简单了解了协程,再让我们来理解一下什么是异步IO吧。严格来说,在Unix下我们常说的异步并不是真异步,而是同步非阻塞,但是其效果和异步非常相近,所以我们日常中还是以异步相称。同步非阻塞和真异步区别在于:真异步是你提交读写请求后直接检查读写是否已完成即可,所以在Win下这样的技术被叫做「完成端口」,而同步非阻塞仅是操作不会长时间地陷入内核,但你需要在检查到可读或可写后,调用API同步地去拷贝数据,这会不可避免地陷入内核态,但read/write通常并不会阻塞太多的时间,从宏观上整个程序仍可以看作是全异步的。

阻塞非阻塞
同步read, write(read, write) + poll/ select / epoll / kqueue
异步-aio_read, aio_write, IOCP(windows)

在实际使用中,「伪异步」的Reactor模型并不比Windows下IOCP的Proactor逊色,并且我更喜欢Reactor的可控性,当然为了追求极致的性能和解决网络和文件异步IO统一的问题,未来Linux的io_uring可能会成为新的趋势。
2023-03-25T15:30:12.png

Reactor运行流程简图
我们可以通过上面的图片简单理解Reactor模型的运行流程,所谓的「异步」不过是多路复用带来的观感效果,你的程序不会阻塞在一个IO上,而是在无事可干的时候再阻塞在一堆IO上,即IO操作不在你需要CPU的时候阻塞你,你就不会感受到IO阻塞的存在。

结合现实情景来说,以前你要买饭(IO操作),你得下楼去买,还得排队等饭店大厨做完才能取回家吃(IO阻塞),到了下一餐,你又得重复之前的操作,很是麻烦,而且越是繁忙的时候等的时间越长(慢速IO),你觉得一天到晚净排队了,极大地浪费了你写代码的时间(CPU时间)。现在有了外卖,你直接下单(异步请求)就可以继续专心写代码(非阻塞),你还可以一次定三份饭(多路IO),饭到了骑手打电话让你下楼取(事件触发),前后只花了不到几分钟(同步读写,如果是Proactor连取餐都省了,直接给你送上楼),周六晚上的九点,你终于合上电脑,觉得充实极了,因为你几乎一整周都在写代码(CPU利用率高)。

协程+异步=同步非阻塞编程

现在我们有了协程和异步,我们可以做什么呢?那就是异步的同步化。这时候有的开发者就会说了,诶呀好不容易习惯异步了,怎么又退回到同步了呢。这就是为什么有些开发者始终写不出最优的协程代码的原因,异步由于操作的完成不是立即的,所以我们需要回调,而回调总是反人类的,嵌套的回调更是如此。

而结合协程,消灭回调我们只需要两步:在发出异步请求之后挂起协程,在异步回调触发时恢复协程。

Swoole\Coroutine\run(function(){
    // 1. 创建定时器并挂起协程#1
    Swoole\Coroutine::sleep(1);
    // 3. 协程恢复,继续向下运行退出,再次让出
});
// 2. 协程#1让出,进入事件循环,等待1s后定时器回调触发,恢复协程#1
// 4. 协程#1退出并让出,没有更多事件,事件循环退出,进程结束
短短的一行协程sleep,使用时几乎与同步阻塞的sleep无异,却是异步的。
for ($n = 10; $n--;) {
    Swoole\Coroutine::create(function(){
        Swoole\Coroutine::sleep(1);
    });
}

我们循环创建十个协程并各sleep一秒,但实际运行可以发现整个进程只阻塞了一秒,这就表明在Swoole提供的API下,阻塞操作都由进程级别的阻塞变为了协程级别的阻塞,这样我们可以以很小的开销在进程内通过创建大量协程来处理大量的IO任务。

协程代码编写思路

定时任务

当我们说到定时任务时,很多人第一时间都想到定时器,这没错,但是在协程世界,它不是最佳选择。

$stopTimer = false;
$timerContext = [];
$timerId = Swoole\Timer::tick(1, function () {
    // do something
    global $timerContext;
    global $timerId;
    global $stopTimer;
    $timerContext[] = 'data';
    if ($stopTimer) {
        var_dump($timerContext);
        Swoole\Timer::clear($timerId);
    }
});
// if we want to stop it:
$stopTimer = true;

在异步回调下,我们需要以这样的方式来掌控定时器,每一次定时器回调都会创建一个新的协程,并且我们不得不通过全局变量来维护它的上下文。

如果是协程呢?

Swoole\Coroutine\run(function() {
    $channel = new Swoole\Coroutine\Channel;
    Swoole\Coroutine::create(function () use ($channel) {
        $context = [];
        while (!$channel->pop(0.001)) {
            $context[] = 'data';
        }
        var_dump($context);
    });
    // if we want to stop it, just call:
    $channel->push(true);
});

完全同步的写法,从始至终只在一个协程里,不会丢失上下文,channel->pop在这里的效果相当于毫秒级sleep,并且我们可以通过push数据去停止这个定时器,非常的简单清晰。

Task

由于开发者的强烈要求,Swoole官方曾经做了一个错误的决定,就是在Task进程中支持协程和异步IO。

图片
正如图中所示,Task进程最初被设计为用来处理无法异步化的任务,充当类似于PHP-FPM的角色(半异步半同步模型),这样各司其职,能够将执行效率最大化。

最早期的Swoole开发者,甚至直接将Swoole的Worker进程用于执行同步阻塞任务,这种做法并非没有可取之处,它比PHP-FPM下的效率更高,因为程序是持续运行,常驻内存的,少了一些VM启动和销毁的开销,只是需要自己处理资源的生命周期等问题。

此外就是使用异步API的开发者,他们会开一堆Task进程,将一些暂时无法异步化的同步阻塞任务丢过去处理。

而以上两种都是历史条件下正确并合适的Swoole打开方式。

但是还有一小撮开发者,一股脑地把所有任务都投递给Task进程,以为这样就实现了任务异步化,Worker进程除了接收响应和投递任务什么也不干,殊不知这就相当于每一个任务的处理多了两次数据序列化开销 + 两次数据反序列开销 + 两次IPC开销 + 进程切换开销。

而当协程逐渐成为新的趋势后,又有越来越多的社区呼声要求Task进程也能支持协程和异步IO,这样他们就可以将协程方式编写的任务投递到Task中执行。但异步任务可以很轻量地在本进程被快速处理掉,对Worker整体性能并不会有太大影响,他们这样的行为,也是典型的舍近求远。

Task方式处理协程任务

$server->on('Receive', function(Swoole\Server $server) {
    # 投递任务,序列化任务数据,通过IPC发送给Task进程
    $task_id = $server->task('foo'); 
});
# 切换到Task进程
# 接收并反序列化Worker通过IPC发送来的任务数据
$server->on('Task', function (Swoole\Server $server, $task_id, $from_id, $data) {
    # 使用协程DNS查询
    $result = \Swoole\Coroutine::gethostbyname($data);
    # 序列化数据,通过IPC发送回Worker进程
    $server->finish($result);
});
# 回到Worker进程
# 接收并反序列化Task通过IPC发送来的结果数据
$server->on('Finish', function (Swoole\Server $server, int $task_id, $result) {
    # 需要通过任务id才能确认是哪个任务的结果
    echo "Task#{$task_id} finished";
    # 打印结果
    var_dump($result);
});

协程方式写Task

注:batch方法由swoole/library提供,内置支持需要Swoole-v4.5.2及以上版本,低版本可以自己使用Channel来调度

use Swoole\Coroutine;

Coroutine\run(function () {
    # 并发三个DNS查询任务
    $result = Coroutine\batch([
        '100tal' => function () {
            return Coroutine::gethostbyname('www.100tal.com');
        },
        'xueersi' => function () {
            return Coroutine::gethostbyname('www.xueersi.com');
        },
        'zhiyinlou' => function () {
            return Coroutine::gethostbyname('www.zhiyinlou.com');
        }
    ]);
    var_dump($result);
});

输出(API保证返回值顺序与输入顺序一致,不会因为异步而乱序)

array(3) {
  ["100tal"]=>
  string(14) "203.107.33.189"
  ["xueersi"]=>
  string(12) "60.28.226.27"
  ["zhiyinlou"]=>
  string(14) "101.36.129.150"
}

非常的简单易懂,不存在任何序列化或者IPC开销,并且由于程序是完全非阻塞的,大量的Task任务也不会对整体性能造成影响,所以说Task进程中使用协程或异步完全就是个错误,作为一个程序员,思维的僵化是很可怕的。

读到这里大家应该也能明白,我们所谈论的协程化技术实际上可以看做传统同步阻塞和非阻塞技术的超集,非阻塞的技术让程序可以同时处理大量IO,协程技术则是实现了可调度的异步单元,它让异步程序的行为变得更加可控。如果你的程序只有一个协程,那么程序整体就是同步阻塞的;如果你的程序在创建某个协程以后不关心它的内部返回值,它就是异步的。

希望通过本文,大家能够加深对协程和异步IO的理解,写出高质量可维护性强的协程程序。

cover

PHP 是无处不在的,可以说是互联网 Web 应用上使用最广泛的语言。

然而,它的高性能并不为人所知,尤其是在涉及到高并发系统时。这就是为什么对于这样特殊的用例,正在被 Node (是的,我知道,它不是一种语言)、Go 和 Elixir 等语言接管。

也就是说,您可以做很多事情来改进服务器上的 PHP 性能。本文主要关注 php-fpm 方面的内容,如果您使用 Nginx,这是在服务器上的默认配置。

如果你知道 php-fpm 是什么,请直接跳到优化部分。

什么是 php-fpm?

许多开发人员对 DevOps 方面的知识不太感兴趣,即使是那些对此感兴趣的开发人员,也极少有人知道它的底层原理。有趣的是,当浏览器发送一个请求到运行 PHP 的服务器上时,PHP 也不是最先进行处理请求的服务;而是,HTTP 服务器,Apache 和 Nginx 是其中最主要的两个。「web 服务器」决定如何与 PHP 进行通信,然后传递请求的类型,数据和头部信息到 PHP 进程。

php-fpm

上图是 PHP 项目的请求 - 响应生命周期(图片来源: ProinerTech)

在现代 PHP 应用中,「find file」部分即为 index.php 文件,它是在服务器配置文件中配置的用于处理所有请求的代理。

如今,Web 服务器究竟如何连接 PHP 正在进化,如果我们要深入研究所有细节,这篇文章的长度将激增。但粗略来说,在 Apache 作为 Web 服务器首选的时间段,PHP 是作为包含在服务器内部的模块。

所以每当一个请求被接收,服务器将开启一个新的进程,它将自动包含 PHP 和执行请求。这个方法被称作 mod_php,“PHP 作为一个模块” 的缩写。这种方法有其局限性,而 Nginx 和 php-fpm 克服了它。

在 php-fpm 中,管理 PHP 的责任在于服务器内部的 PHP 程序。换言之,Web 服务器 (Nginx, 在本例中), 不在乎 PHP 在哪和怎样运行的,只要它知道如何发送和接收数据即可。如果需要,在这种情况下,您可以将 PHP 视为另一台服务器,它管理传入请求的某些子 PHP 进程(因此,我们将请求送到服务器,该请求由服务器接收并传递到服务器 — — 太疯狂了!:-P)。

如果你用过 Nginx, 你会看到这些代码:

location ~ \.php$ {
    try_files $uri =404;
    fastcgi_split_path_info ^(.+\.php)(/.+)$;
    fastcgi_pass unix:/run/php/php7.2-fpm.sock;
    fastcgi_index index.php;
    include fastcgi_params;
    fastcgi_param  SCRIPT_FILENAME $document_root$fastcgi_script_name;
}

对于这一行:fastcgi_pass unix:/run/php/php7.2-fpm.sock;,它告诉 Nginx 通过 php7.2-fpm.sock 的 socket 与 php
进程通信。因此,对于每个传入的请求,Nginx 都通过这个文件写入数据,在接收到输出后,将其发送回浏览器。

我必须再次强调,对于如何运行这不是最完整或者最准确的,但对于大多数 DevOps 任务是完全准确的。

除此之外,让我们回顾一下到目前为止所学到的东西:

  • PHP 不会直接接收浏览器发送的请求。像 Nginx 这种 Web 服务器首先会拦截它。
  • Web 服务器知道如何连接到 PHP 进程,并将所有请求数据(粘贴所有内容)传递到 PHP 上。
  • PHP 完成其职责后,会将响应发送回 Web 服务器,然后将其发送回客户端(在大多数情况下为浏览器)。

流程图如下:

php nginx php-fpm 流程图

PHP 和 Nginx 如何协同工作? (图片来源:数据狗)

到目前为止都不错,那么关键问题来了:PHP-FPM 到底是什么呢?

PHP 中的 FPM 代表 「快速进程管理器」, 花式解释就是说,在服务器上运行的 PHP 并不是单个进程,而是由这个 FPM 进程管理器派生、控制和终止的一些 PHP 进程。web 服务器将请求传递给的就是这个进程管理器。

PHP-FPM 本身就是一个完整的兔子洞,所以如果您愿意,可以随意探索,但是对于我们的目的,这些解释就足够啦。 ?

为什么要优化 php-fpm?

一般在正常运行的情况下,为什么要考虑优化呢? 为什么不将事物保持原样。

具有讽刺意味的是,一般我为大多数用例提供建议的话。 如果您的设置运行良好,并且没有特殊用例,请使用默认设置。 但是,如果您希望扩展一台机器之外的能力,那么从一台机器中挤出最大的处理能力是必不可少的,因为它可以将您服务器的花费减少一半(甚至更多!)。

要说明的另一件事情是,Nginx 是为处理巨大的工作负载而构建的。 它能够同时处理成千上万的连接,但是如果您的 PHP 设置不合理,那么您将浪费很多资源,因为 Nginx 必须等待 PHP 完成当前处理之后才可以接受下一个请求,最终 Nginx 不能为您的服务提供任何优势!

所以,接下来让我们看看尝试优化 php-fpm 时我们到底要优化什么。

如何优化 PHP-FPM ?

php-fpm 的配置文件在不同服务器上的位置可能不同,因此您需要做一些调查来确定它的位置。在 UNIX 上,你可以使用 find 命令。在我的 Ubuntu 上,它的路径是 /etc/php/7.2/fpm/php-fpm.conf 。当然,7.2 是我正在运行的 PHP 版本。
下面是这个文件的前几行代码:

;;;;;;;;;;;;;;;;;;;;;
; FPM Configuration ;
;;;;;;;;;;;;;;;;;;;;;

; All relative paths in this configuration file are relative to PHP's install
; prefix (/usr). This prefix can be dynamically changed by using the
; '-p' argument from the command line.

;;;;;;;;;;;;;;;;;;
; Global Options ;
;;;;;;;;;;;;;;;;;;

[global]
; Pid file
; Note: the default prefix is /var
; Default Value: none
pid = /run/php/php7.2-fpm.pid

; Error log file
; If it's set to "syslog", log is sent to syslogd instead of being written
; into a local file.
; Note: the default prefix is /var
; Default Value: log/php-fpm.log
error_log = /var/log/php7.2-fpm.log

很明显:这一行 pid = /run/php/php7.2-fpm.pid 告诉我们哪个文件包含了 php-fpm 进程的进程 id。

我们还看到 /var/log/php7.2-fpm.log 是 php-fpm 存储日志的地方。

在这个文件中,像下面这样添加三个变量:

emergency_restart_threshold 10
emergency_restart_interval 1m
process_control_timeout 10s

前两个设置是警告性的,它们告诉 php-fpm 进程,如果 10 个子进程在一分钟内失败,主 php-fpm 进程应该重新启动自己。
这听起来可能不够稳健,但是 PHP 是一个短暂的进程,它会泄漏内存,所以在出现高故障时重新启动主进程可以解决很多问题。
第三个选项是 process_control_timeout,它告诉子进程在执行从父进程接收到的信号之前需要等待这么长的时间。这个设置是非常有用的。例如,当父进程发送终止信号时,子进程正在处理某些事情的时候。十秒的时间,他们会有一个更好的机会完成任务并且优雅地退出。

令人惊讶的是,这 不是 php-fpm 的核心配置!这是因为,为了 web 请求服务,php-fpm 创建了一个新的进程池,它将具有一个单独的配置。在我的例子中,进程池的名称是 www,我想编辑的文件是 /etc/php/7.2/fpm/pool.d/www.conf。
让我们来看看文件的内容:

; Start a new pool named 'www'.
; the variable $pool can be used in any directive and will be replaced by the
; pool name ('www' here)
[www]

; Per pool prefix
; It only applies on the following directives:
; - 'access.log'
; - 'slowlog'
; - 'listen' (unixsocket)
; - 'chroot'
; - 'chdir'
; - 'php_values'
; - 'php_admin_values'
; When not set, the global prefix (or /usr) applies instead.
; Note: This directive can also be relative to the global prefix.
; Default Value: none
;prefix = /path/to/pools/$pool

; Unix user/group of processes
; Note: The user is mandatory. If the group is not set, the default user's group
;       will be used.
user = www-data
group = www-data

快速浏览一下上面代码片段的末尾,您就会明白为什么服务器进程以 www-data 的形式运行了。如果您在设置网站时遇到文件权限问题,您可能要将目录的所有者或组更改为 www-data,从而允许 PHP 进程写入日志文件和上传文档等。
最后,我们到达了问题的根源,流程管理器 (pm) 设置。一般情况下,默认值是这样的:

pm = dynamic
pm.max_children = 5
pm.start_servers = 3
pm.min_spare_servers = 2
pm.max_spare_servers = 4
pm.max_requests = 200

那么,这里的 「dynamic(动态)」是什么意思呢?我认为官方文档最好地解释了这一点 (我的意思是,这应该已经是您正在编辑的文件的一部分,但是我在这里复制了它,以防它不是):

; Choose how the process manager will control the number of child processes.
; Possible Values:
;   static  - a fixed number (pm.max_children) of child processes;
;   dynamic - the number of child processes are set dynamically based on the
;             following directives. With this process management, there will be
;             always at least 1 children.
;             pm.max_children      - the maximum number of children that can
;                                    be alive at the same time.
;             pm.start_servers     - the number of children created on startup.
;             pm.min_spare_servers - the minimum number of children in 'idle'
;                                    state (waiting to process). If the number
;                                    of 'idle' processes is less than this
;                                    number then some children will be created.
;             pm.max_spare_servers - the maximum number of children in 'idle'
;                                    state (waiting to process). If the number
;                                    of 'idle' processes is greater than this
;                                    number then some children will be killed.
;  ondemand - no children are created at startup. Children will be forked when
;             new requests will connect. The following parameter are used:
;             pm.max_children           - the maximum number of children that
;                                         can be alive at the same time.
;             pm.process_idle_timeout   - The number of seconds after which
;                                         an idle process will be killed.
; Note: This value is mandatory.

由此可见,有三个可用值:

  • Static: 无论什么情况,都会保持一个固定的 PHP 进程数量。
  • Dynamic: 我们需要指定 php-fpm 在任何给定时间点会保持活动的最小以及最大进程数量。
  • ondemand: 按照需求创建和销毁进程。

那这些设置有什么影响呢?

简而言之,如果你有个小流量的网站,“dynamic” 设置在大多数时间内都是一种资源的浪费。假设你的 pm.min_spare_servers 设置成了 3,那会有三个 PHP 进程会被创建并保持运行,甚至是网站没有流量时。这种情况下,“ondemand” 就是个更好的选择,可以让系统决定何时启动新的进程。

另一方面,大流量 或者必须快速响应的网站将在这种情况下被惩罚。 最好避免创建新的 PHP 进程的额外开销,使其成为池的一部分并对其进行监控。

使用 pm = static 固定子进程的数量,使最大的系统资源用于服务请求而不是管理 PHP。假如你确定走这条路,注意它有其指导方针和陷阱。关于它的一篇相当密集但非常有用的文章是 这篇 。

写在最后

由于有关网络性能的文章可能会引发争论或使人们感到困惑,因此在结束本文之前,我觉得需要讲几句话。 性能调优既涉及系统知识,也涉及猜测和技巧。

即使您完全了解 php-fpm 的所有设置,也无法保证成功。 如果您不了解 php-fpm 的存在,那么您就不必浪费时间担心它。 继续做您已经在做的事情并继续下去。

同时,尽可能不让结果变得很戏剧性。 是的,您可以通过从头开始重新编译 PHP 并删除所有不需要的模块来获得更好的性能,但是这种方法在生产环境中不够明智。 优化某些内容的整个想法是查看您的需求是否与默认值不同(它们很少这样做!),并根据需要进行较小的更改。