Python爬蟲4.1 — threading(多線程)用法教程

綜述

本系列文檔用於對Python爬蟲技術的學習進行簡單的教程講解,鞏固自己技術知識的同時,萬一一不小心又正好對你有用那就更好了。
Python 版本是3.7.4

前面的文章記錄了網絡請求(urllib,requests)、數據提取(beautiful,xpath,正則)、數據存儲(json,csv)的學習,下面進行一個多線程的學習。

多線程爬蟲

有些時候,比如下載圖片,因爲下載圖片是一個耗時的操作,如果採用之前那種同步的方式下載,那效率會特別慢。這時候我們就可以考慮使用多線程的方式來下載圖片。

多線程介紹

多線程是爲了同步完成多項任務,通過提高資源使用來提高系統的效率,線程是在同一時間需要完成多項任務的時候是西納的,最簡單的比喻多線程就像火車的每一節車廂,二進程就是火車。車廂離開火車是無法跑動的,同理火車可以有多節車廂,多線程的出現是爲了提高效率,同時他的出現也帶來一些問題。

簡單來講,多線程就相當於你原來開了一個窗口爬取,限制開了十個窗口來爬取。

threading模塊介紹

threading模塊是Python中專門提供用來做多線程的模塊。threading模塊中最常用的類是Thread。下面一個簡單的多線程程序:

# 引入所需庫
import threading
import time


def coding():
    """
    coding函數
    :return:
    """
    for x in range(5):
        print('%s 號程序員正則寫代碼...' % x)
        time.sleep(1)


def drawing():
    """
    drawing函數
    :return:
    """
    for x in range(5):
        print('%s 號設計師正在設計圖片...' % x)
        time.sleep(1)


def single_thread():
    """
    單線程執行
    :return:
    """
    coding()
    drawing()


def multi_thread():
    """
    多線程執行
    :return:
    """
    # 創建線程
    # 注意:target參數是函數名,不能帶括號
    t1 = threading.Thread(target=coding, name='coding')
    t2 = threading.Thread(target=drawing, name='drawing')
    # 啓動線程
    t1.start()
    t2.start()


if __name__ == '__main__':
    # single_thread()
    multi_thread()

查看線程數:

    num = threading.enumerate()
    print(num)

查看當前進程名字:

    threading.current_thread()

Thread類的使用

爲了讓線程代碼更好的封裝,可以使用threading模塊下的Thread類,繼承自這個類,然後實現run()方法,線程就會自動運行run()方法中的代碼,示例代碼如下:

# 引入所需庫
import threading
import time


class CodingThread(threading.Thread):
    """
    寫程序進程類
    """

    def run(self):
        for x in range(5):
            print('%s 號程序員正則寫代碼...' % threading.current_thread())
            time.sleep(1)


class DrawingThread(threading.Thread):
    """
    設計進程類
    """

    def run(self):
        for x in range(5):
            print('%s 號設計師正在設計圖片...' % threading.current_thread())
            time.sleep(1)


def multi_thread():
    t1 = CodingThread()
    t2 = DrawingThread()

    t1.start()
    t2.start()


if __name__ == '__main__':
    multi_thread()

多線程共享全局變量問題

多線程都是在同一個進程中運行的,因此在進程中的全局變量所有的線程都是可以共享的。這就造就了一個問題,因爲線程執行的順序是無序的,有可能會造成數據錯誤。例如如下代碼:

# 引入threading庫
import threading

# 定義全局變量
VALUE = 0


def add_value():
    """
    增加數值
    :return:
    """
    global VALUE
    for x in range(1000000):
        VALUE += 1
    print(VALUE)


def main():
    for x in range(2):
        t = threading.Thread(target=add_value)
        t.start()


if __name__ == '__main__':
    main()

以上的代碼結果正常來講應該是:

1000000
2000000

但是由於多線程運行的不確定性,因此結果可能是隨機的。

鎖機制

爲了解決上述問題由於多線程運行的不確定性,threading庫增加了Lock類鎖機制進行處理,當某個線程對全局變量進行修改時則將此變量加鎖不允許其他線程進行修改,知道當前線程修改完這個變量之後再進行解鎖釋放,之後其他線程纔可進行修改,這就保證了數據的安全性。修改上述代碼如下:

# 引入threading庫
import threading

# 定義全局變量
VALUE = 0
# 創建鎖
gLock = threading.Lock()


def add_value():
    """
    增加數值
    :return:
    """
    global VALUE
    # 加鎖
    gLock.acquire()
    for x in range(1000000):
        VALUE += 1
    # 解鎖
    gLock.release()
    print(VALUE)


def main():
    for x in range(2):
        t = threading.Thread(target=add_value)
        t.start()


if __name__ == '__main__':
    main()

Lock版生產者和消費者模式

生產者和消費者模式時多線程開發中的經常見到的一種模式。生產者的線程專門用來生產一些數據,然後存放到一箇中間的變量中。消費者再從這個中間的變量中取出數據進行消費,但是因爲要使用中間變量,中間變量經常是一些全局變量,因此需要使用鎖來保證數據的完整性。以下是使用threading.Lock()鎖實現“生產者與消費者模式”的一個例子:

# 引入所需庫
import random
import threading
import time

gMoney = 1000
gTimes = 0
# 定義鎖
gLock = threading.Lock()


class Producer(threading.Thread):
    """
    生產者
    """

    def run(self):
        global gMoney
        global gTimes
        while True:
            money = random.randint(100, 1000)
            gLock.acquire()
            # 僅允許生產10次
            if gTimes >= 10:
                gLock.release()
                break
            gMoney += money
            print('%s生產了%d元錢,剩餘%d元錢' % (threading.current_thread(), money, gMoney))
            gTimes += 1
            gLock.release()
            time.sleep(0.5)


class Consumer(threading.Thread):
    """
    消費者
    """

    def run(self):
        global gMoney
        while True:
            money = random.randint(100, 1000)
            gLock.acquire()
            if gMoney >= money:
                gMoney -= money
                print('%s消費了%d元錢,剩餘%d元錢' % (threading.current_thread(), money, gMoney))
            else:
                if gTimes >= 10:
                    gLock.release()
                    break
                print("%s消費者消費錢不夠,不消費" % threading.current_thread())
            gLock.release()
            time.sleep(0.5)


def main():
    # 定義三個消費者
    for x in range(3):
        t = Consumer(name='消費者線程%d' % x)
        t.start()
    # 定義五個生產者
    for x in range(5):
        t = Producer(name='生產者線程%d' % x)
        t.start()


if __name__ == '__main__':
    main()

Condition版生產者與消費者模式

Lock()版的生產者與消費者模式可以正常的運行,但是存在一個不足,在消費者中,總是通過while True死循環並且上鎖的方法去判斷錢夠不夠,上鎖是一個很好CPU資源的行爲。因此這種方式不是最好的,還有一種更好的方式便是使用threading.Condition來實現。

threading.Condition可以在沒有數據的時候處於阻塞等等狀態。一旦有合適的數據了,還可以使用notify相關的函數來通知其他處於等待狀態的線程,這樣就可以不用做一些無用的上鎖和解鎖的操作,可以提高程序的性能。

首先對threading.Condition相關的函數做個介紹,threading.Condition類似threading.Lock,可以在修改全部數據的時候進行上鎖,也可以在修改完畢後進行解鎖。以下將一些常用的函數做個簡單的介紹:

  1. acquire : 上鎖
  2. release : 解鎖
  3. wait : 將當前線程處於等待狀態,並且會釋放鎖。可以被其他線程使用notifynotify_all函數喚醒,被喚醒後會繼續等待上鎖,上鎖後繼續執行後續的代碼
  4. notify : 通知某個正在等待的線程,默認是第1個等待的線程
  5. notify_all : 通知所有正在等待的線程。notifynotify_all不會釋放鎖,並且需要在release之前掉用

Condition版生產者與消費者模式示例代碼如下:

# 引入所需庫
import random
import threading
import time

gMoney = 1000
gTimes = 0
# 定義Condition
gCondition = threading.Condition()


class Producer(threading.Thread):
    """
    生產者
    """

    def run(self):
        global gMoney
        global gTimes
        while True:
            money = random.randint(100, 1000)
            gCondition.acquire()
            # 僅允許生產10次
            if gTimes >= 10:
                gCondition.release()
                break
            gMoney += money
            print('%s生產了%d元錢,剩餘%d元錢' % (threading.current_thread(), money, gMoney))
            gTimes += 1
            gCondition.notify_all()
            gCondition.release()
            time.sleep(0.5)


class Consumer(threading.Thread):
    """
    消費者
    """

    def run(self):
        global gMoney
        while True:
            money = random.randint(100, 1000)
            gCondition.acquire()
            while gMoney < money:
                if gTimes >= 10:
                    gCondition.release()
                    return
                print("%s消費者消費錢不夠,不消費" % threading.current_thread())

                gCondition.wait()
            gMoney -= money
            print('%s消費了%d元錢,剩餘%d元錢' % (threading.current_thread(), money, gMoney))
            gCondition.release()
            time.sleep(0.5)


def main():
    # 定義三個消費者
    for x in range(3):
        t = Consumer(name='消費者線程%d' % x)
        t.start()
    # 定義五個生產者
    for x in range(5):
        t = Producer(name='生產者線程%d' % x)
        t.start()


if __name__ == '__main__':
    main()

Queue線程安全隊列

在線程中,訪問一些全局變量,加鎖是一個經常的過程,如果你想把一些數據存儲到莫格隊列中,那麼Python內置了一個線程安全的模塊叫queue模塊。Python中的queue模塊中提供了同步的、線程安全的對咧咧,包括FIFO(先進先出)隊列Queue,LIFO(後入先出)隊列LifoQueue。這些隊列都實現了所原理(可以理解爲原子操作,即要麼不做,要麼都做完),能夠在多線程中直接使用。可以使用隊列來實現線程間的同步,相關函數如下:

  1. 初始化Queue(maxsize) : 創建一個先進先出的隊列
  2. qsize() : 返回隊列的大小
  3. empty() : 判斷隊列是否爲空
  4. full() : 判斷隊列是否已滿
  5. get() : 從隊列中獲取最後一個數據
  6. put() : 將一個數據放到隊列中

使用代碼示例:

# 引入所需庫
import threading
import time
from queue import Queue


def set_value(q):
    """
    寫入隊列
    :param q:
    :return:
    """
    index = 1
    while True:
        q.put(index)
        index += 1
        time.sleep(3)


def get_value(q):
    """
    從隊列取值
    :param q:
    :return:
    """
    while True:
        print(q.get())
        # time.sleep(4)
        # print("qsize:", q.qsize())


def main():
    """
    主函數
    :return:
    """
    q = Queue(5)
    t1 = threading.Thread(target=set_value, args=[q])
    t2 = threading.Thread(target=get_value, args=[q])

    t1.start()
    t2.start()


if __name__ == '__main__':
    main()

使用實例

單線程爬取表情包,實例代碼如下:

# 引入所需庫
import os
import re
import requests
from lxml import etree


def parse_page(url):
    """
    請求 解析 下載
    :param url: 
    :return: 
    """
    # 聲明定義請求頭
    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36',
    }
    req = requests.get(url=url, headers=headers)
    html = req.text
    tree = etree.HTML(html)
    imgs = tree.xpath('//div[@class="page-content text-center"]//img[@class!="gif"]')
    for img in imgs:
        img_url = img.get('data-original')
        alt = img.get('alt')
        alt = re.sub(r'[\??\..,!!]]', '', alt)
        suffix = os.path.splitext(img_url)[1]
        file_name = alt + suffix
        req_img = requests.get(url=img_url, headers=headers)
        with open('images/' + file_name, 'wb') as fp:
            fp.write(req_img.content)
        print(file_name)


def main():
    """
    主函數
    :return: 
    """
    for x in range(1, 101):
        print("第%d頁開始下載..." % x)
        url = 'http://www.doutula.com/photo/list/?page=%d' % x
        parse_page(url)
        print("第%d頁結束下載..." % x)


if __name__ == '__main__':
    main()

多線程爬取表情包,實例代碼如下:

# 引入所需庫
import os
import re
import threading
from queue import Queue
import requests
from lxml import etree


class Producer(threading.Thread):
    """
    生產者 - 手機表情包圖片地址
    """

    def __init__(self, page_queue, img_queue):
        super(Producer, self).__init__()
        self.page_queue = page_queue
        self.img_queue = img_queue

    def run(self):
        while True:
            if self.page_queue.empty():
                break
            url = self.page_queue.get()
            self.parse_page(url)

    def parse_page(self, url):
        """
        請求 解析 下載
        :param url:
        :return:
        """
        # 聲明定義請求頭
        headers = {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36',
        }
        req = requests.get(url=url, headers=headers)
        html = req.text
        tree = etree.HTML(html)
        imgs = tree.xpath('//div[@class="page-content text-center"]//img[@class!="gif"]')
        for img in imgs:
            img_url = img.get('data-original')
            alt = img.get('alt')
            alt = re.sub(r'[\??\..,!!\*]]', '', alt)
            suffix = os.path.splitext(img_url)[1]
            file_name = alt + suffix
            self.img_queue.put((img_url, file_name))


class Consumer(threading.Thread):
    """
    消費者 - 下載表情包圖片
    """

    def __init__(self, page_queue, img_queue):
        super(Consumer, self).__init__()
        self.page_queue = page_queue
        self.img_queue = img_queue

    def run(self):
        # 聲明定義請求頭
        headers = {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36',
        }
        while True:
            if self.img_queue.empty() and self.page_queue.empty():
                break
            img_url, file_name = self.img_queue.get()
            req_img = requests.get(url=img_url, headers=headers)
            with open('images/' + file_name, 'wb') as fp:
                fp.write(req_img.content)
            print(file_name + '下載完成...')


def main():
    """
    主函數
    :return:
    """
    page_queue = Queue(100)
    img_queue = Queue(1000)
    for x in range(1, 101):
        url = 'http://www.doutula.com/photo/list/?page=%d' % x
        page_queue.put(url)

    # 定義五個生產者
    for x in range(6):
        t = Producer(page_queue=page_queue, img_queue=img_queue)
        t.start()

    # 定義三個消費者
    for x in range(4):
        t = Consumer(page_queue=page_queue, img_queue=img_queue)
        t.start()


if __name__ == '__main__':
    main()

GIL全局解釋器鎖

Python自帶的解釋器是CPythonCPython解釋器的多線程實際上是一個家的多線程(在多核CPU中,只能利用一核,不能利用多核)。同一時刻只有一個線程在執行,爲了保證同一時刻只有一個線程在執行,在CPython解釋器中有一個功能叫做GIL,叫做全局解釋器鎖。這個解釋器鎖是有必要的,因爲CPython解釋器的內存管理不是線程安全的,當然除了CPython解釋器,還有其他的解釋器,有些解釋器是沒有GIL鎖的,見下面:

  1. Jpython : 用Java實現的Python解釋器,不存在GIL鎖。更多詳情請見:https://zh.wikipedia.org/wiki/Jpython
  2. IronPython : 用.NET實現的Python解釋器,不存在GIL鎖。更多詳情請見:https://zh.wikipedia.org/wiki/IronPython
  3. PyPy : 用Python實現的Python解釋器,存在GIL鎖。更多詳情請見:https://zh.wikipedia.org/wiki/PyPy

GIL雖然是一個假的多線程,但是在處理IO操作時可以提高效率。在CPU計算操作上不建議使用多線程,而建議使用多進程。

其他博文鏈接

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章