不看官方文檔,這個問題你可能會束手無策

  • 2020 年 3 月 13 日
  • 筆記

攝影:產品經理

產品經理親自下廚做的雞 jio jio

在 Python 3.7版本開始,引入了新功能asyncio.run來快速運行一段非同步程式碼。

例如對於一段使用 aiohttp 請求網址的程式碼,在 Python 3.6或者之前的版本,我們是這樣寫的:

import asyncio  import aiohttp      async def main():      async with aiohttp.ClientSession() as client:          resp = await client.get('http://httpbin.org/ip')          ip = await resp.json()          print(ip)      loop = asyncio.get_event_loop()  loop.run_until_complete(main())

運行效果如下圖所示:

現在有了asyncio.run,我們可以少敲幾次鍵盤:

import asyncio  import aiohttp      async def main():      async with aiohttp.ClientSession() as client:          resp = await client.get('http://httpbin.org/ip')          ip = await resp.json()          print(ip)      asyncio.run(main())

運行效果如下圖所示:

這個功能真是太方便了!我準備完全不使用老式寫法了。直到有一天,我使用 Motor 讀取數據。

Motor 是用來非同步讀寫 MongoDB 的庫。我寫程式碼一般會先寫一段 Demo,確認沒有問題了再把 Demo 改成正式程式碼。我們用 Motor寫一段讀取 MongoDB 的程式碼:

import asyncio  import motor.motor_asyncio      async def main():      client = motor.motor_asyncio.AsyncIOMotorClient()      db = client.exercise      collection = db.person_info      async for doc in collection.find({}, {'_id': 0}):          print(doc)      asyncio.run(main())

運行效果符合預期,如下圖所示:

既然 Demo 可以正常運行,那麼我們把這段程式碼修改得稍微正式一些,使用類來包住正常的程式碼:

import asyncio  import motor.motor_asyncio      class MongoUtil:      def __init__(self):          conn = motor.motor_asyncio.AsyncIOMotorClient()          db = conn.exercise          self.collection = db.person_info        async def read_people(self):          async for doc in self.collection.find({}, {'_id': 0}):              print(doc)      util = MongoUtil()  asyncio.run(util.read_people())

運行效果如下圖所示,竟然報錯了:

報錯資訊的最後一句,單獨摘錄出來::

RuntimeError: Task <Task pending coro=<MongoUtil.read_people() running at /Users/kingname/test_fastapi/test_motor.py:12> cb=[_run_until_complete_cb() at /Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.7/lib/python3.7/asyncio/base_events.py:158]> got Future <Future pending cb=[run_on_executor.._call_check_cancel() at /Users/kingname/.virtualenvs/test_fastapi-v5CW09hz/lib/python3.7/site-packages/motor/frameworks/asyncio/init.py:80]> attached to a different loop 」

其中最關鍵的一句話是:attached to a different loop

顯然我們這個程式是單進程單執行緒的程式,這段報錯說明當前的這個執行緒裡面,在運行asyncio.run之前,就已經存在一個事件循環了。而根據 asyncio 的規定,一個執行緒裡面只能有一個事件循環正在運行,所以就導致報錯。

為了解釋這個報錯的原因,我們來看看 Python 的官方文檔中,asyncio.run相關說明[1],如下圖所示:

其中畫紅色方框的兩個地方:

This function cannot be called when another asyncio event loop is running in the same thread. 當另一個 asyncio 事件循環正在當前執行緒運行的時候,不能調用這個函數。 」

This function always creates a new event loop and closes it at the end. 這個函數總是創建一個新的事件循環並在最後(運行完成)關閉它。 」

所以,當我們調用asyncio.run的時候,必須確保當前執行緒沒有事件循環正在運行。

但是,當我們在運行上圖第16行程式碼,初始化MongoUtil的時候,它的構造函數__init__會運行,於是第7行程式碼就會運行。

來看一下Motor 的官方文檔中關於AsyncIOMotorClient描述[2]

AsyncIOMotorClient有一個參數叫做io_loop,如果不傳入事件循環對象的話,就會使用默認的。但程式運行到這個位置的時候,還沒有誰創建了事件循環,於是Motor就會自己創建一個事件循環。

關於這一點,大家可以閱讀Motor 的源程式碼[3]第150-154行:

在不傳入io_loop的時候,會調用self._framework.get_event_loop()。其中,self._framework可能是trio也可能是asyncio。因為 Motor 支援這兩種非同步框架。我們這裡使用的是asyncio。由於當前沒有正在運行的事件循環,所以asyncio.get_event_loop就會創建一個,並讓它運行起來。

所以當我們使用 Motor 初始化 MongoDB 的連接時,就已經創建了一個事件循環了。但當程式碼運行到asyncio.run的時候,又準備創建一個新的事件循環,自然而然程式就運行錯了。

所以,要讓程式正常運行,我們在最後一行不能創建新的事件循環,而是需要獲取由 Motor 已經創建好的事件循環。所以程式碼需要改成老式寫法:

loop = asyncio.get_event_loop()  loop.run_until_complete(util.read_people())

這樣一來,程式就能正常工作了:

這個問題通過官方文檔就能找到原因並解決。但如果你不看官方文檔,而是一味在網上亂搜索,恐怕很難找到解決辦法。

參考資料

[1]

相關說明: https://docs.python.org/3/library/asyncio-task.html#asyncio.run

[2]

描述: https://motor.readthedocs.io/en/stable/api-asyncio/asyncio_motor_client.html#motor.motor_asyncio.AsyncIOMotorClient

[3]

源程式碼: https://github.com/mongodb/motor/blob/4c7534c6200e4f160268ea6c0e8a9038dcc69e0f/motor/core.py#L154