python3比多線程和多進(jìn)程還好的新模塊 —— 協(xié)程Coroutine

引子

最近把所有系統(tǒng)的python3 版本都更新到了python3.7扩淀,然后更新了一下代碼缰趋,發(fā)現(xiàn)這個(gè)版本改動(dòng)還是很大的脏嚷,之前更多還是在使用python2.7做ETL或者操作一些API丸氛,沒想到python的變化如此之大衔憨,看來自己還是太落伍了奋献。于是在知乎和官網(wǎng)上找資料學(xué)習(xí)了下站超,看到一篇講協(xié)程的文章很受啟發(fā)企锌,以后應(yīng)該會(huì)較多使用這個(gè)功能寸癌,之前使用的多進(jìn)程多線程效果都不明顯专筷,而協(xié)程應(yīng)該是一個(gè)python的效率利器。

前言

多進(jìn)程和多線程除了創(chuàng)建的開銷大之外還有一個(gè)難以根治的缺陷蒸苇,就是處理進(jìn)程之間或線程之間的協(xié)作問題磷蛹,因?yàn)槭且蕾嚩噙M(jìn)程和多線程的程序在不加鎖的情況下通常是不可控的,而協(xié)程則可以完美地解決協(xié)作問題溪烤,由用戶來決定協(xié)程之間的調(diào)度味咳。

總所周知,Python因?yàn)橛蠫IL(全局解釋鎖)這玩意檬嘀,不可能有真正的多線程的存在槽驶,因此很多情況下都會(huì)用multiprocessing實(shí)現(xiàn)并發(fā),而且在Python中應(yīng)用多線程還要注意關(guān)鍵地方的同步枪眉,不太方便捺檬,用協(xié)程代替多線程和多進(jìn)程是一個(gè)很好的選擇,因?yàn)樗说奶匦裕?em>主動(dòng)調(diào)用/退出贸铜,狀態(tài)保存堡纬,避免cpu上下文切換等…

協(xié)程

基本概念

協(xié)程聂受,又稱作Coroutine,通過 async/await 語法進(jìn)行聲明,是編寫異步應(yīng)用的推薦方式烤镐。

從字面上來理解蛋济,即協(xié)同運(yùn)行的例程,它是比是線程(thread)更細(xì)量級的用戶態(tài)線程炮叶,特點(diǎn)是允許用戶的主動(dòng)調(diào)用和主動(dòng)退出碗旅,掛起當(dāng)前的例程然后返回值或去執(zhí)行其他任務(wù),接著返回原來停下的點(diǎn)繼續(xù)執(zhí)行镜悉。等下祟辟,這是否有點(diǎn)奇怪?我們都知道一般函數(shù)都是線性執(zhí)行的侣肄,不可能說執(zhí)行到一半返回旧困,等會(huì)兒又跑到原來的地方繼續(xù)執(zhí)行。但一些熟悉python(or其他動(dòng)態(tài)語言)的童鞋都知道這可以做到稼锅,答案是用yield語句吼具。其實(shí)這里我們要感謝操作系統(tǒng)(OS)為我們做的工作,因?yàn)樗哂術(shù)etcontext和swapcontext這些特性矩距,通過系統(tǒng)調(diào)用拗盒,我們可以把上下文和狀態(tài)保存起來,切換到其他的上下文锥债,這些特性為coroutine的實(shí)現(xiàn)提供了底層的基礎(chǔ)陡蝇。操作系統(tǒng)的Interrupts和Traps機(jī)制則為這種實(shí)現(xiàn)提供了可能性,因此它看起來可能是下面這樣的:

image
>>> import asyncio

>>> async def main():
...     print('hello')
...     await asyncio.sleep(1)
...     print('world')

>>> asyncio.run(main())
hello
world

理解生成器(generator)

學(xué)過生成器和迭代器的同學(xué)應(yīng)該都知道python有yield這個(gè)關(guān)鍵字赞弥,yield能把一個(gè)函數(shù)變成一個(gè)generator毅整,與return不同,yield在函數(shù)中返回值時(shí)會(huì)保存函數(shù)的狀態(tài)绽左,使下一次調(diào)用函數(shù)時(shí)會(huì)從上一次的狀態(tài)繼續(xù)執(zhí)行悼嫉,即從yield的下一條語句開始執(zhí)行,這樣做有許多好處拼窥,比如我們想要生成一個(gè)數(shù)列戏蔑,若該數(shù)列的存儲空間太大,而我們僅僅需要訪問前面幾個(gè)元素鲁纠,那么yield就派上用場了总棵,它實(shí)現(xiàn)了這種一邊循環(huán)一邊計(jì)算的機(jī)制,節(jié)省了存儲空間改含,提高了運(yùn)行效率情龄。

運(yùn)行協(xié)程

  1. asyncio.run() 函數(shù)用來運(yùn)行最高層級的入口點(diǎn) "main()" 函數(shù)

  2. 等待一個(gè)協(xié)程。以下代碼段會(huì)在等待 1 秒后打印 "hello",然后 再次 等待 2 秒后打印 "world":

    import asyncio
    import time
    
    async def say_after(delay, what):
        await asyncio.sleep(delay)
        print(what)
    
    async def main():
        print(f"started at {time.strftime('%X')}")
    
        await say_after(1, 'hello')
        await say_after(2, 'world')
    
        print(f"finished at {time.strftime('%X')}")
    
    asyncio.run(main())
    
  1. asyncio.create_task() 函數(shù)用來并發(fā)運(yùn)行作為 asyncio 任務(wù) 的多個(gè)協(xié)程骤视。

    async def main():
        task1 = asyncio.create_task(
            say_after(1, 'hello'))
    
        task2 = asyncio.create_task(
            say_after(2, 'world'))
    
        print(f"started at {time.strftime('%X')}")
    
        # Wait until both tasks are completed (should take
        # around 2 seconds.)
        await task1
        await task2
    
        print(f"finished at {time.strftime('%X')}")
    

可等待對象

如果一個(gè)對象可以在 await 語句中使用鞍爱,那么它就是 可等待 對象。許多 asyncio API 都被設(shè)計(jì)為接受可等待對象专酗。

可等待 對象有三種主要類型: 協(xié)程, 任務(wù) 和 Future.

協(xié)程

Python 協(xié)程屬于 可等待 對象睹逃,因此可以在其他協(xié)程中被等待:

import asyncio

async def nested():
    return 42

async def main():
    # Nothing happens if we just call "nested()".
    # A coroutine object is created but not awaited,
    # so it *won't run at all*.
    nested()

    # Let's do it differently now and await it:
    print(await nested())  # will print "42".

asyncio.run(main())

重要

在本文檔中 "協(xié)程" 可用來表示兩個(gè)緊密關(guān)聯(lián)的概念:

  • 協(xié)程函數(shù): 定義形式為 async def 的函數(shù);
  • 協(xié)程對象: 調(diào)用 協(xié)程函數(shù) 所返回的對象。

asyncio 也支持舊式的 基于生成器的 協(xié)程祷肯。

任務(wù)

任務(wù) 被用來設(shè)置日程以便 并發(fā) 執(zhí)行協(xié)程沉填。

當(dāng)一個(gè)協(xié)程通過 asyncio.create_task() 等函數(shù)被打包為一個(gè) 任務(wù),該協(xié)程將自動(dòng)排入日程準(zhǔn)備立即運(yùn)行:

import asyncio

async def nested():
    return 42

async def main():
    # Schedule nested() to run soon concurrently
    # with "main()".
    task = asyncio.create_task(nested())

    # "task" can now be used to cancel "nested()", or
    # can simply be awaited to wait until it is complete:
    await task

asyncio.run(main())

Future 對象

Future 是一種特殊的 低層級 可等待對象佑笋,表示一個(gè)異步操作的 最終結(jié)果翼闹。

當(dāng)一個(gè) Future 對象 被等待,這意味著協(xié)程將保持等待直到該 Future 對象在其他地方操作完畢允青。

在 asyncio 中需要 Future 對象以便允許通過 async/await 使用基于回調(diào)的代碼橄碾。

通常情況下 沒有必要 在應(yīng)用層級的代碼中創(chuàng)建 Future 對象卵沉。

Future 對象有時(shí)會(huì)由庫和某些 asyncio API 暴露給用戶颠锉,用作可等待對象:

async def main():
    await function_that_returns_a_future_object()

    # this is also valid:
    await asyncio.gather(
        function_that_returns_a_future_object(),
        some_python_coroutine()
    )

一個(gè)很好的返回對象的低層級函數(shù)的示例是 loop.run_in_executor()

并發(fā)運(yùn)行任務(wù)

awaitable asyncio.gather(*aws, loop=None, return_exceptions=False)

并發(fā) 運(yùn)行 aws 序列中的 可等待對象史汗。

如果 aws 中的某個(gè)可等待對象為協(xié)程琼掠,它將自動(dòng)作為一個(gè)任務(wù)加入日程。

如果所有可等待對象都成功完成停撞,結(jié)果將是一個(gè)由所有返回值聚合而成的列表瓷蛙。結(jié)果值的順序與 aws 中可等待對象的順序一致。

如果 return_exceptionsFalse (默認(rèn))戈毒,所引發(fā)的首個(gè)異常會(huì)立即傳播給等待 gather() 的任務(wù)艰猬。aws序列中的其他可等待對象 不會(huì)被取消 并將繼續(xù)運(yùn)行。

如果 return_exceptionsTrue埋市,異常會(huì)和成功的結(jié)果一樣處理冠桃,并聚合至結(jié)果列表。

如果 gather() 被取消道宅,所有被提交 (尚未完成) 的可等待對象也會(huì) 被取消食听。

如果 aws 序列中的任一 Task 或 Future 對象 被取消,它將被當(dāng)作引發(fā)了 CancelledError 一樣處理 -- 在此情況下 gather() 調(diào)用 不會(huì) 被取消污茵。這是為了防止一個(gè)已提交的 Task/Future 被取消導(dǎo)致其他 Tasks/Future 也被取消樱报。

import asyncio

async def factorial(name, number):
    f = 1
    for i in range(2, number + 1):
        print(f"Task {name}: Compute factorial({i})...")
        await asyncio.sleep(1)
        f *= i
    print(f"Task {name}: factorial({number}) = {f}")

async def main():
    # Schedule three calls *concurrently*:
    await asyncio.gather(
        factorial("A", 2),
        factorial("B", 3),
        factorial("C", 4),
    )

asyncio.run(main())

# Expected output:
#
#     Task A: Compute factorial(2)...
#     Task B: Compute factorial(2)...
#     Task C: Compute factorial(2)...
#     Task A: factorial(2) = 2
#     Task B: Compute factorial(3)...
#     Task C: Compute factorial(3)...
#     Task B: factorial(3) = 6
#     Task C: Compute factorial(4)...
#     Task C: factorial(4) = 24

爬蟲例子

使用爬蟲爬取豆瓣top250

from lxml import etree
from time import time
import asyncio
import aiohttp

url = "https://movie.douban.com/top250"
header = {
    "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36",
    "content-type": "text/plain;charset=UTF-8",
}


async def fetch_content(url):
    # await asyncio.sleep(1) # 防止請求過快 等待1秒
    async with aiohttp.ClientSession(
        headers=header, connector=aiohttp.TCPConnector(ssl=False)
    ) as session:
        async with session.get(url) as response:
            return await response.text()


async def parse(url):
    page = await fetch_content(url)
    html = etree.HTML(page)

    xpath_movie = '//*[@id="content"]/div/div[1]/ol/li'
    xpath_title = './/span[@class="title"]'
    xpath_pages = '//*[@id="content"]/div/div[1]/div[2]/a'
    xpath_descs = './/span[@class="inq"]'
    xpath_links = './/div[@class="info"]/div[@class="hd"]/a'

    pages = html.xpath(xpath_pages)  # 所有頁面的鏈接都在底部獲取
    fetch_list = []
    result = []

    for element_movie in html.xpath(xpath_movie):
        result.append(element_movie)

    for p in pages:
        fetch_list.append(url + p.get("href"))  # 解析翻頁按鈕對應(yīng)的鏈接 組成完整后邊頁面鏈接

    tasks = [fetch_content(url) for url in fetch_list]  # 并行處理所有翻頁的頁面
    pages = await asyncio.gather(*tasks)
    # 并發(fā) 運(yùn)行 aws 序列中的 可等待對象。
    # 如果 aws 中的某個(gè)可等待對象為協(xié)程泞当,它將自動(dòng)作為一個(gè)任務(wù)加入日程迹蛤。
    # 如果所有可等待對象都成功完成,結(jié)果將是一個(gè)由所有返回值聚合而成的列表。結(jié)果值的順序與 aws 中可等待對象的順序一致盗飒。
    for page in pages:
        html = etree.HTML(page)
        for element_movie in html.xpath(xpath_movie):
            result.append(element_movie)

    for i, movie in enumerate(result, 1):
        title = movie.find(xpath_title).text
        desc = (
            "<" + movie.find(xpath_descs).text + ">"
            if movie.find(xpath_descs) is not None
            else None
        )
        link = movie.find(xpath_links).get("href")
        print(i, title, desc, link)


async def main():
    start = time()
    for i in range(5):
        await parse(url)
    end = time()
    print("Cost {} seconds".format((end - start) / 5))


if __name__ == "__main__":
    asyncio.run(main())

參考文章

  1. 從0到1穷缤,Python異步編程的演進(jìn)之路
  2. Python3 Async/Await解釋
  3. 官方文檔 協(xié)程與任務(wù)
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市箩兽,隨后出現(xiàn)的幾起案子津肛,更是在濱河造成了極大的恐慌,老刑警劉巖汗贫,帶你破解...
    沈念sama閱讀 206,602評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件身坐,死亡現(xiàn)場離奇詭異,居然都是意外死亡落包,警方通過查閱死者的電腦和手機(jī)部蛇,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,442評論 2 382
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來咐蝇,“玉大人涯鲁,你說我怎么就攤上這事∮行颍” “怎么了抹腿?”我有些...
    開封第一講書人閱讀 152,878評論 0 344
  • 文/不壞的土叔 我叫張陵,是天一觀的道長旭寿。 經(jīng)常有香客問我警绩,道長,這世上最難降的妖魔是什么盅称? 我笑而不...
    開封第一講書人閱讀 55,306評論 1 279
  • 正文 為了忘掉前任肩祥,我火速辦了婚禮,結(jié)果婚禮上缩膝,老公的妹妹穿的比我還像新娘混狠。我一直安慰自己,他們只是感情好疾层,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,330評論 5 373
  • 文/花漫 我一把揭開白布将饺。 她就那樣靜靜地躺著,像睡著了一般云芦。 火紅的嫁衣襯著肌膚如雪俯逾。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,071評論 1 285
  • 那天舅逸,我揣著相機(jī)與錄音桌肴,去河邊找鬼。 笑死琉历,一個(gè)胖子當(dāng)著我的面吹牛坠七,可吹牛的內(nèi)容都是我干的水醋。 我是一名探鬼主播,決...
    沈念sama閱讀 38,382評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼彪置,長吁一口氣:“原來是場噩夢啊……” “哼拄踪!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起拳魁,我...
    開封第一講書人閱讀 37,006評論 0 259
  • 序言:老撾萬榮一對情侶失蹤惶桐,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后潘懊,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體姚糊,經(jīng)...
    沈念sama閱讀 43,512評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,965評論 2 325
  • 正文 我和宋清朗相戀三年授舟,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了救恨。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,094評論 1 333
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡释树,死狀恐怖肠槽,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情奢啥,我是刑警寧澤秸仙,帶...
    沈念sama閱讀 33,732評論 4 323
  • 正文 年R本政府宣布,位于F島的核電站扫尺,受9級特大地震影響筋栋,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜正驻,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,283評論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望抢腐。 院中可真熱鬧姑曙,春花似錦、人聲如沸迈倍。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,286評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽啼染。三九已至宴合,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間迹鹅,已是汗流浹背卦洽。 一陣腳步聲響...
    開封第一講書人閱讀 31,512評論 1 262
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留斜棚,地道東北人阀蒂。 一個(gè)月前我還...
    沈念sama閱讀 45,536評論 2 354
  • 正文 我出身青樓该窗,卻偏偏與公主長得像,于是被迫代替她去往敵國和親蚤霞。 傳聞我的和親對象是個(gè)殘疾皇子酗失,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,828評論 2 345