工程實(shí)踐:用Asyncio協(xié)程構(gòu)建高并發(fā)應(yīng)用
本文轉(zhuǎn)載自微信公眾號「小菜學(xué)編程」,作者 fasionchan。轉(zhuǎn)載本文請聯(lián)系小菜學(xué)編程公眾號。
C10K問題
在互聯(lián)網(wǎng)尚未普及的早期,一臺服務(wù)器同時(shí)在線 100 個(gè)用戶已經(jīng)算是非常大型的應(yīng)用了,工程上沒有什么挑戰(zhàn)。
隨著 Web 2.0 時(shí)代的到來,用戶群體成幾何倍數(shù)增長,服務(wù)器需要更強(qiáng)的并發(fā)處理能力才能承載海量的用戶。這時(shí),著名的 C10K 問題誕生了——如何讓單臺服務(wù)器同時(shí)支撐 1 萬個(gè)客戶端連接?
最初的服務(wù)器應(yīng)用編程模型,是基于進(jìn)程/線程的:當(dāng)一個(gè)新的客戶端連接上來,服務(wù)器就分配一個(gè)進(jìn)程或線程,來處理這個(gè)新連接。這意味著,想要解決 C10K 問題,操作系統(tǒng)需要同時(shí)運(yùn)行 1 萬個(gè)進(jìn)程或線程。
進(jìn)程和線程是操作系統(tǒng)中,開銷最大的資源之一。每個(gè)新連接都新開進(jìn)程/線程,將造成極大的資源浪費(fèi)。況且,受硬件資源制約,系統(tǒng)同一時(shí)間能運(yùn)行的進(jìn)程/線程數(shù)存在上限。
換句話講,在進(jìn)程/線程模型中,每臺服務(wù)器能處理的客戶端連接數(shù)是非常有限的。為支持海量的業(yè)務(wù),只能通過堆服務(wù)器這種簡單粗暴的方式來實(shí)現(xiàn)。但這樣的人海戰(zhàn)術(shù),既不穩(wěn)定,也不經(jīng)濟(jì)。
為了在單個(gè)進(jìn)程/線程中同時(shí)處理多個(gè)網(wǎng)絡(luò)連接,select 、 poll 、epoll 等 IO多路復(fù)用 技術(shù)應(yīng)運(yùn)而生。在IO多路復(fù)用模型,進(jìn)程/線程不再阻塞在某個(gè)連接上,而是同時(shí)監(jiān)控多個(gè)連接,只處理那些有新數(shù)據(jù)達(dá)到的活躍連接。
為什么需要協(xié)程
單純的IO多路復(fù)用編程模型,不像阻塞式編程模型那樣直觀,這為工程項(xiàng)目帶來諸多不便。最典型的像 JavaScript 中的回調(diào)式編程模型,程序中各種 callback 函數(shù)滿天飛,這不是一種直觀的思維方式。
為實(shí)現(xiàn)阻塞式那樣直觀的編程模型,協(xié)程(用戶態(tài)線程)的概念被提出來。協(xié)程在進(jìn)程/線程基礎(chǔ)之上,實(shí)現(xiàn)多個(gè)執(zhí)行上下文。由 epoll 等IO多路復(fù)用技術(shù)實(shí)現(xiàn)的事件循環(huán),則負(fù)責(zé)驅(qū)動協(xié)程的調(diào)度、執(zhí)行。
協(xié)程可以看做是IO多路復(fù)用技術(shù)更高層次的封裝。雖然與原始IO多路復(fù)用相比有一定的性能開銷,但與進(jìn)程/線程模型相比卻非常突出。協(xié)程占用資源比進(jìn)程/線程少,而且切換成本比較低。因此,協(xié)程在高并發(fā)應(yīng)用領(lǐng)域潛力無限。
然而,協(xié)程獨(dú)特的運(yùn)行機(jī)制,讓初學(xué)者吃了不少虧,錯(cuò)漏百出。
接下來,我們通過若干簡單例子,探索協(xié)程應(yīng)用之道,從中體會協(xié)程的作用,并揭示高并發(fā)應(yīng)用設(shè)計(jì)、部署中存在的常見誤區(qū)。由于 asyncio 是 Python 協(xié)程發(fā)展的主要趨勢,例子便以 asyncio 為講解對象。
第一個(gè)協(xié)程應(yīng)用
協(xié)程應(yīng)用由事件循環(huán)驅(qū)動,套接字必須是非阻塞模式,否則會阻塞事件循環(huán)。因此,一旦使用協(xié)程,就要跟很多類庫說拜拜了。以 MySQL 數(shù)據(jù)庫操作為例,如果我們使用 asyncio ,就要用 aiomysql 包來連數(shù)據(jù)庫。
而想要開發(fā) Web 應(yīng)用,則可以用 aiohttp 包,它可以通過 pip 命令安裝:
- $ pip install aiohttp
這個(gè)例子實(shí)現(xiàn)一個(gè)完整 Web 服務(wù)器,雖然它只有返回當(dāng)前時(shí)間的功能:
- 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 行,實(shí)現(xiàn)處理函數(shù),獲取當(dāng)前時(shí)間并返回;
第 7 行,創(chuàng)建應(yīng)用對象,并將處理函數(shù)注冊到路由中;
第 13 行,將 Web 應(yīng)用跑起來,默認(rèn)端口是 8080 ;
當(dāng)一個(gè)新的請求到達(dá)時(shí),aiohttp 將創(chuàng)建一個(gè)新協(xié)程來處理該請求,它將負(fù)責(zé)執(zhí)行對應(yīng)的處理函數(shù)。因此,處理函數(shù)必須是合法的協(xié)程函數(shù),以 async 關(guān)鍵字開頭。
將程序跑起來后,我們就可以通過它獲悉當(dāng)前時(shí)間。在命令行中,可以用 curl 命令來發(fā)起請求:
- $ curl http://127.0.0.1:8080/
- 2020-08-06 15:50:34
壓力測試
研發(fā)高并發(fā)應(yīng)用,需要評估應(yīng)用的處理能力。我們可以在短時(shí)間內(nèi)發(fā)起大量的請求,并測算應(yīng)用的吞吐能力。然而,就算你手再快,一秒鐘也只能發(fā)起若干個(gè)請求呀。怎么辦呢?
我們需要借助一些壓力測試工具,例如 Apache 工具集中的 ab 。如何安裝使用 ab 不在本文的討論范圍,請參考這篇文章:Web壓力測試(https://network.fasionchan.com/zh_CN/latest/performance/web-pressure-test.html) 。
事不宜遲,我們先以 100 為并發(fā)數(shù),壓 10000 個(gè)請求看看結(jié)果:
- $ 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 選項(xiàng),指定總請求數(shù),即總共發(fā)多少個(gè)請求;
-c 選項(xiàng),指定并發(fā)數(shù),即同時(shí)發(fā)多少個(gè)請求;
從 ab 輸出的報(bào)告中可以獲悉,10000 個(gè)請求全部成功,總共耗時(shí) 5.972 秒,處理速度可以達(dá)到 1674.43 個(gè)每秒。
現(xiàn)在,我們嘗試提供并發(fā)數(shù),看處理速度有沒有提升:
- $ ab -n 10000 -c 100 http://127.0.0.1:8080/
在 1000 并發(fā)數(shù)下,10000 個(gè)請求在 5.771 秒內(nèi)完成,處理速度是 1732.87 ,略有提升但很不明顯。這一點(diǎn)也不意外,例子中的處理邏輯絕大部分都是計(jì)算型,虛增并發(fā)數(shù)幾乎沒有任何意義。
協(xié)程擅長做什么
協(xié)程擅長處理 IO 型的應(yīng)用邏輯,舉個(gè)例子,當(dāng)某個(gè)協(xié)程在等待數(shù)據(jù)庫響應(yīng)時(shí),事件循環(huán)將喚醒另一個(gè)就緒協(xié)程來執(zhí)行,以此提高吞吐。為降低復(fù)雜性,我們通過在程序中睡眠來模擬等待數(shù)據(jù)庫的效果。
- 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)
并發(fā)數(shù) | 請求總數(shù) | 耗時(shí)(秒) | 處理速度(請求/秒) |
---|---|---|---|
100 | 10000 | 102.310 | 97.74 |
500 | 10000 | 22.129 | 451.89 |
1000 | 10000 | 12.780 | 782.50 |
可以看到,隨著并發(fā)數(shù)的增加,處理速度也有明顯的提升,趨勢接近線性。