Python的高级并发技术

与“非并发的程序”(nonconcurrent program)相比,并发程序更难编写,也更难维护(有时甚至难度相当大),而且并发程序的运行效率有时比非并发的程序低(甚至低得多)。虽然如此,但优秀的并发程序确实比非并发程序快很多,所以,为了提高效率,我们还是得花时间去研究它。

目前大多数编程语言(包括C++和Java)都直接支持并发,而且其标准库通常还提供了一些封装程度较高的功能。并发可以通过多种方式来实现,这些方式之间最重要的区别在于如何访问“共享数据(shared data)”:是通过“共享内存”(shared memory)等方式直接访问,还是通过“进程间通信”(Inter-Process Communication,IPC)等方式间接访问。“基于线程的并发”(threaded concurrency)是指同一个系统进程里有各自独立的若干个线程,它们都在并发执行任务。这些线程一般会依序访问共享内存,以此实现数据共享。程序员通常采用某种“锁定机制”(locking mechanism)来确保同一时间只有一个线程能够访问数据。“基于进程的并发”(process-based concurrency,或multiprocessing)是指多个进程独立地执行任务。这些进程一般通过IPC来访问共享数据,如果编程语言或程序库支持,那么也可以通过共享内存来实现数据共享。还有一种并发,它基于“并发等待”(concurrent waiting)而非“并发执行”(concurrent execution),这种方式通常用来实现异步I/O。

Python对异步I/O提供了某些底层支持(asyncore与asynchat模块)。Twisted框架则提供了高层支持。Python3.4计划将高层异步I/O功能(也包括事件循环)添加到Python标准库中。

刚才提到了两种传统的并发方式,也就是基于线程和基于进程的并发,这两种方式Python都支持。Python对多线程的支持方式相当普通,但对于多进程的支持方式则比大多数编程语言或程序库更为高级。此外,Python的多进程与多线程采用同一套机制,使得开发者很容易就能在两套方案中来回切换,至少在不使用共享内存是如此的。

由于有“全局解释器锁”(Global Interpreter Lock,GIL),所以Python解释器在同一时刻只能运行于一个处理器之中。Python基本上是用C语言写成的,个别标准库也是这样,而C语言代码是可以获取及释放GIL的,因此没有这样的限制。即便如此,想通过多线程并发来提高程序速度其效果可能仍然不够理想。

一般来说,计算密集型任务不适合用多线程实现,因为这样做通常比非并发程序还要慢。一种办法是改用Cython来编写代码,Cython代码实际上与Python一样,只是多加了一套写法,能够把程序编译成纯C。这样程序执行起来可以比原来快100倍,而用并发则很难达到这样的效果,因为其性能提升程度与处理器核心的数量成正比。如果碰到需要使用并发的场合,而所要执行的任务又是计算密集型的,那么最好避开GIL,改用multiprocessing模块。如果使用多线程,那么同一个进程里的线程在执行时会相互争抢GIL,但如果改用multiprocessing,那么每个进程都是独立的,它们都有自己的Python解释器实例,所以也就不会争抢GIL了。

对于网络通信等“I/O密集型”(I/O-bound)任务来说,并发可以大幅度提升程序执行速度。在这种情况下,决定程序效率的主要因素是网络延迟,这与线程还是进程来实现并发可能并没有多大关系。

并发级别 
* 低级并发(Low-Level Concurrency,底层并发):就是直接用“原子操作”(atomicoperation)所实现的并发。这种并发是给程序库的编写者用的,而应用程序的开发者则不需要它,因为这种写法很容易出错,而且极难调试。虽说Python本身的并发机制一般是用底层操作实现出来的,但开发者不能用Python语言编写这种级别的并发代码。 
* 中级并发(Mid-Level Concurrency):不直接使用原子操作,但却会直接使用“锁”(lock),大多数语言提供的都是这种级别的并发。Python的threading.Semaphore,threading.Lock,及multiprocessing.Lock等类都支持中级并发。开发应用程序的人一般会使用中级并发,因为他们通常只能使用这个级别的开发功能。 
* 高级并发(High-Level Concurrency):既不直接使用原子操作,也不直接使用锁。(锁和原子操作可能在幕后使用,但开发者无须关系这些。)目前已经有编程语言开始支持高级并发了。从3.2版本起,Python提供了支持高级并发的concurrent.futures模块,此外,queue.Queue及multiprocessing这两个“队列集合类”(queue collection class)也支持高级并发。

中级并发用起来虽然简单,但却很容易出错,尤其容易出现那种难于追踪而且调试起来非常麻烦的错误,此外还会导致程序莫名其妙地崩溃或“失去响应”(frozen)。

关键问题出在共享数据上面。如果共享数据可以修改,那么必须用锁保护起来,以确保所有线程或进程都要依序存取它(也就是说,同一个时刻只能有一个线程或进程访问这份数据)。如果多个线程或进程试图访问同一份共享数据,那么只有其中一个能够获取到,而其他的都会阻塞(也就是进入“空闲”(idle)状态)。这就意味着,当锁定机制生效时,应用程序中只能有一个线程或进程起作用(这就变得和非并发程序类似了),其余的都要得等待。由此可见,我们应当尽量少用锁,即便要用,也不要时间太长。最简单的办法是根本不要分享可以修改的数据,这样就可以不加锁了,而大部分并发问题也就随之消失了。

有些时候,多个并发的线程或进程确实需要访问同一份数据,但我们不必直接加锁即可解决此问题。一种办法是采用支持“并发访问”(concurrent access)的数据结构,queue模块提供了许多“能在多线程环境下安全使用的”(thread-safe, “线程安全的”)队列,如果是基于多进程开发,那么可以使用multiprocessing.JoinableQueue及multiprocessing.Queue类。前面这种队列能够统一存放待执行队列,以供并发线程或进程从中领取,后面那种队列则可用来收集任务的执行结果。在整个过程中,加锁操作都是由数据结果自身负责的。

上面这些支持并发的队列也许不适合用来表述我们所要操作的数据,在这种情况下,如果不想加锁,那么最好的办法就是传递“不可变的数据”(immutable data,比如数字或字符串),或者是传递“可变的数据”(mutable data),但却不修改它。假如一定要使用“可变的数据”,那么最安全的办法就是对其“深拷贝”(deep copy),这样可以免去因加锁而带来的开销和麻烦,不过,拷贝操作本身需要占用处理器和内存。另外,如果是多进程并发,那么可以使用支持并发访问的数据类型,特别是multiprocessing.Value与multiprocessing.Array,前者表示单个可变的值,而后者则是由可变的值所构成的数组,二者都需要通过multiprocessing.Manager来创建。

计算密集型并发

缩小图像是一种需要大量运算的操作,所以多进程技术应该能够最大程度地提升程序性能。

程序 并发 执行时间(秒) 速度倍数
imagescale-s.py 784 基准
imagescale-c.py 4个协程 781 1.00
imagescale-t.py 4个线程,使用线程池 1339 0.59
imagescale-q-m.py 4个进程,使用队列 206 3.81
imagescale-m.py 4个进程,使用进程池 201 3.90

imagescale-t.py程序使用了4个线程,其运行结果显然可以说明采用多线程来计算密集型任务的效率比非并发还要低。这是因为Python把所有处理任务放在了同一个核里,除了执行缩小操作之外,还要在四个线程之间进行“上下文切换”(context switching),这将产生大量开销。采用多进程队列和使用进程池的差别不大,两者都与我们所预估的速度相符(也就是说,速度与CPU的核心数成正比)。

所有图像缩小程序都接受命令行参数,我们用argparse来解析这些参数。所有版本都需要指定如下参数:缩小后的图像尺寸,是否使用平滑缩放,缩小前的图像所在的文件夹,缩小后的图像所在的文件夹。如果图像本身比指定的尺寸还小,那么就不缩小了,而是直接拷贝过去。采用并发的那几个版本还可以通过concurrency参数来指定程序所使用的线程或进程个数。该参数纯粹是为了调试和统计执行时间而设的。计算密集型程序所使用的线程或进程数量一般与CPU核心相同。I/O密级型程序会根据网络带宽状况使用核心数的倍数(比如2倍,3倍,4倍等)作为线程或进程的数量。

 
 
  1. def handle_commandline():
  2. parser = argparse.ArgumentParser()
  3. parser.add_argument('-c', '--concurrency', type=int,
  4. default=multiprocessing.cpu_conut(),
  5. help='specify the concurrency (for debuging and '
  6. 'timing) [default: %(default)d]')
  7. parser.add_argument('-s', '--size', default=400, type=int,
  8. help='make a scaled image that fits the given dimension [default: %(default)d]')
  9. parser.add_argument('-S', '--smooth', action='store_true',
  10. help='use smooth scaling (slow but good for text)')
  11. parser.add_argument('source',
  12. help='the directory containing the original .xpm images')
  13. parser.add_argument('target',
  14. help='the directory for the scaled .xpm images')
  15. args = parser.parse_args()
  16. source = os.path.abspath(args.source)
  17. target = os.path.abspath(args.target)
  18. if source == target:
  19. args.error("source and target must be different")
  20. if not os.path.abspath.exists(args.target):
  21. os.makedirs(target)
  22. return args.size, args.smooth, source, target, args.concurrency

应用程序一般都不会向用户提供并发选项,但它可以用于调试,统计执行时间,测试程序等。multiprocessing.cpu_count()函数返回计算机的核心数。

argparse模块采用“陈述式”(declarative)的方式来创建命令行解释器。创建好解释器之后,我们用它来解析命令行,并获取其中的参数,然后会简单地判断一些这些参数是否有效(例如,目标文件夹不得与源文件夹相同)。如果目标文件夹尚未建立,那么就新建一个。os.makedirs()函数与os.mkdir()类似,但后者只能在现存的文件夹下面新建子文件夹,而前者还可以自动创建子文件夹与现有的上层文件夹之间的那些文件夹。

用队列及多进程实现并发

imagescale-q-m.py程序创建了两个队列,一个用来保存待执行的任务(也就是待缩小的图像),另一个用来收集任务的执行结果。

 
 
  1. Result = collections.namedtuple('Result', 'copied scaled name')
  2. Sumary = collections.namedtuple('Summary', 'todo copied scaled canceled')

Result是个“具名元组”(named tuple),用来存放单张图片的处理结果。其中前两个元素表示拷贝及缩小过的图片数量,对于单张照片来说,这两个值要么是1,0,要么是0,1,第三个元素是缩小后的图片名称。Summary也是具名元组,用来汇总所有图片的处理结果。

 
 
  1. def main():
  2. size, smooth, source, target, concurrency = handle_commandline()
  3. Qtrac.report('starting...')
  4. summary = scale(size, smooth, source, target, concurrency)
  5. summarize(summary, concurrency)

所有图像缩小程序的main()函数都一样,它先用自编的handle_commandline()函数读出命令行参数。函数所返回的五个参数分别表示:缩小后的图像尺寸,是否采用平滑缩放,缩小前的图片所在目录,缩小后的图片所在目录以及程序所使用的线程或进程个数(只有并发版本才会用到这个参数,其默认值等于CPU的核心数)。

程序随后告诉用户,现在开始知晓能够图片缩小操作,然后调用scale()函数,全部任务都会在这个函数里完成。scale()函数会把执行结果汇总,并返回给调用者,我们用summarize()函数将其打印出来。

 
 
  1. def report(message='', error=False):
  2. if len(message) >= 70 and not error:
  3. message = message[:67] + '...'
  4. sys.stdout.write('\r{:70}{}'.format(message, '\n' if error else ''))
  5. sys.stdout.flush()

本章与开发相关的所有范例程序都要使用控制台,为了用起来方便一些,我们把report()函数放在了Qtrac.py模块里。该函数用message参数中的消息来覆写控制台当前这一行的文字(如果大于70个字符,那么就截取前67个),并调用flash()方法将其立刻打印到控制台里。如果message中的消息是错误信息,那么就在后面加上换行符,以防其他消息覆写此消息。report()函数不会将错误信息截断。

 
 
  1. def scale(size, smooth, source, target, concurrency):
  2. canceled = False
  3. jobs = multiprocessing.JoinableQueue()
  4. results = multiprocessing.Queue()
  5. create_processes(size, smooth, jobs, results, concurrency)
  6. todo = add_jobs(source, target, jobs)
  7. try:
  8. jobs.join()
  9. except KeyboardInterrupt: # May not work on Windows
  10. Qtrac.report('canceling...')
  11. canceled = True
  12. copied = scaled = 0
  13. while not results.empty(): # Scale because all jobs have finished
  14. result = results.get_nowait()
  15. copied += result.copied
  16. scaled += result.scaled
  17. return Summary(todo, copied, scaled, canceled)

本程序采用基于队列的多进程技术来实现并发式图像缩放,而上面这个函数则是程序的核心。函数首先创建了“joinable queue”(支持join()方法的队列),把待处理的任务放入其中。开发者可以在joinable queue上面调用join(),该方法会一直阻塞,知道任务队列变空后才返回。创建好这个队列之后,scale()函数又创建了一个nonjoinable queue,用于保存执行结果。接下来,创建缩小操作所需的进程,调用完create_processes()函数之后,这些进程就能够执行任务了,但目前它们尚处于阻塞状态,因为工作队列里还没有放入任务。然后,调用add_jobs()函数,把全部任务都添加到工作队列里。

把所有任务都放入队列之后,调用multiprocessing.JoinableQueue.join()方法,等队列变空。

所有任务都执行完(或用户取消了整个操作)之后,我们遍历results队列,以便统计计算结果。一般情况下,在并发队列上调用empty()方法所返回的结果是不可靠的,但此处却可以这么做,因为所有的工作进程都已经完工了。该队列的状态不会再改变了。我们在循环体里面调用multiprocessing.Queue.get_nowait()来获取执行结果,而没有像平时那样调用multiprocessing.Queue.get()方法,也是由于这个原因。

收集到所有任务的执行结果之后,我们把详细信息放在Summary具名元组里并返回给调用者。如果程序照常执行了全部任务,那么todo值是0,canceled值是False;如果任务取消了,那么todo值可能不是0,但canceled值会是True。

这个函数虽然名叫scale(),但却是个相当通用的“并发式任务执行”函数,它会把待执行的任务分派给各个进程,并收集执行结果。只需稍加改编,就能在其他场合使用。

 
 
  1. def create_processes(size, smooth, jobs, results, concurrency):
  2. for _ in range(concurrency):
  3. process = multiprocessing.Process(target=worker, args=(size, smooth, jobs, results))
  4. process.daemon = True
  5. process.start()

上面这个函数会创建许多进程,这些进程都可以执行任务。创建每个进程时,传入的都是同一个worker()函数(因为它们要执行的任务都是相同,就是缩小图像),此外还传入了任务的细节。细节信息里面包含两个共享任务队列,一个用于存放任务,另一个用于收集结果。开发者不需要手工给这些任务加锁,因为它们自己会处理同步问题。创建好进程之后,我们将其设为daemon(守护进程)。主进程终止后,所有守护进程也会照常终止(不是守护进程的那些进程还会继续运行,但是在Unix系统上,它们会变成“僵尸进程”(zombie process)。

create_processes()函数会创建好所有进程,并将它们设置成守护进程,每创建完一个进程,就会调用其start()方法,促使其开始执行worker()函数。这些进程此时必然都会阻塞,因为任务队列里面还没有任务。他们虽然阻塞了,但主进程很快就能把这些进程都创建完,并且从create_processes()函数中返回。然后,调用该函数的那段代码会把待处理的任务添加到队列里,于是,刚才那些阻塞的进程就可以领取任务,并继续往下执行了。

 
 
  1. def worker(size, smooth, jobs, results):
  2. while True:
  3. try:
  4. sourceImage, targetImage = jobs.get()
  5. try:
  6. result = scale_one(size, smooth, sourceIamge, targetImage)
  7. Qtrac.report('{} {}'.format('copied' if result.copied else 'scaled', os.path.basename(result.name)))
  8. results.put(result)
  9. except Image.Error as err:
  10. Qtrac.report(str(err), True)
  11. finally:
  12. jobs.task_done()

本来我们可以从multiprocessing.Process类(或threading.Thread类)中继承子类,然后用子类实例实现并发,但是却采用另一种办法,就是将任务封装在函数里,然后把函数经target参数传给multiprocessing.Process对象,这样会稍微简单一点(创建thread.Thread对象时,也可以这么做。)

worker()函数是个无限循环,每一轮都试着从共享的工作队列里领取一项任务。此外使用无限循环不会出问题,因为执行worker()函数的进程都是守护进程,当整个程序终止时,它们也会随之终止。如果队列里没有任务,那么multiprocessing.Queue.get()方法就会一直阻塞,等获取到任务之后,才会将其返回。返回的值是个二元组,在本例中,二元组的两个元素分别表示源图像和目标图像的名称。

领取任务之后,我们通过scale_one()函数来缩小(或拷贝)图像,并将操作结果告知用户。然后,把result对象(该对象的类型是Result)放到保存操作结果的共享队列里。

使用joinable queue的时候要注意:执行完其中的任务之后,一定要调用multiprocessing.JoinableQueue.task_done()方法。只有这样,multiprocessing.JoinableQueue.join()方法才能知道队列中的所有任务是不是都执行完了(也就是说,队列里是不是已经没有待执行的任务了)。

 
 
  1. def add_jobs(source, target, jobs):
  2. for todo, name in enumerate(os.listdir(source), start=1):
  3. sourceImage = os.path.join(source, name)
  4. targetImage = os.path.join(target, name)
  5. jobs.put((sourceImage, targetImage))
  6. return todo

创建并启动进程之后,它们就处于阻塞状态,所有进程都等着从共享的队列里领取任务。

add_jobs()函数会针对每张待处理的图像创建两个字符串:sourceImage表示源图像的完整路径,而targetImage则表示目标图像的完整路径。函数会把这两个字符串包在二元组里,添加到共享的工作队列中。最后,把待处理的任务数返回给调用者。

任务队列中有了第一个任务之后,原来处于阻塞状态的某个工作进程就会将其领取,并开始执行任务。最后,所有工作进程都能领到任务。在各进程执行任务时,工作队列也会有新的任务加进来,只要有进程执行完自己现有的任务,它就可以从队列中再领取一项新任务。这些进程最终会把队列中的任务都领走,它们执行完自己的任务之后,就会处于阻塞状态,程序终止时,这些进程也会随之终止。

 
 
  1. def scale_one(size, smooth, sourceImage, targetImage):
  2. oldImage = Image.from_file(sourceImage)
  3. if oldImage.width <= size and oldImage.height <= size:
  4. oldImage.save(targetImage)
  5. return Result(1, 0, targetImage)
  6. else:
  7. if smooth:
  8. scale = min(size / oldImage.width, size / oldImage.height)
  9. newImage = oldImage.scale(scale)
  10. else:
  11. stride = int(math.ceil(max(oldImage.width / size,
  12. oldImage.height / size)))
  13. newImage = oldImage.subsample(stride)
  14. newImage.save(targetImage)
  15. return Result(0, 1, targetImage)

上面这个函数负责执行实际的缩小(或拷贝)操作。它会优先考虑使用cyImage模块,若无法使用该模块,则使用Image模块。假如待处理的图像本身就比目标尺寸小,那么直接将其保存到targetImage就可以,此时函数会返回Result对象。构造该对象使传入的三个参数分别表示本次操作所拷贝的图像数量(1张),缩小的图像数量(0张)以及新图像的名称。如果待处理的图像比目标尺寸大,那么就通过oldImage对象的scale()方法或subsample()方法将其缩小,并把处理后的图像保存成新的图片。在这种情况下,也会返回Result对象,构造对象时,前两个参数的值会对调,表明本次操作没有拷贝图像,但是缩小了一张图像,第三个参数仍然是新图像的名称。

 
 
  1. def summarize(summary, concurrency):
  2. message = 'copied {} scaled {}'.format(summary.copied, summary.scaled)
  3. difference = summary.todo - (summary.copied + summary.scaled)
  4. if difference:
  5. message += 'skipped {}'.format(difference)
  6. message += 'using {} processes'.format(concurrency)
  7. if summary.canceled:
  8. message += ' [canceled]'
  9. Qtrac.report(message)
  10. print()

等所有图像都处理完(也就是队列的join()方法返回的时候),scale()函数会创建Summary对象,并将其传给summarize()函数。

  • 2
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值