【python】常用知识点04

并发和并行

在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的大致思路有这么两种就够了:

  1. 绕过CPython,使用JPython(Java实现的Python解释器)等别的实现;
  2. 把关键性能代码,放到别的语言(一般是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方向

  • 合理的系统、框架设计;
  • 简约高效的代码质量;
  • 稳健齐全的单元测试;
  • 出色的性能表现。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值