最近重读《深入理解计算机系统》,在书本第十二章-并发编程中,作者简明地讲解了并发程序的三种基本构造方法并进行了优缺点分析(具体内容下文会略微提到,有兴趣的可以去阅读原书),再加上前阵子看的tornado异步框架源码,向趁此来聊聊并发编程,如有纰漏,欢迎指正。
1 先谈谈几个令人混乱的名词
1.1 阻塞&非阻塞 。
《操作系统设计与实现》一书中提到进程有三种基本状态:就绪,阻塞和运行,因此阻塞和非阻塞就指的是进程或线程(后文会用进程统称)的状态。现代操作系统的内核调度器(调度器本身也是进程,其属于系统进程)统一管理着用户进程;
- 运行是指进程正在CPU上执行;
- 就绪是指其在等待被执行且最终会被内核调度;
- 阻塞是指进程被挂起,暂时不会被调度,阻塞的原因可能是进程执行了IO操作,如向磁盘读写数据,从网络读写数据等,当IO设备执行完指令后,会发出中断信号,调度器会通过该信号重新激活相关进程,使进程进入就绪或运行态。
1.2 同步&异步。
先说结论:异步与同步,是指任务调度时,两个任务之间的协同机制(任务可以是进程,线程或简单的函数调用);若任务间无关系,两者不依赖彼此,就为异步;一个依赖于另一个的执行结果,就为同步;
举个具体的例子来解释下。上文提到了系统的调度,调度器管理着众多进程,CPU轮流地执行着进程。为了更好地理解,以单核CPU为例(多核原理相同):假设调度器管理着进程A和进程B;若A,B之间是同步的,意味着其中一个需要等待另一个的执行结果才能执行,例如CPU会执行进程A直至它完成并返回结果,之后才会进入内核模式,由调度器去执行进程B;若A,B是异步的,意味着A,B相互独立,毫无关系,CPU不用立刻执行完进程A,中途也可以转而去执行进程B。从定义来看,现代操作系统的调度器,与各个进程间都是异步的,这也是操作系统并发的前提。同时,不相关的任务间可以是异步的。因为异步的无序(无法预测的逻辑流的走向),才导致了竞争(多个进程同时访问公共资源)的发生,进程间才会在部分时间需要同步机制。
1.3 并发&并行。
并发和并行的概念比较容易理解,在此不再赘述。上文提到的并发,是系统内核运行多个程序的机制,但如果你是个程序员,就应该知道其在应用程序中的普遍,也或多或少会了解三种基本并发技术:进程(process),线程(thread),IO多路复用(multiplexing)。实际上,正确编写并发程序是非常困难的,因为一个并发程序往往具有多个逻辑流,并且还具有不可预测性。
基于进程的并发编程。相对于IO多路复用而言,基于进程和线程的并发程序更容易编写(也仅是相对而言,考虑进程间通信和同步问题常会让人头大 @v@),并且能充分发挥现代处理器的多核优势,对于CPU密集型程序来说,性能优势会很明显。但进程间独立的地址空间使得进程间需要显式的通信(IPC),而且上下文切换的开销也很大,这也可能是个优点,这样一来可以避免进程间相互覆盖彼此的地址空间。
基于线程的并发编程 对比进程优势明显,属于同个进程的线程运行在一个进程的上下文中,它们共享着部分上下文数据,这使得线程间通信更加容易,当然坑也不少,由此引入的同步问题也值得程序员们小心对待。接下来到了本文的重点——基于IO多路复用的并发编程,希望读者耐心看下去。
2 基于IO多路复用的并发编程(也叫事件驱动编程)
标题中已经提到,web方向的并发编程是本文讨论重点,限于笔者知识的局限性,接下来会以python语言举例,讨论web开发中的并发编程。
2.1 技术原理及优劣
Ryan Dahl在介绍Node.js时主张不能像对待传统函数那样对待阻塞型函数,因为IO操作会拖累整个程序,下面这张表了更易于我们直观理解。为解决IO事件带来的阻塞困境,IO多路复用出现了。
IO多路复用的核心是调用select(在不同os中,可能是epoll, poll 或kqueue)函数,内核会挂起进程,在发生一个或多个IO事件后,再去进行相关调用。原理似乎很简单,但实际编程时,代码量会是基于进程的三倍(引用自csapp书中的数据),编程难度还会随着并发粒度的减少而上升,而且不能充分利用多核处理器。但该方法带来的优势却值得我们费更多精力去编写它。
基于IO多路复用的程序运行在单独的进程上下文中,因此逻辑流之间不存在上下文切换,也没有多线程带来的同步问题,系统资源占用极低,且在IO密集型程序中运行效率非常高,可以对比传统web框架(如Django)和异步框架(如Twisted,tornado)之间的并发量差距。而且web服务器需要处理大量的连接,若采用一个线程去处理一个连接,需消耗大量系统资源来维护线程池,线程之间的上下文切换也会产生大量开销。
2.2 编程实例
讲了一大堆理论,接下来讲讲具体的IO多路复用编程(下文简称为事件驱动编程)实现。
主要有两种主流的实现方式:回调和协程
基于回调的异步编程,最典型的如javascript,有个显著的缺点“回调地狱”:各层调用之间的相互依赖,就必须嵌套回调;回调地狱会使得代码难以阅读,更难以编写。
更现代的方式是采用协程。协程又称为用户级线程,即任务间的切换完全由编程人员控制,这极大提升了编程难度,因此常见的异步框架都会将整个异步模型简化为单线程,并合理地将任务拆分,同时还要避免阻塞调用。
python3.3中正式引入了yield from句法,并实现了底层的事件调度器,这使得协程的使用更加简单。在python的标准库asyncio库中引入了事件循环,可以使用async关键字或@asyncio.coroutine来将函数标记为协程,并在该协程的IO阻塞操作前加上await或yield from关键字来实现异步IO调用,最后将协程加入事件循环即可,但前提是该调用是异步的。下面是简单的asyncio库使用示例:
import asyncio
import aiohttp
import cProfile
async def get_page(index):
url = 'http://www.baidu.com'
async with aiohttp.ClientSession() as session:
print('get:',index)
response = await session.get(url)
if response.status == 200:
html = await response.text()
print('{} recv: {}'.format(index, html))
def test():
tasks = [asyncio.ensure_future(get_page(i)) for i in range(100)]
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))
loop.close()
def run_client():
cProfile.run('test()', 'profile_server.txt', sort='time')
p = pstats.Stats('profile.txt')
p.sort_stats('time').print_stats()
if __name__ == "__main__":
run_client()
如果你觉得只是会调用api还不够,你需要更进一步地了解其底层事件调度器如何实现,你可以继续读下去。
我写了一个基于IO多路复用的简单HttpServer和HttpClient项目,采用了协程的实现方法。源码文件较多,文中展示不便,附上github repo地址。代码经过测试,可以直接运行,基于python3.4+,windows平台,参考了python3-cookbook一书(强烈推荐python程序员阅读此书)中的部分代码和tornado源码。如果大家觉得需要讲下源码的话,可以考虑另写。如果觉得本文或项目对你有所帮助,请给个❤或。
参考文献
[1] David Beazley, Brian K. Jones. Python Cookbook 3rd Edition.
[2] Luciano Ramalho. 流畅的Python. 人民邮电出版社,2017.
[3] Bryant, R.E, O'Hallaron, D.R. 深入理解计算机系统. 北京: 机械工业出版社, 2010.11