以异步方式运行PHP任务

155
我在一款相当大的网页应用程序上工作,后端主要使用PHP。在代码中有几个地方需要完成某些任务,但我不希望让用户等待结果。例如,在创建新帐户时,我需要发送欢迎电子邮件。但是当他们点击“完成注册”按钮时,我不想让他们等到实际发送电子邮件才返回消息给用户,我只想启动流程并立即向用户返回一条消息。
到目前为止,在某些地方,我一直使用像exec()这样的东西,感觉像是一个hack。基本上做一些类似以下的事情:
exec("doTask.php $arg1 $arg2 $arg3 >/dev/null 2>&1 &");

这似乎起作用了,但我想知道是否有更好的方法。我正在考虑编写一个系统,在MySQL表中排队任务,以及一个单独的长时间运行的PHP脚本,每秒查询一次该表,并执行找到的任何新任务。如果将来需要,这也有将任务分配给几台工作机器的优点。

我是在重复造轮子吗?除了exec() hack或MySQL队列之外,有更好的解决方案吗?

16个回答

85

我曾采用队列处理方式,这种方法可以将处理任务推迟到服务器负载空闲时执行,如果你能轻松地将“不紧急的任务”分区,那么你就可以有效地管理负载。

自己编写队列处理并不太难,以下是几个其他可供参考的选择:

  • GearMan - 这个答案是在2009年编写的,在那之后,GearMan看起来成为了一个流行的选择,见下面的评论。
  • ActiveMQ 如果你想要一个完整的开源消息队列。
  • ZeroMQ - 这是一个相当酷的套接字库,使得写分布式代码变得容易,而不必过多担心套接字编程本身。你可以将其用于单个主机上的消息队列 - 你只需让你的Web应用程序将某些东西推送到一个队列中,一个持续运行的控制台应用程序会在下一个合适的机会消费它。
  • beanstalkd - 在编写这篇答案时才发现的这个,但看起来很有趣。
  • dropr 是一个基于PHP的消息队列项目,但自2010年9月以来就没有得到积极维护。
  • php-enqueue 是最近(2017年)维护的一个各种队列系统的包装器
  • 最后,关于使用memcached作为简单的消息队列的博客文章。

另一种可能更简单的方法是使用ignore_user_abort - 一旦页面发送给用户,你可以在不担心过早终止的情况下完成最终处理,但这会影响用户体验,因为页面加载看起来会变得更长。


2
如果您在“感谢您注册”的响应中设置Content-Length HTTP头,则浏览器应在接收到指定字节数后关闭连接。这样可以使服务器端进程保持运行状态(假设设置了ignore_user_abort),而不会让最终用户等待。当然,您需要在呈现标头之前计算响应内容的大小,但对于短响应来说,这非常容易。 - Peter
1
Gearman(http://gearman.org)是一个跨平台的开源消息队列,非常优秀。你可以使用C、PHP、Perl或者其他任何语言编写worker。MySQL有Gearman UDF插件,同时你也可以在PHP中使用Net_Gearman或者gearman pear客户端。 - Justin Swanhart
Gearman是我在2015年推荐的比任何自定义工作队列系统更好的选择。 - Peter
另一个选项是设置一个node js服务器来处理请求,并在任务之间返回快速响应。许多在node js脚本中的操作都是异步执行的,例如HTTP请求。 - Zordon
对于来自搜索引擎的人!随着PHP 8.1的发布,现在可以使用Fibers支持异步任务:https://wiki.php.net/rfc/fibers - CristianHG
显示剩余2条评论

25

当你只想执行一个或多个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连接关闭时,这会导致脚本继续运行而没有时间限制。


如果PHP在安全模式下运行,set_time_limit将无效。 - Baptiste Pernet

18

另一种fork进程的方法是通过curl。您可以将内部任务设置为Web服务。例如:

然后在用户访问的脚本中调用该服务:

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

您的服务可以使用MySQL或者其他您喜欢的工具来追踪任务队列。重要的是,所有这一切都被包含在服务中,而您的脚本只需要消耗URL。这使得您可以轻松地将服务移动到另一个机器/服务器上(即易于扩展)。

添加HTTP授权或自定义授权方案(如Amazon Web Services)可以让其他人/服务打开您的任务并进行消费(如果您想要)。您还可以进一步添加监控服务来跟踪队列和任务状态。

这需要一些设置工作,但是有很多好处。


2
我不喜欢这种方法,因为它会使网络服务器超载。 - Oved Yavine
如果只使用一个服务器,我不知道你如何避免这个问题。如果有多个服务器,你又该如何解决呢?因此,这个答案是唯一的方法,可以避免在 Web 服务器上加载这个工作。 - Flecibel

10
如果只是提供昂贵的任务,而且支持php-fpm,为什么不使用fastcgi_finish_request()函数呢?
这个函数将所有响应数据刷新到客户端并完成请求。这允许执行耗时的任务而不必保持与客户端的连接开放。
你不需要以这种方式使用异步性:
1.首先编写所有主要代码。
2.执行fastcgi_finish_request()
3.进行所有重型工作。
再次强调,需要php-fpm。

7
PHP支持多线程,只是默认情况下未启用,有一个名为“pthreads”的扩展可以实现多线程。但您需要使用带有ZTS的编译PHP(线程安全版本)。以下是相关链接: 示例 另一个教程 pthreads PECL扩展 自PHP 7.2起,引入了parallel扩展来支持并行处理。
参考手册: 教程/示例 参考手册

3
已经过时,被并行取代。 - T.Todua
@T.Todua,谢谢。已更新答案以保持相关性! - Omar

7

我曾经在一个项目中使用过Beanstalkd,并打算再次使用。我发现它是运行异步进程的绝佳方式。

我用它做了一些事情:

  • 图片调整大小 - 在轻负载队列传递到基于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();的默认值),它将先暂停几秒钟。


我喜欢beanstalkd的外观,一旦他们添加持久性,我认为它将是完美的。 - davr
这已经在代码库中并正在稳定化。我也期待着“命名作业”,这样我就可以把东西扔进去,但知道如果已经有一个作业存在,它就不会被添加。对于定期事件非常有用。 - Alister Bulman
@AlisterBulman,您能否提供更多关于“长时间运行的cli脚本从队列中接收任务”的信息或示例?我正在尝试为我的应用程序构建这样的cli脚本。 - Sasi varna kumar

6
这是我为我的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);
        }
    }
}

4

这是我过去几年一直在使用的方法,我还没有看到或发现更好的东西。正如人们所说,PHP是单线程的,所以你不能做太多其他的事情。

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

我还使用了类似的过程来生成报告。我不确定是否应该将“后台处理”用于诸如电子邮件之类的东西,除非存在SMTP缓慢的真正问题。相反,我可能会使用表作为队列,然后运行每分钟一个进程发送队列中的电子邮件。您需要小心避免重复发送电子邮件或其他类似的问题。我也会考虑为其他任务使用类似的排队过程。


1
你在第一句中指的是哪个方法? - Simon East

2

正如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);
}

?>

3
如果源代码有注释,那真的会很有帮助。我不知道里面发生了什么,哪些部分是示例,哪些部分可以重复使用以满足我的需求。 - Thomas Tempelmann

2

有一个名为Swoole的PHP扩展。

虽然它可能没有被启用,但在我的主机上可以轻松启用。

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


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接