PHP 多进程下载必应壁纸

2020-02-02
次阅读
7 分钟阅读时长

手里拿着锤子,看什么都像是钉子

在放假的这几天,断断续续的看了老李关于 PHP 多进程的文章。

在此基础上又看了下 owner888/phpspider 的多进程实现代码,这个是《我用爬虫一天时间“偷了”知乎一百万用户,只为证明PHP是世界上最好的语言 》一文所使用的程序。

等到自我感觉差不多已经掌握多进程时候,它就变成了我手中的锤子:

手里拿着锤子,看什么都像是钉子。

在《QueryList + Redis 下载壁纸》这篇文章中有提到,可以手动多开几个黑窗口提高壁纸下载速度。

正如文章中所说,在此之前,需要用到多进程来处理任务的时候都是用的这种“笨方法”。虽然在启动任务的时候比较麻烦,需要手动打开 n 个黑窗口,然后到指定目录下运行对应的脚本,但是在写代码的时候比较轻松,不用考虑多进程的可能导致的一些问题。

由于文中的壁纸站点倒闭了(与我无瓜),所以后面的代码换了一个站点来进行演示。

PHP 多进程的一些概念

关于 PHP 多进程,上面列出来的文章其实已经讲的差不多了,这里其实就是个观后总结,已经看完文章的可以跳过。

孤儿进程和僵尸进程

父进程在创建子进程后,需要负责子进程的回收,否则就会出现 孤儿进程僵尸进程

  • 孤儿进程:父进程在创建子进程后,子进程还在运行的时候自己先退出了,导致子进程没了爹,就变成了孤儿进程,然后被 Linux 的 “孤儿进程福利院” init 进程(进程 id 为 1)所收养。

  • 僵尸进程:父进程在创建子进程后,子进程退出了,但是父进程没有对其进行回收,导致子进程变成了僵尸进程,子进程的进程 ID、文件描述符等依然保存在系统中,极大的浪费了系统资源,相比孤儿进程危害更大。

回收子进程

在父进程中通过 pcntl_wait()pcntl_waitpid() 函数对子进程进行回收,上面提到的回收其实就是对子进程的状态收集。

  • pcntl_wait():等待或返回创建的子进程状态。该函数是阻塞的,所以当执行到该函数时会阻塞在这里,直到有子进程退出或终止。

  • pcntl_waitpid():等待或返回创建的子进程状态。该函数是非阻塞的,也就是说当没有子进程需要处理时,它会返回 0 并继续执行后面的代码。

信号

信号是异步传送给进程的一种事件通知,进程无法预测何时会出现信号。

信号的产生有多种方式,比如在键盘上按下组合键 ctrl+c 或 ctrl+d 就会产生 SIGINT 信号并终止当前运行的程序;使用 posix_kill() 函数可以向指定的进程发送某种信号。

进程在收到信号后有以下三种处理方式。

  • 直接忽略:对信号不做任何处理,SIGSTOPSIGKILL 两种信号无法忽略,因为这两个信号是提供给用户停止或杀死进程最可靠的手段。

  • 捕获信号:程序自定义信号处理逻辑。

  • 系统默认动作:Linux 内核为每种信号都提供了默认动作,当程序没有主动捕获某种信号时,就会交由系统执行默认动作。大多数默认动作都是终止进程。

捕获信号的处理方式:先通过 pcntl_signal() 函数安装某个信号的回调函数,然后使用 pcntl_signal_dispatch() 调用每个等待信号通过 pcntl_signal() 安装的信号回调函数。

守护进程

非守护进程在启动后,在终端按下组合键 ctrl+c 或 ctrl+d 就会终止当前运行的程序。想要成为守护进程,首先要在父进程中创建一个子进程,然后通过 posix_setsid() 函数将该子进程作为会话的主进程,并退出父进程,断开与终端的连接。

代码实现

进程模型用的是单 Master 多 Worker 进程模型,Master 进程用于收集子进程的状态,一个 Worker 进程用于提取所有的壁纸下载地址,剩下 Worker 进程用于下载下载壁纸,因为下载比较耗时,所以需要多个 Worker 进程同时处理,下载壁纸的 Worker 进程数量可以自定义。

入口函数

首先看一下入口函数 run()

    public function run()
    {
        // 检查运行环境
        $this->checkEnv();
        // 守护进程
        $this->daemonize();
        // 安装信号处理器
        $this->installSignalHandler();
        // 初始化 Redis
        $this->initRedis();
        // 初始化进程
        $this->initWorkers();
        // 监听子进程状态
        $this->monitor();
    }

run() 函数已经概括了程序的运行流程。

首先检查一下当前运行环境,是否在 linux 系统中、是否安装相关扩展,最后是关于信号派遣的,PHP 7.1 新增了 pcntl_async_signals() 函数,在此之前需要 declare() 配合 pcntl_signal_dispatch() 函数进行信号派遣。

    protected function checkEnv()
    {
        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);
        }
    }

守护进程

守护进程上面已经介绍过,可以再配合代码注释理解。

    protected function daemonize()
    {
        if (self::$options['daemonize'] !== true) {
            return;
        }

        // 设置当前进程创建的文件权限为 777
        umask(0);

        $pid = \pcntl_fork();
        if ($pid < 0) {
            $this->log('创建守护进程失败');
            exit;
        } else if ($pid > 0) {
            // 主进程退出
            exit(0);
        }

        // 将当前进程作为会话首进程
        if (\posix_setsid() < 0) {
            $this->log('设置会话首进程失败');
            exit;
        }

        // 两次 fork 保证形成的 daemon 进程绝对不会成为会话首进程
        $pid = \pcntl_fork();
        if ($pid < 0) {
            $this->log('创建守护进程失败');
            exit;
        } else if ($pid > 0) {
            // 主进程退出
            exit(0);
        }
    }

初始化 Redis

初始化 Redis 就是从配置中获取 Redis 参数,然后实例化 Predis/Client。

    protected function initRedis()
    {
        $this->redisClient = new Client(self::$options['redis']);
    }

安装信号处理器

这里只安装了 SIGINTSIGPIP 信号的处理器,收到 SIGINT 信号后,调用 stopAllWorkers() 方法给所有的 Worker 发送 SIGINT 信号,停止所有的 Worker。而收到 SIGPIPE 信号则忽略不做任何处理。

    protected function installSignalHandler()
    {
        // 捕获 SIGINT 信号,终端中断
        \pcntl_signal(SIGINT, [$this, 'stopAllWorkers'], false);

        // 捕获 SIGPIPE 信号,忽略掉所有管道事件
        \pcntl_signal(\SIGPIPE, \SIG_IGN, false);
    }

    protected function stopAllWorkers()
    {
        if (self::$maserPid !== \posix_getpid()) {
            // 子进程
            unset(self::$workers[$this->workerId]);
            exit(0);
        }

        // 父进程
        foreach (self::$workers as $pid) {
            // 给 worker 进程发送关闭信号
            \posix_kill($pid, SIGINT);
        }
    }

初始化进程

接下来就是初始化进程,先通过 posix_getpid() 函数获取当前进程的进程 ID 作为 Master 进程 ID。

再通过 forkWorker() 方法创建提取壁纸地址进程,该进程的处理方法是 extractWallpaperUrl()。因为 work id 为 0 的留给了 Master 进程,所以这里的 work id 从 1 开始。

然后根据配置项 worker_num 创建指定数量的下载壁纸的进程,该进程的处理方法是 downloadWallpaper() 方法。

    protected function initWorkers()
    {
        self::$maserPid = \posix_getpid();

        $this->forkWorker(1, [$this, 'extractWallpaperUrl']);

        $workerNum = (int) self::$options['worker_num'];

        for ($i = 0; $i < $workerNum; $i++) {
            $this->forkWorker($i + 2, [$this, 'downloadWallpaper']);
        }
    }

上面提到了 forkWorker 方法,这个方法其实跟老李文章中写的创建子进程代码差不多,在父进程中记录子进程的进程 ID,在进程中调用匿名函数处理业务逻辑。

    protected function forkWorker($workerId, $callback)
    {
        $pid = \pcntl_fork();

        if ($pid > 0) {
            // 父进程记录子进程 PID
            self::$workers[$workerId] = $pid;
        } elseif ($pid === 0) {
            // 子进程处理业务逻辑
            $this->workerId = $workerId;

            if ($callback instanceof \Closure) {
                $callback();
            } else if (isset($callback[1]) && is_object($callback[0])) {
                \call_user_func($callback);
            }

            exit(0);
        } else {
            $this->log('进程创建失败');
            exit;
        }
    }

壁纸采集逻辑

提取壁纸地址和下载壁纸的逻辑跟之前写的那篇文章差不多。

    protected function extractWallpaperUrl()
    {
        $this->log('提取壁纸地址进程启动...');

        $page = 1;

        do {
            $html = \file_get_contents("https://bing.ioliu.cn/?p={$page}");
            \preg_match_all('/<img([^>]*)\ssrc="([^\s>]+)"/', $html,$matches);

            if (empty($matches[2]) || \count($matches[2]) === 3) {
                $this->log('壁纸地址提取完毕, 当前页码: %s', $page);
                break;
            }

            $urls = \array_unique(\array_filter($matches[2]));

            if (!empty($urls)) {
                // 将壁纸 url 放入队列中
                $this->redisClient->sadd(self::$options['queue_key'], $urls);
            }

            $this->log('提取壁纸数量: %s, 当前页面: %s', count($urls), $page++);
        } while (true);
    }

    protected function downloadWallpaper()
    {
        $this->log('下载壁纸进程启动...');

        while (self::$freeTime < self::$options['max_free_time']) {
            $url = $this->redisClient->spop(self::$options['queue_key']);

            if (empty($url)) {
                $this->log('空闲时间: %s/%ss', self::$freeTime++, self::$options['max_free_time']);
                \sleep(1);
                continue;
            }

            try {
                $result = $this->saveWallpaper($url);
                if (!$result) {
                    $this->redisClient->sadd(self::$options['queue_key'], [$url]);
                }
            } catch (\Exception $e) {
                $result = false;
                $this->log('保存壁纸异常: %s', $e->getMessage());
            }

            $this->log('壁纸下载%s, %s', $result ? '成功' : '失败', $url);
        }
    }

监听子进程状态

进程到目前已经创建完了,接下来就是父进程对子进程状态进行监听,如果该已经已退出就将它从 self::workers 数组中删除,如果没有在运行中的子进程则退出父进程。

acceptSignal() 方法中通过 pcntl_wait() 函数阻塞获取退出的进程 ID。

    protected function monitor()
    {
        while (true) {
            $pid = $this->acceptSignal();

            if ($pid > 0) {
                $this->log('子进程退出信号, PID: %s', $pid);
                // 翻转 workers 的键值
                $workers = \array_flip(self::$workers);
                $workerId = $workers[$pid];
                // 删除子进程
                unset(self::$workers[$workerId]);
                // 如果没有在运行的子进程则退出主进程
                count(self::$workers) === 0 && exit(0);
            } else {
                $this->log('其它信号, PID: %s', $pid);
                exit(0);
            }
        }
    }

    protected function acceptSignal()
    {
        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,   // 是否 daemon 化
    'worker_num'    => 3,       // 下载壁纸进程数量
    'max_free_time' => 60,      // 最大空闲时间(秒)
    'save_dir'      => __DIR__.'/wallpaper',    // 壁纸保存位置
    'queue_key'     => 'wallpaper_url_queue',   // 壁纸下载地址的 redis key
    'redis' => [                                // redis 配置
        'scheme' => 'tcp',
        'host'   => '127.0.0.1',
        'port'   => 6379,
    ],
];

$wallpaper = new BingWallpaperDownloader($options);

$wallpaper->run();

运行效果

vagrant@homestead:~/code/her-cat/download_bing_wallpaper$ php index.php
[2020-02-02 10:41:34] [worker-1] 提取壁纸地址进程启动...
[2020-02-02 10:41:34] [worker-3] 下载壁纸进程启动...
[2020-02-02 10:41:34] [worker-2] 下载壁纸进程启动...
[2020-02-02 10:41:34] [worker-4] 下载壁纸进程启动...
[2020-02-02 10:41:35] [worker-1] 提取壁纸数量: 12, 当前页面: 1
[2020-02-02 10:41:35] [worker-2] 壁纸下载成功, http://h1.ioliu.cn/bing/NutcrackerSeason_EN-AU8373379424_1920x1080.jpg
[2020-02-02 10:41:35] [worker-3] 壁纸下载成功, http://h1.ioliu.cn/bing/zhenghe_ZH-CN9628081460_1920x1080.jpg
[2020-02-02 10:41:36] [worker-4] 壁纸下载成功, http://h1.ioliu.cn/bing/MonumentFountain_EN-AU10536043652_1920x1080.jpg
[2020-02-02 10:41:37] [worker-2] 壁纸下载成功, http://h1.ioliu.cn/bing/JeanLafitte_EN-AU11428973003_1920x1080.jpg
[2020-02-02 10:41:37] [worker-1] 提取壁纸数量: 12, 当前页面: 2
[2020-02-02 10:41:37] [worker-3] 壁纸下载成功, http://h1.ioliu.cn/bing/MorondavaBaobab_EN-AU11363642614_1920x1080.jpg
[2020-02-02 10:41:37] [worker-3] 壁纸下载成功, http://h1.ioliu.cn/bing/SnowHare_ZH-CN9767012872_1920x1080.jpg
[2020-02-02 10:41:38] [worker-4] 壁纸下载成功, http://h1.ioliu.cn/bing/ShenandoahAutumn_EN-AU11784755049_1920x1080.jpg
^C[2020-02-02 10:41:38] [worker-0] 其它信号, PID: -1

保存的壁纸

vagrant@homestead:~/code/her-cat/download_bing_wallpaper/wallpaper$ ls
AbstractSaltBeds_ZH-CN8351691359_1920x1080.jpg         MauiEucalyptus_ZH-CN5616197787_1920x1080.jpg
AcadiaBlueberries_ZH-CN6014510748_1920x1080.jpg        may1_ZH-CN8582006115_1920x1080.jpg
AdelieBreeding_ZH-CN1750945258_1920x1080.jpg           MeerkatHuddle_ZH-CN1358126294_1920x1080.jpg
AdobeSantaFe_EN-AU3063917358_1920x1080.jpg             MeerkatMob_ZH-CN3788674757_1920x1080.jpg
AerialKluaneNP_ZH-CN4080112842_1920x1080.jpg           MeteorCrater_EN-AU9993563603_1920x1080.jpg

最后

完整代码:https://github.com/her-cat/wallpaper_crawler/blob/master/BingWallpaperDownloader.php

关于 PHP 多进程的实践到这里就结束了,目前来看代码好像没啥太问题,后面有问题再来改吧。

溜了…

本文作者:她和她的猫
本文地址https://her-cat.com/posts/2020/02/02/php-multi-process-download-bing-wallpaper/
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!