爬虫教程---第五章:爬虫进阶之多线程爬虫

第五章 爬虫进阶

经过了前面四章的学习,相信小伙伴对爬取基本的网站的时候都可以信手拈来了。那么接下来介绍比较高级一点的东西来帮助我们更顺利更快速的进行爬虫。

首先来看看我们这一章要学哪些进阶技术:多线程爬虫ajax数据爬取图形验证码识别

5.1 多线程

连接线程之前先来看看进程的概念。

进程通俗的讲就是指正在运行的程序,每个进程之间拥有独立的功能。而每一个进程都有至少一个执行单元来完成任务,这个(些)执行单元就是 线程。线程在创建的时候会把进程中的数据进行一份拷贝,作为自己的独有数据。最简单的比喻是进程是一辆火车,每个线程就是每个车厢,车厢离开火车是无法跑动的。

那什么是多线程呢?就是一个程序中有多个线程在同时执行

而当我们爬虫需要下载较多图片的时候,就可以使用多线程来提高效率。


接下来介绍如何使用代码来创建多线程

threading 模块

import threading
import time

def naicha():
    for i in range(3):
        time.sleep(1)
        print(i, '正在喝奶茶。。。')

def xigua():
    for i in range(3):
        time.sleep(1)
        print(i, '正在吃西瓜。。。')

# 创建子线程 name=''表示给线程取别名
t1 = threading.Thread(target=naicha)
t2 = threading.Thread(target=xigua)

# 开启线程
t1.start()
t2.start()

在这里插入图片描述

可以看出这两个线程是同时进行的。

我们还有通过 threading.enumerate() 来查看线程数量。

[<_MainThread(MainThread, started 22596)>, <Thread(Thread-1, started 19476)>, <Thread(Thread-2, started 18868)>] 此时线程数为 3 个,为什么呢?这是因为每个进程中都默认有一个主线程。

threading.current_thread() 这个函数可以获取到当前的线程对象。

除此之外还可以给线程取别名,threading.Thread(target=xigua, name='xg') ,此时该线程对象就变成这样了 <Thread(xg, started 25368)>


线程封装

所谓的线程封装就是将上面一些的步骤封装成类,方便使用。

封装步骤:

  1. 编写类(继承threading.Thread)
  2. 重写 run 方法,在此写线程的操作
import threading
import time

class Naicha(threading.Thread):
    def run(self):
        # 在run方法写线程的操作
        for i in range(3):
            time.sleep(1)
            print(i, '正在喝奶茶。。。')

class Xigua(threading.Thread):
    def run(self):
        for i in range(3):
            time.sleep(1)
            print(i, '正在吃西瓜。。。')
            
if __name__ == '__main__':
    # 创建自己编写的线程对象
    t1 = Naicha()
    t2 = Xigua()
    # 开启线程
    t1.start()
    t2.start()

锁机制

因为多线程是在同一个进程中运行的,所以在进程中的全局变量对所有线程都是可共享的。然后又因为线程的执行是无序的,这就出现了脏读现象。

比如有全局变量n=100,线程1跟线程2同时运行且都需要对n进行加1操作,此时对于线程1来说,n=100 加完1后n=101,因为线程1跟线程2是同时运行的,所以在线程2操作的时候n=100 加完1后n=101。本来n 应该等于102的,现在n却等于101。这种现象就叫做脏读现象。

那如何结果这个问题呢?给当前运行的线程上锁

在线程1进行加n+1的时候进行上锁,当其他线程也要使用全局变量n 的时候,它们就会等待前一个线程释放锁。

在很多语言中都有这样的一把锁,方便我们使用。

import threading

# 全局变量
n = 0
# 创建锁对象
lock = threading.Lock()  # 创建锁对象


def naicha():
    # 声明n为全局变量
    global n
    lock.acquire()  # 加锁
    for i in range(1000000):
        n = n + 1
    print('线程1运行完之后,n=', n)
    lock.release()  # 解锁


def xigua():
    # 声明n为全局变量
    global n
    lock.acquire()  # 加锁
    for i in range(1000000):
        n = n + 1
    print('线程2运行完之后,n=', n)
    lock.release()  # 解锁

# 创建子线程
t1 = threading.Thread(target=naicha)
t2 = threading.Thread(target=xigua)

# 开启线程
t1.start()
t2.start()

小结:

  • 在多线程需要使用到全局变量时需要给线程加锁。

  • acquire() 可以加锁,release() 可以释放锁。

  • 加了锁之后一定要释放锁。不然锁就得不到释放而形成死锁。


生产者与消费者模式

生产者与消费者模式是多线程开发中很常见的一种模式。

这种模式有两种模块,生产者模块与消费者模块,生产者模块负责生产数据,然后将数据存储到中间变量中(一般是全局变量),消费者模块负责从全局变量中消费数据。

比如当爬取大量图片时,我们可以创建多个线程来爬取要下载图片的url,然后将这些url存储到全局的列表中,最后再创建多个线程负责下载这些url。负责爬取图片url的线程就被称为生产者,负责下载的线程就称为消费者

很明显,这种模式中多个线程都在同时使用全局变量,需要怎么保证能正常运行呢,这时你可能会想到加锁,没错,这种模式可以使用加锁,但如果加锁解锁很频繁的情况下最好不要使用加锁的方式,因为加锁解锁需要消耗CPU资源,有一定的开销。

实际上,实现这种模式的方式有多种:

  1. 使用 threading.Condition (继承自threading.Lock
  2. 使用 theading.Lock

threading.Condition可以在没有数据的时候使用wait来使线程处于堵塞等待状态。一旦有合适的数据了,使用notify 等一些函数来通知其他处于等待状态的线程。这样就可以不用做频繁的上锁和解锁的操作,可以提高程序的性能。

下面来介绍一下Condition常用函数的用法:

  • acquire():加锁
  • release():解锁
  • wait():挂起该线程并且释放锁,可被notify或者notify_all唤醒,唤醒后继续执行下面的代码
  • notify():通知某个等待的线程,默认是第一个等待的线程。
  • notify_all():通知所有等待的线程

tips:

  • notify()notify_all()都不会释放锁,只有release()才可以释放,所以两者必须放在release()前面通知。
  • 使用了wait()之后一定要记得用notify()唤醒

下面来说看一下Lock版本与Condition版本的区别:

在这里插入图片描述


Queue线程安全队列

Queue叫做队列,用来在生产者和消费者线程之间的信息传递。自带了锁,可以自己挂起线程自己唤醒线程,全自动化。可以使用队列来实现线程间的同步。

相关的函数如下:

  1. Queue(maxsize):创建一个先进先出的最大容量为maxsize的队列。
  2. qsize():返回队列的大小。
  3. empty():判断队列是否为空。
  4. full():判断队列是否满了。
  5. get(block=True):取出队尾的数据(消费者)。当队空时会将线程挂起,直接队中有数据。
  6. put(block=True):将一个数据放到队尾(生产者)。当队满时会将线程挂起,直接队中有位置。
from queue import Queue

q = Queue(4)  # 创建一个大小为4的队列

for i in range(4):
	q.put(i)
    
while not q.empty():
	print(q.get())

案例

本节的案例是以 生产者消费者模式 + Queue 的方式来爬取下载表情包。

import requests
from urllib import request
from lxml import etree
import threading
from queue import Queue
import time
import re

img_queue = Queue(1000)  # 存放图片url跟名字的队列
headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
    'AppleWebKit/537.36 (KHTML, like Gecko) '
    'Chrome/82.0.4077.0 Safari/537.36'
}


# 创建生产者 即 爬取要下载图片的url的线程
class parse_html(threading.Thread):
    # *args, **kwargs是获取其他传给Thread类的参数
    def __init__(self, img_queue, *args, **kwargs):
        threading.Thread.__init__(self, *args, **kwargs)
        self.img_queue = img_queue

    def run(self):
        # 解析10页的内容
        for i in range(1, 10):
            url = 'https://www.doutula.com/photo/list/?page=%d' % i
            # 解析网页
            self.parse(url)

    def parse(self, url):
        res = requests.get(url, headers=headers)
        html = etree.HTML(res.text)
        # 获取图片所在的所有a标签
        a_s = html.xpath('//div[@class="page-content text-center"]//a')
        # 遍历获取a标签下的img里的相关属性
        for i in a_s:
            # 获取图片路径
            href = i.xpath('./img/@data-original')[0]
            # 获取图片的alt 作为 图片的名字
            alt = i.xpath('./img/@alt')[0]
            # 去掉特殊符号
            alt = re.sub(r'[=!@?≈,]', '', alt)
            # 以元祖的形式存入队列
            self.img_queue.put((href, alt))


# 创建消费者 即 下载图片的线程
class dowmload_img(threading.Thread):
    def __init__(self, img_queue, *args, **kwargs):
        threading.Thread.__init__(self, *args, **kwargs)
        self.img_queue = img_queue

    def run(self):
        while True:
            # 图片全部下载完时跳出循环
            if self.img_queue.empty():
                print('当前图片队列空啦!')
                break
            # 对元祖进行解构
            href, alt = self.img_queue.get()
            # 将图片下载在本项目的images文件夹下
            request.urlretrieve(href, 'images/' + alt + '.jpg')
            print('images/' + alt + '.jpg')


if __name__ == '__main__':
    # 开启5个生产者
    for i in range(8):
        parse_html(img_queue).start()
    # 先让生产者先爬取网页,再来下载
    time.sleep(5)
    # 开启5个消费者
    for i in range(5):
        dowmload_img(img_queue).start()

有一些要注意的点:

  1. 你的项目下要有images文件夹,不然会报错
  2. 给表情包命名是必须去掉一些奇怪的字符,例如!@?≈,
  3. 给表情包命名要加上后缀

总结语:

多线程是比较难理解的一个点,有很多相关的概念本节都没有提及,例如异步与多线程的关系与区别、并发并行等。我在写此篇博客的时候有想过要加上这些,但是我查了一天的资料,感觉都没有可以找到很合适的语言来解释他们,小伙伴们可以自行百度,自行查资料。如果小伙伴们有发现比较好的资料记得分享~~

  • 1
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值