多线程编程技术可以实现代码并行,优化处理能力,同时可以将代码划分为功能更小的模块,使代码的可重用性更好。
这里将介绍Python中的多线程编程。多线程一直是Python学习中的重点和难点,需要反复练习和研究。
线程和进程
在学习多线程的使用之前,需要先了解线程、进程、多线程的概念。
1 进程
进程(Process,有时被称为重量级进程)是程序的一次执行。每个进程都有自己的地址空间、内存、数据栈以及记录运行轨迹的辅助数据,操作系统管理运行的所有进程,并为这些进程公平分配时间。进程可以通过fork和spawn操作完成其他任务。因为各个进程有自己的内存
空间、数据栈等,所以只能使用进程间通信(IPC),而不能直接共享信息。
2 线程
线程(Thread,有时被称为轻量级进程)跟进程有些相似,不同的是所有线程运行在同一个进程中,共享运行环境。
线程有开始、顺序执行和结束3部分,有一个自己的指令指针,记录运行到什么地方。线程的运行可能被抢占(中断)或暂时被挂起(睡眠),从而让其他线程运行,这叫作让步。一个进程中的各个线程之间共享同一片数据空间,所以线程之间可以比进程之间更方便地共享数据和相互通信。
线程一般是并发执行的。正是由于这种并行和数据共享的机制,使得多个任务的合作变得可能。实际上,在单CPU系统中,真正的并发并不可能,每个线程会被安排成每次只运行一小会儿,然后就把CPU让出来,让其他线程运行。
在进程的整个运行过程中,每个线程都只做自己的事,需要时再跟其他线程共享运行结果。多个线程共同访问同一片数据不是完全没有危险的,由于数据访问的顺序不一样,因此有可能导致数据结果不一致的问题,这叫作竞态条件。大多数线程库都带有一系列同步原语,用于控制线程的执行和数据的访问。
3 多线程与多进程
对于“多任务”这个词,相信读者不会第一次看见,现在的操作系统(如Mac OS X、UNIX、Linux、Windows等)都支持“多任务”操作系统。
什么叫“多任务”呢?简单地说,就是系统可以同时运行多个任务。比如,一边用浏览器上网,一边听云音乐,一边聊天,这就是多任务。此时手头已经有3个任务在运行了。如果查看任务管理器,可以看到还有很多任务悄悄在后台运行着,只是桌面上没有显示而已。
对于操作系统来说,一个任务就是一个进程,开启多个任务就是多进程。
有些进程不止可以同时做一件事,比如Word可以同时打字、拼写检查、打印等。在一个进程内部,要同时做多件事,就需要同时运行多个线程。
多线程类似于同时执行多个不同的程序,多线程运行有以下3个优点:
(1)使用线程可以把占据长时间的程序中的任务放到后台去处理。
(2)用户界面可以更加吸引人,比如用户单击一个按钮,用于触发某些事件的处理,可以弹出一个进度条显示处理的进度。
(3)程序的运行速度可能加快。
在实现一些等待任务(如用户输入、文件读写和网络收发数据等)时,使用多线程更加有用。在这种情况下,我们可以释放一些珍贵资源(如内存占用等)。
线程在执行过程中与进程还是有区别的。每个独立线程有一个程序运行的入口、顺序执行序列和程序的出口。但是线程不能独立执行,必须依存在进程中,由进程提供多个线程执行控制。
由于每个进程至少要干一件事,因此一个进程至少有一个线程。当然,如Word这种复杂的进程可以有多个线程,多个线程可以同时执行。多线程的执行方式和多进程是一样的,也是由操作系统在多个线程之间快速切换,让每个线程都短暂交替运行,看起来就像同时执行一样。当然,真正同时执行多线程需要多核CPU才能实现。
我们前面编写的所有Python程序都是执行单任务的进程,也就是只有一个线程。如果我们要同时执行多个任务,怎么办呢?
有两种解决方法:一种方法是启动多个进程,每个进程虽然只有一个线程,但多个进程可以一起执行多个任务。另一种方法是启动一个进程,在一个进程内启动多个线程,这样多个线程也可以一起执行多个任务。
当然,还有第3种方法,就是启动多个进程,每个进程再启动多个线程,这样同时执行的任务就更多了,不过这种模型过于复杂,实际很少采用。
同时执行多个任务时,各个任务之间并不是没有关联的,而是需要相互通信和协调,有时任务1必须暂停等待任务2完成后才能继续执行,有时任务3和任务4不能同时执行。多进程和多线程程序的复杂度远远高于我们前面写的单进程、单线程的程序。
不过很多时候,没有多任务还真不行。想想在电脑上看电影,必须由一个线程播放视频,另一个线程播放音频,否则使用单线程实现只能先把视频播放完再播放音频,或者先把音频播放完再播放视频,这样显然不行。
总而言之,多线程是多个相互关联的线程的组合,多进程是多个互相独立的进程的组合。线程是最小的执行单元,进程至少由一个线程组成。
使用线程
如何使用线程,线程中有哪些比较值得学习的模块呢?本节将对线程的使用做概念性的讲解,下一节再给出一些具体示例以供参考。
1 全局解释器锁
Python代码的执行由Python虚拟机(解释器主循环)控制。Python在设计之初就考虑到在主循环中只能有一个线程执行,虽然Python解释
器中可以“运行”多个线程,但是在任意时刻只有一个线程在解释器中运行。
Python虚拟机的访问由全局解释器锁(GIL)控制,这个锁能保证同一时刻只有一个线程运行。
在多线程环境中,Python虚拟机按以下方式执行:
(1)设置GIL。
(2)切换到一个线程运行。
(3)运行指定数量的字节码指令或线程主动让出控制(可以调用time.sleep(0))。
(4)把线程设置为睡眠状态。
(5)解锁GIL。
(6)再次重复以上所有步骤。
在调用外部代码(如C/C++扩展函数)时,GIL将被锁定。直到这个函数结束为止(由于在此期间没有运行Python的字节码,因此不会做线程切换),编写扩展的程序员可以主动解锁GIL。
2 退出线程
当一个线程结束计算,它就退出了。线程可以调用_thread.exit()等退出函数,也可以使用Python退出进程的标准方法(如sys.exit()或抛出一个SystemExit异常),不过不可以直接“杀掉”(kill)一个线程。
不建议使用_thread模块。很明显的一个原因是,当主线程退出时,其他线程如果没有被清除就会退出。另一个模块threading能确保所有“重要的”子线程都退出后,进程才会结束。
3 Python的线程模块
Python提供了几个用于多线程编程的模块,包括_thread、threading和Queue等。_thread和threading模块允许程序员创建和管理线程。_thread模块提供了基本线程和锁的支持,threading提供了更高级别、功能更强的线程管理功能。Queue模块允许用户创建一个可以用于多个线程之间共享数据的队列数据结构。
避免使用_thread模块,原因有3点。首先,更高级别的threading模块更为先进,对线程的支持更为完善,而且使用_thread模块里的属性有可能与threading冲突;其次,低级别的_thread模块的同步原语很少(实际上只有一个),而threading模块有很多;再者,_thread模块中在主线程结束时,所有线程都会被强制结束,没有警告也不会有正常清除工作,至少threading模块能确保重要子线程退出后进程才退出。
_thread
模块
Python中调用_thread
模块中的start_new_thread()
函数产生新线程。_thread
的语法如下:
_thread.start_new_thread (function, args[, kwargs])
其中,function为线程函数;args为传递给线程函数的参数,必须是tuple类型;kwargs为可选参数。
_thread
模块除了产生线程外,还提供基本同步数据结构锁对象(lock object,也叫原语锁、简单锁、互斥锁、互斥量、二值信号量)。同步原语与线程管理是密不可分的。
我们看如下示例。
#! /usr/bin/python
# -*-coding:UTF-8-*-
import _thread
from time import sleep
from datetime import datetime
date_time_format = '%y-%M-%d %H:%M:%S'
def date_time_str(date_time):
return datetime.strftime(date_time, date_time_format)
def loop_one():
print('+++线程一开始于:', date_time_str(datetime.now()))
print('+++线程一休眠4 秒')
sleep(4)
print('+++线程一休眠结束,结束于:', date_time_str(datetime.now()))
def loop_two():
print('***线程二开始时间:', date_time_str(datetime.now()))
print('***线程二休眠2 秒')
sleep(2)
print('***线程二休眠结束,结束时间:', date_time_str(datetime.now()))
def main():
print('------所有线程开始时间:', date_time_str(datetime.now()))
_thread.start_new_thread(loop_one, ())
_thread.start_new_thread(loop_two, ())
sleep(6)
print('------所有线程结束时间:', date_time_str(datetime.now()))
if __name__ == '__main__':
main()
执行结果如下:
------所有线程开始时间: 16-44-06 21:44:05
+++线程一开始于: 16-44-06 21:44:05
+++线程一休眠4 秒
***线程二开始时间: 16-44-06 21:44:05
***线程二休眠2 秒
***线程二休眠结束,结束时间: 16-44-06 21:44:07
+++线程一休眠结束,结束于: 16-44-06 21:44:09
------所有线程结束时间: 16-44-06 21:44:11
_thread
模块提供了简单的多线程机制,两个循环并发执行,总的运行时间为最慢的线程的运行时间(主线程6s),而不是所有线程的运行时间之和。start_new_thread()
要求至少传两个参数,即使想要运行的函数不要参数,也要传一个空元组。
sleep(6)是让主线程停下来。主线程一旦运行结束,就关闭运行着的其他两个线程。这可能造成主线程过早或过晚退出,这时就要使用线程锁,主线程可认在两个子线程都退出后立即退出。
示例代码如下:
#! /usr/bin/python
# -*-coding:UTF-8-*-
import _thread
from time import sleep
from datetime import datetime
loops = [4, 2]
date_time_format = '%y-%M-%d %H:%M:%S'
def date_time_str(date_time):
return datetime.strftime(date_time, date_time_format)
def loop(n_loop, n_sec, lock):
print('线程(', n_loop, ')开始执行:',
date_time_str(datetime.now()), ',先休眠(', n_sec, ')秒')
sleep(n_sec)
print('线程(', n_loop, ')休眠结束,结束于:', date_time_str(datetime.now()))
lock.release()
def main():
print('---所有线程开始执行...')
locks = []
n_loops = range(len(loops))
for i in n_loops:
lock = _thread.allocate_lock()
lock.acquire()
locks.append(lock)
for i in n_loops:
_thread.start_new_thread(loop, (i, loops[i], locks[i]))
for i in n_loops:
while locks[i].locked(): pass
print('---所有线程执行结束:', date_time_str(datetime.now()))
if __name__ == '__main__':
main()
执行结果如下:
---所有线程开始执行...
线程( 1 )开始执行: 16-44-06 21:44:11 ,先休眠( 2 )秒
线程( 0 )开始执行: 16-44-06 21:44:11 ,先休眠( 4 )秒
线程( 1 )休眠结束,结束于: 16-44-06 21:44:13
线程( 0 )休眠结束,结束于: 16-44-06 21:44:15
---所有线程执行结束: 16-44-06 21:44:15
可以看到,以上代码使用了线程锁。
threading
模块
更高级别的threading
模块不仅提供了Thread
类,还提供了各种非常好用的同步机制。
_thread
模块不支持守护线程,当主线程退出时,所有子线程无论是否在工作,都会被强行退出。threading模块支持守护线程,守护线程一般是一个等待客户请求的服务器,如果没有客户提出请求,就一直等着。如果设定一个线程为守护线程,就表示这个线程不重要,在进程退出时,不用等待这个线程退出。如果主线程退出时不用等待子线程完成,就要设定这些线程的daemon
属性,即在线程Thread.start()
开始前,调用setDaemon()
函数设定线程的daemon标志(Thread.setDaemon(True)),表示这个线程“不重要”。如果一定要等待子线程执行完成再退出主线程,就什么都不用做或显式调用Thread.setDaemon(False)
以保证daemon标志为False,可以调用Thread.isDaemon()
函数判断daemon标志的值。新的子线程会继承父线程的daemon标志,整个Python在所有非守护线程退出后才会结束,即进程中没有非守护线程存在时才结束。
threading
的Thread
类
Thread
有很多_thread
模块里没有的函数,Thread
对象的函数很丰富。下面创建一个Thread
的实例,传给它一个函数。示例如下:
#! /usr/bin/python
# -*-coding:UTF-8-*-
import threading
from time import sleep
from datetime import datetime
loops = [4, 2]
date_time_format = '%y-%M-%d %H:%M:%S'
def date_time_str(date_time):
return datetime.strftime(date_time, date_time_format)
def loop(n_loop, n_sec):
print('线程(', n_loop, ')开始执行:',
date_time_str(datetime.now()), ',先休眠(', n_sec, ')秒')
sleep(n_sec)
print('线程(', n_loop, ')休眠结束,结束于:', date_time_str(datetime.now()))
def main():
print('---所有线程开始执行:', date_time_str(datetime.now()))
threads = []
n_loops = range(len(loops))
for i in n_loops:
t = threading.Thread(target=loop, args=(i, loops[i]))
threads.append(t)
for i in n_loops: # start threads
threads[i].start()
for i in n_loops: # wait for all
threads[i].join() # threads to finish
print('---所有线程执行结束于:', date_time_str(datetime.now()))
if __name__ == '__main__':
main()
执行结果如下:
---所有线程开始执行: 16-44-06 21:44:15
线程( 0 )开始执行: 16-44-06 21:44:15 ,先休眠( 4 )秒
线程( 1 )开始执行: 16-44-06 21:44:15 ,先休眠( 2 )秒
线程( 1 )休眠结束,结束于: 16-44-06 21:44:17
线程( 0 )休眠结束,结束于: 16-44-06 21:44:19
---所有线程执行结束于: 16-44-06 21:44:19
由执行结果我们看到,实例化一个Thread(调用Thread())与调用_thread.start_new_thread()最大的区别是新的线程不会立即开始。创建线程对象却不想马上开始运行线程时,Thread
是一个很有用的同步特性。所有线程都创建之后,再一起调用start()
函数启动,而不是每创建一个线程就启动。而且不用管理一堆锁的状态(分配锁、获得锁、释放锁、检查锁的等状态),只要简单对每个线程调用join()
主线程,等待子线程结束即可。join()
还可以设置timeout
参数,即主线程的超时时间。
join()
的另一个比较重要的方面是可以完全不用调用。一旦线程启动,就会一直运行,直到线程的函数结束并退出为止。如果主线程除了等线程结束外,还有其他事情要做,就不用调用join()
,只有在等待线程结束时才调用。
我们再看示例,创建一个Thread
的实例,并传给它一个可调用的类对象。代码如下:
#! /usr/bin/python
# -*-coding:UTF-8-*-
import threading
from time import sleep
from datetime import datetime
loops = [4, 2]
date_time_format = '%y-%M-%d %H:%M:%S'
class ThreadFunc(object):
def __init__(self, func, args, name=''):
self.name = name
self.func = func
self.args = args
def __call__(self):
self.func(*self.args)
def date_time_str(date_time):
return datetime.strftime(date_time, date_time_format)
def loop(n_loop, n_sec):
print('线程(', n_loop, ')开始执行:',
date_time_str(datetime.now()), ',先休眠(', n_sec, ')秒')
sleep(n_sec)
print('线程(', n_loop, ')休眠结束,结束于:', date_time_str(datetime.now()))
def main():
print('---所有线程开始执行:', date_time_str(datetime.now()))
threads = []
nloops = range(len(loops))
for i in nloops: # create all threads
t = threading.Thread(
target=ThreadFunc(loop, (i, loops[i]), loop.__name__))
threads.append(t)
for i in nloops: # start all threads
threads[i].start()
for i in nloops: # wait for completion
threads[i].join()
print('---所有线程执行结束于:', date_time_str(datetime.now()))
if __name__ == '__main__':
main()
执行结果如下:
---所有线程开始执行: 16-03-06 22:03:18
线程( 0 )开始执行: 16-03-06 22:03:18 ,先休眠( 4 )秒
线程( 1 )开始执行: 16-03-06 22:03:18 ,先休眠( 2 )秒
线程( 1 )休眠结束,结束于: 16-03-06 22:03:20
线程( 0 )休眠结束,结束于: 16-03-06 22:03:22
---所有线程执行结束于: 16-03-06 22:03:22
由执行结果看到,与传一个函数很相似的一个方法是,在创建线程时,传一个可调用的类的实例供线程启动时执行,这是多线程编程的一个面向对象的方法。相对于一个或几个函数来说,类对象可以使用类的强大功能。创建新线程时,Thread
对象会调用ThreadFunc
对象,这时会用到一个特殊函数__call__()
。由于已经有了要用的参数,因此不用再传到Thread()的构造函数中。对于有一个参数的元组,要使用self.func(*self.args)
方法。
从Thread
派生一个子类,创建这个子类的实例。从上面的代码派生的代码如下:
#! /usr/bin/python
# -*-coding:UTF-8-*-
import threading
from time import sleep
from datetime import datetime
loops = [4, 2]
date_time_format = '%y-%M-%d %H:%M:%S'
class MyThread(threading.Thread):
def __init__(self, func, args, name=''):
threading.Thread.__init__(self)
self.name = name
self.func = func
self.args = args
def getResult(self):
return self.res
def run(self):
print('starting', self.name, 'at:', date_time_str(datetime.now()))
self.res = self.func(*self.args)
print(self.name, 'finished at:', date_time_str(datetime.now()))
def date_time_str(date_time):
return datetime.strftime(date_time, date_time_format)
def loop(n_loop, n_sec):
print('线程(', n_loop, ')开始执行:',
date_time_str(datetime.now()), ',先休眠(', n_sec, ')秒')
sleep(n_sec)
print('线程(', n_loop, ')休眠结束,结束于:', date_time_str(datetime.now()))
def main():
print('---所有线程开始执行:', date_time_str(datetime.now()))
threads = []
n_loops = range(len(loops))
for i in n_loops:
t = MyThread(loop, (i, loops[i]),
loop.__name__)
threads.append(t)
for i in n_loops:
threads[i].start()
for i in n_loops:
threads[i].join()
print('---所有线程执行结束于:', date_time_str(datetime.now()))
if __name__ == '__main__':
main()
执行结果如下:
---所有线程开始执行: 16-22-06 22:22:20
starting loop at: 16-22-06 22:22:20
线程( 0 )开始执行: 16-22-06 22:22:20 ,先休眠( 4 )秒
starting loop at: 16-22-06 22:22:20
线程( 1 )开始执行: 16-22-06 22:22:20 ,先休眠( 2 )秒
线程( 1 )休眠结束,结束于: 16-22-06 22:22:22
loop finished at: 16-22-06 22:22:22
线程( 0 )休眠结束,结束于: 16-22-06 22:22:24
loop finished at: 16-22-06 22:22:24
---所有线程执行结束于: 16-22-06 22:22:24
由代码片段和执行结果我们看到,子类化Thread
类,MyThread
子类的构造函数一定要先调用基类的构造函数,特殊函数__call__()
在子类中,名字要改为run()
。在MyThread
类中,加入一些用于调试的输出信息,把代码保存到MyThread
模块中,并导入这个类。使用self.func()
函数运行这些函数,并把结果保存到实现的self.res
属性中,创建一个新函数getResult()
得到结果。
线程同步
如果多个线程共同修改某个数据,就可能出现不可预料的结果。为了保证数据的正确性,需要对多个线程进行同步。
使用Thread
对象的Lock
和RLock
可以实现简单的线程同步,这两个对象都有acquire
方法和release
方法。对于每次只允许一个线程操作的数据,可以将操作放到acquire
和release
方法之间。
多线程的优势在于可以同时运行多个任务,但当线程需要共享数据时,可能存在数据不同步的问题。
考虑这样一种情况:一个列表里所有元素都是0,线程set
从后向前把所有元素改成1,而线程print
负责从前往后读取列表并输出。
线程set开始改的时候,线程print可能就来输出列表了,输出就成了一半0一半1,这就是数据不同步的问题。为了避免这种情况,引入了锁的概念。
锁有两种状态——锁定和未锁定。当一个线程(如set)要访问共享数据时,必须先获得锁定;如果已经有别的线程(如print)获得锁定了,就让线程set暂停,也就是同步阻塞;等到线程print访问完毕,释放锁以后,再让线程set继续。
经过这样的处理,输出列表时要么全部输出0,要么全部输出1,不会再出现一半0一半1的尴尬场面。
示例代码如下:
#! /usr/bin/python
# -*-coding:UTF-8-*-
import threading
from time import sleep
from datetime import datetime
date_time_format = '%y-%M-%d %H:%M:%S'
class MyThread (threading.Thread):
def __init__(self, threadID, name, counter):
threading.Thread.__init__(self)
self.threadID = threadID
self.name = name
self.counter = counter
def run(self):
print ("开启线程: " + self.name)
# 获取锁,用于线程同步
threadLock.acquire()
print_time(self.name, self.counter, 3)
# 释放锁,开启下一个线程
threadLock.release()
def date_time_str(date_time):
return datetime.strftime(date_time, date_time_format)
def print_time(threadName, delay, counter):
while counter:
sleep(delay)
print ("%s: %s" % (threadName, date_time_str(datetime.now())))
counter -= 1
def main():
# 创建新线程
thread1 = MyThread(1, "Thread-1", 1)
thread2 = MyThread(2, "Thread-2", 2)
# 开启新线程
thread1.start()
thread2.start()
# 添加线程到线程列表
threads.append(thread1)
threads.append(thread2)
# 等待所有线程完成
for t in threads:
t.join()
print("退出主线程")
if __name__ == "__main__":
threadLock = threading.Lock()
threads = []
main()
执行结果如下:
开启线程: Thread-1
开启线程: Thread-2
Thread-1: 16-15-06 23:15:25
Thread-1: 16-15-06 23:15:26
Thread-1: 16-15-06 23:15:27
Thread-2: 16-15-06 23:15:29
Thread-2: 16-15-06 23:15:31
Thread-2: 16-15-06 23:15:33
退出主线程
由执行结果看到,程序正确得到了同步效果。
线程优先级队列
Queue模块可以用来进行线程间的通信,让各个线程之间共享数据。
Python的Queue模块提供了同步、线程安全的队列类,包括FIFO(先入先出)队列Queue、LIFO(后入先出)队列LifoQueue和优先级队列PriorityQueue。这些队列都实现了锁原语,能够在多线程中直接使用。可以使用队列实现线程间的同步。
Queue模块中的常用方法如表所示。下面通过以下示例了解其中一些方法的使用。
#! /usr/bin/python
# -*-coding:UTF-8-*-
import threading
import queue
from time import sleep
class MyThread (threading.Thread):
def __init__(self, threadID, name, q):
threading.Thread.__init__(self)
self.threadID = threadID
self.name = name
self.q = q
def run(self):
print ("开启线程:" + self.name)
process_data(self.name, self.q)
print ("退出线程:" + self.name)
def process_data(threadName, q):
while not exitFlag:
queueLock.acquire()
if not workQueue.empty():
data = q.get()
queueLock.release()
print ("%s processing %s" % (threadName, data))
else:
queueLock.release()
sleep(1)
def main():
global exitFlag
exitFlag = 0
threadList = ["Thread-1", "Thread-2", "Thread-3"]
nameList = ["One", "Two", "Three", "Four", "Five"]
threads = []
threadID = 1
# 创建新线程
for tName in threadList:
thread = MyThread(threadID, tName, workQueue)
thread.start()
threads.append(thread)
threadID += 1
# 填充队列
queueLock.acquire()
for word in nameList:
workQueue.put(word)
queueLock.release()
# 等待队列清空
while not workQueue.empty():
pass
# 通知线程是退出的时候了
exitFlag = 1
# 等待所有线程完成
for t in threads:
t.join()
print ("退出主线程")
if __name__ == "__main__":
queueLock = threading.Lock()
workQueue = queue.Queue(10)
main()
执行结果如下:
开启线程:Thread-1
开启线程:Thread-2
开启线程:Thread-3
Thread-3 processing One
Thread-2 processing Two
Thread-1 processing Three
Thread-3 processing Four
Thread-1 processing Five
退出线程:Thread-3
退出线程:Thread-1
退出线程:Thread-2
退出主线程
线程与进程比较
多进程和多线程是实现多任务最常用的两种方式。下面通过线程切换、计算密集情况和异步性能3方面讨论一下这两种方式的优缺点。
首先,要实现多任务,我们通常会设计Master-Worker模式,Master负责分配任务,Worker负责执行任务。因此,在多任务环境下,通常是一个Master、多个Worker。
如果用多进程实现Master-Worker,主进程就是Master,其他进程就是Worker。
如果用多线程实现Master-Worker,主线程就是Master,其他线程就是Worker。
多进程模式最大的优点是稳定性高,因为一个子进程崩溃不会影响主进程和其他子进程(当然,主进程挂了所有进程就全挂了,但是Master进程只负责分配任务,挂掉的概率低)。著名的Apache最早就采用多进程模式。
多进程模式的缺点是创建进程的代价大。在UNIX/Linux系统下用fork调用还行,在Windows下创建进程开销非常大。另外,操作系统能同时运行的进程数有限,在内存和CPU的限制下,如果几千个进程同时运行,操作系统就连调度都会出问题。
多线程模式通常比多进程快一点,但是也快不了多少。多线程模式致命的缺点是任何一个线程挂掉都可能直接造成整个进程崩溃,因为所
有线程共享进程的内存。在Windows中,如果一个线程执行的代码出了问题,就可以看到这样的提示:“该程序执行了非法操作,即将关闭”,其实往往是某个线程出了问题,但是操作系统会强制结束整个进程。
在Windows中,多线程的效率比多进程高,所以微软的IIS服务器默认采用多线程模式。由于多线程存在稳定性的问题,因此IIS的稳定性不如Apache。为了缓解这个问题,IIS和Apache有了多进程+多线程的混合模式,问题越来越复杂。
1 线程切换
无论是多进程还是多线程,数量太多,效率肯定上不去。
我们打个比方,你正在准备中考,每天晚上需要做语文、数学、英语、物理、化学5科作业,每科作业耗时1小时。
如果你先花1小时做语文作业,做完后再花1小时做数学作业,这样依次全部做完,一共花5小时,这种方式称为单任务模型或批处理任务模型。
如果你打算切换到多任务模型,可以先做1分钟语文,切换到数学作业做1分钟,再切换到英语,以此类推,只要切换速度足够快,这种方式就和单核CPU执行多任务一样了。以幼儿园小朋友的眼光来看,你就正在同时写5科作业。
不过切换作业是有代价的,比如从语文切换到数学,要先收拾桌子上的语文书本、钢笔(保存现场),然后打开数学课本,找出圆规直尺(准备新环境),才能开始做数学作业。操作系统在切换进程或线程时也一样,需要先保存当前执行的现场环境(CPU寄存器状态、内存页等),然后把新任务的执行环境准备好(恢复上次的寄存器状态,切换内存页等),才能开始执行。这个切换过程虽然很快,但是也需要耗费时间。如果有几千个任务同时进行,操作系统可能主要忙着切换任务,根本没有多少时间执行任务。这种情况最常见的就是硬盘狂响、点窗口无反应,这时系统处于假死状态。
所以,多任务一旦多到一个限度,就会消耗系统所有资源,导致效率急剧下降,所有任务都做不好。
2 计算密集型与IO密集型
是否采用多任务的第二个考虑是任务类型。我们可以把任务分为计算密集型和IO密集型。
计算密集型任务的特点是要进行大量计算,消耗CPU资源,如计算圆周率、对视频进行高清解码等,全靠CPU的运算能力。计算密集型任务虽然可以用多任务完成,但是任务越多,花在任务切换的时间就越多,CPU执行任务的效率就越低。要最高效地利用CPU,计算密集型任务同时进行的数量应当等于CPU的核心数。
由于计算密集型任务时主要消耗CPU资源,因此代码运行效率至关重要。Python脚本语言运行效率很低,完全不适合计算密集型任务。计算密集型任务最好用C语言编写。
涉及网络、磁盘IO的任务都是IO密集型任务,这类任务的特点是CPU消耗很少,任务的大部分时间都在等待IO操作完成(因为IO的速度远远低于CPU和内存的速度)。IO密集型任务的任务越多,CPU效率越高,不过有一个限度。大部分任务都是IO密集型任务,如Web应用。
IO密集型任务执行期间,99%的时间都花在IO上,花在CPU上的时间很少,因此用运行速度极快的C语言替换Python这样运行速度极低的脚本语言完全无法提升运行效率。对于IO密集型任务而言,最适合的语言是开发效率高(代码量最少)的语言,脚本语言是首选,C语言最差。
3 异步IO
考虑到CPU和IO之间速度差异很大,一个任务在执行的过程中大部分时间都在等待IO操作,单进程单线程模型会导致别的任务无法并行执行,因此需要多进程模型或多线程模型支持多任务并发执行。
现在的操作系统对IO操作已经做了很大改进,最大的特点是支持异步IO。如果充分利用操作系统提供的异步IO支持,就可以用单进程单线程模型执行多任务,这种全新模型称为事件驱动模型。Nginx就是支持异步IO的Web服务器,在单核CPU上采用单进程模型就可以高效支持多任务;在多核CPU上可以运行多个进程(数量与CPU核心数相同),充分利用多核CPU。由于系统总的进程数量十分有限,因此操作系统调度非常高效。用异步IO编程模型实现多任务是主要趋势。
对应到Python语言,单进程的异步编程模型称为协程。有了协程的支持,可以基于事件驱动编写高效的多任务程序。