PHP 如何发起异步请求

来源:互联网 发布:哨兵数据波段介绍 编辑:程序博客网 时间:2024/04/29 22:45

有人说,限制激发创造力。如果真这样,PHP就是成熟的创造性解决方案。我刚上周构建了调用Segment.io的API的PHP库,发现了各种不同的方法可以提高服务端请求性能。

设计客户端类向API发送数据时,我们的首要任务之一就是保证我的代码不影响到你的核心程序。这是很棘手的,尤其是使用单线程,无共享的语言,如PHP。

服务商PHP安装方式很多,让问题更复杂。幸运的,你的服务商允许你创建进程,写入文件和安装自己的扩展。不幸的话,你就得和一些纠结的邻居分享同一个安装配置,只能上传文件。

理想状态,我们喜欢用最小的满足实现各种情况。当运行PHP时(可能就一两个脚本),你应该深入理解这些。

我们尝试用三种主要方法实现PHP发出请求,以下就是。

一:快速打开一个套接字(Socket)

搜索PHP异步请求,最先的结果都是相同的方法:写一个Socket然后在等待返回前关闭它。

这个想法是开启一次连接到服务端,连接好就写入内容。Socket写入是很快的,而且你不要返回信息,写入后直接关闭连接。这就节省了等待一次往返的事件。

但是当你看StackOverflow上的评论,Socket到底发生了什么有一些争论。也让我疑问:Socket怎么实现的异步?

下面是我们的Socket实现:

01<?php
02private function request($body) {
03 
04    $protocol "ssl";
05    $host "api.segment.io";
06    $port = 443;
07    $path "/v1/" $body;
08    $timeout $this->options['timeout'];
09 
10    try {
11      # Open our socket to the API Server.
12      $socket fsockopen($protocol "://" $host$port,
13                          $errno$errstr$timeout);
14 
15      # Create the request body, and make the request.
16      $req $this->create_body($host$path$content);
17      fwrite($socket$req);
18      # ...
19    } catch (Exception $e) {
20      # ...
21    }
22}
23?>

最初的结果并不乐观。一次fsockopen花了300毫秒,偶尔更长。

事实证明,fsockopen是阻塞的——不是异步的!要了解到底发生了什么,需要深入研究fsockopen是怎么工作。当fsockopen选择协议时,需要考虑使用哪种socket。这个过程在连接完成前是阻塞的。

复习一下,internet的基本协议是TCP。它使电脑之间的信息传递可靠并有序。几乎所有HTTP都运行于TCP上。我们用HTTP来简化自定义的客户端使用。

这是TCP Socket创建连接:

  • 客户端发送SYN消息给服务端
  • 服务端返回SYN-ACK消息确认包
  • 客户端发送最终ACK包及传送数据
作为计时的部分,这是传输数据之前完成的完整来回,在fsockopen之前这就已经返回。一旦连接开启,我们可以为socket写入数据。通常,需要30-100ms连接到我们的服务器。

TCP连接比较快,罪魁祸首是SSL需要的额外握手。SSL也实现在TCP上。TCP握手后又开始TLS握手。

光SSL连接就需要三次握手,更不要说加上创建公共密匙的时间。

浏览器的SSL连接可以共享密匙,避免允许访问的客户端和服务端重复握手。可是PHP执行的Socket无法共享密匙,我们只能每次都是重新连接。

还可以使用socket_set_nonblock创建“非阻塞”的Socket。不过这是在打开Socket的时候不阻塞,你还是要等待完成才能写入内容。如果精确考虑打开Socket写入数据的时间,页面加载会慢约100ms。

总结起来:

  • Socket可以在有权限限制的PHP上运行
  • fscokopen是阻塞的,即使不阻塞Socket也需要等待再写入数据
  • SSL连接明显减慢连接,因为额外的握手和加密过程
  • 打开连接使页面延迟100ms

二,写日志文件

如果你没有其他系统权限的时候,Sokets是非常棒的方案。这儿我们介绍一种在性能上更好的方法,那就是把所有事件以日志方式写到文件。这个日志文件可以被工作进程或者cron做"带外"处理。

基于文件方法的优点是具有最小的API对外请求。当php代码发出track 或者identify请求的时候,通过这种方法工作进程可以同时处理100个事件的请求,而不是仅一个请求。

这种方法的另外一个优点是php进程可以相对更快的记录文件,一个写操作往往只需要几毫秒。当php打开一个文件句柄的时候,用fwrite进行追加写是很简单的操作。由于纯php不具有“共享内存队列”机制,在这日志文件实际上和“共享内存队列”具有异曲同工的效果。

为了读日志文件,我利用analytics-pythonlibrary库写了一个python上传脚本。为了防止日志文件太大,脚本自动进行更名操作。可以动态的写php文件,还可以写内存中的文件句柄,在老请求创建的地方,新请求会创建一个新的日志文件。

这种方法没有太多的逻辑,只要开发者多写点cron任务,并且通过PyPI分别安装我们的python库。(这两段感觉是在给他们的python库做公告,既然python那么好,用php干嘛,bs)

方法总结(关键点):

  • 写文件较快,系统资源开销少。
  • 需要消耗磁盘空间,要求守护进程对文件有写权限。
  • 必须运行工作进程处理带外的记录消息。

三:调用Curl过程

还有一个可选择的方法,我们可以通过exec 操作curl工具来发出请求。curl请求才可以做为独立进程一部分来完成,允许php代码继续执行,而不会阻塞socket连接。

这种方法的性能介于前面两种方法之间,比soket方法快,比写文件的方法花费更少的系统资源。

操作 forkd curl 方法,最简单的例子如下:

01<?php
02private function request($url$payload) {
03 
04  $cmd "curl -X POST -H 'Content-Type: application/json'";
05  $cmd.= " -d '" $payload "' " "'" $url "'";
06 
07  if (!$this->debug()) {
08    $cmd .= " > /dev/null 2>&1 &";
09  }
10 
11  exec($cmd$output$exit);
12  return $exit == 0;
13}
14?>

如果运行在生产模式,我们不希望等着fork进程的消息输出。所以代码中加添了"> /dev/null 2>&1 &"让进程正确的执行 ,而把任何可能输出都丢弃掉。

同样功能的shell脚本如下:

1curl -X POST -H 'Content-Type: application/json' \
2  -d '{"batch":[{"secret":"testsecret","userId":"some_user",
3"event":"PHP Fork Queued Event","properties":null,"timestamp":
4"2013-01-30T14:34:50-08:00","context":{"library":"analytics-php"},
5"action":"track"}],"secret":"testsecret"}' \
6  'https://api.segment.io/v1/import' > /dev/null 2>&1 &

脚本花费了大概1秒多一点的时间,占用大约4k的的常驻内存。而curl进程用了 标准SSL 300毫秒完成请求,exce调用立刻相应php程序。这使得服务页面能很快相应用户。

笔者用一台一般水平的机器试验,这种方法curl可以每秒响应100个左右https请求,而没有任何的内存开销。如果不用SSL,响应的请求会更多。

不用等待输入,Fork一个进程非常快。

curl花费了和socket同样时间响应一个请求,但是这个外带的过程。

调用curl需要仅仅普通的unix基础。

Fork发起一个简单的请求,只需要几毫秒的时间,但是大量的同步调用(forks)会导致系统变慢。

使用析构函数减少出栈请求

虽然不是一个异步请求的方法,但是我们可以用析构函数帮助我们进行批量API请求。

为了减少请求的数量,我们首先将他们放在内存中,然后对他们进行批处理。如果不适用运行时扩展,他们只能在一个单一的PHP脚本中运行。要做到这一点,我们首先初始化一个队列,在程序脚本运行结束时,将所有队列请求批量发送出去。

01<?php
02class Analytics_SomeConsumer {
03 
04  public function __construct() {
05    $this->socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
06    socket_set_nonblock($this->socket);
07    socket_connect($this->socket, $this->host, $this->port);
08    $this->queue = array();
09  }
10 
11  public function __destruct() {
12    $payload = json_encode($this->queue);
13    # ... // wait for socket to be writeable
14    socket_write($this->socket, $payload);
15    socket_close($this->socket);
16  }
17 
18  public function track($item) {
19    array_push($this->queue, $item);
20  }
21?>

队列中的对象创建后,当它被销毁时将队列进行刷新,这样保证了队列在每次请求时只刷新一次。

另外,当PHP解释器忙着来渲染页面而我们等待实际写入套接字时,我们可以以非阻塞的方式在构造函数中创建套接字,然后写入析构函数,这样可以预留更多的时间来建立连接。

抉择?

最完美的方法是用纯php实现,而不是调用其他进程,这也是响应请求保守做法。我们更趋向于开发者最方便,不会把精力分散在其他地方。

实际中,这往往是不可触及的。基于处理的问题的大小,以及系统的限制,以上每种方法都是有缺点和限制。由于简单的方法不可能满足实际中的用户状况,我们创建不同的适配器以支持不同用户的不同需求。

我们以调用curl方法做为基础,调用一个进程不会导致重大的页面性能负担,同时他还支持扩展到每属主每秒处理多请求。请求的数量通过usinglimits.conf严格限制。

高并发用户或者拥有高系统权限的用户可以实用日志文件系统。系统权限受限的用户(虚拟空间等)可以使用sockets方法。

最后,需要你去了解一下实际中你能拥有的系统限制和系统的负载情况。这些都最终决定你选择更合适的方法。


原创粉丝点击