妙手回春:內(nèi)存泄漏診斷案例分析
本文轉(zhuǎn)載自微信公眾號「小菜學(xué)編程」,作者fasionchan。轉(zhuǎn)載本文請聯(lián)系小菜學(xué)編程公眾號。
雖然 Python 自帶垃圾回收機(jī)制,替開發(fā)人員管理內(nèi)存,并不意味著 Python 程序沒有內(nèi)存泄露之虞。實(shí)際上,Python 程序內(nèi)存泄露問題時有發(fā)生——程序跑著跑著,占用內(nèi)存越來越多,最后只能動用重啟大法釋放內(nèi)存……
由于內(nèi)存分配回收工作已被 Python 接管,內(nèi)存泄露問題排查起來相對來說也比較晦澀。正常情況下,引用計(jì)數(shù) 機(jī)制確保對象沒有引用時釋放,而 標(biāo)記清除 則解決了 循環(huán)引用 的問題,理論上不存在內(nèi)存泄露的可能性。
那么,Python 程序內(nèi)存泄露問題一般是如何造成的呢?程序員的失誤是其中的主要原因,最常見的是下面兩點(diǎn):
- 容器泄露 ,使用容器對象存儲數(shù)據(jù),但數(shù)據(jù)只進(jìn)不出,沒有清理機(jī)制,容器便慢慢變大,最后撐爆內(nèi)存;
- __del__ 魔術(shù)方法誤用,如果對象實(shí)現(xiàn)了 __del__ 魔術(shù)方法,Python 就無法用標(biāo)記清除法解決循環(huán)引用問題,這必然帶來內(nèi)存泄露風(fēng)險;
既然內(nèi)存泄露無法完全避免,當(dāng) Python 程序發(fā)生內(nèi)存泄漏時,又該如何排查呢?
本節(jié),我們將以一個簡單的案例,詳細(xì)講解預(yù)防、排查、解決 Python 內(nèi)存泄露問題的 方法論 。
工欲善其事,必先利其器。在這個過程中,我們將利用一些趁手的工具(例如 objgraph 等)。只有選擇正確工具,掌握工具正確使用姿勢,才能做到事半功倍。
問題服務(wù)
我們以一個存在內(nèi)存泄露問題的 API 服務(wù)( service.py )作為例子,演示定位內(nèi)存泄露問題的步驟:
- import uvicorn
- from fastapi import FastAPI
- from faker import Faker
- from pyconsole import start_console_server
- faker = Faker()
- cache = {}
- app = FastAPI()
- async def fetch_user_from_database(user_id):
- return {
- 'user_id': faker.sha256() if user_id == 'random' else user_id,
- 'name': faker.name(),
- 'email': faker.email(),
- 'address': faker.address(),
- 'desc': faker.text(),
- }
- async def get_user(user_id):
- data = cache.get(user_id)
- if data is not None:
- return data
- data = await fetch_user_from_database(user_id)
- cache[data['user_id']] = data
- return data
- @app.get('/users/{user_id}')
- async def retrieve_user(user_id):
- return await get_user(user_id)
- if __name__ == '__main__':
- start_console_server()
- uvicorn.run(app)
這是一個基于 fastapi 框架編寫的 API 服務(wù),它只實(shí)現(xiàn)了一個接口:根據(jù)用戶 ID 獲取用戶信息。API 服務(wù)由 uvicorn 啟動,它是一個性能非常優(yōu)秀的 ASGI 服務(wù)器。
為減少數(shù)據(jù)庫訪問頻率,程序?qū)?shù)據(jù)庫返回的用戶數(shù)據(jù),以用戶 ID 為索引,緩存在內(nèi)存中( cache 字典)。注意到,演示服務(wù)直接使用 faker 隨機(jī)生成用戶數(shù)據(jù),模擬數(shù)據(jù)庫查詢,以此消除數(shù)據(jù)庫依賴。
順便提一下,faker 是一個生成假數(shù)據(jù)的模塊,非常好用。特別是需要測試數(shù)據(jù)時,完全不用自己絞盡腦汁拼造。
服務(wù)還啟動了一個遠(yuǎn)程交互式終端,以便我們可以連上服務(wù)進(jìn)程,并在里面執(zhí)行一些代碼。交互式終端的源碼可以在 github 上獲得:pyconsole.py ,原理超過本節(jié)討論范圍不展開介紹。
由于例子代碼非常簡單,哪里內(nèi)存泄露我們甚至僅憑肉眼便可看出。盡管如此,我們假裝什么都不知道,來研究解決問題的思路:如何觀察程序?如何運(yùn)用工具來獲取一些關(guān)鍵信息?如何分析各個線索?如何逐步接近問題的根源?
運(yùn)行服務(wù)
由于服務(wù)依賴幾個第三方包,啟動它之前請先用 pip 安裝這些依賴包,并且確保安裝是成功的:
- $ pip install uvicorn
- $ pip install fastapi
- $ pip install faker
直接執(zhí)行 service.py 即可啟動服務(wù),默認(rèn)它會監(jiān)聽 8000 端口:
- $ python service.py
- INFO: Started server process [76591]
- INFO: Waiting for application startup.
- INFO: Application startup complete.
- INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
服務(wù)啟動后,即可通過 8000 端口訪問用戶信息接口,用戶 ID 可以隨便給:
- $ curl http://127.0.0.1:8000/users/bef76936c7d22e98f3d7b4c7e1aeef524da4ec1b48f871926fee43c5ec071a2d
- {"user_id":"bef76936c7d22e98f3d7b4c7e1aeef524da4ec1b48f871926fee43c5ec071a2d","name":"Patricia Johnson","email":"epatton@yahoo.com","address":"837 Jacobs Field\nGregorybury, ND 81050","desc":"Third choice air together expect account war. Seven dog safe significant. Expect exist wrong finish window there raise. Third blue and cover."}
服務(wù)接口還支持隨機(jī)查詢,隨機(jī)返回一個用戶的信息:
- $ curl http://127.0.0.1:8000/users/random
- {"user_id":"d6a55f04bab8ddec83d651bdca77f7215042b792970482213b6da56a119f18a8","name":"Evan Carter","email":"andrea79@garcia.com","address":"109 Miller Lights Apt. 843\nPort Jamie, IN 97570","desc":"Resource green allow him. Build store enough effect alone. Everybody right remember public coach book not.\nConference respond trip girl."}
遠(yuǎn)程終端
我們直接執(zhí)行 pyconsole.py ,以默認(rèn)端口即可連接正在運(yùn)行中的 API 服務(wù)進(jìn)程:
- $ python pyconsole.py
- Python 3.8.5 (default, Aug 5 2020, 18:49:57)
- [GCC 5.4.0 20160609] on linux
- Type "help", "copyright", "credits" or "license" for more information.
- (ConsoleClient)
- >>>
pyconsole 用法跟 Python 交互式終端一樣,但代碼執(zhí)行環(huán)境是在被連接的服務(wù)進(jìn)程里面,因此可以看到服務(wù)內(nèi)部的實(shí)時狀態(tài)。我們先通過 dir 內(nèi)建函數(shù)看看遠(yuǎn)程終端的名字空間都有些啥:
- >>> dir()
- ['__builtins__', '__doc__', '__name__', 'main', 'sys']
- >>> main
- <module '__main__' from 'service.py'>
- >>> dir(main)
- ['Faker', 'FastAPI', '__annotations__', '__builtins__', '__cached__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__spec__', 'app', 'cache', 'faker', 'fetch_user_from_database', 'get_user', 'retrieve_user', 'start_console_server', 'uvicorn']
main 就是服務(wù)的 main 模塊,從中還可以找到 service.py 導(dǎo)入的 Faker 、FastAPI 等,它定義的函數(shù) retrieve_user 、get_user 等,還有作為全局變量存在的 cache 字典。甚至,我們還可以看到 cache 當(dāng)前緩存了多少用戶信息:
- >>> len(main.cache)
- 2
由于我們前面通過 API 獲取了 2 條用戶數(shù)據(jù),因此 cache 當(dāng)前緩存了 2 條數(shù)據(jù)。當(dāng)我們再次訪問接口獲取其他用戶數(shù)據(jù)時,我們會看到 cache 緩存的用戶數(shù)據(jù)會慢慢增加:
- >>> len(main.cache)
- 3
pyconsole 是一個很神奇的終端,能夠?qū)崟r查看 Python 進(jìn)程里面各種數(shù)據(jù)的狀態(tài),在排查問題時非常方便!