当前位置:首页 > 技术 > 正文内容

PHP多进程实现必应壁纸批量下载

访客 技术 2026年7月25日 1

多进程编程初体验

最近在研究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多进程实践记录到此结束。

相关文章

Linux crontab 详解

1) crontab 是什么cron 是 Linux 的定时任务守护进程;crontab 是用来编辑/查看“按时间周期执行命令”的表(cron table)。常见两类:用户 crontab:每个用户一份(crontab -e 编辑)系统级 crontab / cron.d:可指定执行用户(/etc/crontab、/etc/cron.d/*)2) crontab 时间...

富文本里可以允许的 HTML 属性

一、所有标签默认允许的安全属性(极少)class        (可选)id           (通常建议禁用)title️ 注意:id 容易被滥用做锚点注入,很多系统直接禁用class 允许的话最好只允许固定前缀(如 editor-*)二、a 标签允许属性<a href="" t...

Mac 安装 Node.js 指南

方法一:通过官网安装包(最简单,适合初学者)如果你只是想快速安装并开始使用,这是最直接的方法。访问 Node.js 官网。页面会显示两个版本:LTS (Recommended For Most Users):长期支持版,最稳定。建议选这个。Current:最新特性版,包含最新功能但可能不够稳定。下载 .pkg 安装包并运行。按照安装向导点击“下一步”即可完成。方法二:使用 Homebrew 安装(...

Dom\HTML_NO_DEFAULT_NS 的副作用:自动加闭合标签

在使用Dom\HTMLDocument时,Dom\HTML_NO_DEFAULT_NS 将禁止在解析过程中设置元素的命名空间, 此设置是为了与DOMDocument向后兼容而存在的。当使用它时,已知的一个副作用就是:自动加闭合标签例如 </img> 为什么会这样?当你使用:Dom\HTML_NO_DEFAULT_NS文档会变成 无命名空间模式,此时内部更接近 XML...

Laravel 事件和监听器创建

在 Laravel 中,使用 Artisan 命令创建 Events(事件) 和 Listeners(监听器) 是非常高效的。你可以通过以下几种方式来实现:1. 手动创建单个 Event如果你只想创建一个事件类,可以使用 make:event 命令:Bashphp artisan make:event UserRegistered执行后,文件将生成在 app/Even...

自定义域名解析神器 dnsmasq

什么是 dnsmasq?dnsmasq 是一个轻量级、功能强大的网络服务工具,专为小型和中等规模网络设计。它是一个综合的网络基础设施解决方案[1]。dnsmasq 能做什么?功能说明应用场景DNS 转发与缓存将 DNS 查询转发到上游服务器(ISP、Google DNS 等),并在本地缓存结果加快 DNS 查询速度,减少外部 DNS 流量本地 DNS解析本地网络设备的主机名,无需编辑&n...

发表评论

访客

◎欢迎参与讨论,请在这里发表您的看法和观点。