Python核心技術與實戰——十八|Python併發編程之Asyncio

咱們在上一章學習了Python併發編程的一種實現方法——多線程。今天,咱們趁熱打鐵,看看Python併發編程的另外一種實現方式——Asyncio。和前面協程的那章不太同樣,這節課咱們更加註重原理的理解。html

經過上節課的學習,咱們知道在進行I/O操做的時候,使用多線程與普通的單線程比較,效率有了很大的提升,既然這樣,爲何還要Asyncio呢?python

雖然多線程有諸多優勢而且應用普遍,可是也存在必定的侷限性:編程

※多線程運行過程很容易被打斷,所以有可能出現race condition的狀況安全

※線程的切換存在必定的消耗,線程數量不能無限增長,所以,若是I/O操做很是密集,多線程頗有可能知足不了高效率、高質量的需求。session

針對這些問題,Asyncio應運而生。多線程

什麼是Asyncio?併發

Sync VS Async異步

咱們首先來區分一下Sync(同步)和Async(異步)的概念。async

※所謂Sync,是指操做一個接一個的執行,下一個操做必須等上一個操做完成後才能執行。函數

※而Async是指不一樣操做之間能夠相互交替執行,若是某個操做被block,程序並不會等待,而是會找出可執行的操做繼續執行。


 

舉個簡單的例子,咱們要作一個報表並用郵件發送給老闆,看看兩種方式有什麼不一樣:

※按照Sync的方式,咱們相軟件裏輸入各項數據,而後等5分鐘生成了報代表細之後,再寫郵件發送給老闆

※而按照Async的方式,在輸完數據之後,開始生成報表,但這個時候咱們不幹等這報表生成而是去寫郵件,等報代表細生成之後,咱們暫停郵件的編寫去查看報表,確認之後繼續寫郵件知道發送完畢。 

Asyncio的工做原理

明白了Sync和Async的套路,咱們回到今天的主題,到底什麼是Asyncio呢?

事實上,Asyncio和其餘的Python程序同樣,是單線程的,他只有一個主線程,可是惡意進行多個不一樣任務(task),這裏的任務,就是特殊的future對象,這些不一樣的任務,被一個叫作event loop(事件循環)的對象控制。我能夠把這裏的任務,類比成多線程版本里的多個線程。

爲了簡化的瞭解這個問題,咱們能夠假設任務只有兩個狀態:一是預備狀態;而是等待狀態、預備狀態是指任務目前空閒,但隨時準備運行。而等待狀態,是指已經運行,但正在等待外部的操做完成,好比I/O操做。

在這種狀況下,事件循環會維護兩個任務列表,分別對應這兩種狀態;而且選取預備狀態的一個任務(具體選取那個任務,和其等待的時間長短、佔用的資源等等相關),使其運行,一直到任務把控制權教會給事件循環爲止。

 值得一提的是,對於Asyncio來講,他的任務在運行時不會被外部的因素打斷,所以Asyncio內的操做不會出現race condition的狀況,這樣就不須要咱們擔憂線程安全的問題了。

Asyncio的用法

講完了Asyncio的原理,咱們結合具體的代碼來看一下他的用法。仍是以上一節課裏下載網站上的內容爲例,用Asyncio的寫法以下(依舊是省略了異常處理)

import asyncio
import aiohttp
import time
async def download_one(url):
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as resp:
            print('Read {} from {}.'.format(resp.content_length,url))

async def download_all(sites):
    tasks = [asyncio.create_task(download_one(site)) for site in sites]
    await asyncio.gather(*tasks)


def main():
    sites = [
    'https://en.wikipedia.org/wiki/Portal:Arts',
    'https://en.wikipedia.org/wiki/Portal:History',
    'https://en.wikipedia.org/wiki/Portal:Society', 
    'https://en.wikipedia.org/wiki/Portal:Biography',
    'https://en.wikipedia.org/wiki/Portal:Mathematics',
    'https://en.wikipedia.org/wiki/Portal:Technology',
    'https://en.wikipedia.org/wiki/Portal:Geography',
    'https://en.wikipedia.org/wiki/Portal:Science',
    'https://en.wikipedia.org/wiki/Computer_science',
    'https://en.wikipedia.org/wiki/Python_(programming_language)',
    'https://en.wikipedia.org/wiki/Java_(programming_language)',
    'https://en.wikipedia.org/wiki/PHP',
    'https://en.wikipedia.org/wiki/Node.js',
    'https://en.wikipedia.org/wiki/The_C_Programming_Language',
    'https://en.wikipedia.org/wiki/Go_(programming_language)' 
    ]

    start_time = time.perf_counter()
    asyncio.run(download_all(sites))
    end_time = time.perf_counter()
    print('Down {} sites in {} seconds'.format(len(sites),end_time-start_time))

if __name__ == '__main__':
    main()

這裏的Async和await關鍵字是Asyncio的最新的寫法,表示這個語句/函數是non-blocked的,正好對應了前面講的event loop的概念。若是任務執行的過程須要等待,則將其放入等待的列表中,而後繼續執行狀態列表裏的任務。

主函數裏的asyncio.run(coro)是Asyncio的root call,表示拿到event loop,運行輸入的coro,直到他結束,最後關閉這個event loop。事實上,asyncio.run()是Python3.7+之後才引入的,至關於之前的版本中下面的語法

loop = asyncio.get_event_loop()
try:
    loop.run_until_complete(coro)
finally:
    loop.close()

 至於Asyncio版本內的download_all(),和以前多線程版本也有很大的區別:

task = [asyncio.create_task(download_one(site)) for site in sites]
await asyncio.gather(*task)

這裏的asynco.creat_task(core),表示對輸入的協程coro建立一個任務,安排他的執行,並返回此任務對象。這個函數也是Python3.7之後的版本增長的,若是是以前的版本,咱們能夠用下面的方法代替:

asyncio.ensure_future(coro)

能夠看到,這裏咱們對每個網站的下載,都建立了一個對應的任務。

再往下看,asyncio.gather(*aws,loop=None,return_exception = False),則表示在事件循環中運行aws序列中全部的任務。固然,除了例子中用到的幾個函數,Asyncio還提供了不少其餘的用法,咱們能夠經過Python官方文檔查看

最後咱們能夠經過最後的輸出結果發現,這種方式的效率要比以前的多線程版本還要高一些,充分體現出其優點。

Asyncio的缺陷

經過前面的講解咱們能夠看出Asyncio的強大,可是任何一種方案都不是完美無瑕的,都存在必定的侷限性,固然Asyncio也一樣如此。

在實際的工做中,要想用好Asyncio,特別是要發揮好其強大的功能,不少狀況下必需要有相應的Python庫做爲支持,咱們可能發現了在前面的多線程編程中咱們都是用的request庫,可是在這裏咱們用的是aiohttp庫,緣由就是request庫是不兼容Asyncio的,而aiohttp庫兼容。

Asyncio軟件庫的兼容性問題在Python3的早期一直是一個大問題,可是隨着技術的發展,這個問題也在逐步獲得解決。

另外,在使用Asyncio時,由於在任務的調度方面有了了更大的自主權,寫代碼就要更加註意,不然會很容易出錯。

舉個例子,若是咱們須要await一系列的操做,就帶使用asyncio.gathrer();若是是單個的Futures,或許使用asyncio.wait()就能夠了。那麼,對於一個future,咱們是須要他run_until_complete()仍是run_forever(),都是要好好思考一下的。諸如此類,都是咱們在面對具體問題時須要考慮的。

多線程仍是Asyncio?

咱們已經把併發編程的兩種方式都講了,不過,遇到實際問題,咱們選擇那種編程方式呢?

總得來是,咱們能夠遵循下面的規範

※若是是I/O bound,而且I/O操做很慢,須要不少任務/線程協同實現,那麼使用Asyncio更加合適

※若是是I/O bound,可是I/O操做很快,只須要有限數量的任務或線程,那麼使用多線程就能夠了

※若是是CPU bound,則須要多進程來提升運行效率。

總結

在今天的學習中,咱們一塊兒學習了Asyncio的原理和用法,比較了Asyncio和多線程各自的優缺點。

共同點:

都是併發操做,多線程同一時間點只有一個線程在運行,而協程是隻有一個任務在執行;

不一樣點:多線程是在I/O阻塞的時候經過切換線程來達到併發的效果,何時切換是由操做系統決定的,開發者不用操心,但會形成race  condition;

    協程是隻有一個線程,在I/O阻塞時候經過在線程內切換任務來達到併發的效果,在何時切換是由開發者決定的,不會有race condition的狀況。

不一樣於多線程,Asyncio是單線程,但其內部event loop的訓話機制,可讓他併發的運行多個不一樣的任務,而且比多線程享有更多的自主控制權。

Asyncio中的任務,在運行的過程當中不會被打斷,所以不會出現race condition的狀況。尤爲是咋I/O操做比較密集的時候 ,Asyncio的運行效率會更高,遠比線程切換的損耗要小。而且Asyncio能夠開啓的任務數量也比多線程中的線程數量多。

可是要注意的是,不少狀況下使用Asyncio須要特定的三方庫的支持,,而若是I/O操做比較快而且不heavy,使用多線程也能有效的解決問題。

思考題

咱們已經講了兩種併發編程的思路,也屢次提到了並行編程(multi-processing),其適用於CPU heavy的場景,

如今的需求是輸入一個列表,隨便指定一個元素,求出從0到這個元素全部整數的平方和。下面是常規寫法,若是有多進程版本,又要怎麼寫呢?

import time
def cpu_bound(number):
    print(sum(i*i for i in range(number)))

def calculate_sum(numbers):
    for number in numbers:
        cpu_bound(number)

def main():
    start_time = time.perf_counter()
    numbers = [10000000 + x for x in range(20)]
    calculate_sum(numbers)
    end_time = time.perf_counter()

    print('Calculation takds {} seconds'.format(end_time-start_time))

if __name__ == '__main__':
    main()

運行結果(只貼出來運行的總時長)

Calculation takds 20.637497200000002 seconds

在來看看這種方法

import time
import multiprocessing

def cpu_bound(number):
    return sum(i*i for i in range(number))

def find_sums(numbers):
    with multiprocessing.Pool() as pool:
        pool.map(cpu_bound,numbers)

if __name__ == '__main__':
    start_time = time.perf_counter()
    numbers = [10000000 + x for x in range(20)]
    find_sums(numbers)
    end_time = time.perf_counter()
    print('Calculation takds {} seconds'.format(end_time-start_time))

而後來看看最終的運行時間

Calculation takds 7.3418618 seconds

由於這裏須要用大量的計算,因此使用的是多進程的方式來提升了程序的效率。

相關文章
相關標籤/搜索