本文转载自微信公众号「小菜学编程」,作者 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 |
可以看到,随着并发数的增加,处理速度也有明显的提升,趋势接近线性。