Notice
Recent Posts
Recent Comments
Link
«   2025/04   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30
Tags
more
Archives
Today
Total
관리 메뉴

파이썬쟁이

PYTHON 비동기에 관하여 본문

PYTHON/Base

PYTHON 비동기에 관하여

bhnvx 2023. 1. 22. 18:57

 

Python은 3.4 버전부터 Asyncio 모듈이 Standard library로서 등장했습니다.

이는 기존에 보편적으로 사용되던 라이브러리들과의 성능 경쟁에서 우위를 보여주게 됐습니다.

오늘은 기존 사용되던 비동기 라이브러리들과의 성능을 비교해 가면서

비동기 로직을 설계할 때의 중요한 부분을 짚고 넘어가 보도록 할 것입니다.

기초가 되는 콜백이나, 퓨처에 대한 설명은 지루할 수 있으니 뒤로하고

크롤링 작업을 기준으로 성능이 얼마나 개선되는지 확인해 보겠습니다.


서버는 간단히 FastAPI로 구성했습니다.

아래는 클라이언트 개념으로 사용할 코드입니다.

 

import requests
import string
import random
import time


def generate_urls(base_url, num_urls):
    for i in range(num_urls):
        yield base_url + "".join(random.sample(string.ascii_lowercase, 10))


def run_experiment(base_url, num_iter):
    response_size = 0
    for url in generate_urls(base_url, num_iter):
        response = requests.get(url)
        response_size += len(response.text)
    return response_size


if __name__ == "__main__":
    delay = 100
    num_iter = 1000
    base_url = f"http://127.0.0.1:8080/add?name=serial&delay={delay}&"

    start = time.time()
    result = run_experiment(base_url, num_iter)
    end = time.time()
    print(f"Result: {result}, Time: {end - start}")
    
# Result: 10000, Time: 109.66921281814575

 

먼저 일반적으로 크롤링하는 코드를 구현했습니다.

이 코드는 Python의 GIL로 인해 작성한 로직을 순차적으로 조회를 하게 됩니다.

때문에 Delay로 설정한 0.1 ms 당 1개의 요청이 들어가게 되고,

1000번의 요청은 총 100초에 약간의 시간이 더해진 상태로 결과를 보여주게 됩니다.

이 코드를 실행할 때 주목해야 할 지표는 각 요청의 시작과 종료시간으로, HTTP 서버에서 볼 수 있습니다.

우리의 코드가 I/O 대기 상태에서 얼마나 효율적이었는지를 알려줍니다.

당연히 요청과 요청 사이에 대기 시간이 전혀 없을 것이고, 이것이 순차적 프로세스의 원리입니다.

 

다음은 Gevent로 구성한 크롤링 코드를 보여드리겠습니다.

 

import time
import random
import string
import urllib.request
from contextlib import closing

import gevent
from gevent import monkey
from gevent.lock import Semaphore


monkey.patch_socket()


def generate_urls(base_url, num_urls):
    for i in range(num_urls):
        yield base_url + "".join(random.sample(string.ascii_lowercase, 10))


def download(url, semaphore):
    with semaphore:
        with closing(urllib.request.urlopen(url)) as data:
            return data.read()


def chunked_requests(urls, chunk_size):
    semaphore = Semaphore(chunk_size)
    requests = [gevent.spawn(download, u, semaphore) for u in urls]
    for response in gevent.iwait(requests):
        yield response


def run_experiment(base_url, num_iter):
    urls = generate_urls(base_url, num_iter)
    response_futures = chunked_requests(urls, 100)
    response_size = sum(len(r.value) for r in response_futures)
    return response_size


if __name__ == "__main__":
    delay = 100
    num_iter = 1000
    base_url = f"http://127.0.0.1:8080/add?name=serial&delay={delay}&"

    start = time.time()
    result = run_experiment(base_url, num_iter)
    end = time.time()
    print(f"Result: {result}, Time: {end - start}")

# Result: 10000, Time: 1.6215720176696777

 

시간이 굉장히 많이 단축된 것을 확인할 수 있습니다.

 

Gevent는 Future가 Function을 반환하는 패러다임을 따르는 굉장히 단순한 비동기 라이브러리입니다.

표준 I/O 함수를 monkey.patch_socket() 함수를 통해 비동기적으로 만들어줍니다.

Gevent는 두 가지 메커니즘을 제공하는데, 위에 언급한 것과 나머지는 코루틴의 일종인 스레드를 제공합니다.

정확한 명칭은 Greenlet이라고 하고, 같은 물리 스레드에서 실행이 되는 특징이 있습니다.

 

gevent.spawn을 사용해 Future가 생성되고, gevent.spawn은 함수와 인자를 받아서 Greenlet을 시작합니다.

그리고 wait 함수를 통해 이벤트 루프가 시작되며 큐에 들어간 작업은 각각 완료되고 다음 코드가 순차적으로 실행됩니다.

비동기 I/O를 사용할 때 동시에 너무 많은 파일을 열거나 연결을 맺게 된다면, 원격 서버에

과도한 부하를 주거나 과다한 연산 때문에 컨텍스트 스위칭이 자주 일어나서 처리 속도가 느려질 수 있습니다.

따라서 세마포어를 사용해 한 번에 최대 100개의 그린렛이 HTTP GET 요청을 보내도록 제한했습니다.

세마포어는 Lock 메커니즘의 한 종류로, 다양한 병렬 코드의 흐름 제어에 주로 사용합니다.

이는 코드 진행을 제한하여 프로그램의 한 부분이 다른 부분과 서로 간섭하지 않도록 합니다.

 

다음은 Tornado를 활용한 크롤링 코드를 보여드리겠습니다.

 

# Python 3.6

import asyncio
import random
import string
import time

from tornado.httpclient import AsyncHTTPClient


AsyncHTTPClient.configure(
    "tornado.curl_httpclient.CurlAsyncHTTPClient",
    max_clients=100
)


def generate_urls(base_url, num_urls):
    for i in range(num_urls):
        yield base_url + "".join(random.sample(string.ascii_lowercase, 10))


async def run_experiment(base_url, num_iter):
    http_client = AsyncHTTPClient()
    urls = generate_urls(base_url, num_iter)
    response_num = 0
    tasks = [http_client.fetch(url) for url in urls]
    for task in asyncio.as_completed(tasks):
        response = await task
        response_num += len(response.body)
    return response_num


if __name__ == "__main__":
    delay = 100
    num_iter = 1000
    base_url = f"http://127.0.0.1:8080/add?name=serial&delay={delay}&"

    run_func = run_experiment(base_url, num_iter)

    start = time.time()
    loop = asyncio.get_event_loop()
    result = loop.run_until_complete(run_func)
    end = time.time()
    print(f"Result: {result}, Time: {end - start}")
    
# Result: 10000, Time: 2.3990283012390137

일단 이 코드는 Python 3.6 버전으로 작성된 코드이므로, result 부분에서 asyncio를 호출하며 실행하는 부분이

Python 3.7 이상 버전과 차이가 있을 수 있습니다. (3.7 버전부턴 asyncio.run() 함수로 호출합니다.)

 

Gevent와는 사뭇 다른 이벤트 루프 작동 방식이 눈에 띄는데, Tornado는 항상 실행 중이라는 점입니다.

또한 전형적인 스탑-앤-고 형태이므로, Gevent의 세마포어를 이용해 Lock을 거는 방식과 차이점이 있습니다.

이러한 방식은 열린 연결의 수를 제한하는 내부 메커니즘이 끝나는 연결을 빠르게 처리 못할 수도 있습니다.

즉, 자원을 과도하게 활용 or 낭비한다는 말인데, 이벤트 루프가 최적으로 작동하지 못함을 의미합니다.

 

그렇기에 Tornado 를 사용하며 생긴 속도 저하를 이해할 수 있을 것입니다.

그럼 Gevent를 사용하는 게 더 좋지 않을까라는 의문이 생길 수도 있는데,

Tornado는 전체적으로 비동기적이어야만 하는 I/O 위주의 애플리케이션에 적합하지만

Gevent는 가끔 무거운 I/O 작업을 하는 애플리케이션에 적합하므로, 무조건 Gevent가 좋은 것은 아닙니다.

 

마지막으로 Aiohttp를 활용한 크롤링 코드를 보여드리겠습니다.

 

import asyncio
import random
import string
import aiohttp
import time


def generate_urls(base_url, num_urls):
    for i in range(num_urls):
        yield base_url + "".join(random.sample(string.ascii_lowercase, 10))


def chunked_http_client(num_chunks):
    semaphore = asyncio.Semaphore(num_chunks)

    async def http_get(url, client_session):
        nonlocal semaphore
        async with client_session.request("GET", url) as response:
            return await response.content.read()

    return http_get


async def run_experiment(base_url, num_iter):
    urls = generate_urls(base_url, num_iter)
    http_client = chunked_http_client(100)
    responses_num = 0

    async with aiohttp.ClientSession() as client_session:
        tasks = [http_client(url, client_session) for url in urls]
        for future in asyncio.as_completed(tasks):
            data = await future
            responses_num += len(data)
    return responses_num


if __name__ == "__main__":
    loop = asyncio.new_event_loop()

    delay = 100
    num_iter = 1000
    base_url = f"http://127.0.0.1:8080/add?name=serial&delay={delay}&"

    start = time.time()
    result = loop.run_until_complete(
        run_experiment(base_url, num_iter)
    )
    end = time.time()
    print(f"Result: {result}, Time: {end - start}")

# Result: 10000, Time: 1.517892599105835

 

눈에 띄는 부분은 Asyncio 에서도 세마포어를 채택하여 흐름을 제어한다는 점과

async with, async def, wait 호출이 많다는 점이 될 수 있습니다.

http_get 함수에선 공유 자원의 동시성을 잘 처리할 수 있도록 비동기 컨텍스트 관리자를 사용합니다.

즉 async with을 사용하면 요청한 자원을 획득하려고 기다리는 동안 다른 코루틴을 실행할 수 있다는 뜻입니다.

Asyncio 를 사용한 코드는 Tornado 보다 더 좋은 연결과, Gevent 보다 더 부드러운 전환이 이루어집니다.

 

이제 CPU 메모리 사용량을 서로 비교해 보도록 하겠습니다.

 

python sync.py
# Memory: 108.90234375

python gevent.py
# Memory: 15.453125

python tornado.py
# Memory: 27.390625

python aiohttp.py
# Memory: 18.265625

 

순차 조회보다 비동기 조회가 압도적으로 메모리 사용량이 적게 드는 것을 확인할 수 있습니다.

 


주의할 점

이 문서를 작성하면서 만났던 오류와 주의점에 대해 말씀드리겠습니다.

 

첫 번째로 Python Version에 관련된 것입니다.

Tornado를 활용한 코드에서 제가 굳이 Python 3.6 이라고 적어놓은 것을 확인하신 분이 계실 겁니다.

이 부분은 PycURL과 호환을 위한 것입니다.

PycURL은 Tornado와 같이 작동하는 필수 백엔드는 아니지만,

기본 백엔드의 DNS 요청보다 성능이 월등히 좋기 때문에 사용을 정의했고,

Windows OS에서 사용을 하려면 Python 버전이 3.6 이하일 때만 Install이 가능하니 참고 바랍니다.

 

두 번째로 DNS 관련 설정에 관련된 것입니다.

대체로 Windows OS 환경에서 작업을 하시는 분들이 많이 계실 것입니다.

이때, 백엔드 서버를 구성하면서 localhost 127.0.0.1 로 서버를 호스팅 하게 됩니다.

후자가 더 빠르겠지만, 가끔 Windows OS의 Hosts 파일에 이 두 부분이 주석 처리가 된 경우

제가 작성한 코드만큼 시간이 안 나올 수 있습니다.

그럴 경우엔 당황하지 마시고 해당 파일의 주석 처리를 해제하고 진행하시길 바랍니다.

 

세 번째로 Asyncio에 관련된 것입니다.

완벽한 비동기는 레퍼런스가 없습니다.

본인 상황에 맞는 코드를 직접 작성하여 적용하는 게 해답이라고 생각합니다.

몇 가지 팁을 드리자면,

asyncio.create_task를 사용해 이벤트 루프에 저장 요청을 넣고 함수가 끝나기 전 작업이 완료됐는지 확인

asyncio.sleep(0)을 사용해 이벤트 루프에 다른 작업을 실행하라는 양보 로직을 추가하기

asyncio.wait을 통해 완료되지 않은 작업을 기다리기

위 세 가지 팁을 지켜서 비동기 코드를 작성하신다면 준수한 비동기 프로그래밍을 할 수 있을 것입니다.


* 끝마치면서

 

가장 길게 공부했던 챕터라고 생각합니다.

스스로 이해하려고 노력했고 남이 봤을 때 이해하기 쉽게 설명하고 노력했습니다.

역시 아직 부족한 점이 많다는 것을 다시 느끼는 시간이 됐습니다.

비동기 챕터에선 사실상 시간보다 메모리를 어떻게 효과적으로 사용하는지에 대한 지식이 위주가 됐습니다.

오늘도 길고 긴 포스팅 읽어주셔서 감사합니다.

'PYTHON > Base' 카테고리의 다른 글

PYTHON Sequence에 대하여  (1) 2024.10.24
PYTHON Generator 에 관하여  (0) 2023.01.12
PYTHON Import 에 관하여  (0) 2023.01.11
PYTHON List (리스트) 자료형에 관하여  (3) 2023.01.10