异步运行 PHP 任务

我在一个比较大的 Web 应用程序上工作,后端主要使用 PHP。在代码中有几个地方我需要完成一些任务,但是我不想让用户等待结果。例如,当创建一个新帐户时,我需要向他们发送一封欢迎电子邮件。但是,当他们点击“完成注册”按钮时,我不想让他们等到电子邮件真正发出,我只想启动这个过程,并立即向用户返回一条消息。

到目前为止,在一些地方我一直在使用 exec () ,基本上是这样的:

exec("doTask.php $arg1 $arg2 $arg3 >/dev/null 2>&1 &");

看起来很有效,但我想知道有没有更好的办法。我正在考虑编写一个在 MySQL 表中将任务排队的系统,以及一个单独的长时间运行的 PHP 脚本,该脚本每秒查询该表一次,并执行它找到的任何新任务。如果将来需要的话,这还有一个好处,就是可以让我在几台工人机器之间分配任务。

我是在重新发明轮子吗? 还有比 exec ()黑客或 MySQL 队列更好的解决方案吗?

130859 次浏览

我已经使用了排队方法,它工作得很好,因为您可以将处理推迟到服务器负载空闲时,如果能够轻松地划分“不紧急的任务”,就可以相当有效地管理负载。

自己动手也不是什么难事,下面是一些其他的选择:

  • GearMan -这个答案写于2009年,从那时起,GearMan 看起来是一个流行的选择,见下面的评论。
  • 如果您想要一个完整的开源消息队列,ActiveMQ
  • ZeroMQ -这是一个非常酷的套接字库,它使得编写分布式代码变得非常容易,而不必担心套接字编程本身。你可以用它在单个主机上进行消息排队——你只需要让你的 webapp 把一些东西推到一个队列中,一个持续运行的控制台应用会在下一个合适的时机使用它
  • Beanstalkd -只是在写这个答案的时候发现了这个,但是看起来很有趣
  • Dror 是一个基于 PHP 的消息队列项目,但自2010年9月以来一直没有得到积极的维护
  • Php-enqueue 是最近(2017年)围绕各种队列系统维护的包装器
  • 最后,一篇关于使用 Memcached 用于消息队列的博客文章

另一种,也许更简单的方法是使用 忽略 _ user _ abort——一旦你把页面发送给用户,你可以在不用担心过早终止的情况下进行最后的处理,尽管从用户的角度来看,这确实有延长页面加载的效果。

不幸的是,PHP 没有任何类型的本机线程能力。所以我认为在这种情况下,您别无选择,只能使用某种自定义代码来做您想做的事情。

如果您在网上搜索 PHP 线程,有些人已经想出了在 PHP 上模拟线程的方法。

PHP 是一种单线程语言,因此除了使用 execpopen之外,没有其他正式的方法来启动异步进程。有一篇关于 给你的博文。在 MySQL 中设置队列的想法也是一个好主意。

您在这里的具体要求是向用户发送电子邮件。我很好奇,既然发送电子邮件是一个非常琐碎和快速的任务,为什么要尝试异步执行。我想,如果你发送了大量的电子邮件,你的 ISP 因为怀疑你发送了垃圾邮件而屏蔽了你,这可能是排队的一个原因,但除此之外,我想不出任何理由这样做。

这个方法我已经使用了好几年了,但是我还没有看到或者发现更好的方法。正如人们所说的,PHP 是单线程的,因此您不能做太多其他的事情。

我实际上已经给它添加了一个额外的级别,那就是获取和存储进程 id。这允许我重定向到另一个页面,并让用户坐在该页面上,使用 AJAX 检查进程是否完成(进程 id 不再存在)。这对于脚本的长度会导致浏览器超时的情况很有用,但是用户需要等待该脚本在下一步之前完成。(在我的例子中,它使用 CSV 之类的文件处理大型 ZIP 文件,这些文件将多达30000条记录添加到数据库中,之后用户需要确认一些信息。)

我还使用了类似的过程来生成报告。我不确定我是否会将“后台处理”用于诸如电子邮件之类的事情,除非 SMTP 速度慢确实存在问题。相反,我可以使用一个表作为队列,然后有一个每分钟运行的进程在队列中发送电子邮件。你需要提防两次发送电子邮件或其他类似的问题。对于其他任务,我也会考虑类似的排队过程。

下面是我为 Web 应用程序编写的一个简单类。它允许分叉 PHP 脚本和其他脚本。适用于 UNIX 和 Windows。

class BackgroundProcess {
static function open($exec, $cwd = null) {
if (!is_string($cwd)) {
$cwd = @getcwd();
}


@chdir($cwd);


if (strtoupper(substr(PHP_OS, 0, 3)) == 'WIN') {
$WshShell = new COM("WScript.Shell");
$WshShell->CurrentDirectory = str_replace('/', '\\', $cwd);
$WshShell->Run($exec, 0, false);
} else {
exec($exec . " > /dev/null 2>&1 &");
}
}


static function fork($phpScript, $phpExec = null) {
$cwd = dirname($phpScript);


@putenv("PHP_FORCECLI=true");


if (!is_string($phpExec) || !file_exists($phpExec)) {
if (strtoupper(substr(PHP_OS, 0, 3)) == 'WIN') {
$phpExec = str_replace('/', '\\', dirname(ini_get('extension_dir'))) . '\php.exe';


if (@file_exists($phpExec)) {
BackgroundProcess::open(escapeshellarg($phpExec) . " " . escapeshellarg($phpScript), $cwd);
}
} else {
$phpExec = exec("which php-cli");


if ($phpExec[0] != '/') {
$phpExec = exec("which php");
}


if ($phpExec[0] == '/') {
BackgroundProcess::open(escapeshellarg($phpExec) . " " . escapeshellarg($phpScript), $cwd);
}
}
} else {
if (strtoupper(substr(PHP_OS, 0, 3)) == 'WIN') {
$phpExec = str_replace('/', '\\', $phpExec);
}


BackgroundProcess::open(escapeshellarg($phpExec) . " " . escapeshellarg($phpScript), $cwd);
}
}
}

另一种方法是通过 curl。你可以将你的内部任务设置为一个 webservice。例如:

然后在用户访问的脚本中对服务进行调用:

$service->addTask('t1', $data); // post data to URL via curl

您的服务可以使用 mysql 跟踪任务队列或者其他任何您喜欢的重点: 它们都包装在服务中,脚本只是使用 URL。这使您可以在必要时将服务移动到另一台机器/服务器(即易于扩展)。

添加 http 授权或自定义授权方案(如亚马逊的 Web 服务)可以让你打开任务,供其他人/服务使用(如果你愿意) ,你可以更进一步,在上面添加一个监控服务,以跟踪队列和任务状态。

这确实需要一些准备工作,但是有很多好处。

我已经在一个项目中使用了 豆茎,并计划再次使用。我发现它是运行异步进程的一种很好的方式。

我用它做了几件事:

  • 图像调整大小——由于一个轻量级的队列转移到了基于 CLI 的 PHP 脚本中,调整大图像(2MB +)的大小就可以了,但是在 mod _ PHP 实例中尝试调整相同图像的大小经常会遇到内存空间问题(我将 PHP 进程限制在32MB,调整大小所需的时间超过了这个数字)
  • 近期检查-beanstalkd 具有可用的延迟(使这个作业只能在 X 秒后运行)-所以我可以针对一个事件执行5或10次检查,稍晚一点

我编写了一个基于 Zend-Framework 的系统来解码一个“漂亮”的 URL,例如,调整一个图像的大小,这个图像称为 QueueTask('/image/resize/filename/example.jpg')。首先将 URL 解码为一个数组(模块、控制器、操作、参数) ,然后转换为 JSON 以注入到队列本身。

然后,一个长时间运行的 cli 脚本从队列中提取作业,运行它(通过 Zend _ Router _ Simple) ,如果需要,将信息放入 memcached 中,以便网站 PHP 在完成任务时根据需要提取。

我提出的一个问题是 cli 脚本在重新启动之前只运行了50个循环,但是如果它确实想要按计划重新启动,它会立即这样做(通过 bash 脚本运行)。如果出现问题,我选择 exit(0)(exit;die();的默认值) ,它首先会暂停几秒钟。

当您只想执行一个或多个 HTTP 请求而不必等待响应时,也有一个简单的 PHP 解决方案。

在调用脚本中:

$socketcon = fsockopen($host, 80, $errno, $errstr, 10);
if($socketcon) {
$socketdata = "GET $remote_house/script.php?parameters=... HTTP 1.1\r\nHost: $host\r\nConnection: Close\r\n\r\n";
fwrite($socketcon, $socketdata);
fclose($socketcon);
}
// repeat this with different parameters as often as you like

在名为 script.PHP 的文件中,您可以在第一行中调用这些 PHP 函数:

ignore_user_abort(true);
set_time_limit(0);

这会导致在关闭 HTTP 连接时脚本继续运行而不受时间限制。

如果您在“感谢您注册”响应中设置了 Content-Llength HTTP 头,那么浏览器应该在接收到指定数量的字节后关闭连接。这使得服务器端进程可以运行(假设忽略 _ user _ abort 已经设置) ,这样它就可以在不让最终用户等待的情况下完成工作。

当然,在呈现标题之前,您需要计算响应内容的大小,但是对于简短的响应(将输出写入字符串、调用 strlen ()、调用 header ()、呈现字符串)来说,这非常容易。

这种方法的优点是,没有强制您管理“前端”队列,尽管您可能需要在后端做一些工作,以防止竞争 HTTP 子进程相互干扰,但是无论如何,这是您已经需要做的事情。

正如 rojoca 所建议的那样,使用 cURL 是一个很好的主意。

下面是一个例子,当脚本在后台运行时,您可以监视 text.txt:

<?php


function doCurl($begin)
{
echo "Do curl<br />\n";
$url = 'http://'.$_SERVER['SERVER_NAME'].$_SERVER['REQUEST_URI'];
$url = preg_replace('/\?.*/', '', $url);
$url .= '?begin='.$begin;
echo 'URL: '.$url.'<br>';
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$result = curl_exec($ch);
echo 'Result: '.$result.'<br>';
curl_close($ch);
}




if (empty($_GET['begin'])) {
doCurl(1);
}
else {
while (ob_get_level())
ob_end_clean();
header('Connection: close');
ignore_user_abort();
ob_start();
echo 'Connection Closed';
$size = ob_get_length();
header("Content-Length: $size");
ob_end_flush();
flush();


$begin = $_GET['begin'];
$fp = fopen("text.txt", "w");
fprintf($fp, "begin: %d\n", $begin);
for ($i = 0; $i < 15; $i++) {
sleep(1);
fprintf($fp, "i: %d\n", $i);
}
fclose($fp);
if ($begin < 10)
doCurl($begin + 1);
}


?>

如果您不想要完整的 ActiveMQ,我建议考虑 RabbitMQ。RabbitMQ 是使用 AMQP 标准的轻量级消息传递。

我建议您也研究一下 Php-amqplib-这是一个流行的 AMQP 客户机库,可以访问基于 AMQP 的消息代理。

PHP多线程,默认情况下没有启用,有一个名为 螺纹的扩展可以做到这一点。 不过,您需要使用 ZTS 编译 php (线程安全) 相关网址:

例子

另一个教程

PECL 扩展

更新: 因为 PHP 7.2并行扩展开始发挥作用

教程/示例

参考手册

如果只是提供昂贵的任务的问题,在支持 php-fpm 的情况下,为什么不使用 fastcgi_finish_request()函数呢?

此函数将所有响应数据刷新到客户端并完成请求。这允许在不打开到客户端的连接的情况下执行耗时的任务。

实际上,您并没有以这种方式使用异步性:

  1. 先写好你的主代码。
  2. 执行 fastcgi_finish_request()
  3. 做很重的东西。

同样需要 php-fpm。

我认为你应该尝试这个技术,它将有助于调用尽可能多的页面,你喜欢所有的页面将一次独立运行,而不是等待每个页面的异步响应。

Cornjobpage.php//mainpage

    <?php


post_async("http://localhost/projectname/testpage.php", "Keywordname=testValue");
//post_async("http://localhost/projectname/testpage.php", "Keywordname=testValue2");
//post_async("http://localhost/projectname/otherpage.php", "Keywordname=anyValue");
//call as many as pages you like all pages will run at once independently without waiting for each page response as asynchronous.
?>
<?php


/*
* Executes a PHP page asynchronously so the current page does not have to wait for it to     finish running.
*
*/
function post_async($url,$params)
{


$post_string = $params;


$parts=parse_url($url);


$fp = fsockopen($parts['host'],
isset($parts['port'])?$parts['port']:80,
$errno, $errstr, 30);


$out = "GET ".$parts['path']."?$post_string"." HTTP/1.1\r\n";//you can use POST instead of GET if you like
$out.= "Host: ".$parts['host']."\r\n";
$out.= "Content-Type: application/x-www-form-urlencoded\r\n";
$out.= "Content-Length: ".strlen($post_string)."\r\n";
$out.= "Connection: Close\r\n\r\n";
fwrite($fp, $out);
fclose($fp);
}
?>

Testpage.php

    <?
echo $_REQUEST["Keywordname"];//case1 Output > testValue
?>

PS: 如果你想以循环方式发送 url 参数,那么按照下面的答案: https://stackoverflow.com/a/41225209/6295712

在服务器上使用 exec()或者直接在另一台服务器上使用 curl 产生新的进程并不能很好地扩展,如果我们使用 exec,你基本上是在用长时间运行的进程填充你的服务器,这些进程可以被其他非面向 web 的服务器处理,使用 curl 绑定另一台服务器,除非你建立了某种负载平衡。

我已经在一些情况下使用了 Gearman,我发现它更适合这种用例。我可以使用单个作业队列服务器基本上处理所有需要由服务器完成的作业的队列并旋转工作服务器,每个工作服务器都可以根据需要运行工作进程的任意多个实例,根据需要扩大工作服务器的数量,并在不需要时旋转它们。它还允许我在需要时完全关闭工作进程,并将作业排队等待,直到工作人员重新上线。

有一个 PHP 扩展,称为 羊毛

虽然它可能没有启用,但它可以在我的主机上点击一个按钮就可以启用。

值得一查。我还没有时间使用它,因为我正在这里搜索信息,当我偶然发现它,并认为它值得分享。