PHP 命令行方式实现异步多进程模式的任务处理

用 PHP 来实现异步任务一直是个难题,现有的解决方案中:PHP 知名的异步框架有 swooleWorkerman,但都是无法在 web 环境中直接使用的,即便强行搭建 web 环境,异步调用也是使用多进程模式实现的。但有时真的不需要用启动服务的方式,让服务端一直等待客户端消息,何况中间还不能改动服务端代码。本文就介绍一下不使用任何框架和第三方库的情况下,在 CLI 环境中如何实现多进程以及在 web 环境中的异步调用。

web 环境的异步调用#

常用的方式有两种

1. 使用 socket 连接#

这种方式就是典型的 C/S 架构,需要有服务端支持。

// 1. 创建socket套接字
$socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
// 2. 进行socket连接
socket_connect($socket, '127.0.0.1', '3939');
//socket_set_nonblock($socket); // 以非阻塞模式运行,由于在客户端不实用,所以这里不考虑
// 3. 向服务端发送请求
socket_write($socket, $request, strlen($request));
// 4. 接受服务端的回应消息(忽略非阻塞的情况,如果服务端不是提供异步服务,那这一步可以省略)
$recv = socket_read($socket, 2048);
// 5. 关闭socket连接
socket_close($socket);

2. 使用 popen 打开进程管道#

这种方式是使用操作系统命令,由操作系统直接执行。
本文讨论的异步调用就是使用这种方式。

$sf = '/path/to/cli_async_task.php'; //要执行的脚本文件
$op = 'call'; //脚本文件接收的参数1
$data = base64_encode(serialize(['TestTask', 'arg1', 'arg2'])); //脚本文件接收的参数2
pclose(popen("php '$sf' --op $op --data $data &", 'r')); //打开之后接着就关闭进程管道,让该进程以守护模式运行
echo PHP_EOL.'异步任务已执行。'.PHP_EOL;

这种方式的优点就是:一步解决,当前进程不需要任何开销。
缺点也很明显:无法跟踪任务脚本的运行状态。
所以重头戏会是在执行任务的脚本文件上,下面就介绍任务处理和多进程的实现方式。

CLI 环境的多进程任务处理#

注意:多进程模式仅支持 Linux,不支持 Windows!!

这里会从 0 开始(未使用任何框架和类库)介绍每一个步骤,最后会附带一份完整的代码

1. 创建脚本#

  • 任何脚本不可忽视的地方就是错误处理。所以写一个任务处理脚本首先就是写错误处理方式。
    在 PHP 中就是调用 set_exception_handler set_error_handler register_shutdown_function 这三个函数,然后写上自定义的处理方法。
  • 接着是定义自动加载函数 spl_autoload_register 免去每使用一个新类都要 require / include 的烦恼。
  • 定义日志操作方法。
  • 定义任务处理方法。
  • 读取来自命令行的参数,开始执行任务。

2. 多进程处理#

PHP 创建多进程是使用 pcntl_fork 函数,该函数会 fork 一份当前进程(影分身术),于是就有了两个进程,当前进程是主进程(本体),fork 出的进程是子进程(影分身)。需要注意的是两个进程代码环境是一样的,两个进程都是执行到了 pcntl_fork 函数位置。区别就是 getmypid 获得的进程号不一样,最重要的区分是当调用 pcntl_fork 函数时,子进程获得的返回值是 0,而主进程获得的是子进程的进程号 pid

好了,当我们知道谁是子进程后,就可以让该子进程执行任务了。

那么主进程是如何得知子进程的状态呢?
使用 pcntl_wait。该函数有两个参数 $status$options$status 是引用类型,用来存储子进程的状态,$options 有两个可选常量 WNOHANG|WUNTRACED,分别表示不等待子进程结束立即返回和等待子进程结束。很明显使用 WUNTRACED 会阻塞主进程。(也可以使用 pcntl_waitpid 函数获取特定 pid 子进程状态)

在多进程中,主进程要做的就是管理每个子进程的状态,否则子进程很可能无法退出而变成僵尸进程。

关于多进程间的消息通信
这一块需要涉及具体的业务逻辑,所以只能简单的提一下。不考虑使用第三方比如 redis 等服务的情况下,PHP 原生可以实现就是管道通信共享内存等方式。实现起来都比较简单,缺点就是可使用的数据容量有限,只能用简单文本协议交换数据。

如何手动结束所有进程任务
如果多进程处理不当,很可能导致进程任务卡死,甚至占用过多系统资源,此时只能手动结束进程。
除了一个个的根据进程号来结束,还有一个快速的方法是首先在任务脚本里自定义进程名称,就是调用 cli_set_process_title 函数,然后在命令行输入:ps aux|grep cli_async_worker |grep -v grep|awk '{print $2}'|xargs kill -9 (里面的 cli_async_worker 就是自定义的进程名称),这样就可以快速结束多进程任务了。


未完待续...


以下是完整的任务执行脚本代码:
可能无法直接使用,需要修改的地方有:

  1. 脚本目录和日志目录常量
  2. 自动加载任务类的方法(默认是加载脚本目录中以 Task 结尾的文件)
  3. 其他的如:错误和日志处理方式和文本格式就随意吧...
  4. 如果命名管道文件设置有错误,可能导致进程假死,你可能需要手动删除进程管道通信的代码。
  5. 多进程的例子:execAsyncTask('multi', [ 'test' => ['a', 'b', 'c'], 'grab' => [['url' => 'https://www.baidu.com', 'callback' => 'http://localhost']] ]);。执行情况可以在日志文件中查看。execAsyncTask 函数参考【使用 popen 打开进程管道】。
代码已被折叠,点此展开
php
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
《L02 从零构建论坛系统》
以构建论坛项目 LaraBBS 为线索,展开对 Laravel 框架的全面学习。应用程序架构思路贴近 Laravel 框架的设计哲学。