×

Python进程、线程、回调与协程 总结笔记 适合新手明确基本概念

96
treelake
2016.09.28 23:00* 字数 4452

怎样让python在现代的机器上运行的更快,充分利用多个核心,有效地实现并行、并发一直是人们的追求方向。

GIL

  • 谈到Python的执行效率就不得不提到GIL。Python的GIL(Global Interpreter Lock)全局解释器锁会阻止Python代码同时在多个处理器核心上运行,因为一个Python解释器在同一时刻只能运行于一个处理器之中。(CPython有此限制,Cpython是大部分Python程序员所使用的参考实现。而某些Python实现则没有这一限制,这其中最有名的就是Jython——用Java语言实现的Python)。

1、CPU Bound

  • 但是实际上对于计算密集型程序来说,可以用multiprocessing模块的多进程实现并行/并发(并发和并行概念参考),每个进程都是独立的,它们都有自己的Python解释器实例,所以就不会争夺GIL了,可以完全利用每个核心。处理速度的提升大致同CPU的核心数成正比。CPU核心数可以用multiprocessing.cpu_count()语句得到。如果同时运行小于CPU核心数的任务,则任务是完全并行执行的,在不需要同步的情况下互不干扰。如果同时运行大于CPU核心数的任务,则至少有个核心要同时运行2个或以上的任务,这样的并发执行中会带来任务的切换开销,降低效率。
    补充:进程是一种古老而典型的上下文系统,每个进程有独立的地址空间,资源句柄,他们互相之间不发生干扰。很显然,当新建进程时,我们需要分配新的进程描述符,并且分配新的地址空间(和父地址空间的映射保持一致,但是两者同时进入COW(写时复制)状态。这些过程需要一定的开销。Pool()连接池用来保持连接,减少新建和释放,同时尽量复用连接而不是随意的新建连接,来减少系统开销。(池的参考
    multiprocessing:左侧为多进程,右侧为多线程,接口统一

拓展:python3中multiprocessing中使用多参数的技巧,来自stackOverFlow

#!/usr/bin/env python3
from functools import partial
from itertools import repeat
from multiprocessing import Pool, freeze_support
def func(a, b):
    return a + b
def main():
    a_args = [1,2,3]
    second_arg = 1
    with Pool() as pool:
        L = pool.starmap(func, [(1, 1), (2, 1), (3, 1)])
        M = pool.starmap(func, zip(a_args, repeat(second_arg)))
        N = pool.map(partial(func, b=second_arg), a_args)
        assert L == M == N
if __name__=="__main__":
    freeze_support()
    main()
  • python的多线程受制于全局解释器锁,使得它们不能真正的执行并行计算,在计算密集型应用面前相当弱势。甚至于,多核多线程还会比单核多线程慢很多,在多核CPU上,存在严重的线程颠簸(thrashing)。
    借用一刀捅死大萌德的说法,多核多线程比单核多线程更差,原因是单核下多线程,每次释放GIL,唤醒的那个线程都能获取到GIL锁,所以能够无缝执行,但多核下,CPU0释放GIL后,其他CPU上的线程都会进行竞争,但GIL可能会马上又被CPU0拿到,导致其他几个CPU上被唤醒后的线程会醒着等待直到切换时间结束后又进入待调度状态,导致线程颠簸,降低了系统效率。
    GIL_2cpu

    在文末参考中的《Python的GIL是什么鬼,多线程性能究竟如何》中的《当前GIL设计的缺陷》一节中有一个对于多核多线程缺陷的分析。
    另外即使是单核多线程,由于线程切换开销,对于计算密度大的应用还可能比单核单线程要慢。

2、I/O Bound

  • 越来越多的程序是IO密集型而非CPU密集型。IO密集型任务,例如爬虫http服务这些网络程序,它们将时间花在维持许多低效连接和应对偶发事件上。
    进程的分配和释放有非常高的成本,对于大量IO事件分配进程来处理是不划算的。那么我们来考虑线程。线程模式比进程模式更耐久一些,性能更好,但是还是存在实际使用的问题。

补充:线程是一种轻量进程,实际上在linux内核中,两者几乎没有差别,除了一点——线程并不产生新的地址空间和资源描述符表,而是复用父进程的。线程的调度和进程一样,都必须陷入内核态。

  • 爬虫是一个典型的异步应用程序:它等待很多应答,但是计算很少,主要时间在I/O上。目标是要在同一时间内爬取尽可能多的页面。如果给每一个在途请求分发一个线程,当并发的请求数量增多时,它会在用光系统能够提供的(socket)套接字描述符之前先耗尽内存或其它线程相关的资源。

  • Ø 其实从著名的 C10K 问题的时候, 就谈到了高并发编程时, 采用多线程(或进程)是一种不可取的解决方案, 核心原因是因为线程(或进程)本质上都是操作系统的资源, 每个线程需要额外占用一定量的内存空间, 在Jesse的系统上每个python线程消耗50K的内存,开启上万个线程会导致故障。
    Ø 线程由操作系统调度,线程切换时存在内核陷入开销,但是有说法是当代操作系统上内核陷入开销是非常惊人的小的(10个时钟周期这个量级),所以线程的主要问题在于调度切换成本太高,当线程数超过一定数量,操作系统就会不堪重负。而且线程的调度器的实现目的和我们的具体应用程序的调度原则并不就是一致的,例如对于http服务来说,并不需要对于每个用户完全公平,偶尔某个用户的响应时间大大延长了是可以接受的,在这种情况下,线程的调度器就实现了一些不必要的效果而浪费了资源。

补充:类似进程池,我们也会使用线程池。简单解释就是一个复杂点的程序,会将线程频繁创建的开销通过在线程池中保存空闲线程的方式摊销,然后再从线程池中取出并重用这些线程去处理随后的任务;这样和使用socket连接池效果差不多。

  • Python的多线程处在很尴尬的位置,对于CPU密集型任务,它不能真正实现并行,而对于IO密集型任务,它的实现效果也不是很好(在有上述缺陷的情况下还有GIL的限制),但是一定程度上还是有效的,虽然有GIL,但是在IO等待期间线程会释放解释器,这样别的线程就有机会使用解释器,实现了并发。Python多线程的实现最简便的就是用上面multiprocessing图中右侧的方法。

  • 此外,线程的抢占式切换容易使它们陷入竞态。要加锁控制同步。

  • 抢占式:现行进程在运行过程中,如果有重要或紧迫的进程到达(其状态必须为就绪),则现运行进程将被迫放弃处理机,系统将处理机立即分配给新到达的进程。
  • 非抢占式:让进程运行直到结束或阻塞的调度方式。

  • 于是我们尝试使用异步IO来避免对大量线程的需求。典型的有Nodejs利用事件驱动来解决高并发问题,其实就是在底层使用了libuv然后通过各种回调函数来注册事件,当事件触发时回调函数也被触发。但是回调的嵌套导致代码的逻辑结构不清晰。

事件循环是一种等待程序分配事件或消息的编程架构。“当A发生时,执行B”。事件循环被认为是一种循环是因为它不停地收集事件并通过循环它们来处理事件。(监听)。

from selectors import DefaultSelector, EVENT_WRITE
import socket
selector = DefaultSelector()
sock = socket.socket()
sock.setblocking(False)
# 设置socket为阻塞或非阻塞
# sock.setblocking(True) is equivalent to sock.settimeout(None)
# sock.setblocking(False) is equivalent to sock.settimeout(0.0)
try:
    sock.connect(('xkcd.com', 80))  # 仅仅发送连接请求
except BlockingIOError:
    pass
# 设置socket为非阻塞必然抛出异常
def connected():
    selector.unregister(sock.fileno())
    print('connected!')
# 注册回调(文件描述符, 事件, 回调函数)
selector.register(sock.fileno(), EVENT_WRITE, connected)
#
def loop():
    while True:
        events = selector.select()
        for event_key, event_mask in events:
            callback = event_key.data
            callback()

我们无视虚假的错误(BlockingIOError)然后调用selector.register,并传入socket文件描述符和一个代表我们等待事件类型的常量。同时我们传入一个回调函数connected用来在事件发生时被调用。connected回调函数被存储为event_key.data。下面的循环中调用在select()处暂停了,直到有下一个IO事件发生,当发生时就获取回调函数并调用。
到此,我们展示了如何开始一个操作,并在该操作的I/O准备好之后执行一个回调。一个异步框架基于两个我们展示的特性:非阻塞的套接字和事件循环,以此来实现单线程的并发。
我们在这里实现的是并发而不是并行。也就是说,我们创建了一个操作重叠IO的小系统。它能在其它I/O作业还在途时(即I/O还未准备好时)开始新的作业。它并没有真正利用多核心来执行并行计算。但是它就是被设计来应对高IO问题,而不是CPU密集型问题的。
异步并不就比多线程快,通常它不是的,实际上就python而言,在服务小数量级的很活跃的链接时,一个类似于上文的事件循环会一定程度上慢于多线程。在这样的工作负载下,如果没有运行时的全局解释器锁,多线程将会表现得更好。异步IO适合的是许多缓慢和可睡眠的链接,适用于相应的情况。

  • 再进一步,我们尝试将它拓展为一个异步爬虫。抓取一个页面需要一系列的回调。当套接字连接时connected函数被调用,向服务器发送一个GET请求。但是它必须等待服务器的响应,所以它注册了另一个回调。当这个新注册的回调被调用时,很可能它并没有读完所有的应答内容,它必须再次注册一个回调,如此以往。函数在需要等待事件的地方必须注册另一个回调,然后放弃控制权给事件循环。当所有页面被下载完后,抓取器停止全局事件循环然后程序退出。
    然而这样的程序的编写使得异步的问题很明显:会导致无法控制的面条代码(指代码控制结构复杂、混乱而难以理解)。
    我们需要某个方法来表达一系列的计算和IO操作,并且调度多个这样一系列的动作使它们同时运行。但是没有多线程的情况下,一系列的操作不能被放在单个函数中:不论何时一个函数开始一个IO操作,它显式地保存了在未来需要的状态,然后再返回。我们需要自己完成关于状态保存的代码。
    为了解释上面所述,考虑下我们以传统方式阻塞套接字的程序:
# 阻塞方式的爬虫主干代码
def fetch(url):
    sock = socket.socket()
    sock.connect(('xkcd.com', 80))  # 请求连接,可能会被阻塞
    request = 'GET {} HTTP/1.0\r\nHost: xkcd.com\r\n\r\n'.format(url)
    sock.send(request.encode('ascii'))  # 发送请求内容,可能会被阻塞
    response = b''
    chunk = sock.recv(4096)  # 读取接收信息,可能会被阻塞
    while chunk:
        response += chunk
        chunk = sock.recv(4096) # 读取接收信息,可能会被阻塞

    # 解析页面信息,返回新的链接,然后将它们加入待爬取队列
    links = parse_links(response)
    q.add(links)

在一次socket操作和下一次之间函数记住了什么状态呢?它拥有套接字,一个URL,还有积累的response。一个运行在线程上的函数使用了编程语言的基本特性来保存临时状态在本地变量中,即它的栈上。这个函数还具有一个延续——那就是,它在IO完成后计划执行的代码。运行时通过储存线程的指令指针来记住函数的后续内容。在IO操作之后,你不需要考虑重新加载这些东西。因为它是语言内在的特性。
但是在异步框架中就不能指望它们自动完成。当要等待一个IO时,一个函数必须显式地保存它的状态,因为函数在IO完成前已经返回了并且丧失了它的栈帧。为了替代本地变量,我们的基于回调的爬虫需要存储了sock和response作为抓取器实例的属性。为了替代指令指针,它需要通过注册回调函数connected和read_response来记录它的后续操作。当应用程序的特性不断增加时,我们通过回调手动储存的状态的复杂性也在增加。这让人头痛。

  • 更糟糕的是,当一个回调函数抛出异常时我们无法知道我们从何而来,要去何方。

  • 在关于多线程和异步的效率之争外,还有关于谁更易出错的争论:多线程很容易出现数据的竞态,如果你没有处理好他们之间的同步;但是(在较为复杂的任务中)回调很难调试因为stack ripping(意指程序运行上下文的丢失)。

  • 但是Python中引入的协程消除了这个缺点,兼具了效率和可维护性。(对于爬虫我们可以使用python3.4之后的asyncio标准库和一个叫aiohttp的包。)

相比于每个线程的50k的内存消耗和操作系统本身对于线程的硬限制。一个python协程只需要3k的内存(在Jesse的系统上)。Python能够轻易地开始上万的协程。

  • 协程的概念很简单:它是可以暂停和恢复的子程序。线程由操作系统抢占式多任务调度,而协程采用合作式多任务调度:它们自己选择什么时候暂停,谁下一个运行,亦即线程和进程是由操作系统调度的,而协程的调度由用户自己控制。它比回调要清晰和简单。协程是一种更好的高并发解决方案。它将复杂的逻辑和异步都封装在底层,让程序员感觉不到异步的存在。协程也叫作用户级线程*。

    当然,一些实现中,使用callback也有好处——coroutine协程的最小切换开销也在50ns,而call本身则只有2ns。

  • 协程有很多种实现,即使在Python中也是。Python3.4标准库中的asyncio基于生成器、一个Future类和yield from 语句构建。而从python3.5开始,协程变成了语言的原生特性。但是理解协程在python3.4中是怎样利用之前存在的语言工具实现的,是搞定python3.5中的原生协程的基础。

  • 理解协程可看文末的参考。

补充:协程是为非抢占式多任务产生子程序的计算机程序组件,协程允许不同入口点在不同位置暂停或开始执行程序,亦即可以暂停执行的函数。

  • 协程良好的异常处理和堆栈跟踪,使其成为好的选择。不像线程,协程呈现了代码中可以被中断的地方和不能的。线程使得局部推理(local reasoning)变得困难,而局部推理可能是软件开发中最重要的事情了。协程明确地产出(yield)使得不需要去检查整个系统就能理解一段程序的行为和正确性。

  • 在了解asyncio的协程是如何工作的之后,你可以忘掉大部分细节。机制隐藏在一个短小精悍的接口后面。使用协程编程非常简单,但是你对于基本概念的掌握使你能够正确有效地在现代异步环境中编程。

参考


Python
Web note ad 1