運維學python之爬蟲中級篇(二)線程、協程

上一篇講了多進程,今天我們就繼續講多線程。

1 簡介

線程
線程也叫輕量級進程,它是一個基本的CPU執行單元,也是程序執行過程中的最小單元,由線程ID、程序計數器、寄存器集合和堆棧共同組成。線程的引入減小了程序併發執行時的開銷,提高了操作系統的併發性能。 線程沒有自己的系統資源,只擁有在運行時必不可少的資源。但線程可以與同屬與同一進程的其他線程共享進程所擁有的其他資源。

多線程類似同時執行多個不同程序,python的標準庫提供了兩個模塊thread和threading,thread是低級模塊,threading是高級模塊,對thread進行了封裝,大多情況下,我們只使用threading模塊即可。

2 threading模塊

此模塊在較低級別的thread模塊之上構建的更高級別的線程接口,一般通過兩種方式實現多線程,第一種方式是把一個函數傳入並創建實例,然後調用start方法執行;第二種方式是直接從threading.Thread繼承並創建線程類,然後重寫_init_方法和run方法。
第一種方式代碼示例:

# -*- coding: utf-8 -*-

import time
import random
import threading

def t_run(urls):
    """
    線程執行代碼
    """
    # threading.current_thread()返回當前的Thread對象,對應於調用者控制的線程。
    # 如果調用者控制的線程不是通過threading模塊創建的,則返回一個只有有限功能的虛假線程對象。
    print('Current %s is running...' % threading.current_thread().name)
    for url in urls:
        print(' threading %s -----> %s ' % (threading.current_thread().name, url))
        time.sleep(random.random())
    print('%s ended.' % threading.current_thread().name)

if __name__ == '__main__':
    # 創建兩個線程實例
    t1 = threading.Thread(target=t_run, name='Thread_1', args=(['url1', 'url2'],))
    t2 = threading.Thread(target=t_run, name='Thread_2', args=(['url3', 'url4'],))
    # 啓動線程
    t1.start()
    t2.start()
    # 等待線程結束
    t1.join()
    t2.join()
    print('%s ended.' % threading.current_thread().name)

運行結果如下:

Current Thread_1 is running...
 threading Thread_1 -----> url1 
Current Thread_2 is running...
 threading Thread_2 -----> url3 
 threading Thread_1 -----> url2 
 threading Thread_2 -----> url4 
Thread_2 ended.
Thread_1 ended.
MainThread ended.

第二種方式用threading.Thread繼承創建線程類

# -*- coding: utf-8 -*-

import time
import random
import threading

class MyThread(threading.Thread):
    """
    定義線程類
    """

    def __init__(self, name, urls):
        """
        初始化,重寫線程
        """
        threading.Thread.__init__(self, name=name)
        self.urls = urls

    def run(self):
        """
        執行函數
        """
        # 打印當前線程名
        print('Current %s is running...' % threading.current_thread().name)
        for url in self.urls:
            print('Thread %s ------> %s' % (threading.current_thread().name, url))
            time.sleep(random.random())
        print('%s ended.' % threading.current_thread().name)

if __name__ == '__main__':
    print('%s is running...' % threading.current_thread().name)
    t1 = MyThread(name='Thread_1', urls=['url1', 'url2'])
    t2 = MyThread(name='Thread_2', urls=['url3', 'url4'])
    t1.start()
    t2.start()
    t1.join()
    t2.join()
    print('%s ended.' % threading.current_thread().name)

結果如下:

MainThread is running...
Current Thread_1 is running...
Thread Thread_1 ------> url1
Current Thread_2 is running...
Thread Thread_2 ------> url3
Thread Thread_1 ------> url2
Thread Thread_2 ------> url4
Thread_1 ended.
Thread_2 ended.
MainThread ended.

3 線程同步

如果多個線程共同對某個數據進行修改,就有可能會造成不可預料的結果,爲了防止這種情況發生,需要對線程進行同步,使用Lock和Rlock可以實現簡單線程同步。
Lock 對象
一個可重入鎖處於“locked”或者“unlocked”狀態中的一種。它創建時處於unlocked狀態。它有兩個基本方法,acquire()和release()。當狀態是unlocked時,acquire()改變該狀態爲locked並立即返回。當狀態被鎖定時,acquire()阻塞,直到在另一個線程中對release()的調用將其改爲unlocked,然後acquire()執行,release()方法只應在鎖定狀態下調用;它將狀態更改爲已解鎖並立即返回。如果嘗試釋放已解鎖的鎖,將會引發RuntimeError。
Rlock 對象
一個可重入鎖必須由獲得它的線程釋放。一旦線程獲得了可重入鎖,同一線程可以再次獲取它而不阻塞;在所有的release操作完成後,別的線程才能申請Rlock對象,見下面例子:

# -*- coding: utf-8 -*-

import threading
# 創建Rlock實例
lock = threading.RLock()
# 定義變量
num = 0

class MyThread(threading.Thread):
    """
    定義線程類
    """

    def __init__(self, name):
        """
        重新定義name
        """
        threading.Thread.__init__(self, name=name)

    def run(self):
        """
        執行函數
        """
        # 全局變量num
        global num
        while True:
            # 加鎖
            lock.acquire()
            print('%s locked, Number: %d' % (threading.current_thread().name, num))
            if num >= 4:
                # 解鎖
                lock.release()
                print('%s released, Number: %d' % (threading.current_thread().name, num))
                break
            num += 1
            print('%s released, Number: %d' % (threading.current_thread().name, num))
            lock.release()

if __name__ == '__main__':
    thread1 = MyThread('Thread_1')
    thread2 = MyThread('Thread_2')
    thread3 = MyThread('Thread_3')
    thread1.start()
    thread2.start()
    thread3.start()

運行結果如下:

Thread_1 locked, Number: 0
Thread_1 released, Number: 1
Thread_1 locked, Number: 1
Thread_1 released, Number: 2
Thread_1 locked, Number: 2
Thread_1 released, Number: 3
Thread_1 locked, Number: 3
Thread_1 released, Number: 4
Thread_1 locked, Number: 4
Thread_1 released, Number: 4
Thread_2 locked, Number: 4
Thread_2 released, Number: 4
Thread_3 locked, Number: 4
Thread_3 released, Number: 4

可以看出Rlock鎖只有線程1的num爲4時,調用release方法,全部解鎖後,線程2纔可以調用,線程2開始時num就是4,所以也直接到if判斷結束,調用release後,線程3開始執行。

4 全局解釋器鎖(GIL)

首先說的一點是GIL並不是Python的特性,它是Python解析器(CPython)引入的一個概念。像其中的JPython就沒有GIL。然而因爲CPython是大部分環境下默認的Python執行環境。所以在很多人的概念裏CPython就是Python,也就把GIL歸結爲Python語言的缺陷。所以這裏要先明確一點:GIL並不是Python的特性,Python完全可以不依賴於GIL。
GIL全稱Global Interpreter Lock,是一個互斥鎖,它可以防止多個本地線程同時執行Python的某個值,毫無疑問全局鎖的存在會對多線程的效率有不小影響。幾乎等於Python是個單線程的程序。(這也是大家吐槽python多線程慢的槽點)

5 協程

協程又稱微線程、纖程,就好比同時開啓多個任務,但一次只順序執行一個。等到所執行的任務遭遇阻塞,就切換到下一個任務繼續執行,以期節省下阻塞所佔用的時間。
協程與線程類似,每個協程表示一個執行單元,有自己的本地數據,與其它協程共享全局數據和其它資源。對CPU來說協程就是單線程,不必考慮切換開銷。
那麼python如何實現協程呢?Python對協程的支持是通過generator實現的。在generator中,我們不但可以通過for循環來迭代,還可以不斷調用next()函數獲取由yield語句返回的下一個值。但是Python的yield不但可以返回一個值,它還可以接收調用者發出的參數。見下面簡單生產者消費者示例:

# -*- coding:utf-8 -*-

def consumer():
    r = ''
    while True:
        # 這個地方注意,到達這個yield後,就會拋出n的值,暫停等待next或send繼續
        n = yield r
        if not n:
            return
        print('[CONSUMER] Consuming %s ...' % n)
        r = '200 OK'

def produce(c):
    c.send(None)
    n = 0
    while n < 5:
        n = n + 1
        print('[PRODUCER] Porducing %s ...' % n)
        r = c.send(n)
        print('[PRODUCER] Consumer return: %s...' % r)
    c.close()

c = consumer()
produce(c)

結果如下:

[PRODUCER] Porducing 1 ...
[CONSUMER] Consuming 1 ...
[PRODUCER] Consumer return: 200 OK...
[PRODUCER] Porducing 2 ...
[CONSUMER] Consuming 2 ...
[PRODUCER] Consumer return: 200 OK...
[PRODUCER] Porducing 3 ...
[CONSUMER] Consuming 3 ...
[PRODUCER] Consumer return: 200 OK...
[PRODUCER] Porducing 4 ...
[CONSUMER] Consuming 4 ...
[PRODUCER] Consumer return: 200 OK...
[PRODUCER] Porducing 5 ...
[CONSUMER] Consuming 5 ...
[PRODUCER] Consumer return: 200 OK...

注意到consumer函數是一個generator,把一個consumer傳入produce後:
首先調用c.send(None)啓動生成器;
然後,一旦生產了東西,通過c.send(n)切換到consumer執行;
consumer通過yield拿到消息,處理,又通過yield把結果傳回;
produce拿到consumer處理的結果,繼續生產下一條消息;
produce決定不生產了,通過c.close()關閉consumer,整個過程結束。
整個流程無鎖,由一個線程執行,produce和consumer協作完成任務,所以稱爲“協程”,而非線程的搶佔式多任務。
最後套用Donald Knuth的一句話總結協程的特點:
“子程序就是協程的一種特例。”

6 asyncio

asyncio是Python 3.4版本引入的標準庫,直接內置了對異步IO的支持。用asyncio實現Hello world代碼如下:

# -*- coding: utf-8 -*-
# 導入模塊
import asyncio

# 把一個generator標記爲協程類型
@asyncio.coroutine
def hello():
    """
    定義一個生成器
    """
    print('Hello world!')
    # 通過yield from調用另一個標記爲協程的生成器
    r = yield from asyncio.sleep(2)
    print('Hello again!')

# 生成實例
loop = asyncio.get_event_loop()
# 將標記爲協程的生成器執行
loop.run_until_complete(hello())
loop.close()

執行結果:

Hello world!
# 此處等待了2秒
Hello again!

從結果可以看出hello()會首先打印出Hello world!,然後,yield from語法可以讓我們方便地調用另一個generator。由於asyncio.sleep()也是一個coroutine,所以線程不會等待asyncio.sleep(),而是直接中斷並執行下一個消息循環。當asyncio.sleep()返回時,線程就可以從yield from拿到返回值(此處是None),然後接着執行下一行語句。把asyncio.sleep(1)看成是一個耗時1秒的IO操作,在此期間,主線程並未等待,而是去執行EventLoop中其他可以執行的coroutine了,因此可以實現併發執行。
我們用Task封裝兩個coroutine試試:

# -*- coding: utf-8 -*-

import asyncio
import threading

@asyncio.coroutine
def hello():
    print('Hello world! %s' % threading.current_thread())
    r = yield from asyncio.sleep(5)
    print('Hello again! %s' % threading.current_thread())

loop = asyncio.get_event_loop()
tasks = [hello(), hello()]
loop.run_until_complete(asyncio.wait(tasks))
loop.close()

輸出結果如下:

Hello world! <_MainThread(MainThread, started 22668)>
Hello world! <_MainThread(MainThread, started 22668)>
# 此處暫停5秒
Hello again! <_MainThread(MainThread, started 22668)>
Hello again! <_MainThread(MainThread, started 22668)>

由打印的當前線程名稱可以看出,兩個coroutine是由同一個線程併發執行的。
如果把asyncio.sleep()換成真正的IO操作,則多個coroutine就可以由一個線程併發執行。
我們用asyncio的異步網絡連接來獲取sina、sohu和163的網站首頁:

# -*- coding: utf-8 -*-

import asyncio

@asyncio.coroutine
def wget(host):
    print('wget %s ...' % host)
    connect = asyncio.open_connection(host, 80)
    reader, writer = yield from connect
    header = 'GET / HTTP/1.0\r\nHost: %s\r\n\r\n' % host
    writer.write(header.encode('utf-8'))
    yield from writer.drain()
    while True:
        line = yield from reader.readline()
        if line == b'\r\n':
            break
        print('%s header > %s ' % (host, line.decode('utf-8').rstrip()))
    writer.close()

loop = asyncio.get_event_loop()
tasks = [wget(host) for host in ['www.sina.com.cn', 'www.baidu.com', 'www.163.com']]
loop.run_until_complete(asyncio.wait(tasks))
loop.close()

輸出結果如下:

wget www.163.com ...
wget www.sina.com.cn ...
wget www.baidu.com ...
www.163.com header > HTTP/1.0 302 Moved Temporarily 
www.163.com header > Server: Cdn Cache Server V2.0 
www.163.com header > Date: Sat, 06 Jan 2018 14:14:58 GMT 
www.163.com header > Content-Length: 0 
www.163.com header > Location: http://www.163.com/special/0077jt/error_isp.html 
www.163.com header > Connection: close 
www.sina.com.cn header > HTTP/1.1 200 OK 
www.sina.com.cn header > Server: nginx 
www.sina.com.cn header > Date: Sat, 06 Jan 2018 14:12:15 GMT 
www.sina.com.cn header > Content-Type: text/html 
www.sina.com.cn header > Content-Length: 605048 
www.sina.com.cn header > Connection: close 
www.sina.com.cn header > Last-Modified: Sat, 06 Jan 2018 14:09:06 GMT 
www.sina.com.cn header > Vary: Accept-Encoding 
www.sina.com.cn header > Expires: Sat, 06 Jan 2018 14:13:12 GMT 
www.sina.com.cn header > Cache-Control: max-age=60 
www.sina.com.cn header > X-Powered-By: shci_v1.03 
www.sina.com.cn header > Age: 3 
www.sina.com.cn header > Via: http/1.1 cnc.beixian.ha2ts4.205 (ApacheTrafficServer/6.2.1 [cMsSf ]), http/1.1 gwbn.beijing.ha2ts4.23 (ApacheTrafficServer/6.2.1 [cHs f ]) 
www.sina.com.cn header > X-Via-Edge: 15152479356296c6422730904eedb7d91845d 
www.sina.com.cn header > X-Cache: HIT.23 
www.sina.com.cn header > X-Via-CDN: f=edge,s=gwbn.beijing.ha2ts4.21.nb.sinaedge.com,c=115.34.100.108;f=Edge,s=gwbn.beijing.ha2ts4.23,c=219.238.4.21 
www.baidu.com header > HTTP/1.1 200 OK 
www.baidu.com header > Date: Sat, 06 Jan 2018 14:12:15 GMT 
www.baidu.com header > Content-Type: text/html 
www.baidu.com header > Content-Length: 14613 
www.baidu.com header > Last-Modified: Fri, 29 Dec 2017 03:29:00 GMT 
www.baidu.com header > Connection: Close 
www.baidu.com header > Vary: Accept-Encoding 
www.baidu.com header > Set-Cookie: BAIDUID=BEA2CAC2706F8386AAC50DEEC6287BD9:FG=1; expires=Thu, 31-Dec-37 23:55:55 GMT; max-age=2147483647; path=/; domain=.baidu.com 
www.baidu.com header > Set-Cookie: BIDUPSID=BEA2CAC2706F8386AAC50DEEC6287BD9; expires=Thu, 31-Dec-37 23:55:55 GMT; max-age=2147483647; path=/; domain=.baidu.com 
www.baidu.com header > Set-Cookie: PSTM=1515247935; expires=Thu, 31-Dec-37 23:55:55 GMT; max-age=2147483647; path=/; domain=.baidu.com 
www.baidu.com header > P3P: CP=" OTI DSP COR IVA OUR IND COM " 
www.baidu.com header > Server: BWS/1.1 
www.baidu.com header > X-UA-Compatible: IE=Edge,chrome=1 
www.baidu.com header > Pragma: no-cache 
www.baidu.com header > Cache-control: no-cache 
www.baidu.com header > Accept-Ranges: bytes 

可見3個連接由一個線程通過coroutine併發完成。

7 async/await

asyncio提供的@asyncio.coroutine可以把一個generator標記爲coroutine類型,然後在coroutine內部用yield from調用另一個coroutine實現異步操作。
爲了簡化並更好地標識異步IO,從Python 3.5開始引入了新的語法async和await,可以讓coroutine的代碼更簡潔易讀。
請注意,async和await是針對coroutine的新語法,要使用新的語法,只需要做兩步簡單的替換:

  • 把@asyncio.coroutine替換爲async;
  • 把yield from替換爲await。
    那麼6部分的hello代碼就可以這樣替換了
    # 把一個generator標記爲協程類型
    @asyncio.coroutine
    def hello():
    """
    定義一個生成器
    """
    print('Hello world!')
    # 通過yield from調用另一個標記爲協程的生成器
    r = yield from asyncio.sleep(2)
    print('Hello again!')

    替換爲

    async def hello():
    """
    定義一個生成器
    """
    print('Hello world!')
    # 通過await調用另一個標記爲協程的生成器
    r = await asyncio.sleep(2)
    print('Hello again!')

    其它位置的代碼均不變化。
    部分內容參考:廖雪峯python教程

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