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

在放假的这几天,断断续续的看了老李关于 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. 对标 Spring Boot & Cloud ,轻量框架 Solon 1.4.14 发布

    Solon 是一个轻量的Java基础开发框架.强调,克制 + 简洁 + 开放的原则:力求,更小.更快.更自由的体验.支持:RPC.REST API.MVC.Job.Micro service.WebS ...

  2. 『居善地』接口测试 — 12、Moco框架介绍

    目录 1.Mock功能介绍 2.Moco框架介绍 3.Moco框架在接口测试中的作用 4.Moco框架的优点 5.Moco框架的下载与启动 (1)Moco框架的下载 (2)Moco框架的启动 1.Mo ...

  3. 【NX二次开发】Block UI 从列表选择部件

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

  4. 【模拟8.11】星空(差分转化,状压DP,最短路)

    一道很好的题,综合很多知识点. 首先复习差分:      将原来的每个点a[i]转化为b[i]=a[i]^a[i+1],(如果是求和形式就是b[i]=a[i+1]-a[i]) 我们发现这样的方便在于我 ...

  5. 一台服务器能支撑多少个TCP连接

    1. 困惑很多人的并发问题 在网络开发中,我发现有很多同学对一个基础问题始终是没有彻底搞明白.那就是一台服务器最大究竟能支持多少个网络连接?我想我有必要单独发一篇文章来好好说一下这个问题. 很多同学看 ...

  6. [Linux]Ansible自动化运维① - 入门知识

    目录 一.Ansible 概述 1.1 Ansible 是什么 1.2 Ansible 优势 1.3 Ansible 特性 二.Ansible 入门 2.1 Ansible 架构 2.2 Ansibl ...

  7. Typecho 安装教程 -- Linux

    1.下载宝塔面板 1 使用 SSH 连接工具,如堡塔SSH终端连接到您的 Linux 服务器后,挂载磁盘,根据系统执行相应命令开始安装(大约2分钟完成面板安装): 2 Centos安装脚本 yum i ...

  8. 9、SpringBoot整合之SpringBoot整合SpringSecurity

    SpringBoot整合SpringSecurity 一.创建项目,选择依赖 选择Spring Web.Thymeleaf即可 二.在pom文件中导入相关依赖 <!-- 导入SpringSecu ...

  9. Blazor 组件入门指南

    翻译自 Waqas Anwar 2021年3月19日的文章 <A Beginner's Guide to Blazor Components> [1] Blazor 应用程序是组件的组合, ...

  10. springboot集成swagger添加消息头(header请求头信息)

    springboot集成swagger上篇文章介绍: https://blog.csdn.net/qiaorui_/article/details/80435488 添加头信息: package co ...