잡동사니

코루틴과 태스크의 이해 본문

IT/Python

코루틴과 태스크의 이해

yeTi 2020. 11. 27. 15:47

안녕하세요. yeTi입니다.
오늘은 Python을 사용하면서 겪은 궁금증을 해소하는 시간을 가져볼까 합니다.

궁금 키워드

  • async
  • await
  • asyncio
  • Coroutine
  • Task
  • Future

Asyncio

asyncio는 network, web-servers, database connection libraries, distributed task queue 등 을 고성능으로 사용할 수 있도록 도와주는 asynchronous framework입니다.

이를 위해 다양한 High-level API들을 제공하는데 그 중 Coroutines and Tasks - Python Documentation코루틴태스크에 대한 설명을 제공하고 있습니다.

Coroutines

Coroutine이란 무엇일까요?

Coroutines are computer program components that generalize subroutines for non-preemptive multitasking, by allowing execution to be suspended and resumed
코루틴은 실행을 일시적으로 중단하고 다시 시작할 수 있도록하여 비선점 멀티태스킹을 위한 서브루틴을 일반화하는 컴퓨터 프로그램 구성요소입니다.

Coroutines are very similar to threads. But coroutines provide concurrency but not parallelism
코루틴은 스레드와 매우 유사합니다. 그러나 코루틴은 동시성을 제공하지만 병렬성은 제공하지 않습니다.

...???

이해를 위해 그림으로 표현해보겠습니다.

Routine의 CPU점유 상황

  1. Process - 0이 루틴을 수행하는 상황에서 Process - 1이 수행을 위해 기다립니다.
  2. CPU는 Process - 0의 루틴을 모두 수행한 후 Process - 1을 수행합니다.

Subroutine의 CPU점유 상황

  1. Process - 0이 루틴을 수행하는 상황에서 Process - 1이 수행을 위해 기다립니다.
  2. Process - 1이 대기하는 상황에서 Process - 0의 subroutine을 수행합니다.
  3. CPU는 Process - 0의 루틴을 모두 수행한 후 Process - 1을 수행합니다.

Coroutine의 CPU점유 상황

  1. Process - 0이 corountine1을 수행하는 상황에서 Process - 1의 corountine이 수행을 위해 기다립니다.
  2. Process - 1의 corountine이 대기하는 상황에서 Process - 0의 corountine1이 coroutine1-1을 기다립니다.
  3. CPU는 Process - 0의 corountine1을 수행한 후 Process - 1의 corountine을 수행합니다.
  4. CPU는 Process - 1의 corountine을 수행 후 CPU는 Process - 0의 corountine1-1을 수행합니다.

다시 코루틴의 정의를 보겠습니다.

Coroutines are computer program components that generalize subroutines for non-preemptive multitasking, by allowing execution to be suspended and resumed
코루틴은 실행을 일시적으로 중단하고 다시 시작할 수 있도록하여 비선점 멀티태스킹을 위한 서브루틴을 일반화하는 컴퓨터 프로그램 구성요소입니다.

Coroutines are very similar to threads. But coroutines provide concurrency but not parallelism
코루틴은 스레드와 매우 유사합니다. 그러나 코루틴은 동시성을 제공하지만 병렬성은 제공하지 않습니다.

코루틴은 다른 코루틴의 대기를 위해 일시적으로 중단하고 다시 시작할 수 있으며, 이 떄 처음부터 시작하는것이 아니라 중단된 시점부터 시작한다. 그리고 외부에서 제어할 수 없는 비선점 멀티태스킹을 지원합니다.

프로그램은 실행되면 프로세스가 되고 프로세스는 역시 여러 쓰레드를 실행
CPU를 차지하고 있는 쓰레드가 자신이 이제 CPU 연산이 필요 없음을 나타냈을 때에만 운영체제가 이를 회수할 수 있는 경우를 비선점형 멀티태스킹
프로세스가 CPU를 차지해서 사용하더라도 운영체제가 타이머나 여타 트리거를 통해 개입하여 강제로 CPU 사용을 빼앗아 올 수 있는 경우를 선점형 멀티태스킹

또한 스레드와 유사하게 하나 이상의 루틴을 동시에 진행한다는 의미이지 병렬로 처리하여 처리량을 늘리는것은 아닙니다.

그렇다면 각 루틴을 순차적으로 처리하는것과 동시성을 가지는것이 최종 처리시간에서는 이득이 없는데 왜 사용하는 것일까요?

예를 들어, CPU 코어가 하나인 컴퓨터에 윈도우를 설치하여 사용하고 있는데 10분동안 CPU를 점유하는 프로그램을 돌린다고 가정합니다. 동시성을 지원하지 않는다면 CPU는 10분동안 특정 프로그램을 처리하느라 UI에 대한 이벤트 처리를 할 수 없습니다. 동시성을 지원한다면 연산 중간중간 UI 이벤트를 처리하여 사용자가 마치 병렬적으로 연산을 수행하는 것처럼 느낄 수 있습니다.

결론적으로 코루틴이란, 동시성을 제공하기 위한 개념적인 구성요소입니다. (코루틴이 쓰레드냐? 프로세스냐?가 아니라 코루틴입니다.)

Async/Await

이어서 async/await에 대해 알아보겠습니다.

async/await는 코루틴을 구현하기 위한 프로그래밍 패턴입니다. 간략한 정의를 보면 다음과 같습니다.

allows an asynchronous, non-blocking function to be structured in a way similar to an ordinary synchronous function
asynchronous, non-blocking 함수를 일반적인 synchronous 함수와 유사한 방식으로 구성 할 수 있습니다.

It is semantically related to the concept of a coroutine
코루틴의 개념과 의미상 관련이 있습니다.

이해를 위해 예제를 보겠습니다.

본 예제는 서브루틴을 활용한 예제입니다.

import time

def say_after(delay, what):
    time.sleep(delay)
    print(what)

def main():
    print(f"started at {time.strftime('%X')}")

    say_after(5, 'hello')
    say_after(5, 'world')

    print(f"finished at {time.strftime('%X')}")

main()
결과
started at 09:32:46
hello
world
finished at 09:32:56

main함수를 기점으로 5초의 간격을 두고 'hello'와 'world'를 출력하고 있습니다.

다음 예제는 async/await 패턴을 활용한 예제입니다.

import asyncio
import time

async def say_after(delay, what):
    await asyncio.sleep(delay)
    print(what)

async def main():
    print(f"started at {time.strftime('%X')}")

    await say_after(5, 'hello')
    await say_after(5, 'world')

    print(f"finished at {time.strftime('%X')}")

asyncio.run(main())
결과
started at 09:33:20
hello
world
finished at 09:33:30

동일하게 main함수를 기점으로 5초의 간격을 두고 'hello'와 'world'를 출력하고 있습니다.

본 예제에서 확인할 수 있는 점은 서브루틴과 코루틴의 코딩 스타일이 유사하다는 것입니다.
asynchronous, non-blocking 함수를 일반적인 synchronous 함수와 유사한 방식으로 구성 할 수 있습니다.

예제를 하나 더 보겠습니다.

import asyncio
import time

async def say_after(delay, what):
    print(f"run say_after at {time.strftime('%X')}")
    await asyncio.sleep(delay)
    print(what)

async def main():
    print(f"started at {time.strftime('%X')}")

    co_obj = say_after(5, 'hello')
    print('created co_obj')

    await asyncio.sleep(3)
    print(f"sleeped finish at {time.strftime('%X')}")

    print('start await co_obj')
    await co_obj

    print(f"finished at {time.strftime('%X')}")

asyncio.run(main())
결과
started at 09:33:54
created co_obj
sleeped finish at 09:33:57
start await co_obj
run say_after at 09:33:57
hello
finished at 09:34:02

본 예제를 통해 say_after함수를 요청하는 것은 코루틴 인스턴스를 생성하는 것이고 함수를 실행하지 않는다는 것과 await를 해야 코루틴 인스턴스가 동작하는 것을 확인할 수 있습니다.

정리하면, async/await 패턴은 절차적인 프로그래밍의 스타일을 유지하면서 코루틴을 구현하는 프로그래밍 스킬이라는 것, 그리고 코루틴 함수를 호출하는 것은 코루틴 인스턴스를 만드를 것이고 await시 수행한다는 것입니다.

Awaitable

다음 개념을 확인하기전에 Awaitable에 대해서 확인하고 넘어가겠습니다.

await를 할 수 있는 객체를 awaitable 객체로 정의하고, coroutines, Tasks, and Futures가 이에 해당합니다.

Task

예제를 이어서 보겠습니다.

import asyncio
import time

async def say_after(delay, what):
    print(f"run say_after at {time.strftime('%X')}")
    await asyncio.sleep(delay)
    print(what)

async def main():
    print(f"started at {time.strftime('%X')}")

    task = asyncio.create_task(say_after(5, 'hello'))
    print('created co_obj')

    await asyncio.sleep(3)
    print(f"sleeped finish at {time.strftime('%X')}")

    print('start await co_obj')
    await task

    print(f"finished at {time.strftime('%X')}")

asyncio.run(main())
결과
started at 09:34:36
created co_obj
run say_after at 09:34:36
sleeped finish at 09:34:39
start await co_obj
hello
finished at 09:34:41

앞선 예제와 비교해본 예제로 코루틴 인스턴스를 생성하는 시점에 태스크로 생성한 부분이 다릅니다.
본 예제를 통해 알고자 하는 부분은 코루틴 인스턴스의 루틴이 어느 시점에 수행하는 것인가입니다. 앞서 코루틴 인스턴스를 생성했을때는 await시점에 수행을 했지만 태스크로 만들었을 때는 태스크를 만든 시점에 수행하는것을 확인할 수 있고, 이어지는 결과로 5초 후에 모든 루틴의 수행이 끝난다는 것입니다.

다른 예제를 보겠습니다.

import asyncio
import time

async def say_after(delay, what):
    await asyncio.sleep(delay)
    print(what)

async def main():
    task1 = asyncio.create_task(
        say_after(5, 'hello'))

    task2 = asyncio.create_task(
        say_after(5, 'world'))

    print(f"started at {time.strftime('%X')}")

    # Wait until both tasks are completed (should take
    # around 5 seconds.)
    await task1
    await task2

    print(f"finished at {time.strftime('%X')}")

asyncio.run(main())
결과
started at 09:35:18
hello
world
finished at 09:35:23

본 예제에서도 main함수를 기점으로 'hello'와 'world'를 5초정도의 시간이 흐른후 출력하는 것을 확인할 수 있고, 코루틴이 가지지 않았던 병렬성이 생겼다는 것을 알 수 있습니다.

여기서 태스크에 대한 정의를 확인해보겠습니다.

asyncio.Task inherits from Future
태스크는 퓨처를 상속받았습니다.

A Future-like object that runs a Python coroutine. Not thread-safe.
파이썬 코루틴을 구동하는 퓨처와 같은 객체입니다. thread-safe하지 않습니다.

Tasks are used to run coroutines in event loops.
태스크는 수행할 코루틴을 이벤트 루프에 전달합니다.

If a coroutine awaits on a Future, the Task suspends the execution of the coroutine and waits for the completion of the Future. When the Future is _done_, the execution of the wrapped coroutine resumes.
코루틴이 퓨처를 기다리면, 태스크는 코루틴의 수행을 중단하고 퓨처가 완료되기를 기다립니다. 퓨처가 완료되면 코루틴의 수행이 재개됩니다.

An event loop runs one Task at a time. While a Task awaits for the completion of a Future, the event loop runs other Tasks, callbacks, or performs IO operations.
이벤트 루프는 한번에 하나의 태스크만 수행합니다. 태스크가 퓨처의 수행이 끝나기를 기다리는 동안 이벤트 루프는 다른 태스크나 콜백, IO 작업을 수행합니다.

태스크가 퓨처를 상속하고 있고 이벤트 루프에 태스크를 전달한다는 것을 알았습니다. 그렇다면 앞서 본 코드의 수행에 따라 이벤트 루프의 상태가 어떻게 변하는지 확인해보겠습니다. (바로 앞의 코드에 대한 도식화 입니다.)

이벤트 루프 상태
main()에서 task1task2를 생성하면 두 태스크는 이벤트 루프에 추가됩니다.


main()에서 task을 await하면 suspend됩니다.


task1 수행중 sleep을 걸면 suspend됩니다.


task2 수행중 sleep을 걸면 suspend됩니다. 이 후 main(), task1, task2 모두가 await 상태이기 때문에 sleep이 끝나기를 기다립니다.


task1의 sleep이 끝나면 resume되어 수행을 마무리합니다. 이후 task2는 대기 상태이므로 main()await task1을 끝내고 await task2를 진입합니다.


이후 task2의 sleep이 끝나면 resume되어 수행을 마무리하고 main()await task2도 끝이나 모든 루틴을 종료합니다.

Future

앞서 코루틴, 태스크에 대해서 알아보았습니다.
이제 퓨처에 대해 알아보겠습니다.

Future objects are used to bridge low-level callback-based code with high-level async/await code.
퓨처는 낮은 수준의 콜백기반 코드로, 사용자 인터페이스에는 노출하지 않으면서 높은 수준의 async/await 코드를 연결하는 역할을 합니다.

정리

  • asyncio : asynchronous framework
  • Coroutine : 코루틴
  • async/await : 코루틴의 구현방식
  • Task : 코루틴을 랩핑하여 스케쥴링 하기위한 단위
  • Future : Task의 낮은 수준의 구현체

의견

await의 대상이 코루틴이면 이벤트 루프에 넣지 않고 바로 실행한다.

검증을 위해 다음과 같이 가정을 세웁니다.

  1. 태스크1을 동작한다.
  2. 태스크2를 이벤트루프에 추가한다.
  3. 태스크1에서 코루틴1-1을 await한다.
  4. 태스크2보다 코루틴1-1이 먼저 수행하면 해당 내용이 검증된다

가정한 코드를 구현하면 다음과 같습니다.

import asyncio
import time

async def inf_loop():
    loop_cnt = 0
    while loop_cnt < 5:
        print('loop is running, cnt:', loop_cnt)
        if loop_cnt == 2:
            await say('I am coroutine in inf_loop')
        loop_cnt += 1

async def say(what):
    print(what)

async def say_after(delay, what):
    await asyncio.sleep(delay)
    print(what)

async def main():
    '''
    await의 대상이 코루틴이면 이벤트 루프에 넣지 않고 바로 실행한다는것에 대한 검즘을 해보는 코드입니다.

    flow 가정
    1. 태스크1을 동작한다.
    2. 태스크2를 이벤트루프에 추가한다.
    3. 태스크1에서 코루틴1-1을 await한다.
    4. 태스크2보다 코루틴1-1이 먼저 수행하면 해당 내용이 검증된다.
    '''
    print('create task1')
    task1 = asyncio.create_task(
        inf_loop()
    )

    print('create task2')
    task2 = asyncio.create_task(
        say_after(5, 'I will say after 5s')
    )

    print('await task1')
    await task1

    print('await task2')
    await task2

asyncio.run(main())
create task1
create task2
await task1
loop is running, cnt: 0   
loop is running, cnt: 1   
I am coroutine in inf_loop
loop is running, cnt: 3
loop is running, cnt: 4
await task2
I will say after 5s

결과를 보면 가정한 대로 task2가 이벤트루프에 있음에도 불구하고 inf_loop에서 호출한 코루틴이 먼저 수행되는것을 확인할 수 있습니다.

Comments