传统基于LNMP的Web架构中,Nginx作为Web服务器,PHP-FPM维护一个进程池去运行Web项目。简单、成熟、稳定、一次运行随后销毁带来的开发便捷性是最大的特点。
PHP-FPM + Nginx LNMP- Nginx
Nginx本身是基于Linux的epoll
事件模型,即一个worker
工作进程会同时去处理多个请求。epoll
是多路复用IO(I/O Multiplexing)中的一种方式,仅用于Linux2.6+的内核。
- PHP-FPM
PHP的FastCGI进程管理器PHP-FPM本身是同步阻塞进程模型,在请求结束后会释放掉所有资源,包括框架初始化创建的一系列对象,导致PHP进程空转并消耗大量的CPU资源,因此单机吞吐能力有限。简单来说就是请求夯住会导致CPU不能释放资源大大浪费CPU使用率。
PHP-FPMPHP-FPM引入了进程常驻避免了每次请求创建和销毁进程时的性能开销并拓展了加载的开销,但每个请求仍然要执行PHP RINT
与RSHUTDOWN
之间的所有流程,包括重新加载依次框架源码和项目代码,造成了极大的性能浪费。
PHP-FPM的fpm-worker
工作进程只能在同一时刻处理一个请求,每次处理请求前都需要重新初始化MVC
框架然后再释放资源,同时fpm-worker
进程间的切换消耗也很大。在高并发请求场景下,fpm-worker
工作进程是完全不够用的,此时Nginx会直接响应502。
PHP-FPM进程模型属于预派生子进程模式,即来一个请求就会fork
派生一个进程,进程的开销大因此大大降低吞吐率,另外并发量也只能由进程数决定。
预派生子进程模式是指程序启动后会创建多个进程,每个子进程会进入Accept
,等待新的连接进入。当客户端连接到服务器时,其中一个子进程会被唤醒,开始处理客户端请求,并且不再接受新的TCP连接。当此连接关闭时子进程会释放,重新进入Accept
参与处理新的连接。
预派生子进程模式的优势是完全可以复用进程且无需太多的上下文切换,缺点是这种模型严重依赖进程的数量来解决并发问题。由于一个客户端连接需要占用一个进程,工作进程数量有多少并发处理能力就有多少,可是操作系统能够创建的进程数量都是有限的。
PHP框架在初始化时会占用大量计算资源,而每个请求都需要重新进行初始化。当启动大量进程时会带来额外的进程调度消耗,虽然数百个进程出现进程上下文切换调度消耗所占的CPU不足1%可以忽略不计,但同时启动成千上万个进程消耗会直接上升,调度消耗可能占满CPU。
另外,请求第三方接口会非常慢,请求过程中会一直占用CPU资源,浪费昂贵的硬件资源。比如即时聊天程序的单机可能要维持数十万的连接,那么也就要启动数十万的进程,这显然是不可能的。那么,有没有 一种技术可以在一个进程内处理所有并发IO呢?答案是采用IO复用技术。
那么有什么样的解决方案呢?
通过业务分析不难发现,Web应用中90%以上的都是IO密集型业务,只要提高IO复用的能力就可以提升单机吞吐能力,另外需要将PHP-FPM的同步阻塞模式调整成异步非阻塞模式,也就可以解决核心的性能问题。
在IO密集型业务中需要频繁的上下文切换,如果采用线程模式开发会太过复杂,另外一个进程中能开的线程数量也是有限的,线程太多会直接增加CPU和内存资源的消耗。
如何提升IO复用能力呢?
通常来说,网络IO可以抽象成用户态和内核态之间的是数据交换。一次网络数据读取操作read
可拆分为两个步骤:
- 网卡驱动等待数据准备好(内核态)
- 将数据从内核空间拷贝到进程空间(用户态)
根据这两个步骤处理方式不同,通常把网络IO分为阻塞IO和非阻塞IO。
- 阻塞IO
用户调用网络相关系统调用时(如read
),如果此时内核网卡没有读取到网络数据,那么本次系统调用会一直阻塞,直到对端系统发送的数据到达为止,如果对端一直没有发送数据则本次调用将永远不会返回。 - 非阻塞IO
当用户调用网络IO相关的系统调用时(如read
),如果此时内核网络还没有接收到网络数据,那么本次系统调用会立即返回,并返回一个EAGAIN
的错误码。
在没有IO多路复用技术之前,由于没有一种很好的方式来探测网络IO是否可读可写。因此,为了增加系统的并发连接数,一般是借助于多进程或多线程来增加系统的并发连接数。但这种方式有个问题是系统的并发连接数受限于系统的最大线程或进程数量,并且随着操作系统的线程或进程数量增加,将会引发大量的上下文切换,导致系统的性能急剧下降。为了解决这个问题,操作系统引入IO多路转接技术(IO Multiplexing)。
IO多路复用这里的“复用”实际上指的是复用的线程
关于IO复用技术的历史其实和多进程一样长,很早之前Linux就提供了select
系统调用,它可以在一个进程内维持1024个连接。后来又加入了poll
系统调用,poll
做了改进解决了1024个连接限制。但select
和poll
存在的问题是需要循环检测连接是否有事件。
问题来了,如果服务器上有100w个连接,某一时刻只有一个连接向服务器发送了数据,此时select
和poll
就需要做100W次循环,其中只有1次是命中的,剩下都是无效的,这不白白浪费了CPU的资源吗?直到Linux2.6内核提供了epoll
系统调用才可以维持无限数量的连接,且无需轮询,这才真正解决了C10K问题。
现在各种高并发异步IO的服务器程序都是基于epoll
实现的,比如Nginx
、Node.js
、Erlang
、Golang
... 像Node.js
、Redis
这样单进程单线程的程序都可以维持超过100w的TCP连接,这全部要归功于epoll
技术。
这里不得不提的是基于epoll
实现的Reactor
反应堆模型,IO复用异步非阻塞程序使用了经典的Reactor
模型,Reactor
模型本身不处理任何数据收发,只监视一个socket
句柄的事件变化。
Reactor 事件处理模型为什么高效呢?
Reactor反应堆模型- 主进程或线程向
epoll
内核事件中注册socket
上的读就绪事件 - 主进程或线程调用
epoll_wait
等待socket
上有数据可读 - 当
socket
上有数据可读时,epoll_wait
通知主进程或线程,主进程或线程则将socket
可读事件放入请求队列。 - 睡眠在请求队列上的某个工作线程被唤醒,会从
socket
中去读数据,并处理客户请求,然后向epoll
内核表中注册该socket
上的写就绪事件。 - 主线程调用
epoll_wait
等待socket
可写 - 当
socket
可写时epoll_wait
通知主进程或线程将socket
可写事件放入请求队列 - 睡眠在请求队列上的工作线程被唤醒,从
socket
上写入服务器处理客户请求。
Swoole的Reactor线程也是基于Reactor模型实现的
Swoole的HTTP服务器采用了Reactor模型,处理速度可逼近Nginx处理静态页面的速度,对于API或基础服务来说非常适合。性能瞬间翻几倍,再也不用担心PHP-FPM进程数量过多导致CPU被打满了。
如何将PHP-FPM同步阻塞模式转化为异步非阻塞模式呢?
线程本身是没有阻塞态的,当IO阻塞时也不会主动让出CPU资源,这种抢占式调度模式不太适合PHP开发。不过可以使用全协程模式让同步代码异步执行来解决这个问题。
什么是协程呢?
协程(Coroutine)又称为微线程或纤程, 是一种程序组件。其执行过程更类似于子例程,或者说不带返回值的函数调用。
协程Coroutine
的历史比线程Thread
的历史更为悠久,协程可以理解为纯用户态的线程通过协作而非抢占式来进行切换。相对于进程或线程,协程所有的操作都可以在用户态完成,创建和切换的消耗更低。
为什么要使用Swoole呢?
Swoole的强大之处在于进程模型的设计,既解决了异步又解决了并行问题。Swoole中提供了完整的协程(Coroutine)和通道(Channel)特性,带来全新的CSP(Communicating Sequential Process)并发编程模型。应用层可以使用完全同步的编程方式,底层将自动实现异步IO。另外,使用常驻内存模式可以避免每次框架的初始化,节约了性能上的开销。
Swoole可以为每个请求创建对应的协程,通过IO的状态来合理的调度协程。开发者可以无感知的使用同步代码编写方式达到异步IO的效果和性能,避免传统异步回调带来的离散代码逻辑和陷入多层回调嵌套中而导致代码无法维护的情况。
Swoole底层封装了协程,对比传统PHP层的协程框架,开发者不需要使用yield
关键字来标识一个协程的IO操作,无需对yield
语义进行深入理解以及对每一级的调用都修改为yield
,这极大地提高了开发效率。
Swoole的协程和Golang的调度方式完全不同,每个进程里面的协程都是串行执行的,所以无需担心访问资源加锁问题,这也符合PHP的简单特性。
既然进程的协程是串行执行的,那应该如何利用多核CPU实现并行呢?
答案是利用多进程实现,Swoole的task
可以开启协程。
Swoole的协程也许没有Golang性能那么好,但对于IO密集型业务是非常合适的,协程的上下文切换非常快。试想切换一门语言的成本对公司来说也是非常巨大的。
对比下多进程、多线程、协程
- 多进程使用
fork
创建,使用wait
回收,通信方式是IPC进程间通信,资源消耗主要是进程切换开销,并发能力在数百左右,编程难度相对困难。 - 多线程使用
pthread_create
创建,使用pthread_join
回收,通信方式采用数据同步或锁,资源消耗集中在进程切换开销,并发能力在数千左右,编程难度非常困难。 - 协程,使用
go
创建,通信方式为array/chan
,资源消耗非常低,并发能力为50w,编程难度简单。
协程有什么优点呢?
- 用户态线程,遇到IO会主动让出。
- PHP代码依然是串行执行的无需加锁
- 开销极低仅占用内存,不存在进程或线程切换的开销。
- 并发量大,单个进程可开启50w个协程。
- 随时随地只要想并发就调用
go
创建新协程。
Swoole的缺点是什么呢?
Swoole的缺陷是无法做密集计算,这一点是PHP甚至所有动态脚本语言都存在的问题。另外是更容易内存泄漏,在处理全局变量、静态变量的时候需要小心,这种不会被GC垃圾收集器清理的变量会存在整个生命周期中,如果没有正确的处理,很容易消耗完所有的内存。而以往PHP-FPM下PHP代码执行完内存就会被完全释放掉。
CSP编程经典:“不要通过共享内存来通信,而应该通过通信来通向内存。”
CSP模型提供了一种多个进程公用的“管道”(Channel),这个管道中存放的是一个个“任务”。这个通信可理解为利用channel
通道通信,虽然Swoole的协程是串行的,但业务上可能会有交叉。比如一个业务在添加配置,另一个业务在写配置,上下文一切换可能会造成配置不一致。你可能会好奇,本地跑着没事线上跑一段事件就出问题,这本身就是业务设计上的问题。
- LNMP + Swoole
LNMP+Swoole 是LNMP的一种变体,是在LNMP的基础上引入了Swoole组件。和PHP-FPM一样,Swoole有一套自己的进程管理机制,由于代码变得高度常驻,编程思维需要从同步转变到异步。所以Swoole和传统基于PHP-FPM的Web框架亲和力很低。因此出现了这种折中方案,并没有直接将原有PHP代码运行在Swoole中,而是使用Swoole搭建了一个服务,而是使用Swoole搭建了一个服务,系统通过接口与Swoole通信,从而为Web项目补充了异步处理能力。
LNMP+Swoole虽然引入了Swoole和异步处理能力,但核心仍然是PHP-FPM,实际上并没有发挥出Swoole的真正优势。
- Swoole HTTP Server
Swoole HTTP Server与LNMP+Swoole相比有着巨大的变化,这种模型中充当Web服务器角色的构件不仅仅有Ngnix,应用本身也包含了一个内建的Web服务器,不过由于Swoole HTTP Server不是专业的HTTP服务器,对HTTP的处理不完善,因此仍然需要使用Nginx作为静态资源服务器及反向代理,Swoole HTTP Server仅处理PHP相关的HTTP流量。
由于Swoole已经包含了Web服务器,不再需要实现CGI或FastCGI的通用网关协议和Web服务器进行通信。另一方面Swoole有自己的进程管理,因此PHP-FPM可以直接被去除了。对于PHP资源而言,Swoole HTTP Server相当于Nginx + PHP-FPM。
Swoole HTTP Server一次加载常驻内存,不同的请求之间复用了onRequest
以外的所有流程,使得每个请求的开销大大降低。异步IO的特性使得这种模型吞吐量远远高于LNMP模型。另外相对独立的Swoole服务,内嵌在Web系统中的Swoole使用更加直接方便,支持更好。
Swoft与Swoole的关系是什么?
- Swoole是一个异步引擎,核心是为PHP提供异步IO执行的能力,同时提供一套异步编程可能会用到的工具集。
- Swoole HTTP Server是Swoole的一个组件,是Swoole服务器的一种,提供了一个适合Swoole直接运行的HTTP服务器环境。
- Swoft是一个现代的Web框架,和Swoole亲和性高,同时也是Swoole HTTP Server模型的一个实践。
Swoft管理着Swoole和Swoole HTTP Server,对开发者屏蔽Swoole的各种复杂操作细节,并作为一个Web框架向开发者提供了各种Web开发所需的路由、MVC、数据库访问等功能组件等。
Swoft是如何使用Swoole的呢?
Swoft直接使用的是Swoole内建的\Swoole\Http\Server
,HTTP服务器已经处理好了所有HTTP层面的东西,剩下只需考虑关注应用本身。
HTTP服务生命周期
Swoft的HTTP服务是基于\Swoole\Http\Server
实现的协程HTTP服务,Swoft框架层封装了MVC方便编码以获取协程带来的超高性能。
Swoft HTTP服务器启动会根据.env
环境配置中的设置,在使用composer install
安装组件时会自动复制环境变量配置文件.env
,若没有可手工复制.env.sample
并重命名为.env
。
$ .env
# HTTP 服务设置
HTTP_HOST=0.0.0.0
HTTP_PORT=80
HTTP_MODE=SWOOLE_PROCESS
HTTP_TYPE=SWOOLE_SOCK_TCP
HTTP服务器启动命令
// 启动服务,根据.env环境配置决定是否为守护进程方式(daemonize)。
$ php bin/swoft start
// 以后台后台进程方式启动
$ php bin/swoft start -d
// 重启服务
$ php bin/swoft start restart
// 重新加载
$ php bin/swoft reload
// 关闭服务
$ php bin/swoft stop
Swoft框架是建立在Swoole扩展之上运行的,在Swoft服务启动阶段,首先需要关注的是OnWorkStart
事件,此事件会在Worker
工作进程启动的时候触发,这个过程也是Swoft众多机制实现的关键,此时Swoft会进行扫描目录、读取配置、收集注解、收集事件监听器...。然后会根据扫描到的注解信息执行对应的功能逻辑,并存储在与注解对应的Collector
容器内,包括注册路由、注册事件监听器、注册中间件、注册过滤器等。
在Swoole启动前的重要行为特征
- 基础
bootstrap
行为,如必要的常量定义、Composer加载器引入,读取配置。 - 生成被所有
worker/task
进程共享的程序全局期的对象,如Swoole\Lock
、Swoft\Memory\Table
的创建。 - 启动时所有进程中只能执行一次的操作,如前置
Process
的启动。 -
Bean
容器基本初始化以及项目启动流程需要的coreBean
的加载
和HTTP服务关系最密切的进程是Swoole中Worker进程,绝大部分业务处理都在Worker工作进程中。对于每个Swoole事件,Swoft都提供了对应的Swoole监视器(对应@SwooleListener
注解)作为事件机制的封装。
要理解Swoft的HTTP服务器是如何在Swoole下运行,重点需要关注两个Swoole事件swoole.workerStart
和swoole.onRequest
。
swoole.workerStart事件
workerStart
事件在TaskWorker/Worker
进程启动时发生,每个TaskWorker/Worker
进程里都会执行一次,这是个关键节点,因为swoole.workerStart
回调之后新建的对象都是进程全局期的,使用的内存都属于特定的Task\Worker
进程,相互独立。也只有在这个阶段或以后初始化的部分才是可以被热重载的。
$ vim /vendor/swoft/framework/src/Bootstrap/Server/ServerTrait.php
<?php
namespace Swoft\Bootstrap\Server;
use Swoft\App;
use Swoft\Bean\BeanFactory;
use Swoft\Bean\Collector\ServerListenerCollector;
use Swoft\Bootstrap\SwooleEvent;
use Swoft\Core\ApplicationContext;
use Swoft\Core\InitApplicationContext;
use Swoft\Event\AppEvent;
use Swoft\Helper\ProcessHelper;
use Swoft\Pipe\PipeMessage;
use Swoft\Pipe\PipeMessageInterface;
use Swoole\Server;
/**
* Server trait
*/
trait ServerTrait
{
/**
* OnWorkerStart event callback
*
* @param Server $server server
* @param int $workerId workerId
* @throws \InvalidArgumentException
* @throws \ReflectionException
*/
public function onWorkerStart(Server $server, int $workerId)
{
// Init Worker and TaskWorker
$setting = $server->setting;
$isWorker = false;
if ($workerId >= $setting['worker_num']) {
// TaskWorker
ApplicationContext::setContext(ApplicationContext::TASK);
ProcessHelper::setProcessTitle($this->serverSetting['pname'] . ' task process');
} else {
// Worker
$isWorker = true;
ApplicationContext::setContext(ApplicationContext::WORKER);
ProcessHelper::setProcessTitle($this->serverSetting['pname'] . ' worker process');
}
$this->beforeWorkerStart($server, $workerId, $isWorker);
$this->fireServerEvent(SwooleEvent::ON_WORKER_START, [$server, $workerId, $isWorker]);
}
/**
* @param bool $isWorker
* @throws \InvalidArgumentException
* @throws \ReflectionException
*/
protected function reloadBean(bool $isWorker)
{
BeanFactory::reload();
$initApplicationContext = new InitApplicationContext();
$initApplicationContext->init();
if($isWorker && $this->workerLock->trylock() && env('AUTO_REGISTER', false)){
App::trigger(AppEvent::WORKER_START);
}
}
}
reloadBean
方法作为实践底层关键代码主要完成三件事:
- 初始化Bean容器
BeanFactory::reload()
是Swoft的Bean容器初始化入口,注解的扫描也是在此处进行的,准确来说,Bean容器真正的初始化阶段在Swoole服务器启动前的Bootstrap阶段就已经进行了,只不过那时进行的是少部分的初始化,相对swoole.workerStart
中初始化的Bean数量比重还很小。在workerStart
中初始化Bean容器是Swoft可以热更代码的基础。
- 初始化应用的上下文
initApplicationContext->init()
会注册Swoft事件监听器(对应@Listener
注解),方便用户处理Swoft应用本身的各种钩子。随后触发一个swoft.applicationLoader
事件,各组件通过该事件进行配置文件加载,以及HTTP/RPC路由注册。
- 服务注册
swoole.onRequest事件
Swoft的请求和响应实现了PSR-7,请求和响应对象存在于每次HTTP请求,这里的请求对象Request
指的是Swoft\Http\Message\Server\Request
,响应Response
指的是Swoft\Http\Message\Server\Response
。
每个请求从开始到结束都是由Swoole本身的onRequest
方法或onResponse
方法事件监听并委托给Dispatcher
方法来处理并响应的,Dispatcher
方法的主要职责是负责调度请求生命周期内的各个组件。
HTTP服务中将由ServerDispather
来负责调度,参与者包括RequestContext
、RequestHandler
、ExceptionHandler
。
-
RequestContext
请求上下文作为当前请求信息的容器将贯穿整个请求生命周期,负责信息的存储和传递。 -
RequestHandler
请求处理器是整个请求生命周期的核心组件,其实也就是个中间件Middleware
,该组件实现了PSR-15协议。- 负责将
Request
=>Route
=>Controller
=>Action
=>Renderer
=>Response
整个请求流程贯穿起来,也就是从请求Request
到响应Response
的过程 - 只要在任意一个环节中返回一个有效的响应对象
Response
就能对该请求做出响应并返回
- 负责将
-
ExceptionHandler
异常处理器是在遇到异常的情况下出来收拾场面的,确保在各种异常情况下依旧能给客户端返回一个预期内的结果
每个HTTP请求到来时仅仅会触发swoole.onRequest
事件,Swoft框架本身是由大量进程全局期和少量程序全局期的对象构成。onRequest
中创建的对象比如$request
和$response
都是请求期的,随着HTTP请求的结束而回收。
$ vim /vendor/swoft/http-server/src/ServerDispatcher.php
<?php
namespace Swoft\Http\Server;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Swoft\App;
use Swoft\Contract\DispatcherInterface;
use Swoft\Core\ErrorHandler;
use Swoft\Core\RequestContext;
use Swoft\Core\RequestHandler;
use Swoft\Event\AppEvent;
use Swoft\Http\Message\Server\Response;
use Swoft\Http\Server\Event\HttpServerEvent;
use Swoft\Http\Server\Middleware\HandlerAdapterMiddleware;
use Swoft\Http\Server\Middleware\SwoftMiddleware;
use Swoft\Http\Server\Middleware\UserMiddleware;
use Swoft\Http\Server\Middleware\ValidatorMiddleware;
/**
* The dispatcher of http server
*/
class ServerDispatcher implements DispatcherInterface
{
/**
* Do dispatcher
*
* @param array ...$params
* @return \Psr\Http\Message\ResponseInterface
* @throws \InvalidArgumentException
*/
public function dispatch(...$params): ResponseInterface
{
/**
* @var RequestInterface $request
* @var ResponseInterface $response
*/
list($request, $response) = $params;
try {
// before dispatcher
$this->beforeDispatch($request, $response);
// request middlewares
$middlewares = $this->requestMiddleware();
$request = RequestContext::getRequest();
$requestHandler = new RequestHandler($middlewares, $this->handlerAdapter);
$response = $requestHandler->handle($request);
} catch (\Throwable $throwable) {
/* @var ErrorHandler $errorHandler */
$errorHandler = App::getBean(ErrorHandler::class);
$response = $errorHandler->handle($throwable);
}
$this->afterDispatch($response);
return $response;
}
}
事件底层关键代码
-
beforeDispatch($request, $response)
设置请求上下文并触发一个swoft.beforeRequest
事件。 -
RequestHandler->handle($request)
执行各个中间件和请求对应的动作方法action
-
$afterDispatch($response)
整理HTTP响应报文发送客户端并触发swoft.resourceRelease
事件和swoft.afterRequest
事件。
在HTTP服务器的生命周期中需要重点理解
- Swoole的Worker进程是绝大多数HTTP服务代码的运行环境
- 部分初始化和加载操作在Swoole服务器启动前完成,部分在
swoole.workerStart
事件回调中完成,前者无法热重载但可以被多个进程共享。 - 初始化代码只会在系统启动和Worker/Task进程启动时执行一次,不像PHP-FPM每次请求都会执行一次,框架对象不像PHP-FPM会请求返回而销毁。
- 每次请求都会触发一次
swoole.onRequest
事件,事件中是请求处理代码真正运行的位置,只有事件内产生的对象才会在请求结束时被回收。
未完待续...
网友评论