21-爬虫&并发-多线程

目标:加强对线程和进程的理解,然后试着优化文件

从网页上获取html

通过request库,我们可以让 Python 程序向浏览器一样向 Web 服务器发起请求,并接收服务器返回的响应,从响应中我们就可以提取出想要的数据。浏览器呈现给我们的网页是用 HTML 编写的,浏览器相当于是 HTML 的解释器环境,我们看到的网页中的内容都包含在 HTML 的标签中。在获取到 HTML 代码后,就可以从标签的属性或标签体中提取内容。

除了文本内容,我们也可以使用requests库通过 URL 获取二进制资源。下面的例子演示了如何获取百度 Logo 并保存到名为baidu.png的本地文件中。可以在百度的首页上右键点击百度Logo,并通过“复制图片地址”菜单项获取图片的 URL。

import requests

resp = requests.get('https://www.baidu.com/img/PCtm_d9c8750bed0b3c7d089fa7d55720d6cf.png')
with open('baidu.png', 'wb') as file:
    file.write(resp.content)

关于request库,详细可以参考官方文档:

Requests: HTTP for Humans™ — Requests 2.31.0 documentation

例子1:获取豆瓣电影前250(正则

接下来,我们以“豆瓣电影”为例,为大家讲解如何编写爬虫代码。按照上面提供的方法,我们先使用requests获取到网页的HTML代码,然后将整个代码看成一个长字符串,这样我们就可以使用正则表达式的捕获组从字符串提取我们需要的内容。下面的代码演示了如何从豆瓣电影获取排前250名的电影的名称。豆瓣电影Top250的页面结构和对应代码如下图所示,可以看出,每页共展示了25部电影,如果要获取到 Top250 数据,我们共需要访问10个页面,对应的地址是https://movie.douban.com/top250?start=xxx,这里的xxx如果为0就是第一页,如果xxx的值是100,那么我们可以访问到第五页。为了代码简单易读,我们只获取电影的标题和评分

import random
import re
import time

import requests

for page in range(1, 11):
    resp = requests.get(
        url=f'https://movie.douban.com/top250?start={(page - 1) * 25}',
        # 如果不设置HTTP请求头中的User-Agent,豆瓣会检测出不是浏览器而阻止我们的请求。
        # 通过get函数的headers参数设置User-Agent的值,具体的值可以在浏览器的开发者工具查看到。
        # 用爬虫访问大部分网站时,将爬虫伪装成来自浏览器的请求都是非常重要的一步。
        headers={'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36'}
    )
    # 通过正则表达式获取class属性为title且标签体不以&开头的span标签并用捕获组提取标签内容
    pattern1 = re.compile(r'<span class="title">([^&]*?)</span>')
    titles = pattern1.findall(resp.text)
    # 通过正则表达式获取class属性为rating_num的span标签并用捕获组提取标签内容
    pattern2 = re.compile(r'<span class="rating_num".*?>(.*?)</span>')
    ranks = pattern2.findall(resp.text)
    # 使用zip压缩两个列表,循环遍历所有的电影标题和评分
    for title, rank in zip(titles, ranks):
        print(title, rank)
    # 随机休眠1-5秒,避免爬取页面过于频繁
    time.sleep(random.random() * 4 + 1)

说明:通过分析豆瓣网的robots协议,我们发现豆瓣网并不拒绝百度爬虫获取它的数据,因此我们也可以将爬虫伪装成百度的爬虫,将get函数的headers参数修改为:headers={'User-Agent': 'BaiduSpider'}

使用代理IP

为了防止IP被封,所以一般使用代理服务器来进行爬虫。本例使用Clash这个软件的代理。

  1. 首先,需要确保Clash软件已经安装并且启动,并且在Clash软件中已经添加了需要使用的代理。
  2. 然后,在Python中使用requests库发送请求时,可以通过设置proxies参数来设置代理。proxies参数是一个字典类型,包含代理的类型和代理的地址。

一般本机的Clash软件的HTTP代理地址为127.0.0.1:7890,可以通过以下代码来设置代理:

import requests

proxies = {
    "http": "http://127.0.0.1:7890",
    "https": "http://127.0.0.1:7890",
}

response = requests.get("https://www.example.com", proxies=proxies)

所以上面的代码可以修改如下:

import requests
import re

PROXY_HOST = '127.0.0.1:7890'

for page in range(1, 11):
    resp = requests.get(
        url=f'https://movie.douban.com/top250?start={(page - 1) * 25}',
        # 需要在HTTP请求头设置代理的身份认证方式
        headers={
            'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36',
            'Accept-Language': 'zh-CN,zh;q=0.8,en-US;q=0.6,en;q=0.4'
        },
        # 设置代理服务器
        proxies={
            'http': f'http://{PROXY_HOST}',
            'https': f'https://{PROXY_HOST}'
        },
        verify=False
    )
    pattern1 = re.compile(r'<span class="title">([^&]*?)</span>')
    titles = pattern1.findall(resp.text)
    pattern2 = re.compile(r'<span class="rating_num".*?>(.*?)</span>')
    ranks = pattern2.findall(resp.text)
    for title, rank in zip(titles, ranks):
        print(title, rank)

以下代码可以查看请求IP

import requests
url = 'http://icanhazip.com'
try:
    response = requests.get(url) #不使用代理
    print(response.status_code)
    if response.status_code == 200:
        print(response.text)
except requests.ConnectionError as e:
    print(e.args)

使用代理

import requests
proxies = {
    "http": "http://127.0.0.1:7890",
    "https": "http://127.0.0.1:7890",
}
url = 'http://icanhazip.com'
try:
    response = requests.get(url, proxies=proxies)  # 使用代理
    print(response.status_code)
    if response.status_code == 200:
        print(response.text)
except requests.ConnectionError as e:
    print(e.args)

用CSS选择器解析网页数

用CSS选择器来解析是一种比较常用的方式,我们常用BS库来解析

pip install beautifulsoup4
pip install lxml

上面的例子可以改写成如下这样

import bs4
import requests

for page in range(1, 11):
    resp = requests.get(
        url=f'https://movie.douban.com/top250?start={(page - 1) * 25}',
        headers={'User-Agent': 'BaiduSpider'}
    )
    # 创建BeautifulSoup对象
    soup = bs4.BeautifulSoup(resp.text, 'lxml')
    # 通过CSS选择器从页面中提取包含电影标题的span标签
    # title_spans = soup.select('div.info > div.hd > a > span:nth-child(1)')
    title_spans = soup.select('div.info > div.hd > a > span:first-child')
    # 通过CSS选择器从页面中提取包含电影评分的span标签
    rank_spans = soup.select('div.info > div.bd > div > span.rating_num')
    for title_span, rank_span in zip(title_spans, rank_spans):
        print(title_span.text, rank_span.text)

进程和线程的理解

进程可以理解为任务管理器中的一个任务,我们可以在任务管理器中看到每个任务都有一个独一无二的PID,也就是ProcessID,这就是进程。

一个进程或者说一个任务里,可能需要做多件事情,这些事情在做的时候需要分别去取得CPU的使用权,这就是线程。在多核cpu的电脑中,多个线程就可以使用不同的CPU来达到同时执行的效果,从而提升效率。

多线程

例子1:在不使用多线程的情况下,模拟下载,并输出下载时间

import random
import time

def download(*, filename):
    start = time.time()
    print(f'开始下载 {filename}.')
    time.sleep(random.randint(3, 6))
    print(f'{filename} 下载完成.')
    end = time.time()
    print(f'下载耗时: {end - start:.3f}秒.')

def main():
    start = time.time()
    download(filename='Python从入门到住院.pdf')
    download(filename='MySQL从删库到跑路.avi')
    download(filename='Linux从精通到放弃.mp4')
    end = time.time()
    print(f'总耗时: {end - start:.3f}秒.')

if __name__ == '__main__':
    main()

可以看到,因为程序是顺序执行的,三个下载任务的总时间就是各自的时间加起来。这显然是没有效率的,我们在下载东西的时候并不需要等前一个任务下载完成。

下载耗时: 5.007.
总耗时: 14.025.

Python 标准库中threading模块的Thread类可以帮助我们非常轻松的实现多线程编程,代码如下:

import random
import time
from threading import Thread

def download(*, filename):
    start = time.time()
    print(f'开始下载 {filename}.')
    time.sleep(random.randint(3, 6))
    print(f'{filename} 下载完成.')
    end = time.time()
    print(f'下载耗时: {end - start:.3f}秒.')

def main():
    threads = [
        Thread(target=download, kwargs={'filename': 'Python从入门到住院.pdf'}),
        Thread(target=download, kwargs={'filename': 'MySQL从删库到跑路.avi'}),
        Thread(target=download, kwargs={'filename': 'Linux从精通到放弃.mp4'})
    ]
    start = time.time()
    # 启动三个线程
    for thread in threads:
        thread.start()
    # 等待线程结束
    for thread in threads:
        thread.join()
    end = time.time()
    print(f'总耗时: {end - start:.3f}秒.')

if __name__ == '__main__':
    main()

继承Thread类自定义线程

上面代码的写法看上去不太优雅,我们可以通过自定义类来进行简化

import random
import time
from threading import Thread

class DownloadThread(Thread):

    def __init__(self, filename):
        self.filename = filename
        super().__init__()

    def run(self):
        start = time.time()
        print(f"开始下载{self.filename}\n", end="")
        time.sleep(random.randint(3, 6))
        end = time.time()
        print(f"{self.filename}下载完成,耗时{end - start:.3f}\n", end="")

def main():
    start = time.time()
    threads = [
        DownloadThread("Python从入门到住院.pdf"),
        DownloadThread("Java从入门到放弃.avi"),
        DownloadThread("Mysql从删库到跑路.pdf"),
    ]

    for thread in threads:
        thread.start()

    for thread in threads:
        thread.join()

    end = time.time()
    print(f"总用时{end - start:.3f}")

if __name__ == '__main__':
    main()

本例子中,每个线程的输出都加上了\n", end=“”。这样做的原因是,本来print方法会在打印完成后自动加上一个换行,但是如果另一个线程也使用了print方法,它就会在你俩都打印完之后才加上一个换行,这会导致两部分内容挤在一行。解决这个问题的方法就是手动换行,取消掉print的自动换行。

使用线程池

线程的创建和释放都会对系统造成很大的开销,所以我们可以用线程池实现定义好一些线程,需要做某件事的时候直接复用线程池中的线程,就不需要重新创建线程了。Python 内置的concurrent.futures模块提供了对线程池的支持,代码如下所示。

import random
import time
from concurrent.futures import ThreadPoolExecutor
from threading import Thread

def download(*, filename):
    start = time.time()
    print(f'开始下载 {filename}.')
    time.sleep(random.randint(3, 6))
    print(f'{filename} 下载完成.\n', end="")
    end = time.time()
    print(f'下载耗时: {end - start:.3f}秒.\n', end="")

def main():
    with ThreadPoolExecutor(max_workers=2) as pool:
        filenames = ['Python从入门到住院.pdf', 'MySQL从删库到跑路.avi', 'Linux从精通到放弃.mp4']
        start = time.time()
        for filename in filenames:
            pool.submit(download, filename=filename)
    end = time.time()
    print(f'总耗时: {end - start:.3f}秒.')

if __name__ == '__main__':
    main()

本例中指定了最大线程为2,max_workers=2,所以同时只能有两个线程在工作,只有等到其中一个任务结束之后,下一个线程才会继续工作。

守护线程

以下例子中,我们把子线程的daemon设置为True,意味着它是一个守护进程。虽然这两个子线程中都有死循环,但它们依然会随着主线程的结束而结束。

import time
from threading import Thread

def display(content):
    while True:
        print(content, end='', flush=True)
        time.sleep(0.1)

def main():
    Thread(target=display, args=('Ping', ), daemon=True).start()
    Thread(target=display, args=('Pong', ), daemon=True).start()
    time.sleep(5)

if __name__ == '__main__':
    main()

进程锁和资源竞争

首先看如下例子:

import time

from concurrent.futures import ThreadPoolExecutor

class Account(object):
    """银行账户"""

    def __init__(self):
        self.balance = 0.0

    def deposit(self, money):
        """存钱"""
        new_balance = self.balance + money
        time.sleep(0.01)
        self.balance = new_balance

def main():
    """主函数"""
    account = Account()
    with ThreadPoolExecutor(max_workers=4) as pool:
        for _ in range(100):
            pool.submit(account.deposit, 1)
    print(account.balance)

if __name__ == '__main__':
    main()

在本例中,多个线程有可能同时访问账户的余额,同时去触发存钱操作,然后余额的数据就被覆盖掉了,这就导致最后的金额并不是100。这里设置max_workers为4,结果就很明显了。同时有4个任务在执行,永远只有最后一个才能完成加1的操作,所以结果永远是25。主要原因是中间沉睡了time.sleep(0.01),这导致这个任务计算完的时候,上个任务还没释放余额。

为了解决这个问题,我们使用线程锁,在某个资源在被某个线程访问时,会加一把锁,这个线程访问完毕后再把锁释放,下一个线程才可以进行访问,代码如下:

import time

from concurrent.futures import ThreadPoolExecutor
from threading import RLock

class Account(object):
    """银行账户"""

    def __init__(self):
        self.balance = 0.0
        self.lock = RLock()

    def deposit(self, money):
        # 获得锁
        self.lock.acquire()
        try:
            new_balance = self.balance + money
            time.sleep(0.01)
            self.balance = new_balance
        finally:
            # 释放锁
            self.lock.release()

def main():
    """主函数"""
    account = Account()
    with ThreadPoolExecutor(max_workers=16) as pool:
        for _ in range(100):
            pool.submit(account.deposit, 1)
    print(account.balance)

线程锁的代码可以用上下文语法来写,这样就不需要手动释放了

def deposit(self, money):
        # 通过上下文语法获得锁和释放锁
        with self.lock:
            new_balance = self.balance + money
            time.sleep(0.01)
            self.balance = new_balance
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值