synchronous VS multiprocessing VS multithreading VS asyncio (w/파이썬)

seoyeon hwang
9 min readJan 7, 2021

--

요즘 하고 있는 프로젝트에서 tinyurl을 생성해주는 API를 여러 번 호출한 적이 있다. 생각보다 응답속도가 느려서 구글링을 해봤더니 HTTP request를 여러 번 호출할 때는 async로 구현하면 훨씬 빠르다는 것을 알게 되었다. python async에 관해서 공부하다 보니 multiprocessing, multithreading, asyncio 중 어떤 라이브러리를 사용하여 async를 구현할지 고민이 되었다.

그러던 중 자기 전에 유튜브에서 Making multiple HTTP requests using Python (synchronous, multiprocessing, multithreading, asyncio)를 보게 되었다. 영상을 더 잘 이해하고 싶은 마음에 영상 속의 코드를 직접 실행해보면서 4가지 방법에 대해 정리해보았다. (사실 아직도 어렵다..)

requests.get VS requests.session.get

HTTP requests를 할 때 어떤 코드는 바로 get으로 요청을 하고, 어떤 코드는 session을 만들고 난 뒤 get으로 요청을 한다. 두 방법은 어떤 차이가 있을까?

requests.get()는 매번 request를 할 때마다 새로운 Session 객체를 생성한다. 반면 session을 미리 만들어놓으면 request를 할 때 생성해둔 session 객체를 재사용하게 된다. Session 객체는 requests를 할 때 필요한 파라미터와 쿠키를 보관하므로 session을 재사용하면 매번 생성할 때보다 성능이 향상된다.

목표

  • 수많은 HTTP requests을 가장 빠르게 처리하고 싶다.
  • 4가지 방법으로 해당 요청을 처리한 후 가장 빠른 방법을 찾는다.

1. Synchronous

synchronous는 말 그대로 작업을 하나씩 처리하는 방법이다. 요청 하나가 끝나면 그다음 요청을 처리한다. 말만 들어도 가장 느릴 것 같다.

import requests
from timer import timer
URL = 'https://httpbin.org/uuid'def fetch(session, url):
with session.get(url) as response:
print(response.json()['uuid'])
@timer(1, 1)
def main():
with requests.Session() as session:
for _ in range(100):
fetch(session, URL)
  • https://httpbin.org/uuid : request를 할 때마다 uuid를 return하는 API
  • timer(n, r) : 해당 함수 r번 반복 실행을 n번 실행할 때 걸리는 시간 출력
synchronous 실행 결과 : 23.7초

2. Multiprocessing

multiprocessing은 여러 개의 코어(CPU unit)을 사용하여 작업을 수행하는 방법이다. 즉, 여러 개의 프로세스가 작업을 나눠서 동시에 수행하며, 이때 각각의 프로세스들은 서로 독립적이고 병렬이다.

import requests
from timer import timer
from multiprocessing import Pool
URL = 'https://httpbin.org/uuid'def fetch(session, url):
with session.get(url) as response:
print(response.json()['uuid'])
@timer(1, 1)
def main():
with Pool() as pool:
with requests.Session() as session:
tasks = [(session, URL) for _ in range(100)])
pool.starmap(fetch, tasks) # 함수와 input 맵핑

Pool 객체는 inputs(수많은 작업)을 여러 프로세스로 분산시켜서 함수의 실행을 병렬로 처리한다. Process 객체와 달리, starmap를 사용하여 함수와 inputs를 맵핑해주면 pool이 알아서 분산 처리한다.

multiprocessing 실행 결과 : 6.7초

3. multithreading

multithreading은 하나의 프로세스 안에서 여러 개의 스레드가 작업을 나눠서 동시에 수행하는 방법이다. 이때, 스레드는 자신이 속한 프로세스의 메모리를 공유하기 때문에 자원 소모가 줄어든다.

python은 GIL 때문에 여러 개의 스레드가 존재하여도 한 시점에는 하나의 스레드만 실행할 수 있다. 그렇기 때문에 CPU bound가 많은 작업을 multithreading으로 구현하면 GIL로 인해 성능이 같거나 더 느려진다. 하지만 HTTP requests와 같은 I/O bound가 많은 작업은 응답을 기다리는 시간이 대부분이기 때문에 multithreading으로 구현하면 성능이 향상된다.

import requests
from timer import timer
from concurrent.futures import ThreadPoolExecutor
URL = 'https://httpbin.org/uuid'def fetch(session, url):
with session.get(url) as response:
print(response.json()['uuid'])
@timer(1, 1)
def main():
with ThreadPoolExecutor(max_workers=10) as executor:
with requests.Session() as session:
executor.map(fetch, [session] * 100, [URL] * 100)
executor.shutdown(wait=True)

ThreadPoolExecutor 객체를 사용하여 여러 개의 스레드를 만들고, executor 객체에 map을 사용하여 함수와 inputs를 맵핑해주면, 스레드 풀을 사용하여 비동기적으로 호출을 실행한다.

multithreading 실행 결과(max_workers = 10) : 3.8초

쓰레드 10개로 작업을 수행했을 때 multiprocessing보다 속도가 더 빠르다. 그렇다면 스레드의 개수가 많아질수록 속도가 빠를까? max_workers의 개수를 늘려가면서 실행해보니 예상대로 속도가 증가하다가 어느 순간부터 증가하지 않았다. 적당한 수의 스레드로 구현해야 할 것 같다.

4. asyncio

asyncio는 async/await 구문을 사용하여 파이썬에서 비동기 프로그래밍이 가능하도록 하는 라이브러리이다. multithreading과 달리 하나의 스레드를 사용하여 작업을 동시에 수행한다. 아래 코드에서는 asyncio를 위한 HTTP 서버/클라이언트 프레임워크인 aiohttp를 같이 사용했다.

import aiohttp
import asyncio
from timer import timer
URL = 'https://httpbin.org/uuid'async def fetch(session, url):
async with session.get(url) as response:
json_repsonse = await response.json()
print(json_repsonse['uuid'])
async def main():
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, URL) for _ in range(100)]
await asyncio.gather(*tasks)
@timer(1, 1)
def func():
asyncio.run(main())

asyncio에서는 async def로 네이티브 코루틴을 정의하고, await로 해당 객체가 결과를 반환할 때까지 기다리게 한다. 이때 코루틴은 파이썬의 generator처럼 진입점이 여러 개인 함수를 말한다.

async def로 main함수를 네이티브 코루틴으로 정의한 뒤, asyncio.gather로 fetch함수를 동시에 100번 실행하였다. 이때 100개의 fetch 코루틴은 각각 동시에 request를 한 뒤 response가 반환될 때까지 기다린다.

asyncio 실행 결과 : 1.2초

정리

  • synchrous : 23.7초
  • multiprocessing : 6.7초
  • multithreading : 3.8초
  • asyncio : 1.2초

HTTP request와 같은 I/O bound 작업은 synchrous방식보다 asynchrous방식으로 구현했을 때 더 빠르다. 여러 가지 방법 중 asyncio를 사용하여 asychrous방식으로 구현하였을 때가 가장 빠르다. 데드락, race condition, 오버헤드 문제가 발생할 수 있는 multithreading과 달리 asyncio는 싱글 스레드이기 때문에 그런 문제가 발생하지 않는다. 또한 multiprocessing은 CPU bound 작업을 asynchrous하게 구현할 때 적합한 방법이다.

한가지 코드를 4가지 방법으로 구현해보니 synchronous, multiprocessing, multithreading, asyncio가 어떻게 다른지 이해할 수 있었다. 하지만 영상이 자막을 제공하지 않고 인도 발음에 속도까지 빨라서 놓친 설명이 많아 아쉬웠다. 그리고 막상 공부하다 보니 모르는 게 더 많아진 기분이 들었다. 혹 떼려다가 혹 붙인 느낌이다 :( 다음 글에서는 asyncio 라이브러리를 자세하게 살펴봐야겠다.

참고 자료 : https://www.youtube.com/watch?v=R4Oz8JUuM4s

--

--