This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View File

@@ -0,0 +1,330 @@
<audio id="audio" title="15 | Python对象的比较、拷贝" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/59/6f/59c1f81b6b6176ad81b4db3ea0213b6f.mp3"></audio>
你好,我是景霄。
在前面的学习中,我们其实已经接触到了很多 Python对象比较和复制的例子比如下面这个判断a和b是否相等的if语句
```
if a == b:
...
```
再比如第二个例子这里l2就是l1的拷贝。
```
l1 = [1, 2, 3]
l2 = list(l1)
```
但你可能并不清楚,这些语句的背后发生了什么。比如,
- l2是l1的浅拷贝shallow copy还是深度拷贝deep copy
- `a == b`是比较两个对象的值相等,还是两个对象完全相等呢?
关于这些的种种知识,我希望通过这节课的学习,让你有个全面的了解。
## `'=='` VS `'is'`
等于(==和is是Python中对象比较常用的两种方式。简单来说`'=='`操作符比较对象之间的值是否相等比如下面的例子表示比较变量a和b所指向的值是否相等。
```
a == b
```
`'is'`操作符比较的是对象的身份标识是否相等,即它们是否是同一个对象,是否指向同一个内存地址。
在Python中每个对象的身份标识都能通过函数id(object)获得。因此,`'is'`操作符相当于比较对象之间的ID是否相等我们来看下面的例子
```
a = 10
b = 10
a == b
True
id(a)
4427562448
id(b)
4427562448
a is b
True
```
这里首先Python会为10这个值开辟一块内存然后变量a和b同时指向这块内存区域即a和b都是指向10这个变量因此a和b的值相等id也相等`a == b``a is b`都返回True。
不过,需要注意,对于整型数字来说,以上`a is b`为True的结论只适用于-5到256范围内的数字。比如下面这个例子
```
a = 257
b = 257
a == b
True
id(a)
4473417552
id(b)
4473417584
a is b
False
```
这里我们把257同时赋值给了a和b可以看到`a == b`仍然返回True因为a和b指向的值相等。但奇怪的是`a is b`返回了false并且我们发现a和b的ID不一样了这是为什么呢
事实上出于对性能优化的考虑Python内部会对-5到256的整型维持一个数组起到一个缓存的作用。这样每次你试图创建一个-5到256范围内的整型数字时Python都会从这个数组中返回相对应的引用而不是重新开辟一块新的内存空间。
但是如果整型数字超过了这个范围比如上述例子中的257Python则会为两个257开辟两块内存区域因此a和b的ID不一样`a is b`就会返回False了。
通常来说,在实际工作中,当我们比较变量时,使用`'=='`的次数会比`'is'`多得多因为我们一般更关心两个变量的值而不是它们内部的存储地址。但是当我们比较一个变量与一个单例singleton通常会使用`'is'`。一个典型的例子就是检查一个变量是否为None
```
if a is None:
...
if a is not None:
...
```
这里注意,比较操作符`'is'`的速度效率,通常要优于`'=='`。因为`'is'`操作符不能被重载这样Python就不需要去寻找程序中是否有其他地方重载了比较操作符并去调用。执行比较操作符`'is'`就仅仅是比较两个变量的ID而已。
但是`'=='`操作符却不同,执行`a == b`相当于是去执行`a.__eq__(b)`而Python大部分的数据类型都会去重载`__eq__`这个函数,其内部的处理通常会复杂一些。比如,对于列表,`__eq__`函数会去遍历列表中的元素,比较它们的顺序和值是否相等。
不过对于不可变immutable的变量如果我们之前用`'=='`或者`'is'`比较过,结果是不是就一直不变了呢?
答案自然是否定的。我们来看下面一个例子:
```
t1 = (1, 2, [3, 4])
t2 = (1, 2, [3, 4])
t1 == t2
True
t1[-1].append(5)
t1 == t2
False
```
我们知道元组是不可变的,但元组可以嵌套,它里面的元素可以是列表类型,列表是可变的,所以如果我们修改了元组中的某个可变元素,那么元组本身也就改变了,之前用`'is'`或者`'=='`操作符取得的结果,可能就不适用了。
这一点,你在日常写程序时一定要注意,在必要的地方请不要省略条件检查。
## 浅拷贝和深度拷贝
接下来我们一起来看看Python中的浅拷贝shallow copy和深度拷贝deep copy
对于这两个熟悉的操作,我并不想一上来先抛概念让你死记硬背来区分,我们不妨先从它们的操作方法说起,通过代码来理解两者的不同。
先来看浅拷贝。常见的浅拷贝的方法,是使用数据类型本身的构造器,比如下面两个例子:
```
l1 = [1, 2, 3]
l2 = list(l1)
l2
[1, 2, 3]
l1 == l2
True
l1 is l2
False
s1 = set([1, 2, 3])
s2 = set(s1)
s2
{1, 2, 3}
s1 == s2
True
s1 is s2
False
```
这里l2就是l1的浅拷贝s2是s1的浅拷贝。当然对于可变的序列我们还可以通过切片操作符`':'`完成浅拷贝,比如下面这个列表的例子:
```
l1 = [1, 2, 3]
l2 = l1[:]
l1 == l2
True
l1 is l2
False
```
当然Python中也提供了相对应的函数copy.copy(),适用于任何数据类型:
```
import copy
l1 = [1, 2, 3]
l2 = copy.copy(l1)
```
不过需要注意的是对于元组使用tuple()或者切片操作符`':'`不会创建一份浅拷贝,相反,它会返回一个指向相同元组的引用:
```
t1 = (1, 2, 3)
t2 = tuple(t1)
t1 == t2
True
t1 is t2
True
```
这里,元组(1, 2, 3)只被创建一次t1和t2同时指向这个元组。
到这里,对于浅拷贝你应该很清楚了。浅拷贝,是指重新分配一块内存,创建一个新的对象,里面的元素是原对象中子对象的引用。因此,如果原对象中的元素不可变,那倒无所谓;但如果元素可变,浅拷贝通常会带来一些副作用,尤其需要注意。我们来看下面的例子:
```
l1 = [[1, 2], (30, 40)]
l2 = list(l1)
l1.append(100)
l1[0].append(3)
l1
[[1, 2, 3], (30, 40), 100]
l2
[[1, 2, 3], (30, 40)]
l1[1] += (50, 60)
l1
[[1, 2, 3], (30, 40, 50, 60), 100]
l2
[[1, 2, 3], (30, 40)]
```
这个例子中我们首先初始化了一个列表l1里面的元素是一个列表和一个元组然后对l1执行浅拷贝赋予l2。因为浅拷贝里的元素是对原对象元素的引用因此l2中的元素和l1指向同一个列表和元组对象。
接着往下看。`l1.append(100)`表示对l1的列表新增元素100。这个操作不会对l2产生任何影响因为l2和l1作为整体是两个不同的对象并不共享内存地址。操作过后l2不变l1会发生改变
```
[[1, 2, 3], (30, 40), 100]
```
再来看,`l1[0].append(3)`这里表示对l1中的第一个列表新增元素3。因为l2是l1的浅拷贝l2中的第一个元素和l1中的第一个元素共同指向同一个列表因此l2中的第一个列表也会相对应的新增元素3。操作后l1和l2都会改变
```
l1: [[1, 2, 3], (30, 40), 100]
l2: [[1, 2, 3], (30, 40)]
```
最后是`l1[1] += (50, 60)`因为元组是不可变的这里表示对l1中的第二个元组拼接然后重新创建了一个新元组作为l1中的第二个元素而l2中没有引用新元组因此l2并不受影响。操作后l2不变l1发生改变
```
l1: [[1, 2, 3], (30, 40, 50, 60), 100]
```
通过这个例子,你可以很清楚地看到使用浅拷贝可能带来的副作用。因此,如果我们想避免这种副作用,完整地拷贝一个对象,你就得使用深度拷贝。
所谓深度拷贝,是指重新分配一块内存,创建一个新的对象,并且将原对象中的元素,以递归的方式,通过创建新的子对象拷贝到新对象中。因此,新对象和原对象没有任何关联。
Python中以copy.deepcopy()来实现对象的深度拷贝。比如上述例子写成下面的形式,就是深度拷贝:
```
import copy
l1 = [[1, 2], (30, 40)]
l2 = copy.deepcopy(l1)
l1.append(100)
l1[0].append(3)
l1
[[1, 2, 3], (30, 40), 100]
l2
[[1, 2], (30, 40)]
```
我们可以看到无论l1如何变化l2都不变。因为此时的l1和l2完全独立没有任何联系。
不过,深度拷贝也不是完美的,往往也会带来一系列问题。如果被拷贝对象中存在指向自身的引用,那么程序很容易陷入无限循环:
```
import copy
x = [1]
x.append(x)
x
[1, [...]]
y = copy.deepcopy(x)
y
[1, [...]]
```
上面这个例子列表x中有指向自身的引用因此x是一个无限嵌套的列表。但是我们发现深度拷贝x到y后程序并没有出现stack overflow的现象。这是为什么呢
其实这是因为深度拷贝函数deepcopy中会维护一个字典记录已经拷贝的对象与其ID。拷贝过程中如果字典里已经存储了将要拷贝的对象则会从字典直接返回我们来看相对应的源码就能明白
```
def deepcopy(x, memo=None, _nil=[]):
&quot;&quot;&quot;Deep copy operation on arbitrary Python objects.
See the module's __doc__ string for more info.
&quot;&quot;&quot;
if memo is None:
memo = {}
d = id(x) # 查询被拷贝对象x的id
y = memo.get(d, _nil) # 查询字典里是否已经存储了该对象
if y is not _nil:
return y # 如果字典里已经存储了将要拷贝的对象,则直接返回
...
```
## 总结
今天这节课我们一起学习了Python中对象的比较和拷贝主要有下面几个重点内容。
- 比较操作符`'=='`表示比较对象间的值是否相等,而`'is'`表示比较对象的标识是否相等,即它们是否指向同一个内存地址。
- 比较操作符`'is'`效率优于`'=='`,因为`'is'`操作符无法被重载,执行`'is'`操作只是简单的获取对象的ID并进行比较`'=='`操作符则会递归地遍历对象的所有值,并逐一比较。
- 浅拷贝中的元素,是原对象中子对象的引用,因此,如果原对象中的元素是可变的,改变其也会影响拷贝后的对象,存在一定的副作用。
- 深度拷贝则会递归地拷贝原对象中的每一个子对象因此拷贝后的对象和原对象互不相关。另外深度拷贝中会维护一个字典记录已经拷贝的对象及其ID来提高效率并防止无限递归的发生。
## 思考题
最后,我为你留下一道思考题。这节课我曾用深度拷贝,拷贝过一个无限嵌套的列表。那么。当我们用等于操作符`'=='`进行比较时输出会是什么呢是True或者False还是其他为什么呢建议你先自己动脑想一想然后再实际跑一下代码来检验你的猜想。
```
import copy
x = [1]
x.append(x)
y = copy.deepcopy(x)
# 以下命令的输出是?
x == y
```
欢迎在留言区写下你的答案和学习感想,也欢迎你把这篇文章分享给你的同事、朋友。我们一起交流,一起进步。

View File

@@ -0,0 +1,279 @@
<audio id="audio" title="16 | 值传递引用传递or其他Python里参数是如何传递的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bb/26/bba69bc5548f3ca824b05a9deb3a6c26.mp3"></audio>
你好,我是景霄。
在前面的第一大章节中我们一起学习了Python的函数基础及其应用。我们大致明白了所谓的传参就是把一些参数从一个函数传递到另一个函数从而使其执行相应的任务。但是你有没有想过参数传递的底层是如何工作的原理又是怎样的呢
实际工作中很多人会遇到这样的场景写完了代码一测试发现结果和自己期望的不一样于是开始一层层地debug。花了很多时间可到最后才发现是传参过程中数据结构的改变导致了程序的“出错”。
比如,我将一个列表作为参数传入另一个函数,期望列表在函数运行结束后不变,但是往往“事与愿违”,由于某些操作,它的值改变了,那就很有可能带来后续程序一系列的错误。
因此了解Python中参数的传递机制具有十分重要的意义这往往能让我们写代码时少犯错误提高效率。今天我们就一起来学习一下Python中参数是如何传递的。
## **什么是值传递和引用传递**
如果你接触过其他的编程语言比如C/C++很容易想到常见的参数传递有2种**值传递**和**引用传递**。所谓值传递,通常就是拷贝参数的值,然后传递给函数里的新变量。这样,原变量和新变量之间互相独立,互不影响。
比如我们来看下面的一段C++代码:
```
#include &lt;iostream&gt;
using namespace std;
// 交换2个变量的值
void swap(int x, int y) {
int temp;
temp = x; // 交换x和y的值
x = y;
y = temp;
return;
}
int main () {
int a = 1;
int b = 2;
cout &lt;&lt; &quot;Before swap, value of a :&quot; &lt;&lt; a &lt;&lt; endl;
cout &lt;&lt; &quot;Before swap, value of b :&quot; &lt;&lt; b &lt;&lt; endl;
swap(a, b);
cout &lt;&lt; &quot;After swap, value of a :&quot; &lt;&lt; a &lt;&lt; endl;
cout &lt;&lt; &quot;After swap, value of b :&quot; &lt;&lt; b &lt;&lt; endl;
return 0;
}
Before swap, value of a :1
Before swap, value of b :2
After swap, value of a :1
After swap, value of b :2
```
这里的swap()函数把a和b的值拷贝给了x和y然后再交换x和y的值。这样一来x和y的值发生了改变但是a和b不受其影响所以值不变。这种方式就是我们所说的值传递。
所谓引用传递,通常是指把参数的引用传给新的变量,这样,原变量和新变量就会指向同一块内存地址。如果改变了其中任何一个变量的值,那么另外一个变量也会相应地随之改变。
还是拿我们刚刚讲到的C++代码为例上述例子中的swap()函数,如果改成下面的形式,声明引用类型的参数变量:
```
void swap(int&amp; x, int&amp; y) {
int temp;
temp = x; // 交换x和y的值
x = y;
y = temp;
return;
}
```
那么输出的便是另一个结果:
```
Before swap, value of a :1
Before swap, value of b :2
After swap, value of a :2
After swap, value of b :1
```
原变量a和b的值被交换了因为引用传递使得a和xb和y一模一样对x和y的任何改变必然导致了a和b的相应改变。
不过这是C/C++语言中的特点。那么Python中参数传递到底是如何进行的呢它们到底属于值传递、引用传递还是其他呢
在回答这个问题之前让我们先来了解一下Python变量和赋值的基本原理。
## **Python变量及其赋值**
我们首先来看下面的Python代码示例
```
a = 1
b = a
a = a + 1
```
这里首先将1赋值于a即a指向了1这个对象如下面的流程图所示
<img src="https://static001.geekbang.org/resource/image/97/eb/97c05df49cfe051d7b76addd833f33eb.png" alt="">
接着b = a则表示让变量b也同时指向1这个对象。这里要注意Python里的对象可以被多个变量所指向或引用。
<img src="https://static001.geekbang.org/resource/image/c0/9f/c00c9fc013cea4eb840921eb4b3e499f.png" alt="">
最后执行a = a + 1。需要注意的是Python的数据类型例如整型int、字符串string等等是不可变的。所以a = a + 1并不是让a的值增加1而是表示重新创建了一个新的值为2的对象并让a指向它。但是b仍然不变仍然指向1这个对象。
因此最后的结果是a的值变成了2而b的值不变仍然是1。
<img src="https://static001.geekbang.org/resource/image/fc/17/fc10cd3e3512e984d530a4b82259e917.png" alt="">
通过这个例子你可以看到这里的a和b开始只是两个指向同一个对象的变量而已或者你也可以把它们想象成同一个对象的两个名字。简单的赋值b = a并不表示重新创建了新对象只是让同一个对象被多个变量指向或引用。
同时,指向同一个对象,也并不意味着两个变量就被绑定到了一起。如果你给其中一个变量重新赋值,并不会影响其他变量的值。
明白了这个基本的变量赋值例子,我们再来看一个列表的例子:
```
l1 = [1, 2, 3]
l2 = l1
l1.append(4)
l1
[1, 2, 3, 4]
l2
[1, 2, 3, 4]
```
同样的我们首先让列表l1和l2同时指向了[1, 2, 3]这个对象。
<img src="https://static001.geekbang.org/resource/image/c2/f9/c2f8e0d9a8570bd56a43a21b7bb25af9.png" alt="">
由于列表是可变的所以l1.append(4)不会创建新的列表只是在原列表的末尾插入了元素4变成[1, 2, 3, 4]。由于l1和l2同时指向这个列表所以列表的变化会同时反映在l1和l2这两个变量上那么l1和l2的值就同时变为了[1, 2, 3, 4]。
<img src="https://static001.geekbang.org/resource/image/b1/5f/b16d29112c361f596952961d13da345f.png" alt="">
另外需要注意的是Python里的变量可以被删除但是对象无法被删除。比如下面的代码
```
l = [1, 2, 3]
del l
```
del l 删除了l这个变量从此以后你无法访问l但是对象[1, 2, 3]仍然存在。Python程序运行时其自带的垃圾回收系统会跟踪每个对象的引用。如果[1, 2, 3]除了l外还在其他地方被引用那就不会被回收反之则会被回收。
由此可见在Python中
- 变量的赋值,只是表示让变量指向了某个对象,并不表示拷贝对象给变量;而一个对象,可以被多个变量所指向。
- 可变对象(列表,字典,集合等等)的改变,会影响所有指向该对象的变量。
- 对于不可变对象(字符串、整型、元组等等),所有指向该对象的变量的值总是一样的,也不会改变。但是通过某些操作(+=等等)更新不可变对象的值时,会返回一个新的对象。
- 变量可以被删除,但是对象无法被删除。
## **Python函数的参数传递**
从上述Python变量的命名与赋值的原理讲解中相信你能举一反三大概猜出Python函数中参数是如何传递了吧
这里首先引用Python官方文档中的一段说明
>
“Remember that arguments are passed by assignment in Python. Since assignment just creates references to objects, theres no alias between an argument name in the caller and callee, and so no call-by-reference per Se.”
准确地说Python的参数传递是**赋值传递** pass by assignment或者叫作对象的**引用传递**pass by object reference。Python里所有的数据类型都是对象所以参数传递时只是让新变量与原变量指向相同的对象而已并不存在值传递或是引用传递一说。
比如,我们来看下面这个例子:
```
def my_func1(b):
b = 2
a = 1
my_func1(a)
a
1
```
这里的参数传递使变量a和b同时指向了1这个对象。但当我们执行到b = 2时系统会重新创建一个值为2的新对象并让b指向它而a仍然指向1这个对象。所以a的值不变仍然为1。
那么对于上述例子的情况是不是就没有办法改变a的值了呢
答案当然是否定的我们只需稍作改变让函数返回新变量赋给a。这样a就指向了一个新的值为2的对象a的值也因此变为2。
```
def my_func2(b):
b = 2
return b
a = 1
a = my_func2(a)
a
2
```
不过,当可变对象当作参数传入函数里的时候,改变可变对象的值,就会影响所有指向它的变量。比如下面的例子:
```
def my_func3(l2):
l2.append(4)
l1 = [1, 2, 3]
my_func3(l1)
l1
[1, 2, 3, 4]
```
这里l1和l2先是同时指向值为[1, 2, 3]的列表。不过由于列表可变执行append()函数对其末尾加入新元素4时变量l1和l2的值也都随之改变了。
但是,下面这个例子,看似都是给列表增加了一个新元素,却得到了明显不同的结果。
```
def my_func4(l2):
l2 = l2 + [4]
l1 = [1, 2, 3]
my_func4(l1)
l1
[1, 2, 3]
```
为什么l1仍然是[1, 2, 3],而不是[1, 2, 3, 4]呢?
要注意这里l2 = l2 + [4]表示创建了一个“末尾加入元素4“的新列表并让l2指向这个新的对象。这个过程与l1无关因此l1的值不变。当然同样的如果要改变l1的值我们就得让上述函数返回一个新列表再赋予l1即可
```
def my_func5(l2):
l2 = l2 + [4]
return l2
l1 = [1, 2, 3]
l1 = my_func5(l1)
l1
[1, 2, 3, 4]
```
这里你尤其要记住的是,改变变量和重新赋值的区别:
- my_func3()中单纯地改变了对象的值,因此函数返回后,所有指向该对象的变量都会被改变;
- 但my_func4()中则创建了新的对象,并赋值给一个本地变量,因此原变量仍然不变。
至于my_func3()和my_func5()的用法两者虽然写法不同但实现的功能一致。不过在实际工作应用中我们往往倾向于类似my_func5()的写法,添加返回语句。这样更简洁明了,不易出错。
## **总结**
今天我们一起学习了Python的变量及其赋值的基本原理并且解释了Python中参数是如何传递的。和其他语言不同的是Python中参数的传递既不是值传递也不是引用传递而是赋值传递或者是叫对象的引用传递。
需要注意的是,这里的赋值或对象的引用传递,不是指向一个具体的内存地址,而是指向一个具体的对象。
- 如果对象是可变的,当其改变时,所有指向这个对象的变量都会改变。
- 如果对象不可变,简单的赋值只能改变其中一个变量的值,其余变量则不受影响。
清楚了这一点,如果你想通过一个函数来改变某个变量的值,通常有两种方法。一种是直接将可变数据类型(比如列表,字典,集合)当作参数传入,直接在其上修改;第二种则是创建一个新变量,来保存修改后的值,然后将其返回给原变量。在实际工作中,我们更倾向于使用后者,因为其表达清晰明了,不易出错。
## **思考题**
最后,我为你留下了两道思考题。
第一个问题,下面的代码中, l1、l2和l3都指向同一个对象吗
```
l1 = [1, 2, 3]
l2 = [1, 2, 3]
l3 = l2
```
第二个问题下面的代码中打印d最后的输出是什么呢
```
def func(d):
d['a'] = 10
d['b'] = 20
d = {'a': 1, 'b': 2}
func(d)
print(d)
```
欢迎留言和我分享,也欢迎你把这篇文章分享给你的同事、朋友,一起在交流中进步。

View File

@@ -0,0 +1,471 @@
<audio id="audio" title="17 | 强大的装饰器" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2d/d1/2dd0af09b606bdb461582010ac44cfd1.mp3"></audio>
你好,我是景霄。这节课,我们一起来学习装饰器。
装饰器一直以来都是Python中很有用、很经典的一个feature在工程中的应用也十分广泛比如日志、缓存等等的任务都会用到。然而在平常工作生活中我发现不少人尤其是初学者常常因为其相对复杂的表示对装饰器望而生畏认为它“too fancy to learn”实际并不如此。
今天这节课,我会以前面所讲的函数、闭包为切入点,引出装饰器的概念、表达和基本用法,最后,再通过实际工程中的例子,让你再次加深理解。
接下来,让我们进入正文一起学习吧!
## 函数-&gt;装饰器
### 函数核心回顾
引入装饰器之前,我们首先一起来复习一下,必须掌握的函数的几个核心概念。
第一点我们要知道在Python中函数是一等公民first-class citizen函数也是对象。我们可以把函数赋予变量比如下面这段代码
```
def func(message):
print('Got a message: {}'.format(message))
send_message = func
send_message('hello world')
# 输出
Got a message: hello world
```
这个例子中我们把函数func赋予了变量send_message这样之后你调用send_message就相当于是调用函数func()。
第二点,我们可以把函数当作参数,传入另一个函数中,比如下面这段代码:
```
def get_message(message):
return 'Got a message: ' + message
def root_call(func, message):
print(func(message))
root_call(get_message, 'hello world')
# 输出
Got a message: hello world
```
这个例子中我们就把函数get_message以参数的形式传入了函数root_call()中然后调用它。
第三点,我们可以在函数里定义函数,也就是函数的嵌套。这里我同样举了一个例子:
```
def func(message):
def get_message(message):
print('Got a message: {}'.format(message))
return get_message(message)
func('hello world')
# 输出
Got a message: hello world
```
这段代码中我们在函数func()里又定义了新的函数get_message()调用后作为func()的返回值返回。
第四点,要知道,函数的返回值也可以是函数对象(闭包),比如下面这个例子:
```
def func_closure():
def get_message(message):
print('Got a message: {}'.format(message))
return get_message
send_message = func_closure()
send_message('hello world')
# 输出
Got a message: hello world
```
这里函数func_closure()的返回值是函数对象get_message本身之后我们将其赋予变量send_message再调用send_message(hello world),最后输出了`'Got a message: hello world'`
### 简单的装饰器
简单的复习之后,我们接下来学习今天的新知识——装饰器。按照习惯,我们可以先来看一个装饰器的简单例子:
```
def my_decorator(func):
def wrapper():
print('wrapper of decorator')
func()
return wrapper
def greet():
print('hello world')
greet = my_decorator(greet)
greet()
# 输出
wrapper of decorator
hello world
```
这段代码中变量greet指向了内部函数wrapper()而内部函数wrapper()中又会调用原函数greet()因此最后调用greet()时,就会先打印`'wrapper of decorator'`,然后输出`'hello world'`
这里的函数my_decorator()就是一个装饰器它把真正需要执行的函数greet()包裹在其中并且改变了它的行为但是原函数greet()不变。
事实上上述代码在Python中有更简单、更优雅的表示
```
def my_decorator(func):
def wrapper():
print('wrapper of decorator')
func()
return wrapper
@my_decorator
def greet():
print('hello world')
greet()
```
这里的`@`,我们称之为语法糖,`@my_decorator`就相当于前面的`greet=my_decorator(greet)`语句,只不过更加简洁。因此,如果你的程序中有其它函数需要做类似的装饰,你只需在它们的上方加上`@decorator`就可以了,这样就大大提高了函数的重复利用和程序的可读性。
### 带有参数的装饰器
你或许会想到如果原函数greet()中,有参数需要传递给装饰器怎么办?
一个简单的办法是可以在对应的装饰器函数wrapper()上,加上相应的参数,比如:
```
def my_decorator(func):
def wrapper(message):
print('wrapper of decorator')
func(message)
return wrapper
@my_decorator
def greet(message):
print(message)
greet('hello world')
# 输出
wrapper of decorator
hello world
```
不过新的问题来了。如果我另外还有一个函数也需要使用my_decorator()装饰器,但是这个新的函数有两个参数,又该怎么办呢?比如:
```
@my_decorator
def celebrate(name, message):
...
```
事实上,通常情况下,我们会把`*args``**kwargs`作为装饰器内部函数wrapper()的参数。`*args``**kwargs`,表示接受任意数量和类型的参数,因此装饰器就可以写成下面的形式:
```
def my_decorator(func):
def wrapper(*args, **kwargs):
print('wrapper of decorator')
func(*args, **kwargs)
return wrapper
```
### 带有自定义参数的装饰器
其实,装饰器还有更大程度的灵活性。刚刚说了,装饰器可以接受原函数任意类型和数量的参数,除此之外,它还可以接受自己定义的参数。
举个例子,比如我想要定义一个参数,来表示装饰器内部函数被执行的次数,那么就可以写成下面这种形式:
```
def repeat(num):
def my_decorator(func):
def wrapper(*args, **kwargs):
for i in range(num):
print('wrapper of decorator')
func(*args, **kwargs)
return wrapper
return my_decorator
@repeat(4)
def greet(message):
print(message)
greet('hello world')
# 输出:
wrapper of decorator
hello world
wrapper of decorator
hello world
wrapper of decorator
hello world
wrapper of decorator
hello world
```
### 原函数还是原函数吗?
现在我们再来看个有趣的现象。还是之前的例子我们试着打印出greet()函数的一些元信息:
```
greet.__name__
## 输出
'wrapper'
help(greet)
# 输出
Help on function wrapper in module __main__:
wrapper(*args, **kwargs)
```
你会发现greet()函数被装饰以后它的元信息变了。元信息告诉我们“它不再是以前的那个greet()函数而是被wrapper()函数取代了”。
为了解决这个问题,我们通常使用内置的装饰器`@functools.wrap`,它会帮助保留原函数的元信息(也就是将原函数的元信息,拷贝到对应的装饰器函数里)。
```
import functools
def my_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print('wrapper of decorator')
func(*args, **kwargs)
return wrapper
@my_decorator
def greet(message):
print(message)
greet.__name__
# 输出
'greet'
```
### 类装饰器
前面我们主要讲了函数作为装饰器的用法,实际上,类也可以作为装饰器。类装饰器主要依赖于函数`__call__()`,每当你调用一个类的示例时,函数`__call__()`就会被执行一次。
我们来看下面这段代码:
```
class Count:
def __init__(self, func):
self.func = func
self.num_calls = 0
def __call__(self, *args, **kwargs):
self.num_calls += 1
print('num of calls is: {}'.format(self.num_calls))
return self.func(*args, **kwargs)
@Count
def example():
print(&quot;hello world&quot;)
example()
# 输出
num of calls is: 1
hello world
example()
# 输出
num of calls is: 2
hello world
...
```
这里我们定义了类Count初始化时传入原函数func(),而`__call__()`函数表示让变量num_calls自增1然后打印并且调用原函数。因此在我们第一次调用函数example()时num_calls的值是1而在第二次调用时它的值变成了2。
### 装饰器的嵌套
回顾刚刚讲的例子基本都是一个装饰器的情况但实际上Python也支持多个装饰器比如写成下面这样的形式
```
@decorator1
@decorator2
@decorator3
def func():
...
```
它的执行顺序从里到外,所以上面的语句也等效于下面这行代码:
```
decorator1(decorator2(decorator3(func)))
```
这样,`'hello world'`这个例子,就可以改写成下面这样:
```
import functools
def my_decorator1(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print('execute decorator1')
func(*args, **kwargs)
return wrapper
def my_decorator2(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print('execute decorator2')
func(*args, **kwargs)
return wrapper
@my_decorator1
@my_decorator2
def greet(message):
print(message)
greet('hello world')
# 输出
execute decorator1
execute decorator2
hello world
```
## 装饰器用法实例
到此,装饰器的基本概念及用法我就讲完了,接下来,我将结合实际工作中的几个例子,带你加深对它的理解。
### 身份认证
首先是最常见的身份认证的应用。这个很容易理解,举个最常见的例子,你登录微信,需要输入用户名密码,然后点击确认,这样,服务器端便会查询你的用户名是否存在、是否和密码匹配等等。如果认证通过,你就可以顺利登录;如果不通过,就抛出异常并提示你登录失败。
再比如一些网站,你不登录也可以浏览内容,但如果你想要发布文章或留言,在点击发布时,服务器端便会查询你是否登录。如果没有登录,就不允许这项操作等等。
我们来看一个大概的代码示例:
```
import functools
def authenticate(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
request = args[0]
if check_user_logged_in(request): # 如果用户处于登录状态
return func(*args, **kwargs) # 执行函数post_comment()
else:
raise Exception('Authentication failed')
return wrapper
@authenticate
def post_comment(request, ...)
...
```
这段代码中我们定义了装饰器authenticate而函数post_comment(),则表示发表用户对某篇文章的评论。每次调用这个函数前,都会先检查用户是否处于登录状态,如果是登录状态,则允许这项操作;如果没有登录,则不允许。
### 日志记录
日志记录同样是很常见的一个案例。在实际工作中如果你怀疑某些函数的耗时过长导致整个系统的latency延迟增加所以想在线上测试某些函数的执行时间那么装饰器就是一种很常用的手段。
我们通常用下面的方法来表示:
```
import time
import functools
def log_execution_time(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
res = func(*args, **kwargs)
end = time.perf_counter()
print('{} took {} ms'.format(func.__name__, (end - start) * 1000))
return res
return wrapper
@log_execution_time
def calculate_similarity(items):
...
```
这里装饰器log_execution_time记录某个函数的运行时间并返回其执行结果。如果你想计算任何函数的执行时间在这个函数上方加上`@log_execution_time`即可。
### 输入合理性检查
再来看今天要讲的第三个应用,输入合理性检查。
在大型公司的机器学习框架中我们调用机器集群进行模型训练前往往会用装饰器对其输入往往是很长的JSON文件进行合理性检查。这样就可以大大避免输入不正确对机器造成的巨大开销。
它的写法往往是下面的格式:
```
import functools
def validation_check(input):
@functools.wraps(func)
def wrapper(*args, **kwargs):
... # 检查输入是否合法
@validation_check
def neural_network_training(param1, param2, ...):
...
```
其实在工作中,很多情况下都会出现输入不合理的现象。因为我们调用的训练模型往往很复杂,输入的文件有成千上万行,很多时候确实也很难发现。
试想一下,如果没有输入的合理性检查,很容易出现“模型训练了好几个小时后,系统却报错说输入的一个参数不对,成果付之一炬”的现象。这样的“惨案”,大大减缓了开发效率,也对机器资源造成了巨大浪费。
### 缓存
最后我们来看缓存方面的应用。关于缓存装饰器的用法其实十分常见这里我以Python内置的LRU cache为例来说明如果你不了解 [LRU cache](https://en.wikipedia.org/wiki/Cache_replacement_policies#Examples),可以点击链接自行查阅)。
LRU cache在Python中的表示形式是`@lru_cache``@lru_cache`会缓存进程中的函数参数和结果当缓存满了以后会删除least recenly used 的数据。
正确使用缓存装饰器,往往能极大地提高程序运行效率。为什么呢?我举一个常见的例子来说明。
大型公司服务器端的代码中往往存在很多关于设备的检查比如你使用的设备是安卓还是iPhone版本号是多少。这其中的一个原因就是一些新的feature往往只在某些特定的手机系统或版本上才有比如Android v200+)。
这样一来,我们通常使用缓存装饰器,来包裹这些检查函数,避免其被反复调用,进而提高程序运行效率,比如写成下面这样:
```
@lru_cache
def check(param1, param2, ...) # 检查用户设备类型,版本号等等
...
```
## 总结
这节课,我们一起学习了装饰器的概念及用法。**所谓的装饰器,其实就是通过装饰器函数,来修改原函数的一些功能,使得原函数不需要修改。**
>
Decorators is to modify the behavior of the function through a wrapper so we dont have to actually modify the function.
而实际工作中,装饰器通常运用在身份认证、日志记录、输入合理性检查以及缓存等多个领域中。合理使用装饰器,往往能极大地提高程序的可读性以及运行效率。
## 思考题
那么,你平时工作中,通常会在哪些情况下使用装饰器呢?欢迎留言和我讨论,也欢迎你把这篇文章分享给你的同事、朋友,一起在交流中进步。

View File

@@ -0,0 +1,256 @@
<audio id="audio" title="18 | metaclass是潘多拉魔盒还是阿拉丁神灯" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f5/58/f553a545f2646e3acba6ccd0a08b6058.mp3"></audio>
你好我是蔡元楠极客时间《大规模数据处理实战》专栏的作者。今天我想和你分享的主题是metaclass是潘多拉魔盒还是阿拉丁神灯
Python中有很多黑魔法比如今天我将分享的metaclass。我认识许多人对于这些语言特性有两种极端的观点。
- 一种人觉得这些语言特性太牛逼了简直是无所不能的阿拉丁神灯必须找机会用上才能显示自己的Python实力。
- 另一种观点则是认为这些语言特性太危险了,会蛊惑人心去滥用,一旦打开就会释放“恶魔”,让整个代码库变得难以维护。
其实这两种看法都有道理却又都浅尝辄止。今天我就带你来看看metaclass到底是潘多拉魔盒还是阿拉丁神灯
市面上的很多中文书都把metaclass译为“元类”。我一直认为这个翻译很糟糕所以也不想在这里称metaclass为元类。因为如果仅从字面理解“元”是“本源”“基本”的意思“元类”会让人以为是“基本类”。难道Python的metaclass指的是Python 2的Object吗这就让人一头雾水了。
事实上meta-class的meta这个词根起源于希腊语词汇meta包含下面两种意思
1. “Beyond”例如技术词汇metadata意思是描述数据的超越数据
1. “Change”例如技术词汇metamorphosis意思是改变的形态。
metaclass一如其名实际上同时包含了“超越类”和“变形类”的含义完全不是“基本类”的意思。所以要深入理解metaclass我们就要围绕它的**超越变形**特性。接下来我将为你展开metaclass的超越变形能力讲清楚metaclass究竟有什么用怎么应用Python语言设计层面是如何实现metaclass的 以及使用metaclass的风险。
## metaclass的超越变形特性有什么用
[YAML](https://pyyaml.org/wiki/PyYAMLDocumentation)是一个家喻户晓的Python工具可以方便地序列化/逆序列化结构数据。YAMLObject的一个**超越变形能力**就是它的任意子类支持序列化和反序列化serialization &amp; deserialization。比如说下面这段代码
```
class Monster(yaml.YAMLObject):
yaml_tag = u'!Monster'
def __init__(self, name, hp, ac, attacks):
self.name = name
self.hp = hp
self.ac = ac
self.attacks = attacks
def __repr__(self):
return &quot;%s(name=%r, hp=%r, ac=%r, attacks=%r)&quot; % (
self.__class__.__name__, self.name, self.hp, self.ac,
self.attacks)
yaml.load(&quot;&quot;&quot;
--- !Monster
name: Cave spider
hp: [2,6] # 2d6
ac: 16
attacks: [BITE, HURT]
&quot;&quot;&quot;)
Monster(name='Cave spider', hp=[2, 6], ac=16, attacks=['BITE', 'HURT'])
print yaml.dump(Monster(
name='Cave lizard', hp=[3,6], ac=16, attacks=['BITE','HURT']))
# 输出
!Monster
ac: 16
attacks: [BITE, HURT]
hp: [3, 6]
name: Cave lizard
```
这里YAMLObject的特异功能体现在哪里呢
你看调用统一的yaml.load()就能把任意一个yaml序列载入成一个Python Object而调用统一的yaml.dump()就能把一个YAMLObject子类序列化。对于load()和dump()的使用者来说,他们完全不需要提前知道任何类型信息,这让超动态配置编程成了可能。在我的实战经验中,许多大型项目都需要应用这种超动态配置的理念。
比方说在一个智能语音助手的大型项目中我们有1万个语音对话场景每一个场景都是不同团队开发的。作为智能语音助手的核心团队成员我不可能去了解每个子场景的实现细节。
在动态配置实验不同场景时经常是今天我要实验场景A和B的配置明天实验B和C的配置光配置文件就有几万行量级工作量不可谓不小。而应用这样的动态配置理念我就可以让引擎根据我的文本配置文件动态加载所需要的Python类。
对于YAML的使用者这一点也很方便你只要简单地继承yaml.YAMLObject就能让你的Python Object具有序列化和逆序列化能力。是不是相比普通Python类有一点“变态”有一点“超越”
事实上我在Google见过很多Python开发者发现能深入解释YAML这种设计模式优点的人大概只有10%。而能知道类似YAML的这种动态序列化/逆序列化功能正是用metaclass实现的人更是凤毛麟角可能只有1%了。
## metaclass的超越变形特性怎么用
刚刚提到估计只有1%的Python开发者知道YAML的动态序列化/逆序列化是由metaclass实现的。如果你追问YAML怎样用metaclass实现动态序列化/逆序列化功能可能只有0.1%的人能说得出一二了。
因为篇幅原因我们这里只看YAMLObject的load()功能。简单来说我们需要一个全局的注册器让YAML知道序列化文本中的 `!Monster` 需要载入成 Monster这个Python类型。
一个很自然的想法就是,那我们建立一个全局变量叫 registry把所有需要逆序列化的YAMLObject都注册进去。比如下面这样
```
registry = {}
def add_constructor(target_class):
registry[target_class.yaml_tag] = target_class
```
然后在Monster 类定义后面加上下面这行代码:
```
add_constructor(Monster)
```
但这样的缺点也很明显对于YAML的使用者来说每一个YAML的可逆序列化的类Foo定义后都需要加上一句话`add_constructor(Foo)`。这无疑给开发者增加了麻烦,也更容易出错,毕竟开发者很容易忘了这一点。
那么更优的实现方式是什么样呢如果你看过YAML的源码就会发现正是metaclass解决了这个问题。
```
# Python 2/3 相同部分
class YAMLObjectMetaclass(type):
def __init__(cls, name, bases, kwds):
super(YAMLObjectMetaclass, cls).__init__(name, bases, kwds)
if 'yaml_tag' in kwds and kwds['yaml_tag'] is not None:
cls.yaml_loader.add_constructor(cls.yaml_tag, cls.from_yaml)
# 省略其余定义
# Python 3
class YAMLObject(metaclass=YAMLObjectMetaclass):
yaml_loader = Loader
# 省略其余定义
# Python 2
class YAMLObject(object):
__metaclass__ = YAMLObjectMetaclass
yaml_loader = Loader
# 省略其余定义
```
你可以发现YAMLObject把metaclass都声明成了YAMLObjectMetaclass尽管声明方式在Python 2 和3中略有不同。在YAMLObjectMetaclass中 下面这行代码就是魔法发生的地方:
```
cls.yaml_loader.add_constructor(cls.yaml_tag, cls.from_yaml)
```
YAML应用metaclass拦截了所有YAMLObject子类的定义。也就说说在你定义任何YAMLObject子类时Python会强行插入运行下面这段代码把我们之前想要的`add_constructor(Foo)`给自动加上。
```
cls.yaml_loader.add_constructor(cls.yaml_tag, cls.from_yaml)
```
所以YAML的使用者无需自己去手写`add_constructor(Foo)` 。怎么样,是不是其实并不复杂?
看到这里我们已经掌握了metaclass的使用方法超越了世界上99.9%的Python开发者。更进一步如果你能够深入理解Python的语言设计层面是怎样实现metaclass的你就是世间罕见的“Python大师”了。
## **Python底层语言设计层面是如何实现metaclass的**
刚才我们提到metaclass能够拦截Python类的定义。它是怎么做到的
要理解metaclass的底层原理你需要深入理解Python类型模型。下面我将分三点来说明。
### 第一所有的Python的用户定义类都是type这个类的实例。
可能会让你惊讶,事实上,类本身不过是一个名为 type 类的实例。在Python的类型世界里type这个类就是造物的上帝。这可以在代码中验证
```
# Python 3和Python 2类似
class MyClass:
pass
instance = MyClass()
type(instance)
# 输出
&lt;class '__main__.C'&gt;
type(MyClass)
# 输出
&lt;class 'type'&gt;
```
你可以看到instance是MyClass的实例而MyClass不过是“上帝”type的实例。
### 第二用户自定义类只不过是type类的`__call__`运算符重载。
当我们定义一个类的语句结束时真正发生的情况是Python调用type的`__call__`运算符。简单来说,当你定义一个类时,写成下面这样时:
```
class MyClass:
data = 1
```
Python真正执行的是下面这段代码
```
class = type(classname, superclasses, attributedict)
```
这里等号右边的`type(classname, superclasses, attributedict)`就是type的`__call__`运算符重载,它会进一步调用:
```
type.__new__(typeclass, classname, superclasses, attributedict)
type.__init__(class, classname, superclasses, attributedict)
```
当然,这一切都可以通过代码验证,比如下面这段代码示例:
```
class MyClass:
data = 1
instance = MyClass()
MyClass, instance
# 输出
(__main__.MyClass, &lt;__main__.MyClass instance at 0x7fe4f0b00ab8&gt;)
instance.data
# 输出
1
MyClass = type('MyClass', (), {'data': 1})
instance = MyClass()
MyClass, instance
# 输出
(__main__.MyClass, &lt;__main__.MyClass at 0x7fe4f0aea5d0&gt;)
instance.data
# 输出
1
```
由此可见正常的MyClass定义和你手工去调用type运算符的结果是完全一样的。
### 第三metaclass是type的子类通过替换type的`__call__`运算符重载机制,“超越变形”正常的类。
其实理解了以上几点我们就会明白正是Python的类创建机制给了metaclass大展身手的机会。
一旦你把一个类型MyClass的metaclass设置成MyMetaMyClass就不再由原生的type创建而是会调用MyMeta的`__call__`运算符重载。
```
class = type(classname, superclasses, attributedict)
# 变为了
class = MyMeta(classname, superclasses, attributedict)
```
所以我们才能在上面YAML的例子中利用YAMLObjectMetaclass的`__init__`方法为所有YAMLObject子类偷偷执行`add_constructor()`
## **使用metaclass的风险**
前面的篇幅我都是在讲metaclass的原理和优点。的的确确只有深入理解metaclass的本质你才能用好metaclass。而不幸的是正如我开头所说深入理解metaclass的Python开发者只占了0.1%不到。
不过凡事有利必有弊尤其是metaclass这样“逆天”的存在。正如你所看到的那样metaclass会"扭曲变形"正常的Python类型模型。所以如果使用不慎对于整个代码库造成的风险是不可估量的。
换句话说metaclass仅仅是给小部分Python开发者在开发框架层面的Python库时使用的。而在应用层metaclass往往不是很好的选择。
也正因为这样据我所知在很多硅谷一线大厂使用Python metaclass需要特例特批。
## 总结
这节课我们通过解读YAML的源码围绕metaclass的设计本意“超越变形”解析了metaclass的使用场景和使用方法。接着我们又进一步深入到Python语言设计层面搞明白了metaclass的实现机制。
正如我取的标题那样metaclass是Python黑魔法级别的语言特性。天堂和地狱只有一步之遥你使用好metaclass可以实现像YAML那样神奇的特性而使用不好可能就会打开潘多拉魔盒了。
所以今天的内容一方面是帮助有需要的同学深入理解metaclass更好地掌握和应用另一方面也是对初学者的科普和警告不要轻易尝试metaclass。
## 思考题
学完了上节课的Python装饰器和这节课的metaclass你知道了它们都能干预正常的Python类型机制。那么你觉得装饰器和metaclass有什么区别呢欢迎留言和我讨论。

View File

@@ -0,0 +1,345 @@
<audio id="audio" title="19 | 深入理解迭代器和生成器" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/08/44/08acc4e1cf0d7c2dd1b47d706c075544.mp3"></audio>
你好,我是景霄。
在第一次接触 Python 的时候,你可能写过类似 `for i in [2, 3, 5, 7, 11, 13]: print(i)` 这样的语句。for in 语句理解起来很直观形象,比起 C++ 和 java 早期的 `for (int i = 0; i &lt; n; i ++) printf("%d\n", a[i])` 这样的语句,不知道简洁清晰到哪里去了。
但是,你想过 Python 在处理 for in 语句的时候,具体发生了什么吗?什么样的对象可以被 for in 来枚举呢?
这一节课,我们深入到 Python 的容器类型实现底层去走走,了解一种叫做迭代器和生成器的东西。
## 你肯定用过的容器、可迭代对象和迭代器
容器这个概念非常好理解。我们说过在Python 中一切皆对象,对象的抽象就是类,而对象的集合就是容器。
列表list: [0, 1, 2]元组tuple: (0, 1, 2)字典dict: {0:0, 1:1, 2:2}集合set: set([0, 1, 2]))都是容器。对于容器,你可以很直观地想象成多个元素在一起的单元;而不同容器的区别,正是在于内部数据结构的实现方法。然后,你就可以针对不同场景,选择不同时间和空间复杂度的容器。
所有的容器都是可迭代的iterable。这里的迭代和枚举不完全一样。迭代可以想象成是你去买苹果卖家并不告诉你他有多少库存。这样每次你都需要告诉卖家你要一个苹果然后卖家采取行为要么给你拿一个苹果要么告诉你苹果已经卖完了。你并不需要知道卖家在仓库是怎么摆放苹果的。
严谨地说迭代器iterator提供了一个 next 的方法。调用这个方法后,你要么得到这个容器的下一个对象,要么得到一个 StopIteration 的错误苹果卖完了。你不需要像列表一样指定元素的索引因为字典和集合这样的容器并没有索引一说。比如字典采用哈希表实现那么你就只需要知道next 函数可以不重复不遗漏地一个一个拿到所有元素即可。
而可迭代对象,通过 iter() 函数返回一个迭代器,再通过 next() 函数就可以实现遍历。for in 语句将这个过程隐式化,所以,你只需要知道它大概做了什么就行了。
我们来看下面这段代码,主要向你展示怎么判断一个对象是否可迭代。当然,这还有另一种做法,是 isinstance(obj, Iterable)。
```
def is_iterable(param):
try:
iter(param)
return True
except TypeError:
return False
params = [
1234,
'1234',
[1, 2, 3, 4],
set([1, 2, 3, 4]),
{1:1, 2:2, 3:3, 4:4},
(1, 2, 3, 4)
]
for param in params:
print('{} is iterable? {}'.format(param, is_iterable(param)))
########## 输出 ##########
1234 is iterable? False
1234 is iterable? True
[1, 2, 3, 4] is iterable? True
{1, 2, 3, 4} is iterable? True
{1: 1, 2: 2, 3: 3, 4: 4} is iterable? True
(1, 2, 3, 4) is iterable? True
```
通过这段代码,你就可以知道,给出的类型中,除了数字 1234 之外,其它的数据类型都是可迭代的。
## 生成器,又是什么?
据我所知,很多人对生成器这个概念会比较陌生,因为生成器在很多常用语言中,并没有相对应的模型。
这里,你只需要记着一点:**生成器是懒人版本的迭代器**。
我们知道,在迭代器中,如果我们想要枚举它的元素,这些元素需要事先生成。这里,我们先来看下面这个简单的样例。
```
import os
import psutil
# 显示当前 python 程序占用的内存大小
def show_memory_info(hint):
pid = os.getpid()
p = psutil.Process(pid)
info = p.memory_full_info()
memory = info.uss / 1024. / 1024
print('{} memory used: {} MB'.format(hint, memory))
```
```
def test_iterator():
show_memory_info('initing iterator')
list_1 = [i for i in range(100000000)]
show_memory_info('after iterator initiated')
print(sum(list_1))
show_memory_info('after sum called')
def test_generator():
show_memory_info('initing generator')
list_2 = (i for i in range(100000000))
show_memory_info('after generator initiated')
print(sum(list_2))
show_memory_info('after sum called')
%time test_iterator()
%time test_generator()
########## 输出 ##########
initing iterator memory used: 48.9765625 MB
after iterator initiated memory used: 3920.30078125 MB
4999999950000000
after sum called memory used: 3920.3046875 MB
Wall time: 17 s
initing generator memory used: 50.359375 MB
after generator initiated memory used: 50.359375 MB
4999999950000000
after sum called memory used: 50.109375 MB
Wall time: 12.5 s
```
声明一个迭代器很简单,`[i for i in range(100000000)]`就可以生成一个包含一亿元素的列表。每个元素在生成后都会保存到内存中,你通过代码可以看到,它们占用了巨量的内存,内存不够的话就会出现 OOM 错误。
不过,我们并不需要在内存中同时保存这么多东西,比如对元素求和,我们只需要知道每个元素在相加的那一刻是多少就行了,用完就可以扔掉了。
于是,生成器的概念应运而生,在你调用 next() 函数的时候,才会生成下一个变量。生成器在 Python 的写法是用小括号括起来,`(i for i in range(100000000))`,即初始化了一个生成器。
这样一来,你可以清晰地看到,生成器并不会像迭代器一样占用大量内存,只有在被使用的时候才会调用。而且生成器在初始化的时候,并不需要运行一次生成操作,相比于 test_iterator() test_generator() 函数节省了一次生成一亿个元素的过程,因此耗时明显比迭代器短。
到这里,你可能说,生成器不过如此嘛,我有的是钱,不就是多占一些内存和计算资源嘛,我多出点钱就是了呗。
哪怕你是土豪,请坐下先喝点茶,再听我继续讲完,这次,我们来实现一个自定义的生成器。
## 生成器,还能玩什么花样?
数学中有一个恒等式,`(1 + 2 + 3 + ... + n)^2 = 1^3 + 2^3 + 3^3 + ... + n^3`,想必你高中就应该学过它。现在,我们来验证一下这个公式的正确性。老规矩,先放代码,你先自己阅读一下,看不懂的也不要紧,接下来我再来详细讲解。
```
def generator(k):
i = 1
while True:
yield i ** k
i += 1
gen_1 = generator(1)
gen_3 = generator(3)
print(gen_1)
print(gen_3)
def get_sum(n):
sum_1, sum_3 = 0, 0
for i in range(n):
next_1 = next(gen_1)
next_3 = next(gen_3)
print('next_1 = {}, next_3 = {}'.format(next_1, next_3))
sum_1 += next_1
sum_3 += next_3
print(sum_1 * sum_1, sum_3)
get_sum(8)
########## 输出 ##########
&lt;generator object generator at 0x000001E70651C4F8&gt;
&lt;generator object generator at 0x000001E70651C390&gt;
next_1 = 1, next_3 = 1
next_1 = 2, next_3 = 8
next_1 = 3, next_3 = 27
next_1 = 4, next_3 = 64
next_1 = 5, next_3 = 125
next_1 = 6, next_3 = 216
next_1 = 7, next_3 = 343
next_1 = 8, next_3 = 512
1296 1296
```
这段代码中,你首先注意一下 generator() 这个函数,它返回了一个生成器。
接下来的yield 是魔术的关键。对于初学者来说,你可以理解为,函数运行到这一行的时候,程序会从这里暂停,然后跳出,不过跳到哪里呢?答案是 next() 函数。那么 `i ** k` 是干什么的呢?它其实成了 next() 函数的返回值。
这样,每次 next(gen) 函数被调用的时候,暂停的程序就又复活了,从 yield 这里向下继续执行;同时注意,局部变量 i 并没有被清除掉,而是会继续累加。我们可以看到 next_1 从 1 变到 8next_3 从 1 变到 512。
聪明的你应该注意到了,这个生成器居然可以一直进行下去!没错,事实上,迭代器是一个有限集合,生成器则可以成为一个无限集。我只管调用 next(),生成器根据运算会自动生成新的元素,然后返回给你,非常便捷。
到这里,土豪同志应该也坐不住了吧,那么,还能再给力一点吗?
别急,我们再来看一个问题:给定一个 list 和一个指定数字,求这个数字在 list 中的位置。
下面这段代码你应该不陌生,也就是常规做法,枚举每个元素和它的 index判断后加入 result最后返回。
```
def index_normal(L, target):
result = []
for i, num in enumerate(L):
if num == target:
result.append(i)
return result
print(index_normal([1, 6, 2, 4, 5, 2, 8, 6, 3, 2], 2))
########## 输出 ##########
[2, 5, 9]
```
那么使用迭代器可以怎么做呢?二话不说,先看代码。
```
def index_generator(L, target):
for i, num in enumerate(L):
if num == target:
yield i
print(list(index_generator([1, 6, 2, 4, 5, 2, 8, 6, 3, 2], 2)))
########## 输出 ##########
[2, 5, 9]
```
聪明的你应该看到了明显的区别,我就不做过多解释了。唯一需要强调的是, index_generator 会返回一个 Generator 对象,需要使用 list 转换为列表后,才能用 print 输出。
这里我再多说两句。在Python 语言规范中,用更少、更清晰的代码实现相同功能,一直是被推崇的做法,因为这样能够很有效提高代码的可读性,减少出错概率,也方便别人快速准确理解你的意图。当然,要注意,这里“更少”的前提是清晰,而不是使用更多的魔术操作,虽说减少了代码却反而增加了阅读的难度。
回归正题。接下来我们再来看一个问题给定两个序列判定第一个是不是第二个的子序列。LeetCode 链接如下:[https://leetcode.com/problems/is-subsequence/](https://leetcode.com/problems/is-subsequence/)
先来解读一下这个问题本身。序列就是列表,子序列则指的是,一个列表的元素在第二个列表中都按顺序出现,但是并不必挨在一起。举个例子,[1, 3, 5] 是 [1, 2, 3, 4, 5] 的子序列,[1, 4, 3] 则不是。
要解决这个问题,常规算法是贪心算法。我们维护两个指针指向两个列表的最开始,然后对第二个序列一路扫过去,如果某个数字和第一个指针指的一样,那么就把第一个指针前进一步。第一个指针移出第一个序列最后一个元素的时候,返回 True否则返回 False。
不过,这个算法正常写的话,写下来怎么也得十行左右。
那么如果我们用迭代器和生成器呢?
```
def is_subsequence(a, b):
b = iter(b)
return all(i in b for i in a)
print(is_subsequence([1, 3, 5], [1, 2, 3, 4, 5]))
print(is_subsequence([1, 4, 3], [1, 2, 3, 4, 5]))
########## 输出 ##########
True
False
```
这简短的几行代码,你是不是看得一头雾水,不知道发生了什么?
来,我们先把这段代码复杂化,然后一步步看。
```
def is_subsequence(a, b):
b = iter(b)
print(b)
gen = (i for i in a)
print(gen)
for i in gen:
print(i)
gen = ((i in b) for i in a)
print(gen)
for i in gen:
print(i)
return all(((i in b) for i in a))
print(is_subsequence([1, 3, 5], [1, 2, 3, 4, 5]))
print(is_subsequence([1, 4, 3], [1, 2, 3, 4, 5]))
########## 输出 ##########
&lt;list_iterator object at 0x000001E7063D0E80&gt;
&lt;generator object is_subsequence.&lt;locals&gt;.&lt;genexpr&gt; at 0x000001E70651C570&gt;
1
3
5
&lt;generator object is_subsequence.&lt;locals&gt;.&lt;genexpr&gt; at 0x000001E70651C5E8&gt;
True
True
True
False
&lt;list_iterator object at 0x000001E7063D0D30&gt;
&lt;generator object is_subsequence.&lt;locals&gt;.&lt;genexpr&gt; at 0x000001E70651C5E8&gt;
1
4
3
&lt;generator object is_subsequence.&lt;locals&gt;.&lt;genexpr&gt; at 0x000001E70651C570&gt;
True
True
False
False
```
首先,第二行的`b = iter(b)`,把列表 b 转化成了一个迭代器,这里我先不解释为什么要这么做。
接下来的`gen = (i for i in a)`语句很好理解,产生一个生成器,这个生成器可以遍历对象 a因此能够输出 1, 3, 5。而 `(i in b)`需要好好揣摩,这里你是不是能联想到 for in 语句?
没错,这里的`(i in b)`,大致等价于下面这段代码:
```
while True:
val = next(b)
if val == i:
yield True
```
这里非常巧妙地利用生成器的特性next() 函数运行的时候,保存了当前的指针。比如再看下面这个示例:
```
b = (i for i in range(5))
print(2 in b)
print(4 in b)
print(3 in b)
########## 输出 ##########
True
True
False
```
至于最后的 all() 函数,就很简单了。它用来判断一个迭代器的元素是否全部为 True如果是则返回 True否则就返回 False.
于是到此,我们就很优雅地解决了这道面试题。不过你一定注意,面试的时候尽量不要用这种技巧,因为你的面试官有可能并不知道生成器的用法,他们也没有看过我的极客时间专栏。不过,在这个技术知识点上,在实际工作的应用上,你已经比很多人更加熟练了。继续加油!
## 总结
总结一下,今天我们讲了四种不同的对象,分别是容器、可迭代对象、迭代器和生成器。
- 容器是可迭代对象,可迭代对象调用 iter() 函数,可以得到一个迭代器。迭代器可以通过 next() 函数来得到下一个元素,从而支持遍历。
- 生成器是一种特殊的迭代器(注意这个逻辑关系反之不成立)。使用生成器,你可以写出来更加清晰的代码;合理使用生成器,可以降低内存占用、优化程序结构、提高程序速度。
- 生成器在 Python 2 的版本上,是协程的一种重要实现方式;而 Python 3.5 引入 async await 语法糖后,生成器实现协程的方式就已经落后了。我们会在下节课,继续深入讲解 Python 协程。
## 思考题
最后给你留一个思考题。对于一个有限元素的生成器,如果迭代完成后,继续调用 next() ,会发生什么呢?生成器可以遍历多次吗?
欢迎留言和我讨论,也欢迎你把这篇文章分享给你的同事、朋友,一起在交流中进步。

View File

@@ -0,0 +1,492 @@
<audio id="audio" title="20 | 揭秘 Python 协程" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9a/94/9ad1eede616d4b7ee4c4e04dbb0b7b94.mp3"></audio>
你好,我是景霄。
上一节课的最后,我们留下一个小小的悬念:生成器在 Python 2 中还扮演了一个重要角色,就是用来实现 Python 协程。
那么首先你要明白,什么是协程?
协程是实现并发编程的一种方式。一说并发,你肯定想到了多线程/多进程模型,没错,多线程/多进程,正是解决并发问题的经典模型之一。最初的互联网世界,多线程/多进程在服务器并发中,起到举足轻重的作用。
随着互联网的快速发展,你逐渐遇到了 C10K 瓶颈,也就是同时连接到服务器的客户达到了一万个。于是很多代码跑崩了,进程上下文切换占用了大量的资源,线程也顶不住如此巨大的压力,这时, NGINX 带着事件循环出来拯救世界了。
如果将多进程/多线程类比为起源于唐朝的藩镇割据,那么事件循环,就是宋朝加强的中央集权制。事件循环启动一个统一的调度器,让调度器来决定一个时刻去运行哪个任务,于是省却了多线程中启动线程、管理线程、同步锁等各种开销。同一时期的 NGINX在高并发下能保持低资源低消耗高性能相比 Apache 也支持更多的并发连接。
再到后来出现了一个很有名的名词叫做回调地狱callback hell手撸过 JavaScript 的朋友肯定知道我在说什么。我们大家惊喜地发现,这种工具完美地继承了事件循环的优越性,同时还能提供 async / await 语法糖,解决了执行性和可读性共存的难题。于是,协程逐渐被更多人发现并看好,也有越来越多的人尝试用 Node.js 做起了后端开发。讲个笑话JavaScript 是一门编程语言。)
回到我们的 Python。使用生成器是 Python 2 开头的时代实现协程的老方法了Python 3.7 提供了新的基于 asyncio 和 async / await 的方法。我们这节课,同样的,跟随时代,抛弃掉不容易理解、也不容易写的旧的基于生成器的方法,直接来讲新方法。
我们先从一个爬虫实例出发,用清晰的讲解思路,带你结合实战来搞懂这个不算特别容易理解的概念。之后,我们再由浅入深,直击协程的核心。
## 从一个爬虫说起
爬虫,就是互联网的蜘蛛,在搜索引擎诞生之时,与其一同来到世上。爬虫每秒钟都会爬取大量的网页,提取关键信息后存储在数据库中,以便日后分析。爬虫有非常简单的 Python 十行代码实现,也有 Google 那样的全球分布式爬虫的上百万行代码,分布在内部上万台服务器上,对全世界的信息进行嗅探。
话不多说,我们先看一个简单的爬虫例子:
```
import time
def crawl_page(url):
print('crawling {}'.format(url))
sleep_time = int(url.split('_')[-1])
time.sleep(sleep_time)
print('OK {}'.format(url))
def main(urls):
for url in urls:
crawl_page(url)
%time main(['url_1', 'url_2', 'url_3', 'url_4'])
########## 输出 ##########
crawling url_1
OK url_1
crawling url_2
OK url_2
crawling url_3
OK url_3
crawling url_4
OK url_4
Wall time: 10 s
```
(注意:本节的主要目的是协程的基础概念,因此我们简化爬虫的 scrawl_page 函数为休眠数秒,休眠时间取决于 url 最后的那个数字。)
这是一个很简单的爬虫main() 函数执行时,调取 crawl_page() 函数进行网络通信,经过若干秒等待后收到结果,然后执行下一个。
看起来很简单,但你仔细一算,它也占用了不少时间,五个页面分别用了 1 秒到 4 秒的时间,加起来一共用了 10 秒。这显然效率低下,该怎么优化呢?
于是,一个很简单的思路出现了——我们这种爬取操作,完全可以并发化。我们就来看看使用协程怎么写。
```
import asyncio
async def crawl_page(url):
print('crawling {}'.format(url))
sleep_time = int(url.split('_')[-1])
await asyncio.sleep(sleep_time)
print('OK {}'.format(url))
async def main(urls):
for url in urls:
await crawl_page(url)
%time asyncio.run(main(['url_1', 'url_2', 'url_3', 'url_4']))
########## 输出 ##########
crawling url_1
OK url_1
crawling url_2
OK url_2
crawling url_3
OK url_3
crawling url_4
OK url_4
Wall time: 10 s
```
看到这段代码,你应该发现了,在 Python 3.7 以上版本中,使用协程写异步程序非常简单。
首先来看 import asyncio这个库包含了大部分我们实现协程所需的魔法工具。
async 修饰词声明异步函数,于是,这里的 crawl_page 和 main 都变成了异步函数。而调用异步函数我们便可得到一个协程对象coroutine object
举个例子,如果你 `print(crawl_page(''))`,便会输出`&lt;coroutine object crawl_page at 0x000002BEDF141148&gt;`,提示你这是一个 Python 的协程对象,而并不会真正执行这个函数。
再来说说协程的执行。执行协程有多种方法,这里我介绍一下常用的三种。
首先,我们可以通过 await 来调用。await 执行的效果,和 Python 正常执行是一样的,也就是说程序会阻塞在这里,进入被调用的协程函数,执行完毕返回后再继续,而这也是 await 的字面意思。代码中 `await asyncio.sleep(sleep_time)` 会在这里休息若干秒,`await crawl_page(url)` 则会执行 crawl_page() 函数。
其次,我们可以通过 asyncio.create_task() 来创建任务,这个我们下节课会详细讲一下,你先简单知道即可。
最后,我们需要 asyncio.run 来触发运行。asyncio.run 这个函数是 Python 3.7 之后才有的特性,可以让 Python 的协程接口变得非常简单你不用去理会事件循环怎么定义和怎么使用的问题我们会在下面讲。一个非常好的编程规范是asyncio.run(main()) 作为主程序的入口函数,在程序运行周期内,只调用一次 asyncio.run。
这样,你就大概看懂了协程是怎么用的吧。不妨试着跑一下代码,欸,怎么还是 10 秒?
10 秒就对了还记得上面所说的await 是同步调用,因此, crawl_page(url) 在当前的调用结束之前,是不会触发下一次调用的。于是,这个代码效果就和上面完全一样了,相当于我们用异步接口写了个同步代码。
现在又该怎么办呢?
其实很简单也正是我接下来要讲的协程中的一个重要概念任务Task。老规矩先看代码。
```
import asyncio
async def crawl_page(url):
print('crawling {}'.format(url))
sleep_time = int(url.split('_')[-1])
await asyncio.sleep(sleep_time)
print('OK {}'.format(url))
async def main(urls):
tasks = [asyncio.create_task(crawl_page(url)) for url in urls]
for task in tasks:
await task
%time asyncio.run(main(['url_1', 'url_2', 'url_3', 'url_4']))
########## 输出 ##########
crawling url_1
crawling url_2
crawling url_3
crawling url_4
OK url_1
OK url_2
OK url_3
OK url_4
Wall time: 3.99 s
```
你可以看到,我们有了协程对象后,便可以通过 `asyncio.create_task` 来创建任务。任务创建后很快就会被调度执行,这样,我们的代码也不会阻塞在任务这里。所以,我们要等所有任务都结束才行,用`for task in tasks: await task` 即可。
这次,你就看到效果了吧,结果显示,运行总时长等于运行时间最长的爬虫。
当然,你也可以想一想,这里用多线程应该怎么写?而如果需要爬取的页面有上万个又该怎么办呢?再对比下协程的写法,谁更清晰自是一目了然。
其实,对于执行 tasks还有另一种做法
```
import asyncio
async def crawl_page(url):
print('crawling {}'.format(url))
sleep_time = int(url.split('_')[-1])
await asyncio.sleep(sleep_time)
print('OK {}'.format(url))
async def main(urls):
tasks = [asyncio.create_task(crawl_page(url)) for url in urls]
await asyncio.gather(*tasks)
%time asyncio.run(main(['url_1', 'url_2', 'url_3', 'url_4']))
########## 输出 ##########
crawling url_1
crawling url_2
crawling url_3
crawling url_4
OK url_1
OK url_2
OK url_3
OK url_4
Wall time: 4.01 s
```
这里的代码也很好理解。唯一要注意的是,`*tasks` 解包列表,将列表变成了函数的参数;与之对应的是, `** dict` 将字典变成了函数的参数。
另外,`asyncio.create_task``asyncio.run` 这些函数都是 Python 3.7 以上的版本才提供的,自然,相比于旧接口它们也更容易理解和阅读。
## 解密协程运行时
说了这么多,现在,我们不妨来深入代码底层看看。有了前面的知识做基础,你应该很容易理解这两段代码。
```
import asyncio
async def worker_1():
print('worker_1 start')
await asyncio.sleep(1)
print('worker_1 done')
async def worker_2():
print('worker_2 start')
await asyncio.sleep(2)
print('worker_2 done')
async def main():
print('before await')
await worker_1()
print('awaited worker_1')
await worker_2()
print('awaited worker_2')
%time asyncio.run(main())
########## 输出 ##########
before await
worker_1 start
worker_1 done
awaited worker_1
worker_2 start
worker_2 done
awaited worker_2
Wall time: 3 s
```
```
import asyncio
async def worker_1():
print('worker_1 start')
await asyncio.sleep(1)
print('worker_1 done')
async def worker_2():
print('worker_2 start')
await asyncio.sleep(2)
print('worker_2 done')
async def main():
task1 = asyncio.create_task(worker_1())
task2 = asyncio.create_task(worker_2())
print('before await')
await task1
print('awaited worker_1')
await task2
print('awaited worker_2')
%time asyncio.run(main())
########## 输出 ##########
before await
worker_1 start
worker_2 start
worker_1 done
awaited worker_1
worker_2 done
awaited worker_2
Wall time: 2.01 s
```
不过,第二个代码,到底发生了什么呢?为了让你更详细了解到协程和线程的具体区别,这里我详细地分析了整个过程。步骤有点多,别着急,我们慢慢来看。
1. `asyncio.run(main())`,程序进入 main() 函数,事件循环开启;
1. task1 和 task2 任务被创建,并进入事件循环等待运行;运行到 print输出 `'before await'`
1. await task1 执行,用户选择从当前的主任务中切出,事件调度器开始调度 worker_1
1. worker_1 开始运行,运行 print 输出`'worker_1 start'`,然后运行到 `await asyncio.sleep(1)` 从当前任务切出,事件调度器开始调度 worker_2
1. worker_2 开始运行,运行 print 输出 `'worker_2 start'`,然后运行 `await asyncio.sleep(2)` 从当前任务切出;
1. 以上所有事件的运行时间,都应该在 1ms 到 10ms 之间,甚至可能更短,事件调度器从这个时候开始暂停调度;
1. 一秒钟后worker_1 的 sleep 完成,事件调度器将控制权重新传给 task_1输出 `'worker_1 done'`task_1 完成任务,从事件循环中退出;
1. await task1 完成,事件调度器将控制器传给主任务,输出 `'awaited worker_1'`,·然后在 await task2 处继续等待;
1. 两秒钟后worker_2 的 sleep 完成,事件调度器将控制权重新传给 task_2输出 `'worker_2 done'`task_2 完成任务,从事件循环中退出;
1. 主任务输出 `'awaited worker_2'`,协程全任务结束,事件循环结束。
接下来,我们进阶一下。如果我们想给某些协程任务限定运行时间,一旦超时就取消,又该怎么做呢?再进一步,如果某些协程运行时出现错误,又该怎么处理呢?同样的,来看代码。
```
import asyncio
async def worker_1():
await asyncio.sleep(1)
return 1
async def worker_2():
await asyncio.sleep(2)
return 2 / 0
async def worker_3():
await asyncio.sleep(3)
return 3
async def main():
task_1 = asyncio.create_task(worker_1())
task_2 = asyncio.create_task(worker_2())
task_3 = asyncio.create_task(worker_3())
await asyncio.sleep(2)
task_3.cancel()
res = await asyncio.gather(task_1, task_2, task_3, return_exceptions=True)
print(res)
%time asyncio.run(main())
########## 输出 ##########
[1, ZeroDivisionError('division by zero'), CancelledError()]
Wall time: 2 s
```
你可以看到worker_1 正常运行worker_2 运行中出现错误worker_3 执行时间过长被我们 cancel 掉了,这些信息会全部体现在最终的返回结果 res 中。
不过要注意`return_exceptions=True`这行代码。如果不设置这个参数,错误就会完整地 throw 到我们这个执行层,从而需要 try except 来捕捉,这也就意味着其他还没被执行的任务会被全部取消掉。为了避免这个局面,我们将 return_exceptions 设置为 True 即可。
到这里,发现了没,线程能实现的,协程都能做到。那就让我们温习一下这些知识点,用协程来实现一个经典的生产者消费者模型吧。
```
import asyncio
import random
async def consumer(queue, id):
while True:
val = await queue.get()
print('{} get a val: {}'.format(id, val))
await asyncio.sleep(1)
async def producer(queue, id):
for i in range(5):
val = random.randint(1, 10)
await queue.put(val)
print('{} put a val: {}'.format(id, val))
await asyncio.sleep(1)
async def main():
queue = asyncio.Queue()
consumer_1 = asyncio.create_task(consumer(queue, 'consumer_1'))
consumer_2 = asyncio.create_task(consumer(queue, 'consumer_2'))
producer_1 = asyncio.create_task(producer(queue, 'producer_1'))
producer_2 = asyncio.create_task(producer(queue, 'producer_2'))
await asyncio.sleep(10)
consumer_1.cancel()
consumer_2.cancel()
await asyncio.gather(consumer_1, consumer_2, producer_1, producer_2, return_exceptions=True)
%time asyncio.run(main())
########## 输出 ##########
producer_1 put a val: 5
producer_2 put a val: 3
consumer_1 get a val: 5
consumer_2 get a val: 3
producer_1 put a val: 1
producer_2 put a val: 3
consumer_2 get a val: 1
consumer_1 get a val: 3
producer_1 put a val: 6
producer_2 put a val: 10
consumer_1 get a val: 6
consumer_2 get a val: 10
producer_1 put a val: 4
producer_2 put a val: 5
consumer_2 get a val: 4
consumer_1 get a val: 5
producer_1 put a val: 2
producer_2 put a val: 8
consumer_1 get a val: 2
consumer_2 get a val: 8
Wall time: 10 s
```
## 实战:豆瓣近日推荐电影爬虫
最后,进入今天的实战环节——实现一个完整的协程爬虫。
任务描述:[https://movie.douban.com/cinema/later/beijing/](https://movie.douban.com/cinema/later/beijing/) 这个页面描述了北京最近上映的电影,你能否通过 Python 得到这些电影的名称、上映时间和海报呢?这个页面的海报是缩小版的,我希望你能从具体的电影描述页面中抓取到海报。
听起来难度不是很大吧?我在下面给出了同步版本的代码和协程版本的代码,通过运行时间和代码写法的对比,希望你能对协程有更深的了解。(注意:为了突出重点、简化代码,这里我省略了异常处理。)
不过,在参考我给出的代码之前,你是不是可以自己先动手写一下、跑一下呢?
```
import requests
from bs4 import BeautifulSoup
def main():
url = &quot;https://movie.douban.com/cinema/later/beijing/&quot;
init_page = requests.get(url).content
init_soup = BeautifulSoup(init_page, 'lxml')
all_movies = init_soup.find('div', id=&quot;showing-soon&quot;)
for each_movie in all_movies.find_all('div', class_=&quot;item&quot;):
all_a_tag = each_movie.find_all('a')
all_li_tag = each_movie.find_all('li')
movie_name = all_a_tag[1].text
url_to_fetch = all_a_tag[1]['href']
movie_date = all_li_tag[0].text
response_item = requests.get(url_to_fetch).content
soup_item = BeautifulSoup(response_item, 'lxml')
img_tag = soup_item.find('img')
print('{} {} {}'.format(movie_name, movie_date, img_tag['src']))
%time main()
########## 输出 ##########
阿拉丁 05月24日 https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2553992741.jpg
龙珠超:布罗利 05月24日 https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2557371503.jpg
五月天人生无限公司 05月24日 https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2554324453.jpg
... ...
直播攻略 06月04日 https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2555957974.jpg
Wall time: 56.6 s
```
```
import asyncio
import aiohttp
from bs4 import BeautifulSoup
async def fetch_content(url):
async with aiohttp.ClientSession(
headers=header, connector=aiohttp.TCPConnector(ssl=False)
) as session:
async with session.get(url) as response:
return await response.text()
async def main():
url = &quot;https://movie.douban.com/cinema/later/beijing/&quot;
init_page = await fetch_content(url)
init_soup = BeautifulSoup(init_page, 'lxml')
movie_names, urls_to_fetch, movie_dates = [], [], []
all_movies = init_soup.find('div', id=&quot;showing-soon&quot;)
for each_movie in all_movies.find_all('div', class_=&quot;item&quot;):
all_a_tag = each_movie.find_all('a')
all_li_tag = each_movie.find_all('li')
movie_names.append(all_a_tag[1].text)
urls_to_fetch.append(all_a_tag[1]['href'])
movie_dates.append(all_li_tag[0].text)
tasks = [fetch_content(url) for url in urls_to_fetch]
pages = await asyncio.gather(*tasks)
for movie_name, movie_date, page in zip(movie_names, movie_dates, pages):
soup_item = BeautifulSoup(page, 'lxml')
img_tag = soup_item.find('img')
print('{} {} {}'.format(movie_name, movie_date, img_tag['src']))
%time asyncio.run(main())
########## 输出 ##########
阿拉丁 05月24日 https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2553992741.jpg
龙珠超:布罗利 05月24日 https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2557371503.jpg
五月天人生无限公司 05月24日 https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2554324453.jpg
... ...
直播攻略 06月04日 https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2555957974.jpg
Wall time: 4.98 s
```
## 总结
到这里,今天的主要内容就讲完了。今天我用了较长的篇幅,从一个简单的爬虫开始,到一个真正的爬虫结束,在中间穿插讲解了 Python 协程最新的基本概念和用法。这里带你简单复习一下。
- 协程和多线程的区别,主要在于两点,一是协程为单线程;二是协程由用户决定,在哪些地方交出控制权,切换到下一个任务。
- 协程的写法更加简洁清晰把async / await 语法和 create_task 结合来用,对于中小级别的并发需求已经毫无压力。
- 写协程程序的时候,你的脑海中要有清晰的事件循环概念,知道程序在什么时候需要暂停、等待 I/O什么时候需要一并执行到底。
最后的最后,请一定不要轻易炫技。多线程模型也一定有其优点,一个真正牛逼的程序员,应该懂得,在什么时候用什么模型能达到工程上的最优,而不是自觉某个技术非常牛逼,所有项目创造条件也要上。技术是工程,而工程则是时间、资源、人力等纷繁复杂的事情的折衷。
## 思考题
最后给你留一个思考题。协程怎么实现回调函数呢?欢迎留言和我讨论,也欢迎你把这篇文章分享给你的同事朋友,我们一起交流,一起进步。

View File

@@ -0,0 +1,307 @@
<audio id="audio" title="21 | Python并发编程之Futures" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/50/bc/50b65305196c43d6eb6f815d620578bc.mp3"></audio>
你好,我是景霄。
无论对于哪门语言并发编程都是一项很常用很重要的技巧。比如我们上节课所讲的很常见的爬虫就被广泛应用在工业界的各个领域。我们每天在各个网站、各个App上获取的新闻信息很大一部分便是通过并发编程版的爬虫获得。
正确合理地使用并发编程无疑会给我们的程序带来极大的性能提升。今天这节课我就带你一起来学习理解、运用Python中的并发编程——Futures。
## 区分并发和并行
在我们学习并发编程时常常同时听到并发Concurrency和并行Parallelism这两个术语这两者经常一起使用导致很多人以为它们是一个意思其实不然。
首先你要辨别一个误区在Python中并发并不是指同一时刻有多个操作thread、task同时进行。相反某个特定的时刻它只允许有一个操作发生只不过线程/任务之间会互相切换,直到完成。我们来看下面这张图:
<img src="https://static001.geekbang.org/resource/image/37/3f/37cbce0eb67909990d83f21642fb863f.png" alt="">
图中出现了thread和task两种切换顺序的不同方式分别对应Python中并发的两种形式——threading和asyncio。
对于threading操作系统知道每个线程的所有信息因此它会做主在适当的时候做线程切换。很显然这样的好处是代码容易书写因为程序员不需要做任何切换操作的处理但是切换线程的操作也有可能出现在一个语句执行的过程中比如 x += 1这样就容易出现race condition的情况。
而对于asyncio主程序想要切换任务时必须得到此任务可以被切换的通知这样一来也就可以避免刚刚提到的 race condition的情况。
至于所谓的并行指的才是同一时刻、同时发生。Python中的multi-processing便是这个意思对于multi-processing你可以简单地这么理解比如你的电脑是6核处理器那么在运行程序时就可以强制Python开6个进程同时执行以加快运行速度它的原理示意图如下
<img src="https://static001.geekbang.org/resource/image/f6/3c/f6b4009c8a8589e8ec1a2bb10d4e183c.png" alt="">
对比来看,
- 并发通常应用于I/O操作频繁的场景比如你要从网站上下载多个文件I/O操作的时间可能会比CPU运行处理的时间长得多。
- 而并行则更多应用于CPU heavy的场景比如MapReduce中的并行计算为了加快运行速度一般会用多台机器、多个处理器来完成。
## 并发编程之Futures
### 单线程与多线程性能比较
接下来我们一起通过具体的实例从代码的角度来理解并发编程中的Futures并进一步来比较其与单线程的性能区别。
假设我们有一个任务,是下载一些网站的内容并打印。如果用单线程的方式,它的代码实现如下所示(为了简化代码,突出主题,此处我忽略了异常处理):
```
import requests
import time
def download_one(url):
resp = requests.get(url)
print('Read {} from {}'.format(len(resp.content), url))
def download_all(sites):
for site in sites:
download_one(site)
def main():
sites = [
'https://en.wikipedia.org/wiki/Portal:Arts',
'https://en.wikipedia.org/wiki/Portal:History',
'https://en.wikipedia.org/wiki/Portal:Society',
'https://en.wikipedia.org/wiki/Portal:Biography',
'https://en.wikipedia.org/wiki/Portal:Mathematics',
'https://en.wikipedia.org/wiki/Portal:Technology',
'https://en.wikipedia.org/wiki/Portal:Geography',
'https://en.wikipedia.org/wiki/Portal:Science',
'https://en.wikipedia.org/wiki/Computer_science',
'https://en.wikipedia.org/wiki/Python_(programming_language)',
'https://en.wikipedia.org/wiki/Java_(programming_language)',
'https://en.wikipedia.org/wiki/PHP',
'https://en.wikipedia.org/wiki/Node.js',
'https://en.wikipedia.org/wiki/The_C_Programming_Language',
'https://en.wikipedia.org/wiki/Go_(programming_language)'
]
start_time = time.perf_counter()
download_all(sites)
end_time = time.perf_counter()
print('Download {} sites in {} seconds'.format(len(sites), end_time - start_time))
if __name__ == '__main__':
main()
# 输出
Read 129886 from https://en.wikipedia.org/wiki/Portal:Arts
Read 184343 from https://en.wikipedia.org/wiki/Portal:History
Read 224118 from https://en.wikipedia.org/wiki/Portal:Society
Read 107637 from https://en.wikipedia.org/wiki/Portal:Biography
Read 151021 from https://en.wikipedia.org/wiki/Portal:Mathematics
Read 157811 from https://en.wikipedia.org/wiki/Portal:Technology
Read 167923 from https://en.wikipedia.org/wiki/Portal:Geography
Read 93347 from https://en.wikipedia.org/wiki/Portal:Science
Read 321352 from https://en.wikipedia.org/wiki/Computer_science
Read 391905 from https://en.wikipedia.org/wiki/Python_(programming_language)
Read 321417 from https://en.wikipedia.org/wiki/Java_(programming_language)
Read 468461 from https://en.wikipedia.org/wiki/PHP
Read 180298 from https://en.wikipedia.org/wiki/Node.js
Read 56765 from https://en.wikipedia.org/wiki/The_C_Programming_Language
Read 324039 from https://en.wikipedia.org/wiki/Go_(programming_language)
Download 15 sites in 2.464231112999869 seconds
```
这种方式应该是最直接也最简单的:
- 先是遍历存储网站的列表;
- 然后对当前网站执行下载操作;
- 等到当前操作完成后,再对下一个网站进行同样的操作,一直到结束。
我们可以看到总共耗时约2.4s。单线程的优点是简单明了但是明显效率低下因为上述程序的绝大多数时间都浪费在了I/O等待上。程序每次对一个网站执行下载操作都必须等到前一个网站下载完成后才能开始。如果放在实际生产环境中我们需要下载的网站数量至少是以万为单位的不难想象这种方案根本行不通。
接着我们再来看,多线程版本的代码实现:
```
import concurrent.futures
import requests
import threading
import time
def download_one(url):
resp = requests.get(url)
print('Read {} from {}'.format(len(resp.content), url))
def download_all(sites):
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
executor.map(download_one, sites)
def main():
sites = [
'https://en.wikipedia.org/wiki/Portal:Arts',
'https://en.wikipedia.org/wiki/Portal:History',
'https://en.wikipedia.org/wiki/Portal:Society',
'https://en.wikipedia.org/wiki/Portal:Biography',
'https://en.wikipedia.org/wiki/Portal:Mathematics',
'https://en.wikipedia.org/wiki/Portal:Technology',
'https://en.wikipedia.org/wiki/Portal:Geography',
'https://en.wikipedia.org/wiki/Portal:Science',
'https://en.wikipedia.org/wiki/Computer_science',
'https://en.wikipedia.org/wiki/Python_(programming_language)',
'https://en.wikipedia.org/wiki/Java_(programming_language)',
'https://en.wikipedia.org/wiki/PHP',
'https://en.wikipedia.org/wiki/Node.js',
'https://en.wikipedia.org/wiki/The_C_Programming_Language',
'https://en.wikipedia.org/wiki/Go_(programming_language)'
]
start_time = time.perf_counter()
download_all(sites)
end_time = time.perf_counter()
print('Download {} sites in {} seconds'.format(len(sites), end_time - start_time))
if __name__ == '__main__':
main()
## 输出
Read 151021 from https://en.wikipedia.org/wiki/Portal:Mathematics
Read 129886 from https://en.wikipedia.org/wiki/Portal:Arts
Read 107637 from https://en.wikipedia.org/wiki/Portal:Biography
Read 224118 from https://en.wikipedia.org/wiki/Portal:Society
Read 184343 from https://en.wikipedia.org/wiki/Portal:History
Read 167923 from https://en.wikipedia.org/wiki/Portal:Geography
Read 157811 from https://en.wikipedia.org/wiki/Portal:Technology
Read 91533 from https://en.wikipedia.org/wiki/Portal:Science
Read 321352 from https://en.wikipedia.org/wiki/Computer_science
Read 391905 from https://en.wikipedia.org/wiki/Python_(programming_language)
Read 180298 from https://en.wikipedia.org/wiki/Node.js
Read 56765 from https://en.wikipedia.org/wiki/The_C_Programming_Language
Read 468461 from https://en.wikipedia.org/wiki/PHP
Read 321417 from https://en.wikipedia.org/wiki/Java_(programming_language)
Read 324039 from https://en.wikipedia.org/wiki/Go_(programming_language)
Download 15 sites in 0.19936635800002023 seconds
```
非常明显总耗时是0.2s左右效率一下子提升了10倍多。
我们具体来看这段代码,它是多线程版本和单线程版的主要区别所在:
```
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
executor.map(download_one, sites)
```
这里我们创建了一个线程池总共有5个线程可以分配使用。executer.map()与前面所讲的Python内置的map()函数类似表示对sites中的每一个元素并发地调用函数download_one()。
顺便提一下在download_one()函数中我们使用的requests.get()方法是线程安全的thread-safe因此在多线程的环境下它也可以安全使用并不会出现race condition的情况。
另外,虽然线程的数量可以自己定义,但是线程数并不是越多越好,因为线程的创建、维护和删除也会有一定的开销。所以如果你设置的很大,反而可能会导致速度变慢。我们往往需要根据实际的需求做一些测试,来寻找最优的线程数量。
当然我们也可以用并行的方式去提高程序运行效率。你只需要在download_all()函数中,做出下面的变化即可:
```
with futures.ThreadPoolExecutor(workers) as executor
=&gt;
with futures.ProcessPoolExecutor() as executor:
```
在需要修改的这部分代码中函数ProcessPoolExecutor()表示创建进程池使用多个进程并行的执行程序。不过这里我们通常省略参数workers因为系统会自动返回CPU的数量作为可以调用的进程数。
我刚刚提到过并行的方式一般用在CPU heavy的场景中因为对于I/O heavy的操作多数时间都会用于等待相比于多线程使用多进程并不会提升效率。反而很多时候因为CPU数量的限制会导致其执行效率不如多线程版本。
### 到底什么是 Futures
Python中的Futures模块位于concurrent.futures和asyncio中它们都表示带有延迟的操作。Futures会将处于等待状态的操作包裹起来放到队列中这些操作的状态随时可以查询当然它们的结果或是异常也能够在操作完成后被获取。
通常来说作为用户我们不用考虑如何去创建Futures这些Futures底层都会帮我们处理好。我们要做的实际上是去schedule这些Futures的执行。
比如Futures中的Executor类当我们执行executor.submit(func)时它便会安排里面的func()函数执行并返回创建好的future实例以便你之后查询调用。
这里再介绍一些常用的函数。Futures中的方法done()表示相对应的操作是否完成——True表示完成False表示没有完成。不过要注意done()是non-blocking的会立即返回结果。相对应的add_done_callback(fn)则表示Futures完成后相对应的参数函数fn会被通知并执行调用。
Futures中还有一个重要的函数result()它表示当future完成后返回其对应的结果或异常。而as_completed(fs)则是针对给定的future迭代器fs在其完成后返回完成后的迭代器。
所以,上述例子也可以写成下面的形式:
```
import concurrent.futures
import requests
import time
def download_one(url):
resp = requests.get(url)
print('Read {} from {}'.format(len(resp.content), url))
def download_all(sites):
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
to_do = []
for site in sites:
future = executor.submit(download_one, site)
to_do.append(future)
for future in concurrent.futures.as_completed(to_do):
future.result()
def main():
sites = [
'https://en.wikipedia.org/wiki/Portal:Arts',
'https://en.wikipedia.org/wiki/Portal:History',
'https://en.wikipedia.org/wiki/Portal:Society',
'https://en.wikipedia.org/wiki/Portal:Biography',
'https://en.wikipedia.org/wiki/Portal:Mathematics',
'https://en.wikipedia.org/wiki/Portal:Technology',
'https://en.wikipedia.org/wiki/Portal:Geography',
'https://en.wikipedia.org/wiki/Portal:Science',
'https://en.wikipedia.org/wiki/Computer_science',
'https://en.wikipedia.org/wiki/Python_(programming_language)',
'https://en.wikipedia.org/wiki/Java_(programming_language)',
'https://en.wikipedia.org/wiki/PHP',
'https://en.wikipedia.org/wiki/Node.js',
'https://en.wikipedia.org/wiki/The_C_Programming_Language',
'https://en.wikipedia.org/wiki/Go_(programming_language)'
]
start_time = time.perf_counter()
download_all(sites)
end_time = time.perf_counter()
print('Download {} sites in {} seconds'.format(len(sites), end_time - start_time))
if __name__ == '__main__':
main()
# 输出
Read 129886 from https://en.wikipedia.org/wiki/Portal:Arts
Read 107634 from https://en.wikipedia.org/wiki/Portal:Biography
Read 224118 from https://en.wikipedia.org/wiki/Portal:Society
Read 158984 from https://en.wikipedia.org/wiki/Portal:Mathematics
Read 184343 from https://en.wikipedia.org/wiki/Portal:History
Read 157949 from https://en.wikipedia.org/wiki/Portal:Technology
Read 167923 from https://en.wikipedia.org/wiki/Portal:Geography
Read 94228 from https://en.wikipedia.org/wiki/Portal:Science
Read 391905 from https://en.wikipedia.org/wiki/Python_(programming_language)
Read 321352 from https://en.wikipedia.org/wiki/Computer_science
Read 180298 from https://en.wikipedia.org/wiki/Node.js
Read 321417 from https://en.wikipedia.org/wiki/Java_(programming_language)
Read 468421 from https://en.wikipedia.org/wiki/PHP
Read 56765 from https://en.wikipedia.org/wiki/The_C_Programming_Language
Read 324039 from https://en.wikipedia.org/wiki/Go_(programming_language)
Download 15 sites in 0.21698231499976828 seconds
```
这里我们首先调用executor.submit()将下载每一个网站的内容都放进future队列to_do等待执行。然后是as_completed()函数在future完成后便输出结果。
不过这里要注意future列表中每个future完成的顺序和它在列表中的顺序并不一定完全一致。到底哪个先完成、哪个后完成取决于系统的调度和每个future的执行时间。
### 为什么多线程每次只能有一个线程执行?
前面我说过同一时刻Python主程序只允许有一个线程执行所以Python的并发是通过多线程的切换完成的。你可能会疑惑这到底是为什么呢
这里我简单提一下全局解释器锁的概念,具体内容后面会讲到。
事实上Python的解释器并不是线程安全的为了解决由此带来的race condition等问题Python便引入了全局解释器锁也就是同一时刻只允许一个线程执行。当然在执行I/O操作时如果一个线程被block了全局解释器锁便会被释放从而让另一个线程能够继续执行。
## 总结
这节课我们首先学习了Python中并发和并行的概念与区别。
- 并发,通过线程和任务之间互相切换的方式实现,但同一时刻,只允许有一个线程或任务执行。
- 而并行,则是指多个进程同时执行。
并发通常用于I/O操作频繁的场景而并行则适用于CPU heavy的场景。
随后我们通过下载网站内容的例子比较了单线程和运用Futures的多线程版本的性能差异。显而易见合理地运用多线程能够极大地提高程序运行效率。
我们还一起学习了Futures的具体原理介绍了一些常用函数比如done()、result()、as_completed()等的用法,并辅以实例加以理解。
要注意Python中之所以同一时刻只允许一个线程运行其实是由于全局解释器锁的存在。但是对I/O操作而言当其被block的时候全局解释器锁便会被释放使其他线程继续执行。
## 思考题
最后给你留一道思考题。你能否通过查阅相关文档,为今天所讲的这个下载网站内容的例子,加上合理的异常处理,让程序更加稳定健壮呢?欢迎在留言区写下你的思考和答案,也欢迎你把今天的内容分享给你的同事朋友,我们一起交流、一起进步。

View File

@@ -0,0 +1,216 @@
<audio id="audio" title="22 | 并发编程之Asyncio" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/45/d4/45dc75a64d1633ef7c4508ae3f5d00d4.mp3"></audio>
你好,我是景霄。
上节课我们一起学习了Python并发编程的一种实现——多线程。今天这节课我们继续学习Python并发编程的另一种实现方式——Asyncio。不同于协程那章这节课我们更注重原理的理解。
通过上节课的学习我们知道在处理I/O操作时使用多线程与普通的单线程相比效率得到了极大的提高。你可能会想既然这样为什么还需要Asyncio
诚然,多线程有诸多优点且应用广泛,但也存在一定的局限性:
- 比如多线程运行过程容易被打断因此有可能出现race condition的情况
- 再如,线程切换本身存在一定的损耗,线程数不能无限增加,因此,如果你的 I/O操作非常heavy多线程很有可能满足不了高效率、高质量的需求。
正是为了解决这些问题Asyncio应运而生。
## 什么是Asyncio
### Sync VS Async
我们首先来区分一下Sync同步和Async异步的概念。
- 所谓Sync是指操作一个接一个地执行下一个操作必须等上一个操作完成后才能执行。
- 而Async是指不同操作间可以相互交替执行如果其中的某个操作被block了程序并不会等待而是会找出可执行的操作继续执行。
举个简单的例子,你的老板让你做一份这个季度的报表,并且邮件发给他。
- 如果按照Sync的方式你会先向软件输入这个季度的各项数据接下来等待5min等报表明细生成后再写邮件发给他。
- 但如果按照Async的方式再你输完这个季度的各项数据后便会开始写邮件。等报表明细生成后你会暂停邮件先去查看报表确认后继续写邮件直到发送完毕。
### Asyncio工作原理
明白了Sync 和Async回到我们今天的主题到底什么是Asyncio呢
事实上Asyncio和其他Python程序一样是单线程的它只有一个主线程但是可以进行多个不同的任务task这里的任务就是特殊的future对象。这些不同的任务被一个叫做event loop的对象所控制。你可以把这里的任务类比成多线程版本里的多个线程。
为了简化讲解这个问题我们可以假设任务只有两个状态一是预备状态二是等待状态。所谓的预备状态是指任务目前空闲但随时待命准备运行。而等待状态是指任务已经运行但正在等待外部的操作完成比如I/O操作。
在这种情况下event loop会维护两个任务列表分别对应这两种状态并且选取预备状态的一个任务具体选取哪个任务和其等待的时间长短、占用的资源等等相关使其运行一直到这个任务把控制权交还给event loop为止。
当任务把控制权交还给event loop时event loop会根据其是否完成把任务放到预备或等待状态的列表然后遍历等待状态列表的任务查看他们是否完成。
- 如果完成,则将其放到预备状态的列表;
- 如果未完成,则继续放在等待状态的列表。
而原先在预备状态列表的任务位置仍旧不变,因为它们还未运行。
这样当所有任务被重新放置在合适的列表后新一轮的循环又开始了event loop继续从预备状态的列表中选取一个任务使其执行…如此周而复始直到所有任务完成。
值得一提的是对于Asyncio来说它的任务在运行时不会被外部的一些因素打断因此Asyncio内的操作不会出现race condition的情况这样你就不需要担心线程安全的问题了。
### Asyncio用法
讲完了Asyncio的原理我们结合具体的代码来看一下它的用法。还是以上节课下载网站内容为例用Asyncio的写法我放在了下面代码中省略了异常处理的一些操作接下来我们一起来看
```
import asyncio
import aiohttp
import time
async def download_one(url):
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
print('Read {} from {}'.format(resp.content_length, url))
async def download_all(sites):
tasks = [asyncio.create_task(download_one(site)) for site in sites]
await asyncio.gather(*tasks)
def main():
sites = [
'https://en.wikipedia.org/wiki/Portal:Arts',
'https://en.wikipedia.org/wiki/Portal:History',
'https://en.wikipedia.org/wiki/Portal:Society',
'https://en.wikipedia.org/wiki/Portal:Biography',
'https://en.wikipedia.org/wiki/Portal:Mathematics',
'https://en.wikipedia.org/wiki/Portal:Technology',
'https://en.wikipedia.org/wiki/Portal:Geography',
'https://en.wikipedia.org/wiki/Portal:Science',
'https://en.wikipedia.org/wiki/Computer_science',
'https://en.wikipedia.org/wiki/Python_(programming_language)',
'https://en.wikipedia.org/wiki/Java_(programming_language)',
'https://en.wikipedia.org/wiki/PHP',
'https://en.wikipedia.org/wiki/Node.js',
'https://en.wikipedia.org/wiki/The_C_Programming_Language',
'https://en.wikipedia.org/wiki/Go_(programming_language)'
]
start_time = time.perf_counter()
asyncio.run(download_all(sites))
end_time = time.perf_counter()
print('Download {} sites in {} seconds'.format(len(sites), end_time - start_time))
if __name__ == '__main__':
main()
## 输出
Read 63153 from https://en.wikipedia.org/wiki/Java_(programming_language)
Read 31461 from https://en.wikipedia.org/wiki/Portal:Society
Read 23965 from https://en.wikipedia.org/wiki/Portal:Biography
Read 36312 from https://en.wikipedia.org/wiki/Portal:History
Read 25203 from https://en.wikipedia.org/wiki/Portal:Arts
Read 15160 from https://en.wikipedia.org/wiki/The_C_Programming_Language
Read 28749 from https://en.wikipedia.org/wiki/Portal:Mathematics
Read 29587 from https://en.wikipedia.org/wiki/Portal:Technology
Read 79318 from https://en.wikipedia.org/wiki/PHP
Read 30298 from https://en.wikipedia.org/wiki/Portal:Geography
Read 73914 from https://en.wikipedia.org/wiki/Python_(programming_language)
Read 62218 from https://en.wikipedia.org/wiki/Go_(programming_language)
Read 22318 from https://en.wikipedia.org/wiki/Portal:Science
Read 36800 from https://en.wikipedia.org/wiki/Node.js
Read 67028 from https://en.wikipedia.org/wiki/Computer_science
Download 15 sites in 0.062144195078872144 seconds
```
这里的Async和await关键字是Asyncio的最新写法表示这个语句/函数是non-block的正好对应前面所讲的event loop的概念。如果任务执行的过程需要等待则将其放入等待状态的列表中然后继续执行预备状态列表里的任务。
主函数里的asyncio.run(coro)是Asyncio的root call表示拿到event loop运行输入的coro直到它结束最后关闭这个event loop。事实上asyncio.run()是Python3.7+才引入的,相当于老版本的以下语句:
```
loop = asyncio.get_event_loop()
try:
loop.run_until_complete(coro)
finally:
loop.close()
```
至于Asyncio版本的函数download_all(),和之前多线程版本有很大的区别:
```
tasks = [asyncio.create_task(download_one(site)) for site in sites]
await asyncio.gather(*task)
```
这里的`asyncio.create_task(coro)`表示对输入的协程coro创建一个任务安排它的执行并返回此任务对象。这个函数也是Python 3.7+新增的,如果是之前的版本,你可以用`asyncio.ensure_future(coro)`等效替代。可以看到,这里我们对每一个网站的下载,都创建了一个对应的任务。
再往下看,`asyncio.gather(*aws, loop=None, return_exception=False)`则表示在event loop中运行`aws序列`的所有任务。当然除了例子中用到的这几个函数Asyncio还提供了很多其他的用法你可以查看 [相应文档](https://docs.python.org/3/library/asyncio-eventloop.html) 进行了解。
最后我们再来看一下最后的输出结果——用时只有0.06s,效率比起之前的多线程版本,可以说是更上一层楼,充分体现其优势。
## Asyncio有缺陷吗
学了这么多内容我们认识到了Asyncio的强大但你要清楚任何一种方案都不是完美的都存在一定的局限性Asyncio同样如此。
实际工作中想用好Asyncio特别是发挥其强大的功能很多情况下必须得有相应的Python库支持。你可能注意到了上节课的多线程编程中我们使用的是requests库但今天我们并没有使用而是用了aiohttp库原因就是requests库并不兼容Asyncio但是aiohttp库兼容。
Asyncio软件库的兼容性问题在Python3的早期一直是个大问题但是随着技术的发展这个问题正逐步得到解决。
另外使用Asyncio时因为你在任务的调度方面有了更大的自主权写代码时就得更加注意不然很容易出错。
举个例子如果你需要await一系列的操作就得使用asyncio.gather()如果只是单个的future或许只用asyncio.wait()就可以了。那么对于你的future你是想要让它run_until_complete()还是run_forever()呢?诸如此类,都是你在面对具体问题时需要考虑的。
## 多线程还是Asyncio
不知不觉我们已经把并发编程的两种方式都给学习完了。不过遇到实际问题时多线程和Asyncio到底如何选择呢
总的来说,你可以遵循以下伪代码的规范:
```
if io_bound:
if io_slow:
print('Use Asyncio')
else:
print('Use multi-threading')
else if cpu_bound:
print('Use multi-processing')
```
- 如果是I/O bound并且I/O操作很慢需要很多任务/线程协同实现那么使用Asyncio更合适。
- 如果是I/O bound但是I/O操作很快只需要有限数量的任务/线程,那么使用多线程就可以了。
- 如果是CPU bound则需要使用多进程来提高程序运行效率。
## 总结
今天这节课我们一起学习了Asyncio的原理和用法并比较了Asyncio和多线程各自的优缺点。
不同于多线程Asyncio是单线程的但其内部event loop的机制可以让它并发地运行多个不同的任务并且比多线程享有更大的自主控制权。
Asyncio中的任务在运行过程中不会被打断因此不会出现race condition的情况。尤其是在I/O操作heavy的场景下Asyncio比多线程的运行效率更高。因为Asyncio内部任务切换的损耗远比线程切换的损耗要小并且Asyncio可以开启的任务数量也比多线程中的线程数量多得多。
但需要注意的是很多情况下使用Asyncio需要特定第三方库的支持比如前面示例中的aiohttp。而如果I/O操作很快并不heavy那么运用多线程也能很有效地解决问题。
## 思考题
这两节课我们学习了并发编程的两种实现方式也多次提到了并行编程multi-processing其适用于CPU heavy的场景。
现在有这么一个需求输入一个列表对于列表中的每个元素我想计算0到这个元素的所有整数的平方和。
我把常规版本的写法放在了下面,你能通过查阅资料,写出它的多进程版本,并且比较程序的耗时吗?
```
import time
def cpu_bound(number):
print(sum(i * i for i in range(number)))
def calculate_sums(numbers):
for number in numbers:
cpu_bound(number)
def main():
start_time = time.perf_counter()
numbers = [10000000 + x for x in range(20)]
calculate_sums(numbers)
end_time = time.perf_counter()
print('Calculation takes {} seconds'.format(end_time - start_time))
if __name__ == '__main__':
main()
```
欢迎在留言区写下你的思考和答案,也欢迎你把今天的内容分享给你的同事朋友,我们一起交流、一起进步。

View File

@@ -0,0 +1,217 @@
<audio id="audio" title="23 | 你真的懂Python GIL全局解释器锁" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d7/03/d7959dcb15a37743ed44e65866e8d303.mp3"></audio>
你好,我是景霄。
前面几节课我们学习了Python的并发编程特性也了解了多线程编程。事实上Python多线程另一个很重要的话题——GILGlobal Interpreter Lock即全局解释器锁却鲜有人知甚至连很多Python“老司机”都觉得GIL就是一个谜。今天我就来为你解谜带你一起来看GIL。
## 一个不解之谜
耳听为虚眼见为实。我们不妨先来看一个例子让你感受下GIL为什么会让人不明所以。
比如下面这段很简单的cpu-bound代码
```
def CountDown(n):
while n &gt; 0:
n -= 1
```
现在假设一个很大的数字n = 100000000我们先来试试单线程的情况下执行CountDown(n)。在我手上这台号称8核的MacBook上执行后我发现它的耗时为5.4s。
这时,我们想要用多线程来加速,比如下面这几行操作:
```
from threading import Thread
n = 100000000
t1 = Thread(target=CountDown, args=[n // 2])
t2 = Thread(target=CountDown, args=[n // 2])
t1.start()
t2.start()
t1.join()
t2.join()
```
我又在同一台机器上跑了一下结果发现这不仅没有得到速度的提升反而让运行变慢总共花了9.6s。
我还是不死心决定使用四个线程再试一次结果发现运行时间还是9.8s和2个线程的结果几乎一样。
这是怎么回事呢难道是我买了假的MacBook吗你可以先自己思考一下这个问题也可以在自己电脑上测试一下。我当然也要自我反思一下并且提出了下面两个猜想。
第一个怀疑:我的机器出问题了吗?
这不得不说也是一个合理的猜想。因此我又找了一个单核CPU的台式机跑了一下上面的实验。这次我发现在单核CPU电脑上单线程运行需要11s时间2个线程运行也是11s时间。虽然不像第一台机器那样多线程反而比单线程更慢但是这两次整体效果几乎一样呀
看起来这不像是电脑的问题而是Python的线程失效了没有起到并行计算的作用。
顺理成章我又有了第二个怀疑Python的线程是不是假的线程
Python的线程的的确确封装了底层的操作系统线程在Linux系统里是Pthread全称为POSIX Thread而在Windows系统里是Windows Thread。另外Python的线程也完全受操作系统管理比如协调何时执行、管理内存资源、管理中断等等。
所以虽然Python的线程和C++的线程本质上是不同的抽象,但它们的底层并没有什么不同。
## 为什么有GIL
看来我的两个猜想都不能解释开头的这个未解之谜。那究竟谁才是“罪魁祸首”呢事实上正是我们今天的主角也就是GIL导致了Python线程的性能并不像我们期望的那样。
GIL是最流行的Python解释器CPython中的一个技术术语。它的意思是全局解释器锁本质上是类似操作系统的Mutex。每一个Python线程在CPython解释器中执行时都会先锁住自己的线程阻止别的线程执行。
当然CPython会做一些小把戏轮流执行Python线程。这样一来用户看到的就是“伪并行”——Python线程在交错执行来模拟真正并行的线程。
那么为什么CPython需要GIL呢这其实和CPython的实现有关。下一节我们会讲Python的内存管理机制今天先稍微提一下。
CPython使用引用计数来管理内存所有Python脚本中创建的实例都会有一个引用计数来记录有多少个指针指向它。当引用计数只有0时则会自动释放内存。
什么意思呢?我们来看下面这个例子:
```
&gt;&gt;&gt; import sys
&gt;&gt;&gt; a = []
&gt;&gt;&gt; b = a
&gt;&gt;&gt; sys.getrefcount(a)
3
```
这个例子中a的引用计数是3因为有a、b和作为参数传递的getrefcount这三个地方都引用了一个空列表。
这样一来如果有两个Python线程同时引用了a就会造成引用计数的race condition引用计数可能最终只增加1这样就会造成内存被污染。因为第一个线程结束时会把引用计数减少1这时可能达到条件释放内存当第二个线程再试图访问a时就找不到有效的内存了。
所以说CPython 引进 GIL 其实主要就是这么两个原因:
- 一是设计者为了规避类似于内存管理这样的复杂的竞争风险问题race condition
- 二是因为CPython大量使用C语言库但大部分C语言库都不是原生线程安全的线程安全会降低性能和增加复杂度
## GIL是如何工作的
下面这张图就是一个GIL在Python程序的工作示例。其中Thread 1、2、3轮流执行每一个线程在开始执行时都会锁住GIL以阻止别的线程执行同样的每一个线程执行完一段后会释放GIL以允许别的线程开始利用资源。
<img src="https://static001.geekbang.org/resource/image/db/8d/dba8e4a107829d0b72ea513be34fe18d.png" alt="">
细心的你可能会发现一个问题为什么Python线程会去主动释放GIL呢毕竟如果仅仅是要求Python线程在开始执行时锁住GIL而永远不去释放GIL那别的线程就都没有了运行的机会。
没错CPython中还有另一个机制叫做check_interval意思是CPython解释器会去轮询检查线程GIL的锁住情况。每隔一段时间Python解释器就会强制当前线程去释放GIL这样别的线程才能有执行的机会。
不同版本的Python中check interval的实现方式并不一样。早期的Python是100个ticks大致对应了1000个bytecodes而 Python 3以后interval是15毫秒。当然我们不必细究具体多久会强制释放GIL这不应该成为我们程序设计的依赖条件我们只需明白CPython解释器会在一个“合理”的时间范围内释放GIL就可以了。
<img src="https://static001.geekbang.org/resource/image/42/88/42791f4cf34c0a784f466be22efeb388.png" alt="">
整体来说每一个Python线程都是类似这样循环的封装我们来看下面这段代码
```
for (;;) {
if (--ticker &lt; 0) {
ticker = check_interval;
/* Give another thread a chance */
PyThread_release_lock(interpreter_lock);
/* Other threads may run now */
PyThread_acquire_lock(interpreter_lock, 1);
}
bytecode = *next_instr++;
switch (bytecode) {
/* execute the next instruction ... */
}
}
```
从这段代码中我们可以看到每个Python线程都会先检查ticker计数。只有在ticker大于0的情况下线程才会去执行自己的bytecode。
## Python的线程安全
不过有了GIL并不意味着我们Python编程者就不用去考虑线程安全了。即使我们知道GIL仅允许一个Python线程执行但前面我也讲到了Python还有check interval这样的抢占机制。我们来考虑这样一段代码
```
import threading
n = 0
def foo():
global n
n += 1
threads = []
for i in range(100):
t = threading.Thread(target=foo)
threads.append(t)
for t in threads:
t.start()
for t in threads:
t.join()
print(n)
```
如果你执行的话就会发现尽管大部分时候它能够打印100但有时侯也会打印99或者98。
这其实就是因为,`n+=1`这一句代码让线程并不安全。如果你去翻译foo这个函数的bytecode就会发现它实际上由下面四行bytecode组成
```
&gt;&gt;&gt; import dis
&gt;&gt;&gt; dis.dis(foo)
LOAD_GLOBAL 0 (n)
LOAD_CONST 1 (1)
INPLACE_ADD
STORE_GLOBAL 0 (n)
```
而这四行bytecode中间都是有可能被打断的
所以千万别想着有了GIL你的程序就可以高枕无忧了我们仍然需要去注意线程安全。正如我开头所说**GIL的设计主要是为了方便CPython解释器层面的编写者而不是Python应用层面的程序员**。作为Python的使用者我们还是需要lock等工具来确保线程安全。比如我下面的这个例子
```
n = 0
lock = threading.Lock()
def foo():
global n
with lock:
n += 1
```
## 如何绕过GIL
学到这里估计有的Python使用者感觉自己像被废了武功一样觉得降龙十八掌只剩下了一掌。其实大可不必你并不需要太沮丧。Python的GIL是通过CPython的解释器加的限制。如果你的代码并不需要CPython解释器来执行就不再受GIL的限制。
事实上很多高性能应用场景都已经有大量的C实现的Python库例如NumPy的矩阵运算就都是通过C来实现的并不受GIL影响。
所以大部分应用情况下你并不需要过多考虑GIL。因为如果多线程计算成为性能瓶颈往往已经有Python库来解决这个问题了。
换句话说如果你的应用真的对性能有超级严格的要求比如100us就对你的应用有很大影响那我必须要说Python可能不是你的最优选择。
当然可以理解的是我们难以避免的有时候就是想临时给自己松松绑摆脱GIL比如在深度学习应用里大部分代码就都是Python的。在实际工作中如果我们想实现一个自定义的微分算子或者是一个特定硬件的加速器那我们就不得不把这些关键性能performance-critical代码在C++中实现不再受GIL所限然后再提供Python的调用接口。
总的来说你只需要重点记住绕过GIL的大致思路有这么两种就够了
1. 绕过CPython使用JPythonJava实现的Python解释器等别的实现
1. 把关键性能代码放到别的语言一般是C++)中实现。
## 总结
今天这节课我们先通过一个实际的例子了解了GIL对于应用的影响之后我们适度剖析了GIL的实现原理你不必深究一些原理的细节明白其主要机制和存在的隐患即可。
自然我也为你提供了绕过GIL的两种思路。不过还是那句话很多时候我们并不需要过多纠结GIL的影响。
## 思考题
最后,我给你留下两道思考题。
第一问在我们处理cpu-bound的任务文中第一个例子为什么有时候使用多线程会比单线程还要慢些
第二问你觉得GIL是一个好的设计吗事实上在Python 3之后确实有很多关于GIL改进甚至是取消的讨论你的看法是什么呢你在平常工作中有被GIL困扰过的场景吗
欢迎在留言区写下你的想法,也欢迎你把今天的内容分享给你的同事朋友,我们一起交流、一起进步。

View File

@@ -0,0 +1,344 @@
<audio id="audio" title="24 | 带你解析 Python 垃圾回收机制" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c1/9d/c1137db3803a9c3bc7524714fb1e009d.mp3"></audio>
你好,我是景霄。
众所周知,我们当代的计算机都是图灵机架构。图灵机架构的本质,就是一条无限长的纸带,对应着我们今天的存储器。在工程学的演化中,逐渐出现了寄存器、易失性存储器(内存)和永久性存储器(硬盘)等产品。其实,这本身来自一个矛盾:速度越快的存储器,单位价格也越昂贵。因此,妥善利用好每一寸高速存储器的空间,永远是系统设计的一个核心。
回到 Python 应用层。
我们知道Python 程序在运行的时候,需要在内存中开辟出一块空间,用于存放运行时产生的临时变量;计算完成后,再将结果输出到永久性存储器中。如果数据量过大,内存空间管理不善就很容易出现 OOMout of memory俗称爆内存程序可能被操作系统中止。
而对于服务器,这种设计为永不中断的系统来说,内存管理则显得更为重要,不然很容易引发内存泄漏。什么是内存泄漏呢?
- 这里的泄漏,并不是说你的内存出现了信息安全问题,被恶意程序利用了,而是指程序本身没有设计好,导致程序未能释放已不再使用的内存。
- 内存泄漏也不是指你的内存在物理上消失了,而是意味着代码在分配了某段内存后,因为设计错误,失去了对这段内存的控制,从而造成了内存的浪费。
那么Python 又是怎么解决这些问题的换句话说对于不会再用到的内存空间Python 是通过什么机制来回收这些空间的呢?
## 计数引用
我们反复提过好几次, Python 中一切皆对象。因此,你所看到的一切变量,本质上都是对象的一个指针。
那么,怎么知道一个对象,是否永远都不能被调用了呢?
我们上节课提到过的,也是非常直观的一个想法,就是当这个对象的引用计数(指针数)为 0 的时候,说明这个对象永不可达,自然它也就成为了垃圾,需要被回收。
我们来看一个例子:
```
import os
import psutil
# 显示当前 python 程序占用的内存大小
def show_memory_info(hint):
pid = os.getpid()
p = psutil.Process(pid)
info = p.memory_full_info()
memory = info.uss / 1024. / 1024
print('{} memory used: {} MB'.format(hint, memory))
```
```
def func():
show_memory_info('initial')
a = [i for i in range(10000000)]
show_memory_info('after a created')
func()
show_memory_info('finished')
########## 输出 ##########
initial memory used: 47.19140625 MB
after a created memory used: 433.91015625 MB
finished memory used: 48.109375 MB
```
通过这个示例,你可以看到,调用函数 func(),在列表 a 被创建之后,内存占用迅速增加到了 433 MB而在函数调用结束后内存则返回正常。
这是因为,函数内部声明的列表 a 是局部变量,在函数返回后,局部变量的引用会注销掉;此时,列表 a 所指代对象的引用数为 0Python 便会执行垃圾回收,因此之前占用的大量内存就又回来了。
明白了这个原理后,我们稍微修改一下代码:
```
def func():
show_memory_info('initial')
global a
a = [i for i in range(10000000)]
show_memory_info('after a created')
func()
show_memory_info('finished')
########## 输出 ##########
initial memory used: 48.88671875 MB
after a created memory used: 433.94921875 MB
finished memory used: 433.94921875 MB
```
新的这段代码中global a 表示将 a 声明为全局变量。那么,即使函数返回后,列表的引用依然存在,于是对象就不会被垃圾回收掉,依然占用大量内存。
同样,如果我们把生成的列表返回,然后在主程序中接收,那么引用依然存在,垃圾回收就不会被触发,大量内存仍然被占用着:
```
def func():
show_memory_info('initial')
a = [i for i in derange(10000000)]
show_memory_info('after a created')
return a
a = func()
show_memory_info('finished')
########## 输出 ##########
initial memory used: 47.96484375 MB
after a created memory used: 434.515625 MB
finished memory used: 434.515625 MB
```
这是最常见的几种情况。由表及里,下面,我们深入看一下 Python 内部的引用计数机制。老规矩,先来看代码:
```
import sys
a = []
# 两次引用,一次来自 a一次来自 getrefcount
print(sys.getrefcount(a))
def func(a):
# 四次引用apython 的函数调用栈,函数参数,和 getrefcount
print(sys.getrefcount(a))
func(a)
# 两次引用,一次来自 a一次来自 getrefcount函数 func 调用已经不存在
print(sys.getrefcount(a))
########## 输出 ##########
2
4
2
```
简单介绍一下sys.getrefcount() 这个函数,可以查看一个变量的引用次数。这段代码本身应该很好理解,不过别忘了,**getrefcount 本身也会引入一次计数**。
另一个要注意的是,在函数调用发生的时候,会产生额外的两次引用,一次来自函数栈,另一个是函数参数。
```
import sys
a = []
print(sys.getrefcount(a)) # 两次
b = a
print(sys.getrefcount(a)) # 三次
c = b
d = b
e = c
f = e
g = d
print(sys.getrefcount(a)) # 八次
########## 输出 ##########
2
3
8
```
看到这段代码需要你稍微注意一下a、b、c、d、e、f、g 这些变量全部指代的是同一个对象而sys.getrefcount() 函数并不是统计一个指针,而是要统计一个对象被引用的次数,所以最后一共会有八次引用。
理解引用这个概念后,引用释放是一种非常自然和清晰的思想。相比 C 语言里,你需要使用 free 去手动释放内存Python 的垃圾回收在这里可以说是省心省力了。
不过,我想还是会有人问,如果我偏偏想手动释放内存,应该怎么做呢?
方法同样很简单。你只需要先调用 del a 来删除对象的引用;然后强制调用 gc.collect(),清除没有引用的对象,即可手动启动垃圾回收。
```
import gc
show_memory_info('initial')
a = [i for i in range(10000000)]
show_memory_info('after a created')
del a
gc.collect()
show_memory_info('finish')
print(a)
########## 输出 ##########
initial memory used: 48.1015625 MB
after a created memory used: 434.3828125 MB
finish memory used: 48.33203125 MB
---------------------------------------------------------------------------
NameError Traceback (most recent call last)
&lt;ipython-input-12-153e15063d8a&gt; in &lt;module&gt;
11
12 show_memory_info('finish')
---&gt; 13 print(a)
NameError: name 'a' is not defined
```
到这里,是不是觉得垃圾回收非常简单呀?
我想,肯定有人觉得自己都懂了,那么,如果此时有面试官问:引用次数为 0 是垃圾回收启动的充要条件吗?还有没有其他可能性呢?
这个问题,你能回答的上来吗?
## 循环引用
如果你也被困住了,别急。我们不妨小步设问,先来思考这么一个问题:如果有两个对象,它们互相引用,并且不再被别的对象所引用,那么它们应该被垃圾回收吗?
请仔细观察下面这段代码:
```
def func():
show_memory_info('initial')
a = [i for i in range(10000000)]
b = [i for i in range(10000000)]
show_memory_info('after a, b created')
a.append(b)
b.append(a)
func()
show_memory_info('finished')
########## 输出 ##########
initial memory used: 47.984375 MB
after a, b created memory used: 822.73828125 MB
finished memory used: 821.73046875 MB
```
这里a 和 b 互相引用,并且,作为局部变量,在函数 func 调用结束后a 和 b 这两个指针从程序意义上已经不存在了。但是,很明显,依然有内存占用!为什么呢?因为互相引用,导致它们的引用数都不为 0。
试想一下,如果这段代码出现在生产环境中,哪怕 a 和 b 一开始占用的空间不是很大但经过长时间运行后Python 所占用的内存一定会变得越来越大,最终撑爆服务器,后果不堪设想。
当然,有人可能会说,互相引用还是很容易被发现的呀,问题不大。可是,更隐蔽的情况是出现一个引用环,在工程代码比较复杂的情况下,引用环还真不一定能被轻易发现。
那么,我们应该怎么做呢?
事实上Python 本身能够处理这种情况,我们刚刚讲过的,可以显式调用 gc.collect() ,来启动垃圾回收。
```
import gc
def func():
show_memory_info('initial')
a = [i for i in range(10000000)]
b = [i for i in range(10000000)]
show_memory_info('after a, b created')
a.append(b)
b.append(a)
func()
gc.collect()
show_memory_info('finished')
########## 输出 ##########
initial memory used: 49.51171875 MB
after a, b created memory used: 824.1328125 MB
finished memory used: 49.98046875 MB
```
所以你看Python 的垃圾回收机制并没有那么弱。
Python 使用标记清除mark-sweep算法和分代收集generational来启用针对循环引用的自动垃圾回收。你可能不太熟悉这两个词这里我简单介绍一下。
先来看标记清除算法。我们先用图论来理解不可达的概念。对于一个有向图,如果从一个节点出发进行遍历,并标记其经过的所有节点;那么,在遍历结束后,所有没有被标记的节点,我们就称之为不可达节点。显而易见,这些节点的存在是没有任何意义的,自然的,我们就需要对它们进行垃圾回收。
当然,每次都遍历全图,对于 Python 而言是一种巨大的性能浪费。所以,在 Python 的垃圾回收实现中mark-sweep 使用双向链表维护了一个数据结构,并且只考虑容器类的对象(只有容器类对象才有可能产生循环引用)。具体算法这里我就不再多讲了,毕竟我们的重点是关注应用。
而分代收集算法,则是另一个优化手段。
Python 将所有对象分为三代。刚刚创立的对象是第 0 代;经过一次垃圾回收后,依然存在的对象,便会依次从上一代挪到下一代。而每一代启动自动垃圾回收的阈值,则是可以单独指定的。当垃圾回收器中新增对象减去删除对象达到相应的阈值时,就会对这一代对象启动垃圾回收。
事实上,分代收集基于的思想是,新生的对象更有可能被垃圾回收,而存活更久的对象也有更高的概率继续存活。因此,通过这种做法,可以节约不少计算量,从而提高 Python 的性能。
学了这么多,刚刚面试官的问题,你应该能回答得上来了吧!没错,引用计数是其中最简单的实现,不过切记,引用计数并非充要条件,它只能算作充分非必要条件;至于其他的可能性,我们所讲的循环引用正是其中一种。
## 调试内存泄漏
不过,虽然有了自动回收机制,但这也不是万能的,难免还是会有漏网之鱼。内存泄漏是我们不想见到的,而且还会严重影响性能。有没有什么好的调试手段呢?
答案当然是肯定的,接下来我就为你介绍一个“得力助手”。
它就是objgraph一个非常好用的可视化引用关系的包。在这个包中我主要推荐两个函数第一个是show_refs(),它可以生成清晰的引用关系图。
通过下面这段代码和生成的引用调用图,你能非常直观地发现,有两个 list 互相引用,说明这里极有可能引起内存泄露。这样一来,再去代码层排查就容易多了。
```
import objgraph
a = [1, 2, 3]
b = [4, 5, 6]
a.append(b)
b.append(a)
objgraph.show_refs([a])
```
<img src="https://static001.geekbang.org/resource/image/fc/ae/fc3b0355ecfdbac5a7b48aa014208aae.png" alt="">
而另一个非常有用的函数,是 show_backrefs()。下面同样为示例代码和生成图,你可以自己先阅读一下:
```
import objgraph
a = [1, 2, 3]
b = [4, 5, 6]
a.append(b)
b.append(a)
objgraph.show_backrefs([a])
```
<img src="https://static001.geekbang.org/resource/image/92/27/9228289bac4976cfa9b11e08c05a7a27.png" alt="">
相比刚才的引用调用图,这张图显得稍微复杂一些。不过,我仍旧推荐你掌握它,因为这个 API 有很多有用的参数比如层数限制max_depth、宽度限制too_many、输出格式控制filename output、节点过滤filter, extra_ignore等。所以建议你使用之前先认真看一下[文档](https://mg.pov.lt/objgraph/)。
## 总结
最后带你来总结一下。今天这节课我们深入了解了Python 的垃圾回收机制,我主要强调下面这几点:
1. 垃圾回收是 Python 自带的机制,用于自动释放不会再用到的内存空间;
1. 引用计数是其中最简单的实现,不过切记,这只是充分非必要条件,因为循环引用需要通过不可达判定,来确定是否可以回收;
1. Python 的自动回收算法包括标记清除和分代收集,主要针对的是循环引用的垃圾收集;
1. 调试内存泄漏方面, objgraph 是很好的可视化分析工具。
## 思考题
最后给你留一道思考题。你能否自己实现一个垃圾回收判定算法呢?我的要求很简单,输入是一个有向图,给定起点,表示程序入口点;给定有向边,输出不可达节点。
希望你可以认真思考这个问题,并且在留言区写下你的答案与我讨论。也欢迎你把这篇文章分享出去,我们一起交流,一起进步。

View File

@@ -0,0 +1,116 @@
<audio id="audio" title="25 | 答疑GIL与多线程是什么关系呢" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2a/7e/2a39adf03fc5aaa05cfafafea3f0387e.mp3"></audio>
你好,我是景霄。
不知不觉中,我们又一起完成了第二大章进阶篇的学习。我非常高兴看到很多同学一直在坚持积极地学习,并且留下了很多高质量的留言,值得我们互相思考交流。也有一些同学反复推敲,指出了文章中一些表达不严谨或是不当的地方,我也表示十分感谢。
大部分留言,我都在相对应的文章中回复过了。而一些手机上不方便回复,或是很有价值很典型的问题,我专门摘录了出来,作为今天的答疑内容,集中回复。
## 问题一列表self append无限嵌套的原理
<img src="https://static001.geekbang.org/resource/image/9d/a0/9d6c8c7a5adc13e9119d08dc3f1052a0.png" alt="">
先来回答第一个问题两个同学都问到了下面这段代码中的x为什么是无限嵌套的列表
```
x = [1]
x.append(x)
x
[1, [...]]
```
我们可以将上述操作画一个图,便于你更直观地理解:
<img src="https://static001.geekbang.org/resource/image/00/5f/001a607f3f29f68975be3e706711325f.png" alt="">
这里x指向一个列表列表的第一个元素为1执行了append操作后第二个元素又反过来指向x即指向了x所指向的列表因此形成了一个无限嵌套的循环[1, [1, [1, [1, …]]]]。
不过虽然x是无限嵌套的列表但x.append(x)的操作并不会递归遍历其中的每一个元素。它只是扩充了原列表的第二个元素并将其指向x因此不会出现stack overflow的问题自然不会报错。
至于第二点为什么len(x)返回的是2我们还是来看x虽然它是无限嵌套的列表但x的top level只有2个元素组成第一个元素为1第二个元素为指向自身的列表因此len(x)返回2。
## 问题二:装饰器的宏观理解
<img src="https://static001.geekbang.org/resource/image/17/6f/17fcf8a9ef8685025fb5f792bc26116f.png" alt="">
再来看第二个问题,胡峣同学对装饰器的疑问。事实上,装饰器的作用与意义,在于其可以通过自定义的函数或类,在不改变原函数的基础上,改变原函数的一些功能。
```
Decorators is to modify the behavior of the function through a wrapper so we don't have to actually modify the function.
```
装饰器将额外增加的功能,封装在自己的装饰器函数或类中;如果你想要调用它,只需要在原函数的顶部,加上@decorator即可。显然,这样做可以让你的代码得到高度的抽象、分离与简化。
光说概念可能还是有点抽象,我们可以想象下面这样一个场景,从真实例子来感受装饰器的魅力。在一些社交网站的后台,有无数的操作在调用之前,都需要先检查用户是否登录,比如在一些帖子里发表评论、发表状态等等。
如果你不知道装饰器,用常规的方法来编程,写出来的代码大概是下面这样的:
```
# 发表评论
def post_comment(request, ...):
if not authenticate(request):
raise Exception('U must log in first')
...
# 发表状态
def post_moment(request, ...):
if not authenticate(request):
raise Exception('U must log in first')
...
```
显然这样重复调用认证函数authenticate()的步骤就显得非常冗余了。更好的解决办法就是将认证函数authenticate()单独分离出来,写成一个装饰器,就像我们下面这样的写法。这样一来,代码便得到了高度的优化:
```
# 发表评论
@authenticate
def post_comment(request, ...):
# 发表状态
@authenticate
def post_moment(request, ...):
```
不过也要注意,很多情况下,装饰器并不是唯一的方法。而我这里强调的,主要是使用装饰器带来的好处:
- 代码更加简洁;
- 逻辑更加清晰;
- 程序的层次化、分离化更加明显。
而这也是我们应该遵循和优先选择的开发模式。
## 问题三GIL与多线程的关系
<img src="https://static001.geekbang.org/resource/image/34/f1/3492e32a3396872095242be23db19ef1.png" alt="">
第三个问题new同学疑惑的是GIL只支持单线程而Python支持多线程这两者之间究竟是什么关系呢
其实GIL的存在与Python支持多线程并不矛盾。前面我们讲过GIL是指同一时刻程序只能有一个线程运行而Python中的多线程是指多个线程交替执行造成一个“伪并行”的结果但是具体到某一时刻仍然只有1个线程在运行并不是真正的多线程并行。这个机制我画了下面这张图来表示
<img src="https://static001.geekbang.org/resource/image/e0/7b/e09b09170e0d2990d2e7f4e6a0292d7b.png" alt="">
举个例子来理解。比如我用10个线程来爬取50个网站的内容。线程1在爬取第1个网站时被I/O block住了处于等待状态这时GIL就会释放而线程2就会开始执行去爬取第2个网站依次类推。等到线程1的I/O操作完成时主程序便又会切回线程1让其完成剩下的操作。这样一来从用户角度看到的便是我们所说的多线程。
## 问题四:多进程与多线程的应用场景
<img src="https://static001.geekbang.org/resource/image/a8/12/a853c99985472bfabc59d76839df4d12.png" alt="">
第四个问题,这个在文章中多次提到,不过,我还是想在这里再次强调一下。
如果你想对CPU密集型任务加速使用多线程是无效的请使用多进程。这里所谓的CPU密集型任务是指会消耗大量CPU资源的任务比如求1到100000000的乘积或者是把一段很长的文字编码后又解码等等。
使用多线程之所以无效原因正是我们前面刚讲过的Python多线程的本质是多个线程互相切换但同一时刻仍然只允许一个线程运行。因此你使用多线程和使用一个主线程本质上来说并没有什么差别反而在很多情况下因为线程切换带来额外损耗还会降低程序的效率。
而如果使用多进程就可以允许多个进程之间in parallel地执行任务所以能够有效提高程序的运行效率。
至于 I/O密集型任务如果想要加速请优先使用多线程或Asyncio。当然使用多进程也可以达到目的但是完全没有这个必要。因为对I/O密集型任务来说大多数时间都浪费在了I/O等待上。因此在一个线程/任务等待I/O时我们只需要切换线程/任务去执行其他 I/O操作就可以了。
不过如果I/O操作非常多、非常heavy需要建立的连接也比较多时我们一般会选择Asyncio。因为Asyncio的任务切换更加轻量化并且它能启动的任务数也远比多线程启动的线程数要多。当然如果I/O的操作不是那么的heavy那么使用多线程也就足够了。
今天主要回答这几个问题,同时也欢迎你继续在留言区写下疑问和感想,我会持续不断地解答。希望每一次的留言和答疑,都能给你带来新的收获和价值。