Python學習筆記 - asyncio

MADAO是不會開花的發表於2019-01-29

一. 協程

我對協程的理解就是在單執行緒中執行函式A,可以中斷函式A的執行,切換去執行其他函式,在合適的時候(由程式自己控制)在切換回A繼續執行,達到類似多執行緒的效果。

這樣做的好處是:

  1. 沒有執行緒的切換,所以不會有執行緒切換的開銷。
  2. 因為只有一個執行緒,所以修改共享的變數不需要鎖
  3. 協程之間的切換由程式控制而不是作業系統控制

二. 協程的效率

  • 計算密集型

    在計算密集型的程式中,協程的效率並不是很高,反而會變低:

    from threading import Thread
    import time
    import queue
    
    
    def work(q):
        res = 0
        for i in range(1000000):
            res += i ** 2
        q.put(res)
    
    
    def mutil_thread():
        res = 0
        threads = []
        q = queue.Queue()
        for i in range(2):
            t = Thread(target=work, args=(q,))
            t.start()
            threads.append(t)
    
        [thread.join() for thread in threads]
        while not q.empty():
            res += q.get()
        print(res)
    
    
    def coroutine_consumer():
        n = 0
        while True:
            m = yield n
            if not isinstance(m, int):
                return
            n += m ** 2
    
    
    def coroutine_producer(consumer):
        consumer.send(None)
        for i in range(2):
            for i in range(1000000):
                res = consumer.send(i)
        consumer.close()
        print(res)
    
    
    def normal():
        res = 0
        for i in range(2):
            for j in range(1000000):
                res += j ** 2
        print(res)
    
    
    t1 = time.time()
    normal()
    print('單執行緒:%fs' % (time.time() - t1))
    t2 = time.time()
    mutil_thread()
    print('多執行緒:%fs' % (time.time() - t2))
    t3 = time.time()
    coroutine_producer(coroutine_consumer())
    print('協程:%fs' % (time.time() - t3))
    複製程式碼

    結果:

    Python學習筆記 - asyncio

    可以看到協程是最慢的

  • IO密集型

    import time
    import asyncio
    import threading
    
    
    def work(t):
        print('task %d start' % t)
        time.sleep(t)   # 想象這是一個費時的io操作
        print('task %d end' % t)
    
    
    def normal():
        [work(i) for i in range(1, 3)]
    
    
    def multi_thread():
        threads = []
        for i in range(1, 3):
            t = threading.Thread(target=work, args=(i,))
            t.start()
            threads.append(t)
        [thread.join() for thread in threads]
    
    
    async def work_coroutine(t):
        print('task %d start' % t)
        await asyncio.sleep(t)  # 想象這是一個費時的io操作
        print('task %d end' % t)
    
    
    async def main(loop):
        tasks = [loop.create_task(work_coroutine(t)) for t in range(1, 3)]
        await asyncio.wait(tasks)
    
    
    t1 = time.time()
    normal()
    print('單執行緒:%fs' % (time.time() - t1))
    t2 = time.time()
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main(loop))
    loop.close()
    print('協程:%fs' % (time.time() - t2))
    t3 = time.time()
    multi_thread()
    print('多執行緒:%fs' % (time.time() - t3))
    複製程式碼

    結果:

    Python學習筆記 - asyncio

    協程是和多執行緒的速度差不多的,但是協程是在一個執行緒裡實現的,沒有執行緒切換的開銷,所以執行緒越多,協程的效能優勢就越明顯。

三. 關於生成器的一些總結

之前在生成器和迭代器中總結過這方面的一些知識,但是看到協程的使用,好多大神都會用使用了yield的函式作為例子講解,但是我並不是很理解。所以在這裡繼續總結一些之前沒有總結到的知識點:

  • 生成器的執行順序

    我一直以為使用了yield的函式(也就是生成器),是執行到了yield返回yield後面的跟著的值,然後繼續執行下面的程式碼,然後第二次執行的時候再從頭開始,其實順序不是這樣的,是第一次執行到yield處,然後返回yield後面的跟著的值,函式就暫停了,等下一次再執行的時候就從上一次暫停的yield處繼續執行,例子:

    def my_generator(max):
    n = 0
    while n <= max:
        print('yield前')
        yield n
        print('yield後')
        n += 1
    
    
    g = my_generator(10)
    print(next(g))
    print('=' * 10)
    print(next(g))
    複製程式碼

    結果:

    Python學習筆記 - asyncio

    可以看到第一次使用next獲取迭代器g的值得時候,是到了yield前然後返回,就停了,第二次則是從上次停止的地方繼續執行下面的程式碼,碰到yield返回,函式暫停。

  • next,send,close,throw

    生成器有幾種狀態,可以通過getgeneratorstate方法看到:

    Python學習筆記 - asyncio

    GEN_CREATED # 等待執行
    GEN_RUNNING # 正在執行
    GEN_SUSPENDED # 暫停(在yield處)
    GEN_CLOSED # 執行結束
    複製程式碼

    當生成器第一次被呼叫的時候是沒有辦法拿到yield返回的結果的:

    from inspect import getgeneratorstate
    
    
    def my_generator(max):
        n = 0
        while n <= max:
            yield n
            n += 1
    
    
    g = my_generator(10)
    print(g)  # <generator object my_generator at 0x102804d68>
    print(getgeneratorstate(g)) # GEN_CREATED
    複製程式碼

    想要拿到yield返回的結果,就要用的send或者next方法,相當於啟用(啟動)生成器。例子:

    def my_generator(max):
        n = 0
        while n < max:
            m = yield n
            n += m
    
    
    g = my_generator(10)
    print(next(g))   # 0
    print(g.send(4)) # 4
    print(g.send(8)) # 8
    複製程式碼

    next和send方法都可以拿到生成器yield返回的結果,它們的不同就是send可以帶一個引數,這個引數指的是上一次yield語句的返回值。分析一下上面的例子就明白了:

    # 生成器
    def my_generator(max):
        n = 0
        while n < max:
            m = yield n
            n += m
            
    # 建立一個generator物件,並沒有執行yield語句
    g = my_generator(10)
    
    '''
    啟用生成器,並執行它,執行到yield n,返回n,注意
    m = yield n的執行順序是從右到左,也就是yield n之後函式就暫停了
    m這時候並沒有被賦值。這時候n = 0
    '''
    print(next(g))
    
    '''
    send 方法指定了一個上次 yield n 的返回值為4,這已經是第二次執行生成器了
    所以從上次停止的地方開始,上次是停在了給m賦值的操作,yield n返回值被指定為
    4,所以m被賦值為4,繼續執行n += m這句程式碼,這時候n = 4, while語句的條件為True,
    所以繼續迴圈到了 m = yield n這句程式碼,同樣從右到左的順序,執行 yield n 返回n的值4
    '''
    print(g.send(4))
    
    '''
    以此類推,上次還還是停在了給m賦值的操作,send方法的引數是8,所以上次 yield n 這句程式碼返回的就是8,把8賦值給m,繼續執行,當再一次執行到m = yield n的時候,n就是12了。
    '''
    print(g.send(8))
    複製程式碼

    如果上面解釋的還是不清楚的話,可以這樣理解,就是 yield n 這句話會讓生成器返回 n的值,然後yield n這句話本身也會返回一個值,send方法的引數就是指定yield n這句話本身的返回值。

    有時候還會看到這樣的程式碼:

    example_generator.send(None)
    複製程式碼

    send(None)就好像第一次呼叫生成器的next(example_generator)一樣,都可以啟用一個生成器,只不過啟用的時候send的引數只能是None。例子:

    def my_generator(max):
    n = 0
    while n < max:
        m = yield n
        n = m
    
    
    g = my_generator(100)
    # g.send(None) == next(g)
    print(g.send(None))  # 0
    print(g.send(4))  # 4
    print(g.send(8)) # 12
    複製程式碼

    close方法就和它的名字一樣,關閉一個生成器。當關閉之後再通過next或者send方法就會報StopIteration。例子:

    def my_generator(max):
    n = 0
    while n < max:
        m = yield n
        n = m
    
    
    g = my_generator(100)
    print(g.send(None))  
    print(g.send(4))
    g.close()
    print(g.send(8))
    複製程式碼
    # 結果
    0
    4
    Traceback (most recent call last):
        File "test.py", line 16, in <module>
            print(g.send(8))
    StopIteration
    複製程式碼

    throw方法可以結束生成器執行,並丟擲指定異常或系統定義異常

    def my_generator(max):
    try: 
        n = 0
        while n < max:
            m = yield n
            n = m
    except ValueError:
        print('ValueError')
    
    
    g = my_generator(100)
    print(g.send(None))  # 0
    print(g.send(4))     # 4
    g.throw(ValueError)  # ValueError
    複製程式碼

四. asyncio

說了那麼多,終於到了asyncio,asyncio是Python內建的一個標準庫,是Python3.4版本之後引入的,也就是Python內建了對非同步IO的支援。

asyncio的使用:

  1. async/await 關鍵字

    給函式前面加上 async 關鍵字可以定義一個協程,直接呼叫這個協程的函式,並不會直接執行這個函式,而是返回一個coroutine物件,而且還會引發一個RuntimeWarning的警告

    import asyncio
    
    
    async def say_hello():
        print('Hello')
        await asyncio.sleep(1)
    複製程式碼
    # 結果
    <coroutine object say_hello at 0x10e797ec8>
    test.py:8: RuntimeWarning: coroutine 'say_hello' was never awaited
        print(say_hello())
    RuntimeWarning: Enable tracemalloc to get the object allocation traceback
    複製程式碼

    await 則是掛起一個耗時的操作,也就是告訴Python這一步是耗時的,主執行緒不用等待這個操作,可以切換去執行其他協程。

    例子:

    import asyncio
    
    
    async def say_hello(i):
        print('Hello start %d ' % i)
        await asyncio.sleep(1)
        print('Hello end %d' % i)
    
    
    async def main(loop):
        tasks = [loop.create_task(say_hello(i)) for i in range(1, 3)]
        await asyncio.wait(tasks)
    
    
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main(loop))
    複製程式碼

    結果:

    Hello start 1
    Hello start 2
    Hello end 1
    Hello end 2
    複製程式碼

    使用await,主執行緒就不會去等待asyncio.sleep(1)這個操作了,它會轉去執行其他協程。只有協程, Task 和 Future才可以被await可以成功掛起,比如 await time.sleep(1)就不會被掛起

  2. asyncio.sleep

    asyncio.sleep有點像time.sleep,但是time.sleep會阻塞主執行緒,這個不會,它會阻塞當前的任務,讓Python去執行其他的任務,引數就阻塞的時間。

  3. loop.create_task

    說loop.create_task之前,最好先知道這兩個概念:

    Future物件: 包含了非同步操作結果的物件。

    Task 物件:繼承自Future,它是對Future和協程的進一步封裝,用於事件迴圈。

    我這個是抄別人部落格中的解釋,原部落格地址

    說實話還是不理解這個兩個概念,目前的理解是,協程函式不能直接執行,會報警告(之前的例子中有),想要執行協程函式就得將它包裝成Task 物件。包裝的方法有兩個:

    • asyncio.ensure_future(coro_or_future, *, loop=None)
    • loop.create_task(coroutine)

    官方推薦使用 loop.create_task,ensure_future接受future物件或者協程物件,create_task只接受協程物件。

  4. asyncio.wait

    asyncio.wait是同時執行任務的方法,和它很像的還有一個asyncio.gather。看下它們兩的區別:

    import asyncio
    
    
    async def foo(i):
        return i ** i
    
    
    async def work_1(loop):
        tasks = [asyncio.create_task(foo(i)) for i in range(4)]
        done, pending = await asyncio.wait(tasks)
        print(done, pending)
        for task in done:
            print(task.result())
            
    loop = asyncio.get_event_loop()
    loop.run_until_complete(work_1(loop))
    複製程式碼
    # 結果:
    
    {<Task finished coro=<foo() done, defined at test.py:4> result=1>, <Task finished coro=<foo() done, defined at test.py:4> result=1>, <Task finished coro=<foo() done, defined at test.py:4> result=4>, <Task finished coro=<foo() done, defined at test.py:4> result=27>} set()
    1
    1
    4
    27
    複製程式碼
    • 第一個引數是包含 coroutines 或 futures 的可迭代物件。
    • 執行是無序的。
    • 返回值是完成的任務和未完成的任務,通過result方法獲取任務的結果
    import asyncio
    
    
    async def foo(i):
        return i ** i
    
    
    
    async def work_2(loop):
        tasks = [asyncio.create_task(foo(i)) for i in range(4)]
        result = await asyncio.gather(*tasks)
        print(result)
    
    loop = asyncio.get_event_loop()
    loop.run_until_complete(work_2(loop))
    複製程式碼
    # 結果:
    
    [1, 1, 4, 27]
    複製程式碼
    • 第一個引數是任意個 coroutines 或 futures。
    • 執行是有序的
    • 返回值是已完成任務的結果
  5. loop = asyncio.get_event_loop

    得到當前上下文的事件迴圈。

    這句話一下子搞了兩個對我來說很模糊的概念,就是事件迴圈和上下文:

    • 事件迴圈:

      在計算系統中,可以產生事件的實體叫做事件源,能處理事件的實體叫做事件處理者。此外,還有一些第三方實體叫做事件迴圈。它的作用是管理所有的事件,在整個程式執行過程中不斷迴圈執行,追蹤事件發生的順序將它們放到佇列中,當主執行緒空閒的時候,呼叫相應的事件處理者處理事件。

      原文

    • 上下文:

      上下文是一段程式執行所需要的最小資料集合。

      原文

    在協程中,把協程函式註冊(放入)事件迴圈中,當事件發生的時候就會呼叫對應的協程函式。

  6. loop.run_until_complete

    執行直到傳入的Future物件完成。run_until_complete方法可以接受Future物件也可以接受協程物件,如果傳入的是協程物件,它會幫你轉換成Future物件。

  7. loop.close()

    關閉事件迴圈。

基本上是把例子中用到的都說了,雖然還是有點不清楚,先記下來,下一步就是應用在爬蟲上,希望可以在用的時候加深理解。

相關文章