비동기 처리가 효율적일까?

2021. 12. 5. 00:39TIL💡/Trial And Error

운영체제 같은 시스템 관련 수업이나, 웹 프로그래밍 실습에서나 비동기는 동기 프로그래밍보다 절대적인 우위는 아니나 보통 리소스의 유휴시간을 줄이면서 효율적이라고 하는 편이다. 이를 철썩 같이 믿고 이번에 회사에서 FIFA Open API를 활용한 개인 과제에서 파이썬 비동기 라이브러리인 asyncio로 API를 만들다가 의문이 들었다.

 

정말로 비동기처리가 효율적일까?

 

실제로 팀원님들도 리뷰 시간에 내 코드를 보고 그러한 의문을 남겨주셨고, 궁금한 마음에 이에 대한 테스트와 분석을 시작해보았다. 지금까지 비동기의 효율성을 보여주는 건 단순히 sleep을 하면서 로그가 찍히는 수준의 확인만 해본 게 다였다. 아직 큰 프로젝트를 구조적으로 접근한 적이 없어서 야매의 기운이 솔솔 날 것 같지만 최대한 자료조사를 하면서 되는 데까지 알아보고자 한다.

 

비동기 처리를 해야하는 대상은 FIFA 게임 자체의 메타 정보를 받아오는 일이다.

크게 세가지 방식으로 시도했다.

1) awaitable object를 asyncio.gather로 동시 수행

2) awaitable object를 차례로 수행

3) Non-awaitably하게 수행

4) Multi Thread로 수행


1) 처음에는 asyncio.gather로 awaitable object를 동시적으로 수행해본다.

for x in range(3):
  async with httpx.AsyncClient() as client:
    start = time.time()
    matchtype, pos, athlete = await asyncio.gather(
      client.get(BASE_URL + "matchtype.json"),
      client.get(BASE_URL + "spposition.json"),
      client.get(BASE_URL + "spid.json")
    )

  	print(f'{time.time() - start} 초가 소요되었습니다.')

결과는 다음과 같다. 

0.49141383171081543 초가 소요되었습니다.
0.405548095703125 초가 소요되었습니다.
0.39850711822509766 초가 소요되었습니다.

두 차례의 시도를 보면 처음엔 첫 번째 시간이 가장 오래걸리고, 그 다음엔 속도가 줄어드는 양상을 보인다. 나는 캐싱을 별도로 처리하지 않았는데 브라우저에서 저절로 API를 요청하는 서버에 대한 정보를 캐싱해두는 것 같다.

 

2) 이번에는 awaitable을 gather로 동시에(concurrently) 수행하지 않고, 하나씩 수행해보고자 한다.

for x in range(3):
  async with httpx.AsyncClient() as client:
    start = time.time()
    
    matchtype = await client.get(BASE_URL + "matchtype.json")
    pos = await client.get(BASE_URL + "spposition.json")
    athlete = await client.get(BASE_URL + "spid.json")

	print(f'{time.time() - start} 초가 소요되었습니다.')

결과는 아래처럼 첫 번째 시도보다 다소 오래 걸렸다.(너무 눈대중이지만..)

0.7264189720153809 초가 소요되었습니다.
0.49577784538269043 초가 소요되었습니다.
0.5621428489685059 초가 소요되었습니다.

3) 하지만 여전히 awaitable한 함수로 각자 실행한 것이기 때문에 비동기성을 제외하고 일반적인 requests 라이브러리를 써서 API 호출을 시도했다.

start = time.time()

matchtype = requests.get(BASE_URL + "matchtype.json")
pos = requests.get(BASE_URL + "spposition.json")
athlete = requests.get(BASE_URL + "spid.json")

print(f'{time.time() - start} 초가 소요되었습니다.')

시도 결과는 물음표이다. 왜냐하면 매 시도때마다 결과가 들쭉날쭉하다. 아무래도 평균을 내어 처리 후 비교하는 거에도 무의미할 수준이다.

2.606715202331543 초가 소요되었습니다.
0.6573467254638672 초가 소요되었습니다.
0.7008779048919678 초가 소요되었습니다.
0.6109991073608398 초가 소요되었습니다.
1.013929843902588 초가 소요되었습니다.
7.17460298538208 초가 소요되었습니다.
0.3988678455352783 초가 소요되었습니다.
0.4182572364807129 초가 소요되었습니다.
2.2140588760375977 초가 소요되었습니다.
0.8155288696289062 초가 소요되었습니다.
1.483699083328247 초가 소요되었습니다.

우선 캐싱 기능을 제외하면 절대적으로 비동기 처리의 성능이 우위에 있다는 결론이 났다.

그런데 비동기 처리말고 멀티 스레드(Multi Thread)도 유용하다기에 추가적으로 도전해봤다.

 

4) 멀티 스레드

간편한 코드 작성을 위해서 위와 약간 다르게 작성하였다. 하지만 주요 처리 내용과 방식은 동일하다.

start = time.perf_counter()
urls = ["spid.json", "spposition.json", "spid.json"]
with futures.ThreadPoolExecutor() as executor:
    to_do = []
    for url in urls:
        # executor.submit은 callabledl 실행될 수 있도록 스케줄링하고 이 작업의 Future를 반환한다.
        future = executor.submit(requests.get, BASE_URL + url)
        # 나중에 as_completed로 가져올 수 있도록 Future 객체를 모두 저장
        to_do.append(future)
    results = []
    # as_complete()는 Future가 완료될 때 해당 Future 객체를 생성한다.
    for future in futures.as_completed(to_do):
        res = future.result().json()
        results.append(res)

finish = time.perf_counter()
print(f'{finish - start} 초가 소요되었습니다.')

그 결과는 균일하게 0.5 ~ 0.6초대의 결과를 보여주었다.

0.567108040999301 초가 소요되었습니다.
0.6505343329990865 초가 소요되었습니다.
0.6102193750011793 초가 소요되었습니다.
0.6727517079998506 초가 소요되었습니다.

전반적으로 비동기 처리 > 멀티스레드, 일반 동기처리 순의 효율을 보여주었다.

다만 파이썬의 경우 GIL(Global Interpreter Lock)을 사용하기에 눈에 띄는 효율을 못 보여준 거라 생각한다.

GIL - 위키백과
Cpython에서 GIL은 Python 코드(bytecode)를 실행할 때에 여러 thread를 사용할 경우, 단 하나의 thread만이 Python object에 접근할 수 있도록 제한하는 mutex이다. 그리고 이 lock이 필요한 이유는 CPython이 메모리를 관리하는 방법이 thread-safe하지 않기 때문이다.

Thread Safety
멀티 스레드 프로그래밍에서 일반적으로 어떤 함수나 변수, 혹은 객체가 여러 스레드로부터 동시에 접근이 이루어져도 프로그램의 실행에 문제가 없음을 뜻한다.보다 엄밀하게는 하나의 함수가 한 스레드로부터 호출되어 실행 중일 때, 다른 스레드가 그 함수를 호출하여 동시에 함께 실행되더라도 각 스레드에서의 함수의 수행 결과가 올바르게 나오는 것으로 정의한다. 

바로 사진과 같은 일이 발생하기 때문에 멀티스레드는 제 힘을 못 낸다.. 🤧

그래서 하나의 스레드를 동시적으로 처리하는 비동기 프로그래밍(Asynchronous Programming) 방식이 그나마 가장 효율적인 것은 맞았다..

물론 아직 미처 내가 발견하지 못한 부분들이 있을 수 있다. 만약 새로운 사실을 발견하면 추가적으로 도전해봐야겠다. 🔥🔥

 

참고