David Beazley - Python Concurrency From the Ground Up笔记
Python Concurrency From the Ground Up,来自捕蛇者说的推荐,是David Beazley在PyCon 2015上的talk。在这个talk中,他边讲边写、外加开点玩笑,可以说David在各种意义上,都是并发的专家,很值得一看。视频和代码如下:
本文记录了这个talk的主要内容,并加上了我自己的理解。
这个talk从零实现了一个能支持多客户端并发访问的server,server计算了菲波那切数列第n项的值,为了展示blocking调用,用的是普通的递归实现fib(n) = fib(n-1) + fib(n-2)
。同时还写了两个简单的client来测试server性能:perf1.py
无限循环fib(30)
,并输出每次调用的时间;perf2.py
无限循环fib(1)
,测试ops,这个调用是立即返回的。
版本1:简单单线程
1 | def fib_server(address): |
这个实现的问题是,server无法同时响应多个client。当某个client连接上后,fib_handler(client)
会执行到这个client断开为止。
版本2:多线程
在版本1的基础上,
1 | def fib_server(address): |
此时server可以响应多个client。但是由于GIL的存在,python是无法利用多个cpu核心的,因此,
perf1.py
的结果(每次调用的时间)会随着perf1.py
实例的增加而增加。同一时刻只能响应一个client,其他的等待,因此每次调用的时间大概是单个client调用时间的均值 * perf1.py实例个数
。perf2.py
的结果(ops)会受其他调用的影响,ops会下降,n
越大,下降越多。David这里提到了,GIL的一个特性是会把优先级给到计算更加密集的任务上,而os的调度却不会受这个影响,运行时间短的任务优先级更高。
python的每个线程实际上都有os实际的线程与其对应,用ps -o cmd,nlwp <pid>
可看,但为何如此调度与其实现有关。对于os,Linux
2.6.23开始采用的是Completely
Fair Scheduler;FreeBSD和macOS采用的是Multilevel
feedback
queue的调度算法,这也就解释了上述为什么运行时间较短的任务优先级更高。因为总能在规定的时间片内运行完成,不会被调度到后面的队列。
版本3:多线程+进程池
在版本2的基础上,
1 | from concurrent.futures import ProcessPoolExecutor as Pool |
由于需要与子进程通信,需要序列化和反序列化数据,引入了额外的开销,因此perf2.py
的ops会下降;但与此同时,server端处理计算任务是在单独的进程中,相当于计算任务的调度是由os来完成了,结合版本2:多线程中对os调度的解释,基本不受其他计算更加密集的任务影响。
版本4:事件循环和协程
回看前三个版本的server,使用线程,本质上是为了解决blocking。而blocking主要发生在等待io的时候,可以考虑只有当io ready的时候才去处理socket。
1 | tasks = deque() |
这个版本的核心在def run()
,利用yield
实现了协程。当遇到io时,yield
跳出当前执行,select
判断io
ready后,才去读写socket。要注意的是,这个版本虽然没有使用多线程,但server是可以服务多个client的,因为在某个client的socket没有ready的时候,server可以做其他的事情。不过由于是单线程,对于所有client提交的计算任务,server只能逐一执行。协程并不能帮助解决多线程中GIL的问题,因为并没有利用到多个cpu核心,版本2:多线程的两个性能问题,这里都存在。
我觉得这里的yield
和事件循环是用的很出彩,换做是我,我首先考虑到的是select
出ready的socket,然后进行读写。弊端在于需要把业务逻辑套在一个大循环里面,每次都先调用select
,在不同的socket
ready的时候,使用相应的业务逻辑进行处理。
版本5:事件循环和协程+多进程
在版本4的基础上,
1 | def fib_handler(client): |
考虑到上一个版本无法利用多个cpu核心进行计算,那么如果像版本3:多线程+进程池一样把fib
放入pool
中,是否能解决问题呢?放入以后,会发现当某个协程执行到'future.result()'的时候就会阻塞,直到pool
中的任务计算完毕,相当于server主线程会逐一等待每个计算任务。版本2:多线程的两个问题,仍然存在。
版本6:事件循环和协程+多进程
在版本5的基础上,
1 | future_wait = { } |
最后这个实现首先将result = fib(n)
,放入了pool
并得到一个future
,yeild
之后为这个future添加计算完成后的回调future_done
。这个pool
可以是线程池(无法利用多个cpu),也可以是进程池。比较hacking的地方是用socketpair
把计算ready转变了socket
ready。
总结
除去实现中用到的一些技巧,这个talk把GIL和blocking的影响、要用什么样的方式来绕开这些问题,以及事件循环和协程讲的很明白。
P.S. 小插曲,现场写的时候,David把
1 | can_recv, can_send, _ = select(recv_wait, send_wait, []) |
写成了
1 | can_recv, can_send, [] = select(recv_wait, send_wait, []) |
最后有个提问者表示,the empty listing was just some incredible next-level thing that I was just not capable of。
至于为什么unpack到空list,原因如下,
1 | # 首先unpack到一个变量的list是可以的 |