送书 | 对充满诱惑的网站,你知道可以用python做什么吗?

共 14233字,需浏览 29分钟

 ·

2021-08-06 08:56


大家好,我是啃书君。

看到文章的标题之后,想必你一定很好奇这篇文章是干什么的吧。目前网络上的很多文章写的爬虫主要是单线程的爬虫,因此,为了提高爬虫的效率,我今天就为大家带来了多线程的爬虫,带领大家一起学习线程,并为你们带来了实战演练。

线程锁

Threading模块为我们提供了一个类,Threading.Lock锁。我们创建该类的对象,在线程函数执行之前,“抢占”该锁,执行完成之后,“释放”该锁,则我们确保了每次只有一个线程占有该锁。这时对一个公共对象进行操作,则不会发生线程不安全的现象了。

当多个线程同时访问一个数据库时,需要加锁,排队变成单线程,一个一个执行。加锁可以避免并发时导致的逻辑错误,每当一个线程a要访问共享数据域时,必须先获得锁定;如果已经有别的线程b获得了锁定,那么就让线程a暂停,也就是同步阻塞;等到线程b执行完毕,释放锁之后,再让线程a继续。

线程锁的基本语法如下:

lock = threading.Lock()
lock.acquire() # 上锁
lock.release() # 释放锁

threading.Lock

实现原始锁对象的类。一旦一个线程获得一个锁,会阻塞随后尝试获得该锁的线程,直到它被释放;任何线程都可以释放它。

原始锁是一个在锁定时不属于特定线程的同步基元组件。在python中,它是能用最低级的同步基元组件,由_thread拓展模块直接实现。

acquire(blocking=True, timeout=-1)

可以阻塞或者非阻塞的获得锁。

当调用参数blocking设置为True,阻塞直到锁被释放,然后将锁锁定并返回True。

当blocking设置为False时,将不会发生阻塞。

当浮点型timeout参数设置为正值时,则阻塞特定的秒数。当timeout为-1时,则表示无限等待。

release()

释放一个锁,这个方法可以在任何线程中调用,当锁被锁定时,调用该方法可以重置为未锁定,并返回。在没有锁定的锁调用时,会引发RuntimeError异常。

实现线程保护

实现线程保护的过程,大致如下:首先要在同步的代码块前加上lock.acquire()语句,表示要先获得该锁,才能继续执行下面的代码,然后在同步代码块后加上lock.release(),表示释放该锁。

当一个线程或进程获取到该锁,但是却没有释放,那么其他的线程或者时进程是无法获取该锁的,从而无法执行下面的同步代码块,因此也就起到了保护作用。

接下来,我就带领大家来看看没有加锁的情况是怎么样的。

具体代码,如下所示:

import threading
import time


def my_print(name):
 for i in range(10):
  time.sleep(1)
  print(name, i)


start = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))
print('开始:', start)
t1 = threading.Thread(target=my_print, args=('threading1: ',))
t2 = threading.Thread(target=my_print, args=('threading2: ',))
t1.start()
t2.start()
t1.join()
t2.join()

end = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))
print('结束:', end)

运行结果,如下所示:

开始:2021-06-23 18:57:58
threading2: threading1:  0 0

threading2: threading1:  1
 1
threading2: threading1:  2 2

threading1: threading2:  3
 3
threading2:  4threading1: 
 4
threading1:  5threading2:  5

threading1:  threading2:  6
6
threading1: threading2:   7
7
threading2:  8
threading1:  8
threading1:  9threading2: 
 9
结束:2021-06-23 18:58:08

从运行结果中可以看到,每当要输出结果时,却输出了threading1或threading2,发生了线程的切换。

如果要避免该情况的发生,那就得使用线程锁了。

上锁之后的效果又是怎么样的呢?

具体代码,如下所示:

def my_print(name):
    for i in range(10):
        time.sleep(1)
        lock.acquire()
        print(name, i)
        lock.release()

运行结果,如下所示:

开始:2021-06-23 19:14:08
threading2:  0
threading1:  0
threading1:  1
threading2:  1
threading2:  2
threading1:  2
threading1:  3
threading2:  3
threading1:  4
threading2:  4
threading1:  5
threading2:  5
threading2:  6
threading1:  6
threading2:  7
threading1:  7
threading1:  8
threading2:  8
threading1:  9
threading2:  9
结束:2021-06-23 19:14:18

线程锁,其实说白了就是:当两个线程都要对公共的数据域出手的时候,那就必须添加上线程锁,防止出现逻辑错误。因为不同的线程是负责不同的任务的,如果没有使用锁的话,发生线程切换,那整个程序就没有太大的意义了。

线程对象

threading.Thread目前还没有优先级和线程组的功能,而创建的线程也无法被销毁、停止、暂定或中断。

非守护线程

一般创建的线程都是非守护线程,包括主线程也是,即在python程序退出时,如果还有非守护线程在运行,程序会等待所有非守护线程退出之后,才退出。

那么,这就会造成一个问题,当其中一个非守护线程是个死循环,那么整个程序将无法退出。

具体代码,如下所示:

import threading
import time


def kitchen_cleaner():
 while True:
  print('Olivia cleaned the kitchen')
  time.sleep(1)


if __name__ == '__main__':
 olivia = threading.Thread(target=kitchen_cleaner)
 olivia.start()

 print('Barron is cokking...')
 time.sleep(0.6)

 print('Barron is cokking...')
 time.sleep(0.6)
 print('Barron is cokking...')
 time.sleep(0.6)
 print('Barron is cokking...')
 time.sleep(0.6)
 print('done!')

当你运行上面代码的时候,你会发现该程序是无法退出的。上面举的例子是主线程无法退出的情况,因此守护线程的到来便解决了此类问题。

守护线程

只有所有的守护线程都结束,整个python程序才会退出,并不是说python程序会等待守护线程运行完毕,相反,当程序退出时,如果还有守护线程在运行,程序会强制终结所有的守护线程,当守护线程都终结后程序才会退出,可以通过修改daemon属性来指定某个线程为守护线程。

具体代码,如下所示:

import threading
import time


def kitchen_cleaner():
 while True:
  print('Olivia cleaned the kitchen')
  time.sleep(1)


if __name__ == '__main__':
 olivia = threading.Thread(target=kitchen_cleaner)
 olivia.daemon = True # 修改olivia为守护线程
 olivia.start()

 print('Barron is cokking...')
 time.sleep(0.6)

 print('Barron is cokking...')
 time.sleep(0.6)
 print('Barron is cokking...')
 time.sleep(0.6)
 print('Barron is cokking...')
 time.sleep(0.6)
 print('done!')

Thread类创建线程对象

下面,我们将使用threading.Thread类创建线程的简单示例。

具体代码,如下所示:

import time
import threading


def test_thread(para='hi', sleep=3):
 # 线程运行函数
 time.sleep(3)
 print(para)


def main():
 # 创建线程
 thread_hi = threading.Thread(target=test_thread)
 thread_hello = threading.Thread(target=test_thread, args=('hello'1))

 # 启动线程
 thread_hi.start()
 thread_hello.start()

 print('main thread has end')


if __name__ == '__main__':
 main()

运行结果,如下所示:

main thread has end
hi
hello
  • target:在run方法中调用的可调用对象,即需要开启线程的可调用对象。
  • args:在参数target中传入可调用对象的参数元组,默认为空元组。
  • start():开启线程活动,它将使得run()方法在一个独立的控制程序中被调用,同一个线程对象只能被调用1次。

阻塞主线程

在上面的代码中想必你会发现,按照代码运行的顺序来说,应该是这样的顺序输出:hi、hello、main thread has end

但是却并非这样,原因是这样的,由于子线程创建之后,主线程与子线程开始轮流获取CPU资源,因为子线程需要等待,因此CPU将资源给了主线程,因此就会看到这样的输出效果。

为了让主线程等待或者说是阻塞主线程,因此可以采用join()方法来实现。

具体代码,如下所示:

import time
import threading


def test_thread(para='hi', sleep=3):
 # 线程运行函数
 time.sleep(3)
 print(para)


def main():
 # 创建线程
 thread_hi = threading.Thread(target=test_thread)
 thread_hello = threading.Thread(target=test_thread, args=('hello'1))

 # 启动线程
 thread_hi.start()
 thread_hello.start()

 print('马上执行join()方法啦')

 # 执行join方法会阻塞调用线程(主线程),直到调用join方法的线程(thread_hi)结束
 thread_hi.join()
 print('thread_hi已经结束')

 thread_hello.join()
 print('thread_hello已经结束')

 print('main thread has end')

 '''
 当然这里只是为了展示join()方法的作用
 如果想要等待所有线程都运行完毕,再做其他操作,可以使用for循环
 for thd in (thread_hi, thread_hello):
  thd.join()

 print('所有线程运行结束后的其他操作')

 '''



if __name__ == '__main__':
 main()

实战演练

可爱图片网双线程抓取

本次实战主要采用多线程的方式进行爬取图片,既实现了提速,同时还有意外的收货哦!

网页分析

可爱图片网的网址如下:

https://www.keaitupian.net/

图片的分类非常丰富:可爱女生、帅气男生、唯美图片、幸福爱情、性感美女等。

不过你们要知道,啃书君写这篇文章的目的仅仅只是为了教你们技术而已,小孩子能有什么坏心思呢?因此为了更好的教大家技术,我就以可爱宠物为例。

本次爬虫使用的模块:

requestsrethreading

列表页与详情页分析

列表页抓取可爱宠物图片,故列表页为:https://www.keaitupian.net/pet/,点击多个页面可以得到,如下所示的URL链接:

  • https://www.keaitupian.net/pet/list-1.html
  • https://www.keaitupian.net/pet/list-2.html
  • https://www.keaitupian.net/pet/list-{不定页码}.html

由于总页数无法获取,经过我的大胆猜测,发现最终锁定在56页,也就是说,对于宠物这一块的列表页是56页。

当我写57时,页面是不存在的。

点击任意详情页,查看图片的具体内容,发现详情页也是存在翻页的。并且翻到最后一页时,可以直接到下一份套图,因此可以考虑直接对详情页进行抓取。

当翻到最后一个套图的最后一张图片时,发现向右翻页的代码是为空的,即无法翻页。

整理逻辑分析

  • 选择第一张图片作为详情页的起始连接。
  • 一线程保存图片
  • 一线程保存下一页地址

基于上述需求,首先实现循环获取URL的线程,该线程主要用于反复爬取URL地址,保存至全局列表中。需要使用threading.Thread创建线程与启动线程,同时为了保证线程之间不会发生逻辑错误,因此需要使用线程互斥锁。

获取URL地址

import requests
import re
import threading
import time

headers = {
    "User-Agent""Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36"}

# 全局 urls
urls = []

lock = threading.Lock()


# 循环获取URL
def get_image(start_url):
    global urls
    urls.append(start_url)
    next_url = start_url
    while next_url != "#":
        res = requests.get(url=next_url, headers=headers)

        if res is not None:
            html = res.text
            pattern = re.compile('<a class="next_main_img" href="(.*?)">')
            match = pattern.search(html)
            if match:
                next_url = match.group(1)
                if next_url.find('www.keaitupian') < 0:
                    next_url = f"https://www.keaitupian.net{next_url}"
                print(next_url)
                # 上锁
                lock.acquire()

                urls.append(next_url)
                # 释放锁
                lock.release()


if __name__ == '__main__':
    # 获取图片线程
    gets = threading.Thread(target=get_image, args=(
        "https://www.keaitupian.net/article/202473.html",))
    gets.start()

提取目标地址图片

下面为最后一个步骤,通过上述代码抓取到链接的地址,提取图片地址,并保存图片,保存图片也为一个线程。

具体代码如下所示:

def save_image():
    global urls
    print(urls)

    while True:
      # 上锁
        lock.acquire()
        if len(urls) > 0:
         # 获取列表第一项
            img_url = urls[0]
            # 删除列表第一项
            del urls[0]
            # 释放锁
            lock.release()
            res = requests.get(url=img_url, headers=headers)

            if res is not None:
                html = res.text

                pattern = re.compile(
                    '<img class="img-responsive center-block" src="(.*?)"/>')

                img_match = pattern.search(html)

                if img_match:
                    img_data_url = img_match.group(1)
                    print("抓取图片中:", img_data_url)
                    try:
                        res = requests.get(img_data_url)
                        with open(f"images/{time.time()}.png""wb+"as f:
                            f.write(res.content)
                    except Exception as e:
                        print(e)
        else:
            print("等待中,长时间等待,可以直接关闭")

在写代码的时候,顺手把性感美女的图片给抓下来了,可以看看图,看你喜不喜欢。

注意哟,该网站反爬比较厉害,建议上代

总结

对于线程锁来说,其实就是当多个线程要访问同一个数据域的时候,防止线程出现切换,因此我们需要对线程上锁,做到保护该线程。

对于守护线程与非守护线程,相信就更容易理解了。程序不会等待守护线程的结束,而是主线程结束之后便会直接关闭守护线程,对于非守护线程无法退出的情况相当好用。

本次的爬虫实战主要是带领大家,熟悉了解并掌握线程锁的应用。

最后

看完这篇文章,觉得对你有帮助的话,可以给啃书君点个[再看],我会继续努力,和你们一起成长前进。

文章的每一个字都是我用心敲出来的,只希望对得起每一位关注我的人。

点个[再看],让我知道你们也在为自己的人生拼尽全力。

我是啃书君,一个专注于学习的人,你懂的越多,你不懂的越多,更多精彩内容,我们下期再见!

送书

又到了每周三的送书时刻,今天给大家带来的是《吃透Ansible》,本书从Ansible的模块运行以及Playbook的解析和执行两个方面剖析了三个版本的Ansible源码。此外,还优化和改造了用于部署Ceph集群的ceph-ansible项目。


公众号回复:送书 ,参与抽奖(共4本)

点击下方回复:送书  即可!



大家如果有什么建议,欢迎扫一扫二维码私聊小编~
回复:加群 可加入Python技术交流群


浏览 44
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报