Python 中的with
语句有时会让新手和有经验的 Python 程序员感到困惑。本章深入解释了作为上下文管理器的with
语句背后的思想及其在并发和并行编程中的使用,特别是关于同步线程时锁的使用。本章还提供了with
语句最常用的具体示例。
本章将介绍以下主题:
- 上下文管理的概念和
with
语句作为上下文管理器提供的选项,特别是在并发和并行编程中 with
语句的语法及其有效使用with
语句在并发编程中的不同使用方法
以下是本章的先决条件列表:
- 您的计算机上必须安装 Python 3
- 在下载 GitHub 存储库 https://github.com/PacktPublishing/Mastering-Concurrency-in-Python
- 在本章中,我们将使用名为
Chapter04
的子文件夹 - 查看以下视频以查看代码的运行:http://bit.ly/2DSGLEZ
新的with
语句最初是在 Python 2.5 中引入的,并且已经使用了相当长的一段时间。然而,即使对于有经验的 Python 程序员来说,关于它的使用似乎仍然存在困惑。with
语句最常用作正确管理资源的上下文管理器,这在并发和并行编程中是必不可少的,其中资源在并发或并行应用中跨不同实体共享。
作为一个有经验的 Python 用户,您可能已经看到了用于在 Python 程序中打开和读取外部文件的with
语句。从较低的层次来看这个问题,在 Python 中打开外部文件的操作将消耗一个资源。在本例中,文件描述符和操作系统将对此资源设置限制。这意味着系统上运行的单个进程可以同时打开的文件数量有一个上限。
让我们考虑一个快速的例子来进一步说明这一点。让我们看看下面的代码,如下面的代码所示:
# Chapter04/example1.py
n_files = 10
files = []
for i in range(n_files):
files.append(open('output1/sample%i.txt' % i, 'w'))
这个快速程序只需在output1
文件夹中创建 10 个文本文件:sample0.txt
、sample1.txt
、sample9.txt
。我们可能更感兴趣的是,这些文件是在for
循环中打开的,但没有关闭。这在编程中是一种不好的做法,我们将在后面讨论。现在,假设我们想将n_files
变量重新赋值为一个大数字,比如 10000,如下代码所示:
# Chapter4/example1.py
n_files = 10000
files = []
# method 1
for i in range(n_files):
files.append(open('output1/sample%i.txt' % i, 'w'))
我们将得到类似以下的错误:
> python example1.py
Traceback (most recent call last):
File "example1.py", line 7, in <module>
OSError: [Errno 24] Too many open files: 'output1/sample253.txt'
仔细查看错误消息,我们可以看到我的笔记本电脑只能同时处理 253 个打开的文件(附带说明,如果您使用的是类 UNIX 系统,则运行ulimit -n
将为您提供系统可以处理的文件数)。更一般地说,这种情况是由所谓的文件描述符泄漏引起的。当 Python 在程序中打开一个文件时,打开的文件本质上由一个整数表示。这个整数充当程序可以用来访问该文件的参考点,同时不让程序完全控制底层文件本身。
通过同时打开太多文件,我们的程序分配了太多的文件描述符来管理打开的文件,因此出现了错误消息。文件描述符泄漏会导致许多难题,特别是在并发和并行编程中,即对打开的文件进行未经授权的 I/O 操作。解决方法是简单地以协调的方式关闭打开的文件。让我们看看第二种方法中的Chapter04/example1.py
文件。在for
循环中,我们将执行以下操作:
# Chapter04/example1.py
n_files = 1000
files = []
# method 2
for i in range(n_files):
f = open('output1/sample%i.txt' % i, 'w')
files.append(f)
f.close()
在现实生活中的应用中,由于忘记关闭程序中打开的文件,因此很容易管理不当;有时也可能出现这样的情况,即无法判断程序是否已完成对文件的处理,因此我们程序员将无法决定何时放置语句以适当地关闭文件。这种情况在并发和并行编程中更为常见,其中不同元素之间的执行顺序经常变化。
这个问题的一个可能的解决方案在其他编程语言中也很常见,就是每次我们想要与外部文件交互时都使用try...except...finally
块。此解决方案仍然需要相同级别的管理和显著的开销,并且也不能很好地提高程序的易用性和可读性。这就是 Python 的with
语句发挥作用的时候。
with
语句为我们提供了一种简单的方法,可以确保在程序使用完所有打开的文件后,它们都得到了正确的管理和清理。使用with
语句最显著的优点在于,即使代码成功执行或返回错误,with
语句始终通过上下文适当地处理和管理打开的文件。例如,让我们更详细地看一下我们的Chapter04/example1.py
文件:
# Chapter04/example1.py
n_files = 254
files = []
# method 3
for i in range(n_files):
with open('output1/sample%i.txt' % i, 'w') as f:
files.append(f)
虽然此方法完成了与我们前面看到的第二种方法相同的工作,但它还提供了一种更干净、更可读的方法来管理与程序交互的打开的文件。更具体地说,with
语句帮助我们指出本例中某些变量的范围,即指向打开的文件的变量,以及它们的上下文。
例如,在前面代码中的第三种方法中,f
变量表示for
循环每次迭代时with
块内当前打开的文件,并且只要我们的程序退出该with
块(不在该f
的范围内)变量),不再有任何其他方式访问它。此体系结构保证与文件描述符关联的所有清理都会正确进行。因此,with
语句被称为上下文管理器。
with
语句的语法可以直观明了。为了使用上下文管理器定义的方法包装块的执行,它由以下简单形式组成:
with [expression] (as [target]):
[code]
请注意,with
语句的as [target]
部分实际上不是必需的,我们将在后面看到。此外,with
语句还可以处理同一行中的多个项目。具体地说,创建的上下文管理器被视为多个with
语句相互嵌套。例如,请查看以下代码:
with [expression1] as [target1], [expression2] as [target2]:
[code]
其解释如下:
with [expression1] as [target1]:
with [expression2] as [target2]:
[code]
显然,打开和关闭外部文件与并发性并不十分相似。但是,我们前面提到,with
语句作为上下文管理器,不仅用于管理文件描述符,而且通常用于管理大多数资源。如果您在阅读第 2 章、Amdahl 定律时发现管理threading.Lock()
类中的锁对象类似于管理外部文件,那么这就是两者之间比较的地方。
作为刷新工具,锁是并发和并行编程中的机制,通常用于同步多线程应用中的线程(即,防止多个线程同时访问关键会话)。但是,正如我们将在第 12 章、饥饿中再次讨论的,锁也是死锁的常见来源,在此期间,线程获取锁,但由于未处理的事件从未释放,从而停止整个程序。
让我们看一下 Python 中的一个快速示例。我们来看一下Chapter04/example2.py
文件,如下代码所示:
# Chapter04/example2.py
from threading import Lock
my_lock = Lock()
def get_data_from_file_v1(filename):
my_lock.acquire()
with open(filename, 'r') as f:
data.append(f.read())
my_lock.release()
data = []
try:
get_data_from_file('output2/sample0.txt')
except FileNotFoundError:
print('Encountered an exception...')
my_lock.acquire()
print('Lock can still be acquired.')
在本例中,我们有一个get_data_from_file_v1()
函数,它接收外部文件的路径,从中读取数据,并将该数据附加到名为data
的预声明列表中。在该函数中,在调用函数之前预先声明的名为my_lock
的锁对象在参数文件读取之前和之后分别被获取和释放。
在主程序中,我们将尝试对不存在的文件调用get_data_from_file_v1()
,这是编程中最常见的错误之一。在程序结束时,我们还再次获取锁对象。关键是看我们的编程是否能够用我们现有的try...except
块恰当而优雅地处理读取不存在的文件的错误。
运行脚本后,您会注意到我们的程序将打印出在try...except
块Encountered an exception...
中指定的错误消息,这是预期的,因为找不到文件。但是,程序也将无法执行其余的代码;它永远不会到达代码的最后一行-print('Lock acquired.')
-并且将永远挂起(或者直到您点击Ctrl+C强制退出程序)。
这是一种死锁情况,当在get_data_from_file_v1()
函数中获取my_lock
时,同样会发生这种情况,但由于我们的程序在执行my_lock.release()
之前遇到错误,所以锁从未释放。这反过来导致程序末尾的my_lock.acquire()
行挂起,因为无法以任何方式获取锁。因此,我们的程序无法到达其最后一行代码print('Lock acquired.')
。
然而,这个问题可以用with
语句轻松地处理。在example2.py
文件中,只需注释掉调用get_data_from_file_v1()
的行,并取消注释调用get_data_from_file_v2()
的行,您将获得以下结果:
# Chapter04/example2.py
from threading import Lock
my_lock = Lock()
def get_data_from_file_v2(filename):
with my_lock, open(filename, 'r') as f:
data.append(f.read())
data = []
try:
get_data_from_file_v2('output2/sample0.txt')
except:
print('Encountered an exception...')
my_lock.acquire()
print('Lock acquired.')
在get_data_from_file_v2()
函数中,我们有一对嵌套的with
语句的等价物,如下所示:
with my_lock:
with open(filename, 'r') as f:
data.append(f.read())
由于Lock
对象是上下文管理器,简单地使用with my_lock:
将确保适当地获取和释放锁对象,即使在块内遇到异常。运行脚本后,您将获得以下输出:
> python example2.py
Encountered an exception...
Lock acquired.
我们可以看到,这一次,我们的程序能够获得锁并优雅地、无误地到达脚本末尾。
Python 中的with
语句提供了一种直观方便的方法来管理资源,同时确保正确处理错误和异常。这种管理资源的能力在并发和并行编程中更为重要,在并发和并行编程中,通过使用with
语句和threading.Lock
对象(用于同步多线程应用中的不同线程)跨不同实体共享和利用各种资源。
除了更好的错误处理和保证的清理任务外,with
语句还提供了程序的额外可读性,这是 Python 为其开发人员提供的最强大的功能之一。
在下一章中,我们将讨论 Python 目前最流行的用途之一:web 抓取应用。我们将了解 web 抓取背后的概念和基本思想,Python 提供的支持 web 抓取的工具,以及并发将如何显著帮助您的 web 抓取应用。
- 什么是文件描述符?在 Python 中可以用什么方式处理它?
- 如果文件描述符处理不仔细,会出现什么问题?
- 什么是锁?在 Python 中可以用什么方式处理它?
- 如果不小心操作锁,会出现什么问题?
- 上下文管理器背后的想法是什么?
- Python 中的
with
语句在上下文管理方面提供了哪些选项?
有关更多信息,请参阅以下链接:
- Python 并行编程食谱,由 Zaccone 和 Giancarlo 编写,由 Packt 出版,2015 年
- 改进您的 Python:with 语句和上下文管理器,Jeff Knupp(https://jeffknupp.com/blog/2016/03/07/improve-your-python-the-with-statement-and-context-managers/
- 复合语句 AUTT1,Python 软件基金会(Po.T2)https://docs.python.org/3/reference/compound_stmts.html