相关文章推荐
稳重的芹菜  ·  django:restframework定义 ...·  6 天前    · 
要出家的火车  ·  Django+rest_framework ...·  6 天前    · 
儒雅的番茄  ·  Socket 连接异常 ...·  5 月前    · 
从容的脆皮肠  ·  wpf treeview checkbox ...·  1 年前    · 
冲动的登山鞋  ·  光标定位函数 ...·  1 年前    · 
买醉的熊猫  ·  python库--pandas--DataF ...·  1 年前    · 
1,461

你好,我是悦创。

在 Python 3.4 时候引进了协程的概念,它使用一种单线程单进程的的方式实现并发。谈到并发,大多数朋友想到更多的应该是多进程和多线程,它们都是比较消耗系统资源的,今天我们不谈线程和进程,而是来说下当前比较火的协程。

因为在爬虫操作中,协程比多线程更有优势。协程是单线程的,单线程就能实现高并发。

什么是协程?

协程,英文名是 Coroutine, 又称为微线程,是一种用户态的轻量级线程。协程不像线程和进程那样,需要进行系统内核上的上下文切换,协程的上下文切换是由程序员决定的。在 Python 中协程就是一个可以暂停执行的函数,听起来和生成器的概念一样。

协程的发展

从 Python3.4 开始协程被加入到标准库,当时的协程是通过 @asyncio.coroutine 和 yeild from 实现的,看起来和生成器的实现方式没什么区别。后来为了更好的区分开协程和生成器,到 Python3.5 的时候引入 async/await 语法糖临时定格了下来,直到 Python3.6 的时候才被更多的人认可,Python3.6 作为 Python3 目前最稳定的版本拥有大量的使用者,后来到了 Python3.7 官方把 async 和 await 作为保留字,同时协程的调用也变得简单了许多,但是,正是保留字的原因导致之前很多 async 为函数名的库报错,典型的有 scrapy,所以这里推荐大家使用 Python3.6。

协程相对于多线程的优点

多线程编程是比较困难的, 因为调度程序任何时候都能中断线程, 必须记住保留锁, 去保护程序中重要部分, 防止多线程在执行的过程中断。

而协程默认会做好全方位保护, 以防止中断。我们必须显示产出才能让程序的余下部分运行。对协程来说, 无需保留锁, 而在多个线程之间同步操作, 协程自身就会同步, 因为在任意时刻, 只有一个协程运行。总结下大概下面几点:

  • 无需系统内核的上下文切换,减小开销;
  • 无需原子操作锁定及同步的开销,不用担心资源共享的问题;
  • 单线程即可实现高并发,单核 CPU 即便支持上万的协程都不是问题,所以很适合用于高并发处理,尤其是在应用在网络爬虫中。
  • 协程的定义

    使用协程也就意味着你需要一直写异步方法。在 Python 中我们使用 asyncio 模块来实现一个协程。如果我们把 Python 中普通函数称之为同步函数(方法),那么用协程定义的函数我们称之为异步函数(方法)。

    注意,以下所有的代码实例运行环境均要求版本大于等于 Python3.6。

    同步函数和异步函数的定义

    同步函数定义

    def regular_double(x):
        return 2 * x
    

    异步函数定义

    async def async_double(x):
        return 2 * x
    

    同步函数和异步函数的调用

    对于同步函数我们知道是这样调用的:

     regular_double(2)
    

    异步函数如何调用呢?带着这个问题我们看下面的一个简单例子。

    import asyncio
    async def foo():
        print("这是一个协程")
    if __name__ == '__main__':
        loop = asyncio.get_event_loop() # 定义一个事件循环
        try:
            print("开始运行协程")
            coro = foo()
            print("进入事件循环")
            loop.run_until_complete(coro) # 运行协程
        finally:
            print("关闭事件循环")
            loop.close() # 运行完关闭协程
    

    这就是最简单的一个协程的例子,让我们了解一下上面的代码。

    首先,需要导入需要的包 -asyncio。然后,创建一个事件循环,因为协程是基于事件循环的。 之后,通过 run_until_complete 方法传入一个异步函数,来运行这个协程。 最后在结束的时候调用 close 方法关闭协程。 综上就是调用一个协程的写法。除此之外协程还有其他的不同之处。

    协程之间的链式调用

    我们可以通过使用 await 关键字,在一个协程中调用一个协程。 一个协程可以启动另一个协程,从而可以使任务根据工作内容,封装到不同的协程中。我们可以在协程中使用 await 关键字,链式地调度协程,来形成一个协程任务流。像下面的例子一样:

    import asyncio
    async def main():
        print("主协程")
        print("等待result1协程运行")
        res1 = await result1()
        print("等待result2协程运行")
        res2 = await result2(res1)
        return (res1,res2)
    async def result1():
        print("这是result1协程")
        return "result1"
    async def result2(arg):
        print("这是result2协程")
        return f"result2接收了一个参数,{arg}"
    if __name__ == '__main__':
        loop = asyncio.get_event_loop()
        try:
            result = loop.run_until_complete(main())
            print(f"获取返回值:{result}")
        finally:
            print("关闭事件循环")
            loop.close()
    等待result1协程运行
    这是result1协程
    等待result2协程运行
    这是result2协程
    获取返回值:('result1''result2接收了一个参数,result1')
    关闭事件循环
    

    在上面,我们知道调用协程需要通过创建一个事件循环然后再去运行。 这里我们需要了解的是如果在协程里想调用一个协程我们需要使用 await 关键字,就拿上面的例子来说在 main 函数里调用协程 result1 和 result2。 那么问题来了:await 干了什么呢?

    await 的作用

    await 的作用就是等待当前的协程运行结束之后再继续进行下面代码。 因为我们执行 result1 的时间很短,所以在表面上看 result1 和 result2 是一起执行的。这就是 await 的作用。等待一个协程的执行完毕,如果有返回结果,那么就会接收到协程的返回结果,通过使用 return 可以返回协程的一个结果,这个和同步函数的 return 使用方法一样。

    并发的执行任务

    一系列的协程可以通过 await 链式调用,但是有的时候我们需要在一个协程里等待多个协程,比如我们在一个协程里等待 1000 个异步网络请求,对于访问次序没有要求的时候,就可以使用关键字 wait 来解决了。wait 可以暂停一个协程,直到后台操作完成。

    Task 的使用

    import asyncio
    async def num(n):
        print(f"当前的数字是:{n}")
        await asyncio.sleep(n)
        print(f"等待时间:{n}")
    async def main():
        tasks = [num(i) for i in range(10)] #协程列表
        #await asyncio.gather(*tasks) #有序并发
        await asyncio.wait(tasks) #并发运行协程列表的协程
    if __name__ == '__main__':
        loop = asyncio.get_event_loop()
        try:
            loop.run_until_complete(main())
        finally:
            loop.close()
    
    当前的数字是:0
    当前的数字是:4
    当前的数字是:8
    当前的数字是:1
    当前的数字是:5
    当前的数字是:7
    当前的数字是:2
    当前的数字是:6
    当前的数字是:9
    当前的数字是:3
    等待时间:0
    等待时间:1
    等待时间:2
    等待时间:3
    等待时间:4
    等待时间:5
    等待时间:6
    等待时间:7
    等待时间:8
    等待时间:9
    

    如果运行的话会发现首先会打印 10 次数字,但是并不是顺序执行的,这也说明 asyncio.wait 并发执行的时候是乱序的。如果想保证顺序只要使用 gather 把 task 写成解包的形式就行了,也就是上面的注释部分的代码。

    如何在协程中使用普通的函数呢?

    我们知道在普通函数中调用普通函数之间,函数名加括号即可,像下面这样:

    def foo():
       print("这是一个普通函数")
       return "test"
    def main():
       print("调用foo函数") 
       res=foo()
       print(f"{接收到来自foo函数的值}:res")
    if __name__=="__main__"
       main()
    

    那么在协程中如何使用一个普通函数呢? 在协程中可以通过一些方法去调用普通的函数。可以使用的关键字有 call_soon 等。

    call_soon

    可以通过字面意思理解调用立即返回。 下面来看一下具体的使用例子:

    import asyncio
    import functools
    def callback(args, *, kwargs="defalut"):
        print(f"普通函数做为回调函数,获取参数:{args}{kwargs}")
    async def main(loop):
        print("注册callback")
        loop.call_soon(callback, 1)
        wrapped = functools.partial(callback, kwargs="not defalut")
        loop.call_soon(wrapped, 2)
        await asyncio.sleep(0.2)
    if __name__ == '__main__':
        loop = asyncio.get_event_loop()
    try:
        loop.run_until_complete(main(loop))
    finally:
        loop.close()
    

    输出结果:

    注册callback
    普通函数做为回调函数,获取参数:1,defalut
    普通函数做为回调函数,获取参数:2not defalut
    

    通过输出结果我们可以发现我们在协程中成功调用了一个普通函数,顺序地打印了 1 和 2。

    看过这些例子之后,也许你就有疑问了,协程没有缺点的么?

    协程的缺点

    同样的总结下大概以下 2 点。

    无法使用 CPU 的多核

    协程的本质是个单线程,它不能同时用上单个 CPU 的多个核,协程需要和进程配合才能运行在多 CPU 上。当然我们日常所编写的绝大部分应用都没有这个必要,就比如网络爬虫来说,限制爬虫的速度还有其他的因素,比如网站并发量、网速等问题都会是爬虫速度限制的因素。除非做一些密集型应用,这个时候才可能会用到多进程和协程。

    处处都要使用非阻塞代码

    写协程就意味着你要一值写一些非阻塞的代码,使用各种异步版本的库,比如后面的异步爬虫教程中用的 aiohttp 就是一个异步版本的request库等。 不过这些缺点并不能影响到使用协程的优势。

    对于协程的入门来说,这些知识已经够用了。当然协程涉及到的知识不止这些,这里只是为了大家提前对协程有一定的了解,后面将继续讲解协程的其他知识,一切的协程知识基础都是为后面的异步爬虫教程做准备,只有熟悉了使用协程才能在后面教程中快速上手操作。 接下来将进一步提到本文没有提及的事件循环、Task、Future、Awaitable 等一系列知识点,以及协程的高层 API 知识。敬请期待!