python 코루틴(coroutine) - 동시성과 병렬성, 동기와 비동기 작업, blocking과 non-blocking 그리고 코루틴 (1)

정현우·2023년 10월 10일
8
post-thumbnail

[ 글의 목적: python 코루틴을 이해하기 위한 기본 동작 원리와 CS 기록 ]

Python Coroutine

python에서 코루틴은 "메인"과 "서브"루틴이 서로 협력하는 루틴, cooperative routine 을 의미하며, "협력"이 핵심이다. 원래 서브 루틴은 메인 루틴에 종속적이며 서브 루틴 실행 후 다시 메인으로 돌아간다. 그리고 서브 루틴은 끝나면 해당 내용은 모두 사라진다. 코루틴은 메인 루틴과 서브 루틴처럼 종속된 관계가 아니라 서로 대등한 관계이며 특정 시점에 상대방의 코드를 실행한다.

🔥 함께 읽으면 좋은 글, (동일 velog에 존재하는 글 링크 입니다 😄)
1. 운영체제(OS)에서 프로세스(process)와 쓰레드(thread)
2. python GIL(Global Interpreter Lock)
3. (다음으로 이어지는 글입니다.) python 코루틴(coroutine) - iterator, generator, asyncio, async, await 그리고 코루틴 (2)

1. 동시성(Concurrency)과 병렬성(Parallelism)

동시성은 "많은 일을 한 번에 처리하는 능력" 을, 병렬성은 "많은 일을 동시에 처리하는 능력" 을 의미한다.

1) 동시성, Concurrency

  • 동시성의 핵심 컨셉은 "동시에 실행되는 것 처럼 Time-sharing 알고리즘(시분할 등)을 이용하여 작업을 수행하는 것" 이다.

  • 하나의 CPU 코어에서 여러 쓰레드를 처리하는 것이며 기본적으로 context swithcing 을 통해 자원을 공유한다.

  • 핵심 목표는 "유휴 시간을 최소화 하는 것" 이다. CPU가 다른 작업을 할 수 있는데 작업이 끝날 때 까지 기다리는 것, 놀고 있는 것이다. 그림으로 표현하면 아래와 같다.

장단점

  • 한 작업이 "대기 상태" 일 때 다른 작업을 수행할 수 있어서 자원을 효율적으로 사용하고 I/O bound (Disk IO, Network IO)에서 특히 유용하다.

  • 직접 동시성을 관리하려면 복잡한 프로그래밍이 필요할 수 있다. Race Condition, Deadlock, Starvation 등의 문제 예방을 위한 Lock관리, Mutex, Semaphore, 자원 할당 순서 정의, 우선 순위 관리 등의 "동기화 매커니즘" 이 필요하다.

2) 병렬성, Parallelism

  • H/W가 발전함에 따라 CPU에서 다중 코어를 가지게 되었고, 그에 따라 특정 프로그램(작업)을 수행할때 "동일한 시간에 독립적인 작업을 수행하는 것" 이다.

  • 즉, 동시성은 하나의 코어에서 여러 쓰레드를 다중 처리하지만 병렬성은 진짜 각 코어가 동시에 독립적으로 수행하는게 핵심이다. 그림으로 표현하면 아래와 같다.

  • 병렬성은 동시성에 속하는 개념이다. 즉 concurrent 하다고 표현한다. 동시성은 "논리적인 개념"에 가깝고 병렬성은 "물리적인 개념"에 가깝다.

장단점

  • 실제 독립적으로 작업을 수행하기 때문에 CPU bound (복잡한 계산 집약적 작업)에 유용하다. 프로그램 자체의 전체 작업량이 많을 때 전체 작업의 실행 시간을 최적화할수 있다.

  • 독립된 작업의 데이터 분할 및 병합이 필요하다면 추가 작업이 필요하고, 물리적으로 더 많은 코어가 필요하다. 상대적으로 디버깅하기 복잡하며 동기화 이슈가 있을 수 있다.


2. 동기(Synchronous)와 비동기(Asynchronous) 작업 & Blocking과 Non-Blocking

동기/비동기 와 블로킹/논블로킹 이 두 개념은 표현 형태는 비슷해 보일지라도, 서로 다른 차원에서 작업의 수행 방식을 설명하는 개념이다. 동기/비동기는 요청한 작업에 대해 완료 여부를 신경 써서 작업을 순차적으로 수행할지 아닌지에 대한 관점이고,블로킹/논블록킹은 단어 그대로 현재 작업이 block(차단, 대기) 되느냐 아니냐에 따라 다른 작업을 수행할 수 있는지에 대한 관점이다.

1) 동기(Synchronous)와 비동기(Asynchronous)

동기(Synchronous)

  • Thread1이 작업을 시작 시키고, Task1이 끝날때까지 기다렸다 Task2를 시작한다.
  • 작업 요청을 했을 때 요청의 결과값(return)을 직접 받는 것 이며 요청의 결과값이 return값과 동일하다.
  • 호출한 함수가 작업 완료를 신경 쓴다.

비동기(Asynchronous)

  • Thread1이 작업을 시작 시킨 뒤 완료를 기다리지 않고 Thread1은 다른 일을 처리할 수 있다.
  • 작업 요청을 했을 때 요청의 결과값(return)을 간접적으로 받는 것 이며 요청의 결과값이 return값과 다를 수 있다.
  • 해당 요청 작업은 별도의 스레드에서 실행하게 된다.
  • 콜백을 통한 처리가 비동기 처리라고 할 수 있다.
  • 호출된 함수(callback 함수)가 작업 완료를 신경 쓴다.
  • 요청한 작업에 대하여 완료 여부를 신경쓰지 않고 자신의 그다음 작업을 수행한다는 것은, I/O 작업과 같은 느린 작업이 발생할 때, 기다리지 않고 다른 작업을 처리하면서 동시에 처리하여 멀티 작업을 진행할수 있기 때문에 시스템 성능 향상에 도움을 줄 수 있다.

2) Blocking과 Non-Blocking

동기/비동기가 전체적인 작업에 대한 순차적인 흐름 유무라면, 블로킹/논블로킹은 전체적인 작업의 흐름 자체를 막냐 안 막냐로 볼 수 있는 것이다

Blocking

  • 요청한 작업을 마칠 때까지 계속 대기한다. 즉시 return 하며 return 값을 받아야 끝난다.
  • Thread 관점으로 본다면, 요청한 작업을 마칠 때까지 계속 대기하며 return 값을 받을 때까지 한 Thread를 계속 사용/대기 한다.

Non-Blocking

  • 요청한 작업을 즉시 마칠 수 없다면 즉시 return한다. 기본적으로 즉시 리턴하지 않는다.
  • Thread 관점으로 본다면, 하나의 Thread가 여러 개의 IO를 처리 가능하다.

3) 차이점

"동기/비동기, blocking/non-blocking 두 그룹의 차이는 관심사가 다르다."

blocking/non-blocking

  • 호출되는 함수가 바로 return하느냐 마느냐가 관심사이다.
  • 호출된 함수가 바로 return해서 호출한 함수에게 제어권을 넘겨주고 호출한 함수가 다른 일 을 할 수 있는 기회를 줄 수 있으면 non-blocking이다.
  • 호출된 함수가 자신의 작업을 모두 마칠 때까지 호출한 함수에게 제어권을 넘겨주지 않고 대기하게 만든다면 blocking이다.

동기/비동기

  • 이 그룹은 호출되는 함수의 작업 완료 여부를 누가 신경쓰느냐가 관심사이다.
  • 호출되는 함수에게 callback을 전달해서 호출되는 함수의 작업이 완료되면 호출되는 함수가 전달받은 callback을 실행하고, 호출한 함수는 작업 완료 여부를 신경쓰지 않는다면 비동기이다.
  • 호출하는 함수가 호출되는 함수의 작업 완료 후 return을 기다리거나 호출되는 함수로부터 바로 return 받더라도 작업 완료 여부를 호출한 함수 스스로 확인하며 신경 쓴다면 동기이다.

"제어권" 과 "return" 의 관심사 관점에서 각 항목이 어떻게 다른지 사진으로 살펴보자

  • 동기 & 블락킹은 "제어권"이 넘어가고 작업이 끝난 뒤 "return"까지 기다리고 다른 작업을 못하고 대기한다. 그렇기 때문에 호출한 함수의 완료 여부를 계속 신경 쓰고 있다.

  • 그에 반해 비동기 & 논블락킹은 "제어권"은 넘어가지 않고 (Promise object와 같은 형태가) 바로 return 되어 다른일을 수행하며, 호출한 함수의 완료 여부는 신경 쓰지 않는다.

  • 그에 반해 동기 & 논블락킹은 특이하다. 동기 메인 task는 "제어권"을 넘기지 않아 다른 일을 수행할 수 있으면서 return 받은 값을 체크한다. 결과가 만들어질 때 까지 계속 완료되었는지 확인며 호출한 함수의 완료 여부를 계속 신경쓴다.

  • Database Connection Pooling 의 작동형태가 이와 비슷하다고 볼 수 있다.

    • 동기: 애플리케이션은 데이터베이스 쿼리를 실행하고, 그 결과를 기다린다.
    • 논블로킹: 연결 풀은 사용 가능한 연결이 없을 때 블로킹되지 않고, 대신 에러를 반환하거나 기다리는 연결 요청을 큐에 넣을 수 있다.

  • 비동기 & 블락킹 역시 특이하다. 비동기 메인 task는 "제어권"을 넘겨 호출한 함수의 작업을 진행한다. 하지만 호출한 함수는 작업 완료 여부를 신경쓰지 않는다.

  • 우리가 레거시 시스템을 통합해야할 때 이와 비슷한 형태로 진행할 수 있다고 생각한다.

    • 비동기: 최신 시스템은 비동기 프레임워크를 사용하여 I/O 작업을 최적화하고, 사용자 인터페이스를 유지하여 작동할 수 있게 한다.
    • 블로킹: 레거시 시스템과의 통신을 블로킹 API를 통해 진행한다.

3. 코루틴

먼 길을 돌아왔다. 코루틴을 조금 더 제대로 이해하기 위해 사실 OS와 CS지식이 조금 필요하다. 코루틴은 해당 글에서 문두에 언급한바와 같이, cooperative routine 을 의미하며, "협력"이 핵심이다. 원래 서브 루틴은 메인 루틴에 종속적이며 서브 루틴 실행 후 다시 메인으로 돌아간다. 그리고 서브 루틴은 끝나면 해당 내용은 모두 사라진다. 코루틴은 메인 루틴과 서브 루틴처럼 종속된 관계가 아니라 서로 대등한 관계이며 특정 시점에 상대방의 코드를 실행한다.

1) 메인 루틴과 서브 루틴

  • 루틴을 하나의 task 덩어리, 간단하게 하나의 함수라고 생각해보자.

  • 메인 루틴에서 서브 루틴을 호출하면 서브 루틴의 코드를 실행한 뒤 다시 메인 루틴으로 돌아온다. 특히 서브 루틴이 끝나면 서브 루틴의 내용은 모두 사라진다. 즉, 서브 루틴은 메인 루틴에 종속된 관계 이다.

  • 하지만 코루틴은 이와 다르게 함수가 종료되지 않은 상태에서 메인 루틴의 코드를 실행한 뒤 다시 돌아와서 코루틴의 코드를 실행한다. 코루틴이 종료되지 않았으므로 코루틴의 내용도 계속 유지된다.

  • 메인 루틴과 서브 루틴처럼 종속된 관계가 아니라 서로 대등한 관계이며 특정 시점에 상대방의 코드를 실행한다. 일반 함수를 호출하면 코드를 한 번만 실행할 수 있지만, 코루틴은 "코드를 여러 번 실행할 수 있다."

  • 함수의 코드를 실행하는 지점을 진입점(entry point)이라고 하는데, 일반적으로 함수는 하나의 진입점(entry point)을 통해 실행되고 하나의 종료 지점(return)을 통해 끝나지만, 코루틴은 진입점이 여러 개인 함수라고 표현할 수 있다.

2) 코루틴

(1) 비선점형 멀티태스킹(Non-preemtive multitasking)

  • 하나의 프로세스 안에서 여러 스레드가 실행되면 이 스레드들은 CPU와 메모리라는 한정적인 자원을 서로 사용하려고 경쟁하게 된다.

  • 운영체제는 하나의 스레드가 자원을 무한정 점유하는 문제를 막기 위해 "스케쥴링"을 하는데, 이 스케쥴링 방식에 선점형(Preemptive)과 비선점형(Non-preemptive) 방식 2가지가 존재한다. 선점형은 강제로 실행권을 빼앗기는 것이고, 비선점형은 실행 주체가 자신의 실행권을 자발적으로 내려 놓는것을 의미한다.

  • 실행권을 내려놓겠다는 신호를 줘야 하는데, 대개 많은 언어에서 async/await, yield, suspend 같은 키워드를 사용하고 있다. yield와 같은 키워드를 만나면 실행권을 내려놓으면서 그 위치를 기억하고, 다음 호출 때 그곳부터 다음을 실행할 수 있도록 하는 것이다. Python 이나 Javascript 에 있는 Generator 에 대해서 이해하고 있다면 더 이해하기 쉽다.

(2) 코루틴과 thread

  • 코루틴은 light-weight thread 라고 한다. 동시에 여러 코드 블록을 실행한다는 개념적인 관점에서 보면 스레드와 매우 유사해 보이지만 코루틴은 "협력적으로 멀티태스팅"(Cooperative multitasking)되는 반면에, 일반적으로 스레드는 "선점형으로 멀티태스킹"(Preemptively multitasking)된다. 여기서 협력적이라는 말은 위에서 살펴본 비선점형 멀티태스킹(Non-preemptive multitasking)과 같은 의미다.

  • python은 기본적으로 single thread 로 작동한다. 병렬 처리를 위해서는 threading 모듈을 이용하여 multi thread 또는 multi processing 처리를 직접해야 한다.

(3) 코루틴의 blocking 관점

  • 실행 흐름의 제어: 코루틴은 yield 또는 await를 사용하여 실행을 일시 중지하고, 호출자에게 실행 흐름의 제어를 반환한다. 이러한 측면에서 보면, 코루틴은 실행 흐름을 명시적으로 제어하고 있으며, 이 점에서 blocking의 특성을 가지고 있다고 볼 수 있다.
def number_coroutine():
    while True:        # 코루틴을 계속 유지하기 위해 무한 루프 사용
        x = (yield)    # 코루틴 바깥에서 값을 받아옴, yield를 괄호로 묶어야 함
        print(x)

co = number_coroutine()
next(co)      # 코루틴 안의 yield까지 코드 실행(최초 실행)

def main():
    print("출력")
    co.send(1)    # 코루틴에 숫자 1을 보냄
    co.send(2)    # 코루틴에 숫자 2을 보냄
    print("출력")
    print("출력")
    co.send(3)    # 코루틴에 숫자 3을 보냄
    print("출력")

main()

# OUTPUT
# 출력
# 1
# 2
# 출력
# 출력
# 3 
# 출력
  • 동기(sync) 함수로 위와 같이 코루틴을 작성할 수 있다.

(4) 코루틴의 async, non blocking 인 관점

  • 비동기 I/O와의 통합: asyncio 와 같은 프레임워크를 사용하면, 코루틴을 이용하여 비동기 I/O 작업을 수행할 수 있다. 이 경우, 코루틴은 I/O 작업이 완료되기를 기다리는 동안 실행을 일시 중지하고, 이벤트 루프가 다른 작업을 수행하도록 한다. 이런 의미에서 코루틴은 비동기 프로그래밍을 가능하게 한다.
import asyncio

async def async_func():
    print("Async Function")
    return 42

async def main():
    coro = async_func()
    print(f"Coroutine Object: {coro}")
    result = await coro
    print(f"Result: {result}")

asyncio.run(main())

# OUTPUT
>>> Coroutine Object: <coroutine object async_func at 0x7fe08a5facc0>
>>> Async Function
>>> Result: 42
  • 위 코드를 보면 main을 가장 먼저 실행하고 async_func 를 호출한다. 해당 함수의 return값을 보면 coroutine object 이다. 함수에서 바로 return 이 이뤄졌고 main 함수는 "다른 작업을 수행할 수 있으며 제어권을 가지고 있다". 비동기 논블락킹 의 특성을 모두 가지고 있다.

  • 당연한 이야기지만 결국, 코루틴이 어떻게 사용되는지에 따라 blocking 여부가 달라진다. 앞서 언급하였듯이 python은 기본적으로 single thread로 실행된다. 그렇기 때문에 코루틴을 어떻게 전체적으로 구성하냐에 따라서 당연하게 실행 컨셉이 달라진다.

  • 글이 너무 길어져서 코루틴의 "실제 사용 예시"와 "발전된 기원(이터레이터, 제네레이터, 코루틴)" 은 다음 글에서!


출처

profile
도메인 중심의 개발, 깊이의 가치를 이해하고 “문제 해결” 에 몰두하는 개발자가 되고싶습니다. 그러기 위해 항상 새로운 것에 도전하고 노력하는 개발자가 되고 싶습니다!

0개의 댓글