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

在放假的这几天,断断续续的看了老李关于 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/2020/02/02/php-multi-process-download-bing-wallpaper.html

PHP 多进程下载必应壁纸的更多相关文章

  1. java 必应壁纸批量下载

    基于java 必应壁纸批量下载 - rookie丶k - 博客园 (cnblogs.com)实现 上面代码运行本地有点小问题,改了改 1.ssl验证 2.请求头 3.需要优化下载速度,多线程方式(还不 ...

  2. [uwp]MVVM模式实战之必应壁纸查看器

    最近学习MVVM,至于什么是MVVM我也在这儿不多说了,一是关于它的解释解释网上非常多,二是我怕自己讲不清,误导自己没关系,误导别人就不好了.. 好了,废话结束,看是实战...... 这个必应壁纸的d ...

  3. Python 爬取必应壁纸

    import re import os import requests from time import sleep headers = { "User-Agent": (&quo ...

  4. 如何使用 Github Actions 自动抓取每日必应壁纸?

    如何白嫖 Github 服务器自动抓取必应搜索的每日壁纸呢? 如果你访问过必应搜索网站,那么你一定会被搜索页面的壁纸吸引,必应搜索的壁纸每日不同,自动更换,十分精美.这篇文章会介绍如何一步步分析出必应 ...

  5. 使用Qt实现一个必应壁纸客户端

    概要 必应的每日壁纸很好看,但是看不到一周以前的壁纸图片,日前使用python开发了必应壁纸收集站,可惜这样的收集站只能在线浏览,我在想要是有一款软件能够下载每日必应壁纸,并应用到windows的桌面 ...

  6. Python爬虫实例(六)多进程下载金庸网小说

    目标任务:使用多进程下载金庸网各个版本(旧版.修订版.新修版)的小说 代码如下: # -*- coding: utf-8 -*- import requests from lxml import et ...

  7. php利用curl实现多进程下载文件类

    批量下载文件一般使用循环的方式,逐一执行下载.但在带宽与服务器性能允许的情况下,使用多进程进行下载可以大大提高下载的效率.本文介绍PHP利用curl的多进程请求方法,实现多进程同时下载文件. 原理: ...

  8. python之爬虫-必应壁纸

    python之爬虫-必应壁纸 import re import requests """ @author RansySun @create 2019-07-19-20:2 ...

  9. python之下载每日必应壁纸

    #!/usr/bin/env python3 # -*- coding: utf-8 -*- __author__ = 'jiangwenwen' from bs4 import BeautifulS ...

随机推荐

  1. 开发掉坑(二)前端静态资源 Uncaught SyntaxError: Unexpected token <

    某天,有同学反馈后台管理系统出现静态资源无法加载的问题. 复现如下: 进入首页. 点击侧边栏某个子功能,静态资源可正常访问到. 等待10分钟左右,点击侧边栏其他子功能,无法访问到静态资源. 查看控制台 ...

  2. Java日期时间API系列39-----中文语句中的时间语义识别(time NLP 输入一句话,能识别出话里的时间)原理分析

    NLP (Natural Language Processing) 是人工智能(AI)的一个子领域.自然语言是人类智慧的结晶,自然语言处理是人工智能中最为困难的问题之一(来自百度百科). 其中中文更是 ...

  3. 【NX二次开发】Block UI 图层

    属性说明 常规         类型 描述     BlockID     String 控件ID     Enable     Logical 是否可操作     Group     Logical ...

  4. 【NX二次开发】打开信息窗口UF_UI_open_listing_window

    头文件:uf_ui_ugopen.h函数名:UF_UI_open_listing_window 函数说明:打开信息窗口 测试代码: #include <uf.h> #include < ...

  5. sql:group by和 max

    通过group by,having,max实现查询出每组里指定列中最大的内容 例如:我需要实现的功能是 获取每个模块中点击量最大的内容(表中有许多内容,内容里) 我写的查询语句如下 查询结果如下: 然 ...

  6. 「10.15」梦境(贪心)·玩具(神仙DP)·飘雪圣域(主席树\树状数组\莫队)

    A. 梦境 没啥可说的原题.... 贪心题的常见套路我们坐标以左端点为第一关键字,右端点为第二关键字 然后对于每个转折点,我们现在将梦境中左端点比他小的区间放进$multiset$里 然后找最近的右端 ...

  7. 最多能创建多少个 TCP 连接?

    我是一个 Linux 服务器上的进程,名叫小进. 老是有人说我最多只能创建 65535 个 TCP 连接. 我不信这个邪,今天我要亲自去实践一下. 我走到操作系统老大的跟前,说: "老操,我 ...

  8. (先导)Git Api对接:获取private_token的两种方式

    " Git是一个开源的分布式版本控制系统,可以有效.高速地处理从很小到非常大的项目版本管理.在公司一般用于代码管理:开发用例管理平台时我们选择使用git来管理用例,期间使用了很多git ap ...

  9. 敢为人先,从阿里巴巴云原生团队实践Dapr案例,看分布式应用运行时前景

    背景 Dapr是一个由微软主导的云原生开源项目,国内云计算巨头阿里云也积极参与其中,2019年10月首次发布,到今年2月正式发布V1.0版本.在不到一年半的时间内,github star数达到了1.2 ...

  10. nexus AD 集成配置

    nexus AD 集成配置 管理用户登录 点击设置图标-->LDAP-->Create connection 进入AD 集成配置页面 Connection配置 User and group ...