工程實踐:用Asyncio協程構建高并發應用
本文轉載自微信公眾號「小菜學編程」,作者 fasionchan。轉載本文請聯系小菜學編程公眾號。
C10K問題
在互聯網尚未普及的早期,一臺服務器同時在線 100 個用戶已經算是非常大型的應用了,工程上沒有什么挑戰。
隨著 Web 2.0 時代的到來,用戶群體成幾何倍數增長,服務器需要更強的并發處理能力才能承載海量的用戶。這時,著名的 C10K 問題誕生了——如何讓單臺服務器同時支撐 1 萬個客戶端連接?
最初的服務器應用編程模型,是基于進程/線程的:當一個新的客戶端連接上來,服務器就分配一個進程或線程,來處理這個新連接。這意味著,想要解決 C10K 問題,操作系統需要同時運行 1 萬個進程或線程。
進程和線程是操作系統中,開銷最大的資源之一。每個新連接都新開進程/線程,將造成極大的資源浪費。況且,受硬件資源制約,系統同一時間能運行的進程/線程數存在上限。
換句話講,在進程/線程模型中,每臺服務器能處理的客戶端連接數是非常有限的。為支持海量的業務,只能通過堆服務器這種簡單粗暴的方式來實現。但這樣的人海戰術,既不穩定,也不經濟。
為了在單個進程/線程中同時處理多個網絡連接,select 、 poll 、epoll 等 IO多路復用 技術應運而生。在IO多路復用模型,進程/線程不再阻塞在某個連接上,而是同時監控多個連接,只處理那些有新數據達到的活躍連接。
為什么需要協程
單純的IO多路復用編程模型,不像阻塞式編程模型那樣直觀,這為工程項目帶來諸多不便。最典型的像 JavaScript 中的回調式編程模型,程序中各種 callback 函數滿天飛,這不是一種直觀的思維方式。
為實現阻塞式那樣直觀的編程模型,協程(用戶態線程)的概念被提出來。協程在進程/線程基礎之上,實現多個執行上下文。由 epoll 等IO多路復用技術實現的事件循環,則負責驅動協程的調度、執行。
協程可以看做是IO多路復用技術更高層次的封裝。雖然與原始IO多路復用相比有一定的性能開銷,但與進程/線程模型相比卻非常突出。協程占用資源比進程/線程少,而且切換成本比較低。因此,協程在高并發應用領域潛力無限。
然而,協程獨特的運行機制,讓初學者吃了不少虧,錯漏百出。
接下來,我們通過若干簡單例子,探索協程應用之道,從中體會協程的作用,并揭示高并發應用設計、部署中存在的常見誤區。由于 asyncio 是 Python 協程發展的主要趨勢,例子便以 asyncio 為講解對象。
第一個協程應用
協程應用由事件循環驅動,套接字必須是非阻塞模式,否則會阻塞事件循環。因此,一旦使用協程,就要跟很多類庫說拜拜了。以 MySQL 數據庫操作為例,如果我們使用 asyncio ,就要用 aiomysql 包來連數據庫。
而想要開發 Web 應用,則可以用 aiohttp 包,它可以通過 pip 命令安裝:
- $ pip install aiohttp
這個例子實現一個完整 Web 服務器,雖然它只有返回當前時間的功能:
- from aiohttp import web
- from datetime import datetime
- async def handle(request):
- return web.Response(text=datetime.now().strftime('%Y-%m-%d %H:%M:%S'))
- app = web.Application()
- app.add_routes([
- web.get('/', handle),
- ])
- if __name__ == '__main__':
- web.run_app(app)
第 4 行,實現處理函數,獲取當前時間并返回;
第 7 行,創建應用對象,并將處理函數注冊到路由中;
第 13 行,將 Web 應用跑起來,默認端口是 8080 ;
當一個新的請求到達時,aiohttp 將創建一個新協程來處理該請求,它將負責執行對應的處理函數。因此,處理函數必須是合法的協程函數,以 async 關鍵字開頭。
將程序跑起來后,我們就可以通過它獲悉當前時間。在命令行中,可以用 curl 命令來發起請求:
- $ curl http://127.0.0.1:8080/
- 2020-08-06 15:50:34
壓力測試
研發高并發應用,需要評估應用的處理能力。我們可以在短時間內發起大量的請求,并測算應用的吞吐能力。然而,就算你手再快,一秒鐘也只能發起若干個請求呀。怎么辦呢?
我們需要借助一些壓力測試工具,例如 Apache 工具集中的 ab 。如何安裝使用 ab 不在本文的討論范圍,請參考這篇文章:Web壓力測試(https://network.fasionchan.com/zh_CN/latest/performance/web-pressure-test.html) 。
事不宜遲,我們先以 100 為并發數,壓 10000 個請求看看結果:
- $ ab -n 10000 -c 100 http://127.0.0.1:8080/
- This is ApacheBench, Version 2.3 <$Revision: 1706008 $>
- Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
- Licensed to The Apache Software Foundation, http://www.apache.org/
- Benchmarking 127.0.0.1 (be patient)
- Completed 1000 requests
- Completed 2000 requests
- Completed 3000 requests
- Completed 4000 requests
- Completed 5000 requests
- Completed 6000 requests
- Completed 7000 requests
- Completed 8000 requests
- Completed 9000 requests
- Completed 10000 requests
- Finished 10000 requests
- Server Software: Python/3.8
- Server Hostname: 127.0.0.1
- Server Port: 8080
- Document Path: /
- Document Length: 19 bytes
- Concurrency Level: 100
- Time taken for tests: 5.972 seconds
- Complete requests: 10000
- Failed requests: 0
- Total transferred: 1700000 bytes
- HTML transferred: 190000 bytes
- Requests per second: 1674.43 [#/sec] (mean)
- Time per request: 59.722 [ms] (mean)
- Time per request: 0.597 [ms] (mean, across all concurrent requests)
- Transfer rate: 277.98 [Kbytes/sec] received
- Connection Times (ms)
- min mean[+/-sd] median max
- Connect: 0 2 1.5 1 15
- Processing: 43 58 5.0 57 89
- Waiting: 29 47 6.3 47 85
- Total: 43 60 4.8 58 90
- Percentage of the requests served within a certain time (ms)
- 50% 58
- 66% 59
- 75% 60
- 80% 61
- 90% 65
- 95% 69
- 98% 72
- 99% 85
- 100% 90 (longest request)
-n 選項,指定總請求數,即總共發多少個請求;
-c 選項,指定并發數,即同時發多少個請求;
從 ab 輸出的報告中可以獲悉,10000 個請求全部成功,總共耗時 5.972 秒,處理速度可以達到 1674.43 個每秒。
現在,我們嘗試提供并發數,看處理速度有沒有提升:
- $ ab -n 10000 -c 100 http://127.0.0.1:8080/
在 1000 并發數下,10000 個請求在 5.771 秒內完成,處理速度是 1732.87 ,略有提升但很不明顯。這一點也不意外,例子中的處理邏輯絕大部分都是計算型,虛增并發數幾乎沒有任何意義。
協程擅長做什么
協程擅長處理 IO 型的應用邏輯,舉個例子,當某個協程在等待數據庫響應時,事件循環將喚醒另一個就緒協程來執行,以此提高吞吐。為降低復雜性,我們通過在程序中睡眠來模擬等待數據庫的效果。
- import asyncio
- from aiohttp import web
- from datetime import datetime
- async def handle(request):
- # 睡眠一秒鐘
- asyncio.sleep(1)
- return web.Response(text=datetime.now().strftime('%Y-%m-%d %H:%M:%S'))
- app = web.Application()
- app.add_routes([
- web.get('/', handle),
- ])
- if __name__ == '__main__':
- web.run_app(app)
| 并發數 | 請求總數 | 耗時(秒) | 處理速度(請求/秒) |
|---|---|---|---|
| 100 | 10000 | 102.310 | 97.74 |
| 500 | 10000 | 22.129 | 451.89 |
| 1000 | 10000 | 12.780 | 782.50 |
可以看到,隨著并發數的增加,處理速度也有明顯的提升,趨勢接近線性。






























