PHP多进程实现必应壁纸批量下载
多进程编程初体验
最近在研究PHP多进程相关的内容,参考了李先生写的系列文章:
- PHP多进程初探 --- 开篇
- PHP多进程初探 --- 孤儿和僵尸
- PHP多进程初探 --- 信号
- PHP多进程初探 --- 利用多进程开发点儿东西吧
- PHP多进程初探 --- 再次谈daemon进程
- PHP多进程初探 --- 进程间通信二三事
另外还参考了owner888/phpspider项目中多进程的实现方式。
学完这些内容后,就想着找个实际项目练练手,正好之前写过一篇关于壁纸下载的文章《QueryList + Redis 下载壁纸》,其中提到可以通过多开终端窗口的方式来提高下载效率。
由于原来的壁纸网站已经关闭,后面的演示换用了其他来源。
PHP多进程核心概念
上面的参考文章已经讲得很详细了,这里做个简要总结。
孤儿进程与僵尸进程
父进程创建子进程后,必须负责子进程的回收工作,否则会产生异常进程:
孤儿进程:父进程先于子进程退出,子进程沦为孤儿,被Linux系统的init进程(PID=1)收养僵尸进程:子进程已退出但父进程未进行回收,其进程ID、文件描述符等资源仍驻留在内存中,会造成系统资源浪费,危害比孤儿进程更大
子进程回收机制
父进程通过pcntl_wait()或pcntl_waitpid()函数完成子进程回收:
pcntl_wait():阻塞等待子进程退出,收到退出信号前会一直停留pcntl_waitpid():非阻塞模式,无子进程需要处理时立即返回0继续执行
信号机制
信号是异步事件通知,进程无法预知信号何时到达。
常见信号产生方式:键盘Ctrl+C或Ctrl+D产生SIGINT信号终止程序;posix_kill()函数可向指定进程发送信号。
进程收到信号后的处理方式包括:直接忽略(SIGSTOP和SIGKILL无法忽略)、自定义信号处理函数、执行系统默认动作(通常为终止进程)。
信号捕获实现:使用pcntl_signal()注册回调函数,通过pcntl_signal_dispatch()触发待处理信号。
守护进程
普通进程在终端按下Ctrl+C时会被终止。若要实现守护进程,需要在父进程中创建子进程,调用posix_setsid()使子进程成为会话首进程,然后父进程退出以断开与终端的关联。
实现方案
采用Master-Worker架构:Master进程负责管理子进程状态,一个Worker进程负责抓取壁纸URL,其余Worker进程并行下载壁纸。下载Worker的数量可根据硬件配置调整。
启动入口
run()方法主导整个程序的执行流程:
public function run()
{
// 验证运行环境
$this->verifyEnvironment();
// 转换为守护进程模式
$this->convertToDaemon();
// 注册信号处理器
$this->registerSignalHandlers();
// 建立Redis连接
$this->setupRedis();
// 创建工作进程
$this->spawnWorkers();
// 监控子进程运行状态
$this->watchChildren();
}
程序执行步骤:环境检查、守护进程化、信号处理、Redis初始化、子进程创建、状态监控。
PHP 7.1引入了pcntl_async_signals()函数简化信号处理,之前版本需要配合declare()和pcntl_signal_dispatch()使用。
protected function verifyEnvironment()
{
if ('//' == \DIRECTORY_SEPARATOR) {
exit('仅支持Linux系统环境'.PHP_EOL);
}
if (!\extension_loaded('pcntl')) {
exit('缺少pcntl扩展支持'.PHP_EOL);
}
if (!\extension_loaded('posix')) {
exit('缺少posix扩展支持'.PHP_EOL);
}
if (version_compare(PHP_VERSION, '7.1', '<')) {
declare(ticks = 1);
} else {
\pcntl_async_signals(true);
}
}
守护进程实现
守护进程的创建流程包括:fork子进程、主进程退出、子进程setsid、再fork一次确保不会成为会话首进程。
protected function convertToDaemon()
{
if (self::$config['daemonize'] !== true) {
return;
}
umask(0);
$pid = \pcntl_fork();
if ($pid < 0) {
$this->output('守护进程创建失败');
exit;
} elseif ($pid > 0) {
exit(0);
}
if (\posix_setsid() < 0) {
$this->output('会话首进程设置失败');
exit;
}
$pid = \pcntl_fork();
if ($pid < 0) {
$this->output('守护进程创建失败');
exit;
} elseif ($pid > 0) {
exit(0);
}
}
Redis连接初始化
从配置中读取Redis参数,使用Predis库创建客户端实例。
protected function setupRedis()
{
$this->redis = new Client(self::$config['redis']);
}
信号处理注册
仅处理SIGINT和SIGTERM信号,收到终止信号时通知所有Worker停止工作,忽略SIGPIPE。
protected function registerSignalHandlers()
{
\pcntl_signal(SIGINT, [$this, 'terminateWorkers'], false);
\pcntl_signal(SIGTERM, [$this, 'terminateWorkers'], false);
\pcntl_signal(\SIGPIPE, \SIG_IGN, false);
}
protected function terminateWorkers()
{
if (self::$masterPid !== \posix_getpid()) {
unset(self::$workers[$this->workerIndex]);
exit(0);
}
foreach (self::$workers as $pid) {
\posix_kill($pid, SIGTERM);
}
}
Worker进程初始化
获取当前进程PID作为Master标识,创建URL提取Worker(workerIndex=1)和指定数量的下载Worker。
protected function spawnWorkers()
{
self::$masterPid = \posix_getpid();
$this->forkProcess(1, [$this, 'fetchImageUrls']);
$workerCount = (int) self::$config['worker_count'];
for ($i = 0; $i < $workerCount; $i++) {
$this->forkProcess($i + 2, [$this, 'downloadImages']);
}
}
forkProcess方法在父进程中记录子进程PID,子进程中执行业务回调:
protected function forkProcess($workerIndex, $handler)
{
$pid = \pcntl_fork();
if ($pid > 0) {
self::$workers[$workerIndex] = $pid;
} elseif ($pid === 0) {
$this->workerIndex = $workerIndex;
if ($handler instanceof \Closure) {
$handler();
} elseif (isset($handler[1]) && is_object($handler[0])) {
\call_user_func($handler);
}
exit(0);
} else {
$this->output('进程创建失败');
exit;
}
}
壁纸采集与下载
URL提取Worker遍历分页获取壁纸地址,放入Redis队列;下载Worker从队列取出URL并保存到本地。
protected function fetchImageUrls()
{
$this->output('URL提取Worker已启动...');
$page = 1;
do {
$content = \file_get_contents("https://bing.ioliu.cn/?p={$page}");
\preg_match_all('/![]()]*)\ssrc="([^\s>]+)"/', $content, $found);
if (empty($found[2]) || \count($found[2]) === 3) {
$this->output('URL提取完成, 页码: %s', $page);
break;
}
$links = \array_unique(\array_filter($found[2]));
if (!empty($links)) {
$this->redis->sadd(self::$config['queue_name'], $links);
}
$this->output('提取链接数: %s, 当前页: %s', count($links), $page++);
} while (true);
}
protected function downloadImages()
{
$this->output('下载Worker已启动...');
while (self::$idleTime < self::$config['max_idle']) {
$url = $this->redis->srandmember(self::$config['queue_name']);
if (empty($url)) {
$this->redis->srem(self::$config['queue_name'], $url);
$this->output('空闲计时: %s/%ss', self::$idleTime++, self::$config['max_idle']);
\sleep(1);
continue;
}
self::$idleTime = 0;
try {
$success = $this->persistImage($url);
if (!$success) {
$this->redis->sadd(self::$config['queue_name'], [$url]);
}
} catch (\Exception $err) {
$success = false;
$this->output('保存异常: %s', $err->getMessage());
}
$this->output('下载%s, %s', $success ? '成功' : '失败', $url);
}
}
子进程状态监控
父进程持续监听子进程状态,子进程退出时从数组中移除,无活跃子进程时退出主进程。
protected function watchChildren()
{
while (true) {
$pid = $this->waitForSignal();
if ($pid > 0) {
$this->output('子进程退出, PID: %s', $pid);
$workers = \array_flip(self::$workers);
$workerIndex = $workers[$pid];
unset(self::$workers[$workerIndex]);
if (count(self::$workers) === 0) {
exit(0);
}
} else {
$this->output('监控结束, PID: %s', $pid);
exit(0);
}
}
}
protected function waitForSignal()
{
if (\version_compare(PHP_VERSION, '7.1', '>=')) {
return \pcntl_wait($status, WUNTRACED);
}
\pcntl_signal_dispatch();
$pid = \pcntl_wait($status, WUNTRACED);
\pcntl_signal_dispatch();
return $pid;
}
配置参数
$options为可选配置项:
$options = [
'daemonize' => false,
'worker_count' => 3,
'max_idle' => 60,
'save_path' => __DIR__.'/images',
'queue_name' => 'image_url_queue',
'redis' => [
'scheme' => 'tcp',
'host' => '127.0.0.1',
'port' => 6379,
],
];
$downloader = new WallpaperFetcher($options);
$downloader->run();
执行结果
vagrant@homestead:~/code/project/wallpaper$ php index.php
[2020-02-02 10:41:34] [worker-1] URL提取Worker已启动...
[2020-02-02 10:41:34] [worker-3] 下载Worker已启动...
[2020-02-02 10:41:34] [worker-2] 下载Worker已启动...
[2020-02-02 10:41:34] [worker-4] 下载Worker已启动...
[2020-02-02 10:41:35] [worker-1] 提取链接数: 12, 当前页: 1
[2020-02-02 10:41:35] [worker-2] 下载成功, http://h1.ioliu.cn/bing/xxx_1920x1080.jpg
[2020-02-02 10:41:35] [worker-3] 下载成功, http://h1.ioliu.cn/bing/yyy_1920x1080.jpg
[2020-02-02 10:41:36] [worker-4] 下载成功, http://h1.ioliu.cn/bing/zzz_1920x1080.jpg
^C[2020-02-02 10:41:38] [worker-0] 监控结束
本地保存的壁纸文件:
vagrant@homestead:~/code/project/wallpaper/images$ ls
image1.jpg
image2.jpg
image3.jpg
...
完整代码见:https://github.com/her-cat/wallpaper_crawler/blob/master/BingWallpaperDownloader.php
PHP多进程实践记录到此结束。