NIO、BIO、AIO 与 PHP 实现

前言

最近看到NIO,AIO,Netty,Promise话题很热,我作为一个phper也想来凑凑热闹,凑着凑着发现周围怎么都是javaer,jser。那么PHP能做NIOAIO么?

什么BIO、NIO、AIO

BIO 同步阻塞I/O。

有小伙伴又要问了啥叫 同步,啥叫阻塞啊?

同步/异步 阻塞/非阻塞

同步: 两个同步任务相互依赖,并且一个任务必须以依赖于另一任务的某种方式执行。 比如在A->B事件模型中,你需要先完成 A 才能执行B。 再换句话说,同步调用种被调用者未处理完请求之前,调用不返回,调用者会一直等待结果的返回。

异步: 两个异步的任务完全独立的,一方的执行不需要等待另外一方的执行。再换句话说,异步调用种一调用就返回结果不需要等待结果返回,当结果返回的时候通过回调函数或者其他方式拿着结果再做相关事情,

阻塞: 阻塞就是发起一个请求,调用者一直等待请求结果返回,也就是当前线程会被挂起,无法从事其他任务,只有当条件就绪才能继续。

非阻塞: 非阻塞就是发起一个请求,调用者不用一直等着结果返回,可以先去干其他事情。

以上就是这四个词汇的解释,那么放到计算机IO上,比较接地气的解释

BIO (Blocking I/O)

那么我们拿快递揽件来举例,一个快递公司,有一部分工作是揽件,它的工作模式是只能一个一个的揽件,你要寄快递,必须排队,一个一个的来,这就是 同步 。好不容易轮到你了,你把快递一扔给他,他还让给你等着,快递工作人员说,我们这后面还有些信息要录入,快递要检查,必须等我们快递公司检查完毕后,你才能离开,这叫 阻塞

NIO (No-Blocking I/O)

同步非阻塞的I/O

继续啊,拿快递公司举例。这个快递公司发现有些用户在后面排队,排着排着,太久了就去隔壁快递公司了,怎么办呢?快递公司想了个办法,置办了一个发号器和一批收纳盒。来一个客户,就把快递放在一个收纳盒里,再给用户一个编号,此时再来一个用户,不论前面一个的快递是否检查完毕,还是给他一个收纳盒,发一个编号。不同客户之间不排队,一来就被受理了,这就是 非阻塞。 我们再来看看内部,快递呢还是一个个地录入信息,X光检查,这样就是 同步 运行的,等待快递人员检查完毕叫号,客户拿到回执才能离开快递点。

AIO (Asynchronous I/O)

异步非阻塞IO

也有Javaer叫他 NIO2,快递公司揽件又升级了,做了一个快递柜,客户又寄件需求,来了就放入快递柜,然后通过手机扫码关注这个柜子的动态,客户就可以离开了,此时服务被受理,并能马上离开。这就是 非阻塞 。等到快递人员来揽件时,会将柜子里面的寄件一并取走,快递点集中一起处理这些快递件,发现有问题的件,不是立即停下手中的活等待客户来出来,而是放一旁通知客户来,然后继续处理下一个快递,这就是 异步

异步 阻塞 IO

同步/异步 阻塞/非阻塞,这4个名词,两两组和,还有一个就是 异步/阻塞

那么我们还是先把例子举出来吧,还是这个快递点,来了一批客户来寄口罩到国外,由于有很大的可能会通不过检查,所以,快递点把大家都留了下来。等所有的 寄件 都检查完了在统一给大家发送回执单,这就是 阻塞 。快递人员检查寄件时,发现问题不是立马通知客户来处理,而已放到一边,继续处理下一个。 这就是 异步

伪异步 IO

这种模式,底层实现是多个 同步阻塞的BIO, 同时运行。

最后总结一下:

阻塞与非阻塞指的的是当不能进行读写(网卡满时的写/网卡空的时候的读)的时候, I/ O操作立即返回还是阻塞;同步异步指的是,当数据已经 ready 的时候,读写操作是同步读还是异步读,阶段不同而已。

区别

异步/同步在计算机区别

以上是一些举例,只是帮助大家理解记忆,接下来我们看看计算上的实现。

最初计算机提供的Web服务,采用的是 CGI 协议,就是纯正的 BIO 模式。一个cgi进程监听一个端口,处理完一个请求,才能接收下一个http请求。这就是同步

而客户的实际体验式是"异步"的,那是因为后来优化了,CGI 程序能够自我fork进程的达到同时响应多个http请求的效果。

注意,我们这里讨论的基础是 单进程 ,上的 异步/同步

阻塞/非阻塞在计算机区别

这里拿购物流程举例,用户的下单,需要做如下操作:

  • 商品可售否
  • 库存数量
  • 用户余额
  • 触发哪些优惠规则
  • 奖券有效性
  • ...

按照一般做法就是一步步验证,上一个检查完了,再进行下一个检查,这就是 阻塞 的方式。

那么非阻塞方式如何做呢,假设在微服务环境中,商品,库存,奖券,促销都是独立的系统,调用商品服务,发起商品可售检查请求;不等商品服务回复,继续调用库存服务,发起商品可售库存请求;紧接着依次发出...检查请求,这样5个检查项目的请求同时发起,最后,我等他们所有的请求都回复我,再来一起来校验是否所有的检查都通过了。就这种发起请求不等响应,就继续做下一件事的叫 非阻塞

转载著名来源sifou

PHP 能做什么

PHP 与 BIO 实现

PHP已经实现啦,这是最基本的好么。但平时测试时却感觉是不阻塞啊,好,我们来一起做个实验,将nginx和php-fpm的进程限制为1个试试。php-fpm就是 多进程的 BIO,现在我们强项改成单进程。

  • 调整Nginx配置

调整 /etc/nginx/nginx.conf 文件:

## 把nginx worker数量设置为1
worker_processes 1;

好了之后我们通过ps命令检查下
image.png

  • 调整PHP配置

调整 /etc/php/php-fpm/conf.d/www.conf 文件:

pm = static

pm.max_children = 1

pm.start_servers = 1

pm.min_spare_servers = 1

pm.max_spare_servers = 1

找到这几个配置都改为如上数值。

最后的结果如下

image.png

我在index.php代码里面加入第一行就加入了sleep。

<?php
sleep(5);

我们同时打开两个网页,一起访问试试
image.png

通过Firefox 抓包可以发现,其中一个耗时5s,另一个页面耗时9.3s,(0.7s误差是我手速慢了) 这就是 BIO。

image.png

好的,我们再做一个实验。把以上nginx,php-fpm配置中1改成2.然后我们打开三个网页,同时访问试试看。

image.png

结果是有两个网页耗时5s一个是9s,也就是说服务器同时处理了2个请求,第三个请求等待了4s才被处理。这就是 多线程-BIO,一个服务同时接待的客户数量取决与worker的数量

PHP 与 NIO 实现

我们写的大部分php-fpm代码以及第三方框架都是阻塞的。PHP也是支持非阻塞IO编程的。

这里其他博主也用PHP原生代码实现NIO编程: PHP回顾之socket编程

I/O 多路复用

在这段小Demo中,PHP 实现 NIO 核心两个函数就是 stream_set_blockingstream_select()
通过以上源码,发现原生的NIO实现还是比较繁琐,不易读的。同时,我就想问一句了,这个 NIO 就是为了实现一个 socket server 么,我们来看看Netty 官网。打开Netty首页,它是这样描述自己的

Netty is a NIO client server framework which enables quick and easy development of network applications such as protocol servers and clients. It greatly simplifies and streamlines network programming such as TCP and UDP socket server.

第一句话:Netty是一个 NIO 客户端 服务框架, 能快速轻松地开发协程客户端。第二句话:简化了网络编程,如创建TCP和UDP套接字服务。

好,重点是什么?第一句话就是重点——开发 协程客户端!回到我们业务上,刚刚举了一个例子,购物到下单,有很多个流程需要做检查,按照一般的BIO那么程序时序图如下:

nio购物流程检查

从上可以看到,三个检查依次分开执行。那么客户的等待时间是大于,库存检查时间加上,产品检查时间加上,促销检查时间 的。

假设, 库存,产品,促销是三个微服务,然后购物车服务用 NIO客户端,与这三个微服务交互,那么会是怎样的效果呢:

nio请求时序图

这里,我们发起检查请求时,是按照顺序发起的,但不等第一个服务返回检查结果就开始发起下一个检查请求。最后三个服务都返回后,综合结果,返回给用户。那么这三个检查的耗时,就等于一个服务(耗时最长的那个服务)的检查耗时。大大减少得了购物车服务响应时间。

我看到一些 NettyNodeJSSwoole 等教程 通篇都在讲如何实现一个WebSocket服务,TCP服务或者是Http服务。对,这是最基础的,但 NIO 框架核心优势在开发一个非阻塞客户端!这才是它的优势,这才是和 BIO 编程差异化所在.

NIO 客户端

看到以上两个时序图,还是给大家演示一下用PHP原生代码实现一个 PHP-BIOPHP Simple NIO Server

建议大家点击链接,把源码git clone https://gitee.com/xupaul/php-nio-server 到本地运行一下,再来看截图更容易理解。

image.png

这三个所依赖的服务响应耗时,我设置为:inventory: 4s, product: 2s, promo:6s

蓝色框和黄色框标注了两个请求,我们主要看参数 noBlocking: true/false 的不同, 第一个是非阻塞方式请求, 可以看到共耗时6s,第二个共耗时12s! (第三个为啥和第二个耗时不一样——6s这个留给大家去研究)。显而易见得非阻塞IO的优势。不过这代码结构就不那么友好了,看到代码 nio_server.php 中,有两种请求方式,阻塞代码流程还能看懂检查完成后就综合结果返回,而非阻塞方式中,发起三个检查后程序流程就开始进入到handleMessage,代码进入哪个分支,取决于 socket_read 的消息,不运行起程序来,没有文档,很难搞懂整个程序流程。

那么,有没有什么什么方便的php类库,让我们编码更友好一点呢,这里介绍下 ReactPHP

这里我用ReactPHP重新实现 nio_server, 代码在这里

这个回调代码写起来有点 NodeJS 的味道呢,当你的PHP没启用 libev 之类的拓展时,ReactPHP内部Loop依然用的 stream_select(), 可以看源码 ~/react/event-loop/src/StreamSelectLoop.php@290 .

执行效果如下:
image.png

能同时发起请求这个功能,那还得提一下 curl_multi, 它能同时发起多个curl请求,最后不断检查是否所有的curl请求已完成。这只是在发起多个Http curl请求阶段做到 非阻塞 运行。

还有个拓展pThreads,能够实现多线程,不过对PHP编译参数有限制,需要在线程安全的模式下运行。

pThreads 现在已不是PHP官方所推荐使用的拓展了,当然了这种就属于伪异步IO范畴了

PHP 与 AIO

PHP 异步&非阻塞 编码。

此处, 非阻塞I/O 系统调用( nonblocking system call ) 和 异步I/O系统调用 (asychronous system call)的区别是:

  • 一个非阻塞I/O 系统调用 read() 操作立即返回的是任何可以立即拿到的数据, 可以是完整的结果, 也可以是不完整的结果, 还可以是一个空值。
  • 而异步I/O系统调用 read() 结果必须是完整的, 但是这个操作完成的通知可以延迟到将来的一个时间点。
<?php

/**
 * 消息处理
 */
function handleMessage() {
    global $changed, $clients, $cartCheck;
    foreach ($changed as $key => $client) {
        while (true) {
            // read socket data
            $msg = @fread($client, 1024);
//            $msg = 1;
            if ($msg) {
                // application process
            } else {
                if (feof($client)) {
                    // TODO check data eof
                }
                break;
            }

可以看到,在文件~/nio_server.php 中, 虽然设置了 stream_set_blocking false, 但是在209行的 fread() , 这是在一个循环里读,这是一个阻塞读取。这的系统函数的响应速度是受系统IO影响的。

而异步调用中,当有I/O事件时,系统会将数据复制到用户内存中,也就是准备好数据,再通知到用户程序。

那么原生PHP显然是不支持的,这里呢就要引入PHP拓展,就是 Event,或者 Ev 拓展。这篇博客主要讲 Event

Event 拓展是基于 libevent 库封装而来,而 Ev 拓展是基于 libev 库封装而来。 通过PHP接口,和C库的接口就能看到他们之间的联系,所以,如果通过PHP文档找不到相关资料可以去,看看C库的文档。而 Libevent 年久失修,不推荐大家使用。

这里放上用Event实现的Tcp Server demo

在用Event做这个demo中,我用到了EventBuffer ,读、写都和Buffer交互, Buffer数据是用户态数据,不会等待系统I/O或被阻塞,避免了程序耗时在I/O数据拷贝上。由此PHP 也能实现 AIO 程式,提高CPU利用率。

讲到这里,就会感觉这个PHP的AIO有些牵强了,我这找了其他博主的论点来帮助大家理解,这两张图展示了 用户程序,与内核采用 分阻塞异步 交互时的异同。

image.png

上面是非阻塞IO,下面是异步IO。中间的区别就是非阻塞IO的应用,需要不断的去访问内核获取数据(当然了,每一次访问都是有求必应,能取到数据),但不一定能取完; 而异步IO的特点就是,你告诉内核取数据,取完整了,我再一起发给应用程序。这就是Linux对异步IO的定义。

image.png

那么再看到我们的Demo,这是一个简单TCP server,一个TCP请求系统是能知道一个数据的包大小的,是否接收完毕,这是传输层要做的。而我们的应用层面,是接收到数据还要做合并,分包,以及数据转码。 这就和 AIO 数据结果必须是完整的,概率有些出入,(在系统层面显然是完整的) . 在应用层面呢,一次性收到的不一定是完整的数据,那么就还需要做额外代码来解决合包,分包,沾包。这就是AIO实现Tcp Server的需要问题。

为了解决以上问题,就需要自定义TCP通讯协议。相当于自己开发RPC框架了。

那我们来看看Http呢,在应用层面有明确公开的协议(协议有头无尾,标明了每次请求具体长度),并有丰富的实现。这就是一个非常适合采用AIO编程协议。而PHP的Event拓展,恰好有EventHttp实现。

话不多说,先上 Demo

<?php
...

/**
 * event http 请求回调函数
 * 
 * @param   \EventHttpRequest   $req    Http请求对象
 */
function _http_about($req) {
    echo __METHOD__, PHP_EOL;
    // print request URL
    echo "URI: ", $req->getUri(), PHP_EOL;
    // print request's headers
    echo "Input headers:"; var_dump($req->getInputHeaders());
    echo "\n >> Sending reply ...";
    /**
     * @var \EventBuffer    $buf
     */
    $buf = $req->getOutputBuffer();
    $buf->add("It's about Event http server");
    $req->sendReply(200, "OK", $buf);
    echo "OK\n";
}

这里是一个回调函数,入参数就是一个由 EventHttp 封装的http请求对象。这就满足了以上 调用时非阻塞,数据完全准备好后,再通知回调——异步I/O。好,借助Event,PHP就实现了AIO.

结语

关于性能提升,这就不做压测了,主要论证PHP实现NIOAIO 的可行性。也实际给大家展示了几个Demo, 简单展示了如何写异步,非阻塞程序。可以看到 异步编程 对大家的要求是比较高的,当需要发起 IO 操作,都要用非阻塞方式调用,不然就会阻塞整个进程,而纯粹的异步编程就是单进程,阻塞后该服务就不能响应新的请求。同时呢,我们常用PDO,mysqli,Redis这些不得不用的拓展,也只提供了阻塞读的接口。而当前PHP环境中,可以说“几乎所有”的第三方框架,都是阻塞编码,如果你的项目中使用了其他框架,那么你写的代码没问题,不保证依赖的第三方框架阻塞方式请求 I\O. 所以,一般 PHP 异步编程,都会采用多进程异步,让异步来提高每个请求的响应速度,如果进程阻塞,就让其他空闲的进程处理新进入的请求。

以上,希望大家通过文章能了解 异步/同步阻塞/非阻塞区别,以及对PHP异步非阻塞编程。

有问题欢迎提问~

参考

  1. PHP实现非阻塞
  2. PHP回顾之socket编程
  3. Cooperative multitasking using coroutines (in PHP!)
  4. IO - 同步,异步,阻塞,非阻塞
  5. 同步/异步,阻塞/非阻塞概念深度解析
  6. PHP之高性能I/O框架:Libevent
  7. 网络编程(三):从libevent到事件通知机制
阅读 7.9k

推荐阅读