Back to home

选择的故事——线程,还是进程

作为一个码农,在开发过程中,并发地执行或者完成某项任务是常有的事情。并发模型用线程还是进程,似乎也不是什么问题。道理很简单:线程就是轻量级的进程,简简单单地就可以实现并发处理,变量也可以跨线程进行共享,仅仅需要注意的就是做好线程间的同步;而进程有独立的内存空间,只能通过信号量,文件系统,共享内存,socket等实现进程间通信。在一般的并发模型中,都会选择轻量级的线程。
这些耳熟能详的理论应付比试、面试绰绰有余,而且在大部分情况下都成立。然而,软件工程界有句至理名言:没有银弹。当“码农”遇到了python,再次证明了这句话的正确性。
Python作为一种脚本语言,由于其灵活,容易上手等特点有了大批的用户。其天然的支持多种语言混搭的方式,带来灵活方便的同时,也有其它的副作用。下面就以笔者的亲身体验为例,向您一一道来。
按道理来讲,python作为一门高级语言,只要正确处理了各种异常,就不会出现类似于段错误、空指针等等这种致命性的导致进程崩溃的错误。然而,python引以为傲的可以方便调用C语言代码的特性却为它的健壮性埋下了颗不定时的炸弹。一个备受煎熬的例子就是python调用的C语言中的外部库函数中出现段错误,而导致整个python进程的崩溃。你应该能想象出来,眼睁睁地看着程序在眼前崩溃而根源却是别人家的代码,是多么无力的一件事情啊。
加之,在实际应用中,并发地执行或者完成某项任务时,我们需要控制各并发的处理流程的执行时间。即如果在期望的时间内完成,则给出结果;如果超过设定的超时时间,该处理流程能终止运行并退出,不再浪费系统资源而做一些已经没有意义的事情。然而,由于从外部终止正在执行的线程是一种不优雅的行为,可能造成不可预料的结果。因此,python语言的线程干脆就没有类似于stop之类的方法,即python的线程在执行的过程中,从外部是无法强制终止的。
python语言下,影响并发模型的选择的第三个理由就是传说中的GIL。官方有云:每一个interpreter进程,只能同时仅有一个线程来执行, 获得相关的锁, 存取相关的资源。GIL优劣之争从python诞生的那天起就没消停过,依照Python自身的哲学, 简单是一个很重要的原则,GIL存在的很重要的一个原因就是它将麻烦且极易出错的加解锁为你屏蔽起来(针对写python扩展而言)。如果你真的需要充分利用多核的速度优势,多线程,甚至可以说python语言本身就不是一个最佳选择。
至此,python语言下,想利用多核的速度优势,同时依赖于外部的C语言库,加之有控制并发处理流程时间的需求等的前提下,并发模型选择多线程似乎是此路不通了。然而,当上帝为你关上了一扇门,一定会为你打开另一扇窗么?且看下回分解。
python语言下,并发模型选择多线程似乎是此路不通了。多进程就一定能解决问题么?诚然,进程可以将段错误限定在某个进程范围内;进程也可以在进程之外被准确地杀掉……但是,我们只看到了多进程有利的方面,却对多进程并发模型带来的高昂开销,进程间共享变量等带来的麻烦之类的问题故意视而不见。显然,有些细节需要特别考虑。
在做出决定之前,我们不妨看下Apache的并发模型。Apache作为一个服务进程,既要快速响应客户端的请求,又要支持大量的并发连接, Apache依靠多路处理模块(MPM)来解决这些问题。Apache MPM主要支持workerprefork两种工作模式。prefork模式使用多个子进程,每个子进程只有一个线程。每个进程在某个确定的时间只能维持一个连接。在大多数平台上,Prefork MPM在效率上要比Worker MPM要高,但是内存使用大得多。worker模式使用多个子进程,每个子进程有多个线程。每个线程在某个确定的时间只能维持一个连接。通常来说,在一个高流量的HTTP服务器上,Worker MPM是个比较好的选择,因为Worker MPM的内存使用比Prefork MPM要低得多。但worker MPM也有不完善的地方,如果一个线程崩溃,整个进程就会连同其所有线程一起"死掉".
站在前人的肩膀上,我们不妨做如下思考:为了避免多进程并发模型中频繁地创建和销毁进程带来的高昂开销,可以引入进程池。进程池的根本目的是实现进程的复用,即进程池本身维护了一定数量的进程,需要执行任务的时候,从进程池申请一个进程并分配任务给它执行,若在预定的时间内执行结束,则返回结果,该进程被进程池回收;若在预定的时间内不能执行结束,则可以从外部结束该进程,并从进程池中丢弃它。更进一步,为了避免内存泄露等长期运行的进程可能存在的一些问题,可以模拟Apache的处理方式,当某个进程完成一定数量的工作之后(对Apache而言,是处理了一定数量的请求之后),可以主动地将其杀死并丢弃。
而进程间共享变量确实比线程间麻烦好多,这是一个无法回避的问题。既然多进程的并发模型帮我们解决了致命性的问题,这点麻烦又算得了什么呢。进程间通信有多种选择,我们尝试让进城通过唯一的双向管道(Pipe)与外部通信,也就是说进程的使用者通过该管道向该进程分派任务,进程通过该管道返回执行结果。
除此之外,进程池还要完成其它的常规性逻辑处理:对外提供获取进程资源的接口,同时检查进程是否已经完成了足够多的任务,是否可以“光荣退休”;定期检查任务的执行时间,查看其是否超时,若超时,则强行终止进程并将其从进程池中移除;定期收取进程的执行结果,等待外部的调用者来获取结果;定期回收已经被获取结果的进程,将其放回进程池的空闲队列;定期检查进程池的使用情况,当没有太多的任务可做时,主动释放进程资源,将进程池的大小调整到一个更合理的区间。
至此,进程池的故事告一段落。