🤖 🐍
🛠 👨‍💻

🤖

Navigate back to the homepage

왜 asyncio에 뮤텍스 락이 필요할까?

Seonghyeon Kim
September 14th, 2019 · 2 min read

이 글은 what’s Python asyncio.Lock() for? 라는 스택오버플로우 질문에 달린 dano라는 분의 답변을 기초로 하여 작성되었습니다.

파이썬 asyncio 문서를 찾아보다 보면 동기화 프리미티브 라는 장이 존재한다. 이 장에는 asyncio가 동시성을 통제하기 위해 제공하는 여러가지 동기화 프리미티브(스레딩 모듈의 것과 상당히 유사하다)가 소개되어 있는데 그 중 당당히 적혀있는 asyncio.Lock이 보일 것이다.

asyncio 태스크를 위한 뮤텍스 록을 구현합니다.

뮤텍스 락? 생각하는 것처럼 멀티스레딩에서 자원 잠금을 사용할때 쓰는 그 뮤텍스 락이다.

asyncio는 싱글 스레드로 돌아가는데 무슨 락이 필요하냐고 생각할 수도 있지만 멀티스레딩 코드에서 락을 사용하는 이유와 동일하게 자원 보호를 위해 락을 사용한다.

asyncio는 기본적으로 싱글스레드로 돌아가지만 우리가 await 할때 동시 실행이 발생하게 되는 경우가 있다.

직접 락을 걸어 보자

웹 서버에서 데이터를 가지고 와 전역 변수에 캐시하는 async 함수를 만들어 보자.

1async def request():
2 global cache
3
4 url = "http://slowwly.robertomurray.co.uk/delay/4600/url/http://example.com"
5
6 if url in cache:
7 return cache[url]
8
9 async with aiohttp.ClientSession() as session:
10 async with session.get(url) as resp:
11 cache[url] = await resp.text()
12
13 return cache[url]

그리고 이 함수로 코루틴을 대략 10개 만들고 asyncio.gather를 통해 동시 실행해보자.

1tasks = [request() for i in range(10)]
2
3
4loop = asyncio.get_event_loop()
5loop.run_until_complete(asyncio.gather(*tasks))

PyCharm의 Concurrency Diagram의 기능으로 살펴본 결과는 다음과 같다. request without lock

모든 코루틴이 각기 새로운 HTTP 세션을 만들고 요청을 하고 있다. 같은 URL에 동시에 요청을 하는데 각자 따로 비용을 들여 따로 가져오는 것은 비용 낭비라고 할 수 있다. 거기다가 우리가 사용하려 하는 캐시를 전혀 쓰지 않을 뿐더러 계속해서 캐시를 덮어쓰고 있다.

여기다가 request 함수에 락을 추가해 보자.

1lock = asyncio.Lock()
2
3async def request():
4 global cache, lock
5 url = "http://slowwly.robertomurray.co.uk/delay/4600/url/http://example.com"
6
7 async with lock:
8 if url in cache:
9 return cache[url]
10
11 async with aiohttp.ClientSession() as session:
12 async with session.get(url) as resp:
13 cache[url] = await resp.text()
14
15 return cache[url]

실행한 결과는 다음과 같다.

request with lock

아까 전과는 다르게 첫번째 코루틴이 HTTP 요청을 하는 동안 나머지 코루틴은 락을 대기하면서 멈춰 있다.

lock close up

끝부분을 확대해 보면 이렇게 보이는데, 첫번째 HTTP 요청으로 인해 걸리는 긴 락이 풀리면 각 코루틴이 찰나의 순간에 락을 걸고 새로 요청하는것이 아니라 캐시된 결과를 가져오고 락을 푸는 것을 볼 수 있다.

생각보단 별로인것 같은데?

네트워크를 사용하는 비용은 1/10으로 줄였지만 아직까지 실행시간의 차이는 많이 나지 않는다. 이걸 보고 프로그래머가 컴퓨터보다 비싸니 이정도는~ ㅎㅎ 할 수도 있지만 동시 실행되는 코루틴이 많아질 수록 차이는 극대화된다.

대충 동시 실행되는 코루틴을 200개20개 정도로 늘려보자. (컴퓨터 성능이 된다면 200개를 동시에 돌려보면 더 확실한 차이를 경험할 수 있을것이다.)

aiohttp는 내부적으로 스레드풀을 쓰는데 컴퓨터가 동시에 돌릴 수 있는 스레드에는 한계가 있기 때문에 동시에 돌릴 수 있는 스레드 개수를 넘는 코루틴을 동사에 실행시키려 하면 병목이 생기며 실행시간이 확 늘어나게 된다.

락을 쓰지 않고 20개 코루틴을 동시에 실행할 경우의 스레드와 asyncio 그래프다

more request without lock thread

more request without lock more request without lock 2

실행되는 스레드는 30개를 넘고 aiohttp가 만든 태스크로 인해 스크롤을 내려야만 다 확인할 수 있으며 실행시간은 약 두배인 13.4초정도가 걸렸다. (여러번 실행해 봤는데 가끔은 15초를 넘어갈 때도 있었다.)

락을 건 코드는 어떨까?

more request with lock thread more request with lock

하지만 락을 쓰면 처음 한번만 요청하고 캐시한 후 이후에는 캐시한 값을 가져오기 때문에 aiohttp가 사용하는 스레드는 훨신 적어지고 캐시된 값을 사용하게 되어 자원을 확실히 절약하는 것을 다시 한번 확인할 수 있다.

하지만… 이건 너무 인위적이지 않나요?

물론 이 예제가 상당히 인위적인 상황이라고 생각할 수도 있다.

하지만 이 예제와 비슷하게 데이터베이스 커넥션 풀을 생성하는 경우에도 동일하게 락의 효과를 확실하게 경험할 수 있다.

데이터베이스에서 수십가지의 값을 동시에 가져오려고 하는데 코루틴이 실행될 때 마다 데이터베이스 커넥션 풀을 매번 새로 만든다면(이 예제에서는 매번 요청을 하면) 자원 낭비가 정말로 심각할 것이다. (이건 HTTP 요청보다도 비싼 작업이다!) 이때 커넥션 풀을 생성하는 부분에 락을 걸고 커넥션 풀을 저장하여 사용한다면(마찬가지로 이 예제에서는 응답을 캐시해 두면) 자원을 훨씬 절약할 수 있을 것이다.

TL; DR, show me the code.

without lock

1import aiohttp
2import asyncio
3
4
5cache = dict()
6lock = asyncio.Lock()
7
8
9async def request():
10 global cache, lock
11 url = "http://slowwly.robertomurray.co.uk/delay/4600/url/http://example.com"
12
13 if url in cache:
14 return cache[url]
15
16 async with aiohttp.ClientSession() as session:
17 async with session.get(url) as resp:
18 cache[url] = await resp.text()
19
20 return cache[url]
21
22
23tasks = [request() for i in range(20)]
24
25
26loop = asyncio.get_event_loop()
27loop.run_until_complete(asyncio.gather(*tasks))

with lock

1import aiohttp
2import asyncio
3
4
5cache = dict()
6lock = asyncio.Lock()
7
8
9async def request():
10 global cache, lock
11 url = "http://slowwly.robertomurray.co.uk/delay/4600/url/http://example.com"
12
13 async with lock:
14 if url in cache:
15 return cache[url]
16
17 async with aiohttp.ClientSession() as session:
18 async with session.get(url) as resp:
19 cache[url] = await resp.text()
20
21 return cache[url]
22
23
24tasks = [request() for i in range(20)]
25
26loop = asyncio.get_event_loop()
27loop.run_until_complete(asyncio.gather(*tasks))

More articles from seonghyeon.dev

리얼월드 파이썬 메타클래스

파이콘 한국 2019에서 파이썬의 메타클래스에 대해 발표한 리얼월드 메타클래스 세션에서 나온 내용들을 정라했습니다. 이 글에서는 메타클래스란 무엇인지, 파이썬에서 메타클래스의 역할은 무엇인지, 메타클래스를 어떻게 활용하고 오픈소스 프로젝트에서 어떻게 활용되는지 알아봅니다.

August 16th, 2019 · 6 min read

Visual Studio Code에서 Rust 개발 시작하기

Rust 프로그래밍 언어를 사용기 위한 편리한 개발환경을 Visual Studio Code 환경에서 구축해 보자

July 1st, 2019 · 2 min read
© 2019–2020 seonghyeon.dev
Link to $https://twitter.com/NovemberOscar_Link to $https://github.com/NovemberOscarLink to $https://www.linkedin.com/in/novemberoscar/