Skip to main content

파이썬 동시성 처리

글에서 다루는 내용

  • 동시성 처리
  • 파이썬에서의 동시성 처리
  • 파이썬 GIL (Global Interpreter Lock)

TL;DR

  1. 파이썬은 GIL (Global Interpreter Lock)으로 인해 멀티 스레딩 환경에서 CPU 바운드 작업에 대한 효율성이 증가하지 않는다.
  2. CPU 바운드 작업 많음 + 작업 사이의 커뮤니케이션 비용 적음 = 멀티프로세싱 (프로세스는 미리 띄우기)
  3. I/O 바운드 작업 많음 = 메인 스레드에서 코루틴 활용
  4. CPU 바운드 + I/O 바운드 작업 많음 = 멀티프로세싱 + 코루틴 활용

들어가며

FastAPI에서 API(=path operation function)을 구현하는 방법은 두 가지가 있다: async def, def. 신기한 것은 def로 구현해도 동시성(=concurrency) 처리가 가능하다는 점이다. FastAPI의 비동기 처리에 들어가기에 앞서, 이번 글에서는 파이썬의 동시성 처리에 대해서 알아보자. 또한, 일반적인 상황에서의 동시성 처리와, 파이썬의 동시성 처리는 어떻게 다른지도 살펴보자. 이후, 다른 글에서 FastAPI의 async def와 def의 차이는 무엇인지, 내부 구조와 동작 방식에 대해서 살펴보도록 하자.

동시성 처리

소프트웨어를 개발할 때, 동시성 처리는 왜 중요할까? 속도(latency)와 처리량(throughput) 때문이라고 생각한다. 일반적으로 동시성 처리를 위한 접근에는 3가지 방식(멀티 프로세싱, 멀티 스레딩, 코루틴)이 있다.

멀티프로세싱은 여러개의 프로세스를 통해 작업을 처리하는 방식이다. 작업 처리를 위해 추가적인 컴퓨터 자원을 할당받을 수 있기 때문에 빠른 처리가 가능하다. 다만, 프로세스를 새로 생성할 때(spawn)와, 프로세스간 커뮤니케이션에서 오버헤드가 발생할 수 있고, 이는 오히려 병목 지점이 될 수 있다.

멀티스레딩은 하나의 프로세스 내에서 여러개의 스레드를 통해 작업을 처리하는 방식이다. 보통은 메인 스레드에서 작업을 처리하는데, 프로세스에 할당된 자원이 여유롭다면 멀티스레딩을 고려해볼 만 하다. 멀티스레딩은 스레드간 커뮤니케이션에서 멀티프로세싱에 비해 오버헤드가 덜 발생한다는 특징이 있다.

스레드는 CPU bound 작업을 할 때는 문제가 없지만, I/O bound 작업을 할 때는 pending 상태로 기다리는데, 가용할 수 있는 자원이 작업을 하지 않고 기다린다는 단점이 있다. 따라서, 스레드의 이런 부분을 개선하기 위해 코루틴(coroutine)이라는 함수 루틴이 등장한다. 일반적인 함수는 함수 내의 명령어를 모두 수행하고 return을 만났을 때 작업 context를 탈출(exit)할 수 있는데, 코루틴은 보다 자유롭게 함수의 작업 context를 탈출하고 진입(entry)할 수 있다. 이를 통해, 스레드는 I/O bound 작업을 만나면 해당 함수의 context에서 탈출해서 다른 일을 처리하다가, I/O bound 작업이 종료되면 다시 돌아와서 나머지 작업을 수행할 수 있게 된다. 이를 통해 스레드가 쉬지 않고 일을 할 수 있게 만들 수 있다.

멀티프로세싱, 멀티스레딩, 코루틴은 상황에 따라 하나씩만 사용할 수도, 섞어서 사용할 수도 있다. 경험을 바탕으로 설명할 수 있다면 좋겠지만, 경험이 많지 않아 해당 설명은 스킵하도록 한다. 나중에 나눌 수 있는 경험이 생기면 글을 수정해야겠다.

여담으로, 멀티 프로세싱이 멀티 스레딩에 비해 커뮤니케이션 비용이 많이 발생한다고 했는데, 이는 스레드와 프로세스가 공유하는 메모리 영역과 관련된 특징이라고 생각한다(확실한 답변은 아니기에, 참고만 하자). 동일한 프로세스 내에서 각 스레드들은 각자의 스택 메모리 영역을 갖는다. 그 말은, 힙, 데이터, 코드 영역은 프로세스와 공유한다는 것이다. 따라서, 스레드간 통신에서는 스택 공간에 대해 공유하면 되는데 비해, 프로세스간 통신에서는 스택, 힙, 데이터, 코드 영역을 모두 공유해야 하기 때문에 비교적 오버헤드가 크게 발생하는 것이다.

파이썬에서의 동시성 처리

파이썬에서의 동시성 처리는 일반적인 소프트웨어와는 조금 다르다. 왜냐하면 파이썬에서는 GIL(Global Interpreter Lock)이 존재하기 때문이다. GIL은 일종의 뮤텍스(mutex)인데, 이를 설명하기 위해서 파이썬의 가비지 컬렉터 (Garbage Collector)의 동작 방식부터 시작해보자.

고수준 언어들은 각자의 가비지 컬렉터를 가지고 있고, 가비지 컬렉션 방식도 가지각색이다. 파이썬의 가비지 컬렉터는 reference count 방식을 사용하는데, 이는 객체가 참조된 횟수를 기반으로 메모리 할당을 해제하는 방식이다(가비지 컬렉션 성능을 위해 generation이라는 개념도 사용하는데, 해당 개념은 여기서는 패스하도록 한다). reference count 방식이 제대로 동작하기 위해서는 count 변수에 대한 접근 통제가 필수적이다. 즉, race condition에 대해 처리해야 한다는 뜻이다.

race condition이란 여러개의 작업자가 동시에 공유 변수에 접근할 때 발생하는 버그나 오류 상황을 말한다. 예를 들어, 공유 변수가 초기값 0으로 설정되어 있다고 생각해보자. 스레드 A는 해당 값을 읽고 1을 증가시키려고 하는데, 동시에 스레드 B도 같은 값을 읽고 1을 증가시키려고 한다. 만약 두 스레드가 동시에 증가 연산을 수행한다면, 2가 아닌 1만 증가한 결과가 나올 수도 있고, 이런 상황을 race condition 문제가 발생했다고 한다.

파이썬에서는 race condition 문제를 해결하기 위해, 1개의 스레드만 변수에 접근할 수 있도록 GIL을 설정했다. 이 말은 멀티스레딩 환경으로 구성해도, 1개의 스레드만 작업을 진행하기 때문에 동시성 처리에 있어서 큰 효과가 없다는 것이다. 물론, GIL이 release 되는 상황도 있다. I/O bound 작업이 발생했거나, CPython이 아닌 다른 파이썬 구현체를 사용하면 된다. 따라서, I/O bound 작업이 많은 경우에 멀티스레딩 환경으로 구성하면 효과가 있을 수 있지만, 차라리 싱글 스레드에서 코루틴을 사용하는게 더 효율적일 것으로 보인다.

정리해보면, CPU bound 작업이 많은데 작업 사이의 커뮤니케이션 비용이 적은 상황이라면 멀티 프로세싱으로 처리하면(단, 프로세스는 미리 띄워놓자) 유리할 것으로 보이고, I/O bound 작업이 많다면 싱글 스레드에 코루틴을 사용하고, CPU bound와 I/O bound 작업이 모두 많다면 멀티 프로세싱에 코루틴을 사용하면 좋을 것으로 보인다.

여담으로, 세마포어(semaphore)와 뮤텍스(mutex)는 race condition을 해결하는 일반적인 방법 중 하나이다. 세마포어는 공유 변수에 접근할 수 있는 작업자의 개수를 n개로 설정하는 방식이고, 뮤텍스는 작업자의 개수를 1개로 설정하는 방식이다. 따라서, 파이썬의 GIL은 뮤텍스의 일종이라고 부를 수 있는 것이다.

정리

동시성 처리와, 파이썬에서의 동시성 처리에 대해 알아봤다. 프로세스, 스레드, 멀티프로세싱, 멀티스레딩, 코루틴, race condition, 세마포어, 뮤텍스, GIL, 가비지 컬렉터, reference count 등 기본적인 토픽에 대해 공부할 수 있었다. 다만, 이게 “정답”이라고 하기에는 내 지식에 대한 자신감이 없는데, 기본기가 튼튼하지 않기 때문이라는 생각이 든다. 따라서, 현재 시점에서 최선을 다해 “합리적으로 정리”한 내용 정도로 보면 좋을 듯 하다. 다양한 블로그를 참고하고 ChatGPT와 대화하며 공부하는 것도 좋지만, “책”으로 공부하는 것에 대한 필요성을 많이 느꼈다.