龙空技术网

在Python中使用Asyncio系统(2)线程的真相

李保银 110

前言:

现在各位老铁们对“python 虚拟内存”都比较讲究,兄弟们都需要分析一些“python 虚拟内存”的相关资讯。那么小编也在网上汇集了一些对于“python 虚拟内存””的相关文章,希望同学们能喜欢,你们一起来了解一下吧!

线程的真相

如果你以前从来没有听说过线程,这里有一个基本的描述:线程是操作系统(OS)提供的一种功能,提供给软件开发人员,以便向操作系统指示他们程序的哪些部分可以并行运行。操作系统决定如何与每个部分共享CPU资源,就像操作系统决定与同时运行的所有其他不同程序(进程)共享CPU资源一样。

既然你正在阅读Asyncio的书籍,那么这一定是我告诉你“线程很糟糕,你不应该使用它们”的部分,对吗?不幸的是,情况并非如此简单。我们需要权衡使用线程的好处和风险,就像任何技术选型一样。

这本书根本就不应该是关于线程的。但这里存在两个问题:Asyncio是作为线程的替代方案提供的,所以如果不进行比较,很难理解其价值主张;即使在使用Asyncio时,你仍然可能需要处理线程和进程,因此你还是需要了解一些关于线程的知识。

线程的优点

这些是线程的优点:

易于阅读代码

你的代码可以并发运行,但仍然以非常简单的、自上而下的线性命令序列进行设置,直到—这是关键—在函数体中好像没有使用并发一样。

共享内存的并行性

你的代码可以在使用线程共享内存的同时利用多个CPU。这在许多业务应用程序中非常重要,在不同进程的不同内存空间之间移动大量数据的开销太大。

技术诀窍和现有代码

有大量的知识和很好的实践经验可用于编写线程应用程序。还有大量现有的“阻塞”代码依赖于多线程来进行并发操作。

现在,对于Python,关于并行性的观点是值得怀疑的,因为Python解释器使用一个全局锁,称为全局解释器锁(GIL),来保护解释器本身的内部状态。也就是说,它可以防止多线程之间的条件竞争产生潜在的灾难性影响。锁的一个副作用是,它最终会把程序中的所有线程都固定到一个CPU上。正如你可能想象的那样,这否定了任何并行性性能的好处(除非你使用Cython或Numba之类的工具来处理这种限制)。

但是关于可感知的简单性的第一点是重要的:Python中的线程感觉非常简单,如果你以前没有被难以想象的条件竞争错误所困扰,那么线程提供了一个非常有吸引力的并发模型。即使你过去有过失败的经历,线程化仍然是一个不可抗拒的选择,因为你可能已经学会了(以艰难的方式)如何保持你的代码既简单又安全。

这里我没有足够的空间来讨论更安全的线程编程,但是一般来说,使用线程的最佳实践是在并发中使用ThreadPoolExecutor类。Futures模块,通过submit()方法传入所有需要的数据。示例 2-1给出了一个基本示例。

示例 2-1。最好的线程实践

from concurrent.futures import ThreadPoolExecutor as Executordef worker(data):    <process the data>with Executor(max_workers=10) as exe:    future = exe.submit(worker, data)

ThreadPoolExecutor为在线程中运行函数提供了一个极其简单的接口,最好的部分是,如果需要,只需使用ProcessPoolExecutor,就可以将线程池转换为子进程池。它具有与ThreadPoolExecutor相同的API,这意味着你的代码将很少受到更改的影响。执行器API也在asyncio中使用,将在下一章中进行描述(参阅示例3-3)

一般情况下,你希望你的任务足够短暂,这样当你的程序需要关闭时,你就可以简单地调用 executor.shutdown(wait=True) 并等待一到两秒钟,以允许执行程序完成。

最重要的是:如果可能的话,你应该尽力不要让你的线程代码(在前面的例子中,worker()函数)访问或写入任何全局变量!

线程的缺点

线程的缺点已经在其他一些地方提到过了,但为了完整起见,我们还是在这里总结一下。

编写线程程序很难

线程程序中的bug和条件竞争是最难修复的一类错误。随着经验的积累,我们可能可以设计出不太容易出现这些问题的软件,但在架构不复杂、设计的比较幼稚的软件中,它们几乎不可能被修复,即使你是老手也不容易。

线程是资源密集型

线程需要额外的操作系统资源来创建,比如预先分配的每个线程的堆栈空间,它会预先消耗进程虚拟内存。这在32位操作系统中是一个大问题,因为每个进程的地址空间限制在3 GB。(32位进程的理论上地址空间是4 GB,但是操作系统通常会保留一部分。通常,只有3GB为可寻址虚拟内存,但在一些操作系统上,它可能低至2GB。这部分提到的数字是大概而不是绝对数字。这里有太多特定于平台(和历史敏感)的细节。)如今,随着64位操作系统的广泛使用,虚拟内存不再像以前那样珍贵(虚拟内存的可寻址空间通常是48位;也就是说,256 TiB)。在现代桌面操作系统上,操作系统甚至在需要时才会为每个线程分配堆栈空间所需的物理内存,包括每个线程的堆栈空间。例如,在具有8gb内存的现代64位Fedora 29 Linux上,使用以下简短代码创建10,000个不做任何事情的线程:

# threadmem.pyimport osfrom time import sleepfrom threading import Threadthreads = [  Thread(target=lambda: sleep(60)) for i in range(10000)][t.start() for t in threads]print(f'PID = {os.getpid()}')[t.join() for t in threads]

执行这个程序时top命令会显示以下信息:

  MiB Mem : 7858.199 total, 1063.844 free, 4900.477 used  MiB Swap: 7935.996 total, 4780.934 free, 3155.062 used    PID USER      PR  NI    VIRT    RES    SHR COMMAND  15166 caleb     20   0 80.291g 131.1m   4.8m python3

预先分配虚拟内存达到了惊人的大约80GB(每个线程堆栈空间有8MB!),但常驻内存只是130MB。在32位的Linux系统上,由于3GB的用户空间地址空间限制,我将无法创建这么多的虚拟内存,而不考虑物理内存的实际消耗。为了在32位系统上解决这个问题,有时需要减少预配置的堆栈大小,这在今天的Python中仍然可以通过threading.stack_size([size])做到。显然,减少堆栈大小对于函数调用嵌套的程度(包括递归)具有运行时安全性的影响。单线程协程没有这些问题,是并发I/O的一个更好的替代方案。

线程可能会影响到吞吐量

在非常高的并发级别(例如,大于5000个线程),由于上下文切换成本,还可能对吞吐量产生影响,假设你知道如何配置操作系统,操作系统甚至真的会允许你创建那么多线程!例如,在最近的macOS版本上,测试前面10,000个不执行任何线程的示例变得非常乏味,因此我彻底放弃了提高限制的尝试。

线程不够灵活

操作系统将持续地与所有线程共享CPU时间,而不管线程是否准备好工作。例如,一个线程可能正在一个套接字上等待数据,但是在需要完成任何实际工作之前,操作系统调度器仍然可能在这个线程之间来回切换数千次。(在异步世界中,select()系统调用用于检查一个等待套接字的协程是否需要调用;否则,协程甚至不会被唤醒,从而完全避免了任何切换成本。)

这些信息都不是新的,线程作为编程模型的问题也不是特定于平台的。例如,以下是Microsoft Visual c++文档中关于线程的说明:

Windows API中的中心并发机制是线程。通常使用CreateThread函数来创建线程。虽然线程相对容易创建和使用,但操作系统分配了大量的时间和其他资源来管理它们。此外,尽管保证每个线程与相同优先级级别的任何其他线程接收相同的执行时间,但相关的开销要求你创建足够大的任务。对于更小或更细粒度的任务,与并发性相关的开销可能超过并行运行任务的好处。

但是——我听到你在抗议——这是Windows,对吧?Unix系统肯定不会有这些问题吧?下面是Mac开发者库的线程编程指南中的一个类似的建议:

就内存使用和性能而言,线程化对程序(和系统)有实际的成本。每个线程都需要在内核内存空间和程序内存空间中分配内存。管理线程和协调其调度所需的核心结构使用有线内存存储在内核中。线程的堆栈空间和每个线程的数据存储在程序的内存空间中。大多数这些结构是在你第一次创建线程时创建和初始化的——由于需要与内核进行交互,这个进程的开销可能比较大。

他们在并发编程指南(重点挖掘)中更进一步:

在过去,向应用程序引入并发性需要创建一个或多个额外线程。不幸的是,编写线程代码很有挑战性。线程是必须手工管理的低级工具。如果应用程序的最佳线程数可以根据当前系统负载和底层硬件动态改变,那么实现正确的线程解决方案将变得极其困难,甚至不可能实现。此外,通常与线程一起使用的同步机制增加了软件设计的复杂性和风险,而不能保证提高性能。

这些主题贯穿始终:

线程化使得代码难以推理对于大规模并发(数以千计的并发任务)来说,线程是一种效率低下的模型。

接下来,让我们看一个案例研究,它涉及强调第一个也是最重要的一点的线程。

案例研究:机器人和餐具

在这本书的开头,我讲述了一个餐馆的故事,在那里,人形机器人ThreadBots完成了所有的工作。在这个类比中,每个工人都是一个线程。在示例2-2的案例研究中,我们要看一下为什么线程被认为是不安全的。

用于餐桌服务的线程机器人编程

import threadingfrom queue import Queueclass ThreadBot(threading.Thread):    def __init__(self):        super().__init__(target=self.manage_table)        self.cutlery = Cutlery(knives=0, forks=0)        self.tasks = Queue()      def manage_table(self):        while True:            task = self.tasks.get()            if task == 'prepare table':                kitchen.give(to=self.cutlery, knives=4, forks=4)            elif task == 'clear table':                self.cutlery.give(to=kitchen, knives=4, forks=4)            elif task == 'shutdown':                return
(L4)ThreadBot是thread的一个子类(L6)线程的目标函数是manage_table()方法,该方法稍后在文件中定义。(L7)这个机器人将会是服务员,需要负责一些餐具。每个机器人都会追踪它从厨房拿走的餐具。(稍后将定义Cutlery类。)(L8)机器人也会被分配给任务。它们将被添加到这个任务队列中,然后机器人将在其主处理循环期间执行它们。(L11)这个机器人的主要程序是这个无限循环。如果你需要关闭一个机器人,你必须给他们关闭任务。(L14)这个机器人只定义了三个任务:准备桌子,机器人必须准备一个新桌子以提供服务。在我们的测试中,唯一的要求是从厨房拿餐具并把它们放在桌子上;清理桌子,是在需要清理桌子时使用:机器人必须将使用过的餐具送回厨房;关机只是关闭了机器人。

示例2-3 餐具类的定义

from attr import attrs, attrib@attrsclass Cutlery:    knives = attrib(default=0)    forks = attrib(default=0)    def give(self, to: 'Cutlery', knives=0, forks=0):        self.change(-knives, -forks)        to.change(knives, forks)    def change(self, knives, forks):            self.knives += knives            self.forks += forkskitchen = Cutlery(knives=100, forks=100)bots = [ThreadBot() for i in range(10)]import sysfor bot in bots:    for i in range(int(sys.argv[1])):          bot.tasks.put('prepare table')        bot.tasks.put('clear table')    bot.tasks.put('shutdown')print('Kitchen inventory before service:', kitchen)for bot in bots:    bot.start()for bot in bots:    bot.join()print('Kitchen inventory after service:', kitchen)
(L3) attrs是一个与线程或asyncio没有任何关系的开源库,它是一个非常棒的库,可以使类的创建变得非常容易。在这里,@attrs装饰器将确保这个Cutlery类将自动设置所有常见的样板代码(如__init__())。(L5) attrb()函数提供了一种简单的方法来创建属性,包括默认值,通常在__init__()方法中将其作为关键字参数处理。(L8) 这种方法是用来把刀叉从一个餐具对象转移到另一个。通常,机器人会使用这个方法从厨房拿取餐具放到新桌子上,并在桌子被清空后将餐具放回厨房。(L12) 这是一个非常简单的实用函数,用于更改对象实例中的库存数据。(L16) 我们将kitchen定义为厨房餐具清单的标识符。每个机器人都会从这个位置获得餐具。当桌子被清理干净时,他们还需要把餐具归还给厨房。(L17)该脚本在测试时执行。对于我们的测试场景,我们将使用10个ThreadBot。(L21)我们将餐桌的数量作为命令行参数,然后给每个机器人指定准备和清除餐厅中的餐桌的任务数量。(L24)shutdown任务会终止机器人(这样,稍后的bot.join()将执行return)。脚本的其余部分打印诊断消息并启动机器人。

测试代码的策略基本上包括:在一系列餐桌服务上运行一组线程机器人。每个线程机器人必须做以下工作:

准备一张“四人桌”,意思是从厨房里准备四套刀叉。收拾桌子,也就是把四副刀叉从桌子上放回厨房。

如果你在一批餐桌上运行一系列线程机器人的操作一定的次数,你肯定希望在所有的工作完成后,所有的刀叉都应该回到厨房并清点完毕。

明智的是,你决定对此进行测试,每个线程机器人准备并收拾100个餐桌,并且所有餐桌都同时运行,因为你希望确保它们可以一起工作,不会出现任何错误。这是测试的输出:

  $ python cutlery_test.py 100  Kitchen inventory before service: Cutlery(knives=100, forks=100)  Kitchen inventory after service: Cutlery(knives=100, forks=100)

所有的刀叉最后都回厨房了!因此,你庆幸自己编写了良好的代码并部署了机器人。不幸的是,在实践中,有时你会发现餐馆关门时,你并没有把所有的餐具都算进去。当你添加更多机器人时,你会发现问题变得更糟。沮丧之余,你再次运行测试,除了测试的规模(10,000个餐桌!)以外,没有更改任何内容:

  $ python cutlery_test.py 10000  Kitchen inventory before service: Cutlery(knives=100, forks=100)  Kitchen inventory after service: Cutlery(knives=96, forks=103)

现在你看到了,这确实是一个问题。餐厅供应了1万张餐桌,结果你在厨房里留下了错数量的刀叉。为了再现性,你要检查错误是否一致:

  $ python cutlery_test.py 10000  Kitchen inventory before service: Cutlery(knives=100, forks=100)  Kitchen inventory after service: Cutlery(knives=112, forks=96)

仍然存在错误,但数量与前一次运行不同。这实在是太荒谬了!记住,这些机器人的构造非常好,它们不会犯错误。那到底是哪里出错了呢?

让我们总结一下现在的情况:

你的线程机器人代码非常简单且易于理解,逻辑清晰。你有一个复现很好的测试(100个餐桌)你有一个复现不好的测试(10000个餐桌)比较大的那个测试以不同的、不可重复的方式失败

这些是竞争条件的错误的几个典型迹象。有经验的读者应该已经看到了原因,所以现在我们来调查一下。这一切都归结为Cutlery类的这个方法:

    def change(self, knives, forks):        self.knives += knives        self.forks += forks

原地求和+=是分为几个单独的步骤内部实现的(在python解释器的C代码中)分为几个单独的步骤:

读取当前self.knives的值,放到一个临时的位置。添加新的值到刚才临时位置的值里。把新的值从临时位置复制到原来的位置。

抢占式多任务处理的问题是:任何忙于执行这些步骤的线程都有可能随时被中断,而且不同的线程有机会执行相同的步骤。

在这种情况下,假如线程机器人A执行了步骤1,然后操作系统的调度器暂停A并且切换到线程机器人B。B也读取self.knives的当前值;然后执行回到A。A增加刀子的总数并且写回,但是B从它刚才暂停的地方继续执行,它增加而且写回新的总数,从而擦除了A所做的更改!

这个问题可以通过围绕共享状态的改动值加一个锁来解决(想象我们给餐具类增加了一个threading.Lock):

    def change(self, knives, forks):        with self.lock:            self.knives += knives            self.forks += forks

但是这样就需要你知道所有多个线程共享状态的位置。当你控制所有的源代码的时候,这种方法是可行的,但当使用很多第三方库就变得很困难了。但这在Python中很有可能,这就要感谢python中优秀的开源生态系统了。

不过要注意仅仅通过看源代码是不可能看到条件竞争的。这是因为源代码没有提供任何关于线程之间将在何处切换的提示。反正这也没什么用,因为操作系统可以在任何地方切换线程。

另一种更好的解决方案——也是异步编程的要点——是修改我们的代码,以便只使用一个ThreadBot,并根据需要配置它在所有餐桌之间移动。对于我们的案例研究,这意味着厨房里的刀叉只需要一个线程就可以修改。

更好的是,在我们的异步程序中,我们能够确切地看到上下文将在多个并发协程之间切换的位置,因为await关键字显式地指出了这些位置。我决定不在这里展示这个案例研究的异步版本,因为第3章详细介绍了如何使用asyncio。但如果你的好奇心无法满足,例子B-1中有一个注释的例子;这可能只有在你阅读下一章后才有意义!

=====================

第2章正文完了,谢谢大家。这是O'Reilly的《Using Asyncio in Python》中文翻译,大概会分5-6期内容写完。我会在完结后放出全文pdf和文档的LaTeX源码的。

标签: #python 虚拟内存