并发和并行
在Python中,并发并不是指同一时刻有多个操作(thread、task)同时进行。相反,某个特定的时刻,它只允许有一个操作发生,只不过线程/任务之间会互相切换,直到完成。分别对应Python中并发的两种形式——threading和asyncio。
对于threading,操作系统知道每个线程的所有信息,因此它会做主在适当的时候做线程切换。很显然,这样的好处是代码容易书写,因为程序员不需要做任何切换操作的处理;但是切换线程的操作,也有可能出现在一个语句执行的过程中(比如 x += 1),这样就容易出现race condition的情况。
而对于asyncio,主程序想要切换任务时,必须得到此任务可以被切换的通知,这样一来也就可以避免刚刚提到的 race condition的情况。
至于所谓的并行,指的才是同一时刻、同时发生。Python中的multi-processing便是这个意思,对于multi-processing,你可以简单地这么理解:比如你的电脑是6核处理器,那么在运行程序时,就可以强制Python开6个进程,同时执行,以加快运行速度。
- 并发通常应用于I/O操作频繁的场景,比如你要从网站上下载多个文件,I/O操作的时间可能会比CPU运行处理的时间长得多。
- 而并行则更多应用于CPU heavy的场景,比如MapReduce中的并行计算,为了加快运行速度,一般会用多台机器、多个处理器来完成。
37.Futures
Python中的Futures模块,位于concurrent.futures和asyncio中,它们都表示带有延迟的操作。Futures会将处于等待状态的操作包裹起来放到队列中,这些操作的状态随时可以查询,当然,它们的结果或是异常,也能够在操作完成后被获取。通常来说,作为用户,我们不用考虑如何去创建Futures,这些Futures底层都会帮我们处理好。我们要做的,实际上是去schedule这些Futures的执行。
Futures中还有一个重要的函数result(),它表示当future完成后,返回其对应的结果或异常。而as_completed(fs),则是针对给定的future迭代器fs,在其完成后,返回完成后的迭代器。
# 使用案例
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
executor.map(download_one, sites)
38.asyncio(异步)
事实上,Asyncio和其他Python程序一样,是单线程的,它只有一个主线程,但是可以进行多个不同的任务(task),这里的任务,就是特殊的future对象。这些不同的任务,被一个叫做event loop的对象所控制。你可以把这里的任务,类比成多线程版本里的多个线程。
import asyncio
import aiohttp
import time
# 实际工作中,想用好Asyncio,特别是发挥其强大的功能,很多情况下必须得有相应的Python库支持。你可能注意到了,上节课的多线程编程中,我们使用的是requests库,但今天我们并没有使用,而是用了aiohttp库,原因就是requests库并不兼容Asyncio,但是aiohttp库兼容。
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)
39.多线程/多进程总结
-
如果是I/O bound,并且I/O操作很慢,需要很多任务/线程协同实现,那么使用Asyncio更合适。
-
如果是I/O bound,但是I/O操作很快,只需要有限数量的任务/线程,那么使用多线程就可以了。
-
如果是CPU bound,则需要使用多进程来提高程序运行效率。
-
多线程编程,模型复杂,容易发生冲突,必须用锁加以隔离,同时,又要小心死锁的发生。
Python解释器由于设计时有GIL全局锁,导致了多线程无法利用多核。多线程的并发在Python中就是一个美丽的梦。但可以通过多进程实现多核任务。多个Python进程有各自独立的GIL锁,互不影响。
# 使用多进程的案例。
def cpu_bound(number):
print(sum(i * i for i in range(number)))
# time:14s
def calculate_sums(numbers):
for number in numbers:
cpu_bound(number)
# time:4s
def calculate_sums1(numbers):
p = Pool(4)
for number in numbers:
p.apply_async(cpu_bound, args=(number, ))
p.close()
p.join()
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))
奇怪的位置?
线程到底用了几个核?多线程在python里面到底有没有效率的提升?
多线程只用一个内核,如果在io密集型代码中,等待时是挂起然后使用单个内核去执行另外的操作提升效率的?
GIL的存在与Python支持多线程并不矛盾。前面我们讲过,GIL是指同一时刻,程序只能有一个线程运行;而Python中的多线程,是指多个线程交替执行,造成一个“伪并行”的结果,但是具体到某一时刻,仍然只有1个线程在运行,并不是真正的多线程并行。
40.GIL(全局解释器锁)
# 查看一个对象的引用,如果当前对象的引用为0,表示永远不可达
sys.getrefcount(a)
GIL,是最流行的Python解释器CPython中的一个技术术语。它的意思是全局解释器锁,本质上是类似操作系统的Mutex。每一个Python线程,在CPython解释器中执行时,都会先锁住自己的线程,阻止别的线程执行。当然,CPython会做一些小把戏,轮流执行Python线程。这样一来,用户看到的就是“伪并行”——Python线程在交错执行,来模拟真正并行的线程。
所以说,CPython 引进 GIL 其实主要就是这么两个原因:
- 一是设计者为了规避类似于内存管理这样的复杂的竞争风险问题(race condition);
- 二是因为CPython大量使用C语言库,但大部分C语言库都不是原生线程安全的(线程安全会降低性能和增加复杂度)。
下面这张图,就是一个GIL在Python程序的工作示例。其中,Thread 1、2、3轮流执行,每一个线程在开始执行时,都会锁住GIL,以阻止别的线程执行;同样的,每一个线程执行完一段后,会释放GIL,以允许别的线程开始利用资源。
细心的你可能会发现一个问题:为什么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就可以了。
不过,有了GIL,并不意味着我们Python编程者就不用去考虑线程安全了。即使我们知道,GIL仅允许一个Python线程执行,但前面我也讲到了,Python还有check interval这样的抢占机制。 GIL的设计,主要是为了方便CPython解释器层面的编写者,而不是Python应用层面的程序员。作为Python的使用者,我们还是需要lock等工具,来确保线程安全。
总的来说,你只需要重点记住,绕过GIL的大致思路有这么两种就够了:
- 绕过CPython,使用JPython(Java实现的Python解释器)等别的实现;
- 把关键性能代码,放到别的语言(一般是C++)中实现。
41.python垃圾回收机制
也是非常直观的一个想法,就是当这个对象的引用计数(指针数)为 0 的时候,说明这个对象永不可达,自然它也就成为了垃圾,需要被回收。
# 手动回收
gc.collect()
回收机制中,不只是计数器为0才会回收,当发生循环引用,引用环的时候也会进行回收。回收的方式有两种,
1.标记清除算法。我们先用图论来理解不可达的概念。对于一个有向图,如果从一个节点出发进行遍历,并标记其经过的所有节点;那么,在遍历结束后,所有没有被标记的节点,我们就称之为不可达节点。显而易见,这些节点的存在是没有任何意义的,自然的,我们就需要对它们进行垃圾回收。
当然,每次都遍历全图,对于 Python 而言是一种巨大的性能浪费。所以,在 Python 的垃圾回收实现中,mark-sweep 使用双向链表维护了一个数据结构,并且只考虑容器类的对象(只有容器类对象才有可能产生循环引用)。具体算法这里我就不再多讲了
2.Python 将所有对象分为三代。刚刚创立的对象是第 0 代;经过一次垃圾回收后,依然存在的对象,便会依次从上一代挪到下一代。而每一代启动自动垃圾回收的阈值,则是可以单独指定的。当垃圾回收器中新增对象减去删除对象达到相应的阈值时,就会对这一代对象启动垃圾回收。
事实上,分代收集基于的思想是,新生的对象更有可能被垃圾回收,而存活更久的对象也有更高的概率继续存活。因此,通过这种做法,可以节约不少计算量,从而提高 Python 的性能。
42.上下文管理器
讲完这两种不同原理的上下文管理器后,还需要强调的是,基于类的上下文管理器和基于生成器的上下文管理器,这两者在功能上是一致的。只不过,
- 基于类的上下文管理器更加flexible,适用于大型的系统开发;
- 而基于生成器的上下文管理器更加方便、简洁,适用于中小型程序。
无论你使用哪一种,请不用忘记在方法“__exit__()”
或者是finally block中释放资源,这一点尤其重要。
上下文管理器通常和with语句一起使用,大大提高了程序的简洁度。需要注意的是,当我们用with语句执行上下文管理器的操作时,一旦有异常抛出,异常的类型、值等具体信息,都会通过参数传入“__exit__()”
函数中。你可以自行定义相关的操作对异常进行处理,而处理完异常后,也别忘了加上“return True”
这条语句,否则仍然会抛出异常。
43.单元测试
Python单元测试的几个技巧,分别是mock、side_effect和patch。
import unittest
from unittest.mock import MagicMock
# 使用mock进行单元测试
class A(unittest.TestCase):
def m1(self):
val = self.m2()
self.m3(val)
def m2(self):
pass
def m3(self, val):
pass
def test_m1(self):
a = A()
# 把m2()替换为一个返回具体数值的value
a.m2 = MagicMock(return_value="custom_val")
# m3()替换为另一个mock(空函数)
a.m3 = MagicMock()
a.m1()
self.assertTrue(a.m2.called) #验证m2被call过
a.m3.assert_called_with("custom_val") #验证m3被指定参数call过
if __name__ == '__main__':
unittest.main(argv=['first-arg-is-ignored'], exit=False)
# Mock Side Effect,这个概念很好理解,就是 mock的函数,属性是可以根据不同的输入,返回不同的数值,而不只是一个return_value。
def side_effect(arg):
if arg < 0:
return 1
else:
return 2
mock = MagicMock()
mock.side_effect = side_effect
mock(-1)
1
# patch,给开发者提供了非常便利的函数mock方法。它可以应用Python的decoration模式或是context manager概念,快速自然地mock所需的函数。
@patch('sort')
def test_sort(self, mock_sort):
......
44.pdb
而Python的pdb,正是其自带的一个调试库。它为Python程序提供了交互式的源代码调试功能,是命令行版本的IDE断点调试器
45.cProfile性能分析
- ncalls,是指相应代码/函数被调用的次数;
- tottime,是指对应代码/函数总共执行所需要的时间(注意,并不包括它调用的其他代码/函数的执行时间);
- tottime percall,就是上述两者相除的结果,也就是
tottime / ncalls
; - cumtime,则是指对应代码/函数总共执行所需要的时间,这里包括了它调用的其他代码/函数的执行时间;
- cumtime percall,则是cumtime和ncalls相除的平均结果。
46.异常处理
- 第一种,在代码中对数据进行检测,并直接处理与抛出异常。可以翻译成“if…elif…” ,第一种方法一旦抛出异常,那么程序就会终止
- 第二种,在异常处理代码中进行处理。可以翻译成try…except…finally,如果抛出异常,会被程序捕获(catch),程序还会继续运行。
47.进阶学习python方向
- 合理的系统、框架设计;
- 简约高效的代码质量;
- 稳健齐全的单元测试;
- 出色的性能表现。