本文转载自微信公众号「小菜学编程」,作者 fasionchan。转载本文请联系小菜学编程公众号。

C10K问题

在互联网尚未普及的早期,一台服务器同时在线 100 个用户已经算是u J \ C 9 @ /非常大型的应用了,工G s 8 = $ T F ?程上没有什么挑战。

随着 Web 2.0 时代的到来,用户群体成几何倍数增长,服务器需要更强的并发处理能力才能承载海量的用户。这时,著名的 C10K 问题诞生了——如何让单台服务器同时支撑 1 万个客户端连接?

最初的服务器应用编程模型,是基于进程/线程的:当一个新的客户端连接上来,服务器就分配一个进程或v Q = | i 1线程,来处理这个新连接。这意味着,想要解决 C10K 问题,操作系统需要同时运行 1 万个进程或线程。

进程和线程是操作系统中,开销最大的资源之一。每个新连接都新开进程/线程,将造成极大的资源浪费。况且,受硬件资源制约,系统同一时间能运行的进程/线程数E 7 k G R存在上限。

换句话讲,在进程/线程模型中,每台服务器能处理的客户端连接数是非常有限的。为支持海量的业务,只能通过堆服务器这种简单粗暴的方式来实现。但这样的人海战术,既不稳定,也t o I t [不经济。

为了在单个进程/线程中同时处理多个网络连接,select 、 poll 、epoll 等 IO+ $ : 0 f多路复用 技术应运而生= & ~ + } u I $。在IO多路复用模型,进程/线程不再阻塞在某个连接上,而是同时监控多个连接,只处理那些有V Y 6 C – 9 I s S新数据达R 3 e 9 N N * C到的活跃连接。

为什么需要协程

单纯的IO多~ / 2 S = n #路复用编程模型,不像阻塞式编程模型那样直观,这为工程项目带来诸多不便。最典型的像 Ja( * [ bvaScript 中的回调式编程模型,程序中各种 callback 函数满天飞,这不是一种直观的思维方式。

为实现阻塞式那样直观的编程模型,协程(用户态线程)的概念被提出来。协程在进程/线程基础之上,实现多个执行上下文。由 epoll 等IO多路复用技术实现的事件循环,则负责驱动协程的调度、执行。

A ? o ? b H程可以看做是IO多路复用技术更高层次的封装。虽然与原始IO多路复用相比有一定的性能开销,但与进程/线程模型相比却非常突出。协程占用资源比进程/线程少,而且切换成本比较低。因此,n ~ S h u F o协程在高并发应用领域潜力无限。

然而,协程独特的运行机制,让初学者吃了不少亏,错漏百出。

接下来,我们通过若干简单例子,探索协程应用之道,从中体会协程的作用,并揭示B d y高并发应用设计、部署中存在的常见误区。由于 asyncio 是 Python 协程发展的主要趋势,h G ; K ! . ~ D n例子u c K o : M 3 # u便以 asyncio 为讲解对象。

第一个协程应用

协程应用由事件循O M f ^ F , k h环驱动,套接字必须是非阻塞模式,否则会阻塞事件循环。因此,一旦使用协程,– H 2就要跟很多类库说拜拜了。以 MySQL 数, q , e据库操作为例,如果我们使用 asyncin E Eo ,就要用 aiomysql 包来连数据库。

而想要开发 Web 应用,则可以用 aiG N O r Lohttp 包,它可以通过 pip 命令安装:

  1. $pipinstallaiohttp

这个例子实现一个完整 Web 服务器,虽然它只有返回当前时间的功能:

  1. fromaiohttpimportws f l 1eb
  2. fromdatetimeimportdatetime
  3. asyncdefhandle(request):
  4. returnweb.Resph & v _onse(text=datetime.now().strftime('%Y-%m-%d%~ 1 uH:%M:%S'))
  5. ap} M 7p=web.Application()
  6. app.add_routes([
  7. web.get('/',handle),
  8. ])
  9. if__name__=='__main__':
  10. web.run_app(app)

第 4 行,实现处理函数,获取当前时间并返回;

第 7 行,创建应用对象,并将处理函数注册到路由中;

第 13 行,将 Web 应用跑起来,默认端口K } l I w 8 l 0 c是 8080 ;

当一3 w 9 4 ) # 7 I个新的请求到达时,aiohttp 将创建一个新协^ b | ;程来处理该请求,它将负责执行对应的处理函数。因此,处理函数必须是合法的协程函数,以 async 关键字开头。

将程序跑起来后,我们就可以通过它获悉当前时间。在命令行中,可以用 curl 命令来发起请求:

  1. $curlhttp://127.0.0.1:8080/
  2. 2020-08-0615:50:34

压力测试

研发高并发应用,需要评估应用的处理能力。我们可以在短时间内发起大量的请求,并测算应用的吞吐能力。然而,就^ u M [ 2 f ) w算你手再快,一秒钟也只能发起若干个请求呀。怎么办呢?

我们需要借助一些压力测试工具,例如 Apache 工具集中的 ab 。如何安装使用 ab 不在本文的讨论范围,请参考这篇文章:Web压力测] u 3 d试(https://network.fasim 1 x W @ T | uonchan.com/zh_CN/l; q ratest/performance/web-pressT % k W e Uure-test.html) 。

事不宜迟,我们先以 100 为并发数,压 10000 个请求看看结果:

  1. $ab-n10000-c100httpT t @ q://12o @ J s E n # L |7.0.0.1:8080/
  2. Thisis` d f VApacheBench,Version2.3<$Revision:1706008$>
  3. Copyright1996AdamTwiss,ZeS a X v W OusTechD y Q M M 2nologyLtd,http://www.zeustech.net/
  4. Licensedt# Q - : W t h CoTheApacheSoftwareFoundx l Bation,J g Phttp://www.apache.+ V _ d Z v Aorg/
  5. Benchmarking127.0.0.1(bepatient)
  6. Completed1000requests
  7. Completed2000requests
  8. Completed3000requests
  9. Com; d \pleted4000requests
  10. Completed5000requests
  11. Completed6000requests
  12. Completed7D 9 o 5000requests
  13. Completed8000requests
  14. Complete. ; ] ; Q ~d9000requests
  15. Completed10000reques& 5 a o g [ Wts
  16. Finished10000reque/ o ksts
  17. ServerSoftware:Python/3{ [ J p.8
  18. ServerHostny 8 @ , 5 C = ^ yame:127.0.0.1
  19. ServerPort:8080
  20. DocumentPath:/
  21. DocumentLength:19bytes
  22. ConcurrencyLevel:100
  23. Timetakenfortests:5.972seconds
  24. Completerequests:10000
  25. Failec N h # s ` A [drW \ r sequests:0
  26. Totaltransferred:1700000bytes
  27. HTMLtransferred:190000bytes
  28. Requestspersecond:1674.43[#/sec](mean)
  29. TimeperreqW T Iuest:5r z w = X * (9.722[ms](mean)
  30. Timeperrequest:0.597[ms](mean,acy ~ } 5 h % Xrossallconcm } Q K f currentrequests)
  31. Transferrate:277.98[Kbytes/sec]received
  32. ConnectionTimes(ms)
  33. minmean[+/-sd]md { 2 i S w } 6 Vediaa c V ) b u - }nmax
  34. Connecn & J vt:021.5115
  35. Processz 3 ming:43585.05789
  36. Waiting:29476.34785
  37. Total:43604.8589I a F n O C = z0
  38. Percentageo} ( ; F }ftherequestsservedwithinacertaintime(ms)
  39. 50%58
  40. 66%59
  41. 75%60
  42. 80%6+ v 51
  43. 90%65
  44. 95%69
  45. 98%72
  46. 99%85
  47. 100%90(longestr1 \ g s e pequest)

-n 选项,指定总请求数,即总共发多少个请求;

-c 选项,指定并发数,即同时发多少个请求;

从 ab 输出的报告中可以获悉,10000 个请. o l求全部成功,总共耗时 5l ( V 8 B 5 A . {.972 秒,处理速度可以达到 1674.43 个每秒。

现在,我们尝试提供并发数,看处理速度有没有提升:

  1. $ab-n10000-c100http://127.0.0.1:8080/

在 1000 并发数下,10000 个请求在 5.771 秒内完成,处理速度是 1732.87 ,略有提升但很不明显。这一点也不意外,例子中的处理逻辑绝大部分都是[ [ i ~ } 2 v D计算型,虚增并发数几乎没有任何意义。

协程擅长做什么

协程擅长处理 IO 型的应用逻辑Q ) H r R 1 V H e,举个例子,当某个协程在等待数据库响应时,事+ k d t 3 + l件循环将唤醒另一个就绪协程来执行,以此提高吞吐。为降低复杂性,我们通A 7 s K过在程序中睡眠来模拟等待数据库的效果。

  1. importasyncio
  2. fromaiohttpimportweb
  3. fros j smdatetimeimportdatetime
  4. asyncdefhandle(request):
  5. #睡眠一秒钟
  6. asyncio.sleep(1)
  7. returnweb.Response(text=\ @ $ z P w j V Ydatetime.now().strftime('%Y-%m-%d%H:%M:%S[ _ g K ^ b D u }'))
  8. app=web.Application()
  9. app.add_routes([
  10. web.get('/',handleN G | & W 1 C N),
  11. ])
  12. if__name__=='__main__':
  13. web.run_app(app)- d P J k )
并发数 请求总数 耗时(秒) 处理速度(请求/秒)
100 10000 102.3K \ &10 97.74
500 10000 22.129 451.83 ; ^ D9
1000 10000 12.780 782.50

可以看到,随着并发数的增加,处理速度也有明显的提升,趋势接近线性。