Search

Python 멀티 태스킹

Intro

하드웨어 성능을 최대로 활용하기 위해 멀티 프로세싱, 멀티 쓰레딩 같은 기술이 소프트웨어 개발에 이용된다.
특히 고도의 연산이 필요한 HPC(High Performance Computing) 영역에서는 멀티 코어를 적극 활용한다.
하지만 일반적인 웹 애플리케이션은 멀티 코어를 활용하는 것보다 I/O 대기시간을 줄이는 것이 더 큰 성능 향상을 기대할 수 있다.
오히려 복잡하고 thread-safe 하지 않은 코드가 더욱 문제가 된다.
네트워크 프로그래밍에는 싱글 쓰레드에서 멀티 태스킹을 처리하는 비동기 방식이 더욱 주목 받고 있다.
Python에도 비동기 프로그래밍을 위한 asyncio가 표준 라이브러리에 추가되며 지속적인 지원을 받고 있다.
이번 글에서는 Python 멀티 태스킹에 대해 알아보자.
멀티 태스킹: 다수의 작업이 CPU와 같은 공용자원을 나누어 사용하는 것

실행 방식

Concurrency(동시성)

일상적으로 동시라고 하면 두 개의 사건이 정확히 같은 시각에 발생하는 것을 의미한다.
하지만 멀티 태스킹에서 동시성은 다수의 작업을 같은 시각에 동작하는 것처럼 교차로 실행하는 것을 말한다.
코어는 한 번에 하나의 명령어만 처리할 수 있다.
멀티 태스킹의 동시성은 다수의 쓰레드가 빠르게 교차하며 실행되어 동시로 느껴지는 것이다.

Parallelism(병렬성)

이와 달리 실제로 다수의 프로세스(또는 쓰레드)가 같은 시각에 실행되는 방법이 있다.
이를 멀티 태스킹에서는 병렬성(parallelism)이라고 말한다.
병렬성은 다수의 코어에서 동작하기 때문에 다수의 프로세스가 동시에 실행된다.

스케쥴링

동시성 프로그래밍에서 다수의 쓰레드가 빠르게 교차로 실행된다고 이야기 했다.
멀티 태스킹에서 하나의 task는 OS로부터 CPU 사용시간을 할당받은 만큼 사용한 뒤 반납한다.
반납된 CPU를 다른 task가 사용할 수 있도록 재배정하는 것을 context switching이라고 한다.
그런데 어떤 기준으로 context switching이 일어날까?
context switching을 일으키는 멀티 태스킹 스케쥴링 방식은 크게 두 가지가 있다.

pre-emptive(선점형)

선점형 멀티 태스킹은 OS에 의해 context switching이 일어난다.
OS가 특정 쓰레드를 실행하거나 중지한다.
이 방식은 코드 레벨에서 context switching을 신경쓰지 않아도 OS에 의해 동작하기 때문에 간편하다.
하지만 선점형 방식은 쓰레드 기준에서는 context switching이 아무때나 발생할 수 있다.
아래와 같은 간단한 연산 중 선점형 멀티 태스킹으로 인해 발생하는 문제를 알아보자.
x = x + 1
Python
복사
위의 연산은 컴퓨터 내부적으로 보면 크게 세 가지 과정으로 이루어진다.
1.
x 값을 읽어 임시 공간에 저장
2.
임시 공간에 +1 연산
3.
연산 결과를 x에 다시 할당
x 값은 0이라고 할 때, 두 개의 쓰레드에서 아래와 같은 순서로 위의 코드를 동시에 실행했다고 해보자.
a.
A 쓰레드에서 x 값을 읽고(1), context switching 발생(x == 0)
b.
B 쓰레드에서 x 값을 읽고(1), context switching 발생(x == 0)
c.
A 쓰레드에서 연산(2)하고 결과를 x에 저장(3) (x == 1)
d.
B 쓰레드에서 연산(2)하고 결과를 x에 저장(3) (x == 1)
두 개의 쓰레드에서 각각 +1 연산을 하였기 때문에 정상적인 결과는 2가 되어야 한다.
하지만 선점형 방식에 의해 race condition이 발생하여 A 쓰레드의 연산이 누락되었다.
Python threading 라이브러리를 이용할 경우, 선점형 멀티 태스킹으로 동작한다.

cooperative(협동형)

이와 달리 협동형 멀티 태스킹은 프로그램에서 task switching을 제어한다.
task가 필요한 작업을 모두 마치고 switching이 준비되었다는 것을 알림으로써 CPU를 반환한다.
대신 switching을 발생시킬 지점을 표시하는 추가적인 코드가 필요하다.
asyncio는 협동형 방식으로 동작하는데 이를 위해 async/await 키워드를 사용한다.
협동형 방식은 선점형 방식에서 발생할 수 있는 비결정성(non-determinism) 문제를 예방할 수 있다.
협동형 방식은 멀티 태스킹 동작 방식을 나타내는 보편적인 용어지만, asyncio가 싱글 쓰레드로 동작하는 것을 강조하기 위해 context switching 대신 task switching으로 표현하였다.

정리

라이브러리
멀티 태스킹 방식
switching 결정
# of processors
# of threads
threading
Pre-emptive multitasking
OS
1
N
asynio
Cooperative multitasking
task
1
1
multiprocessing
Multiprocessing
OS
N

Workload

동시성 프로그래밍은 크게 두 종류의 작업에 영향을 미칠 수 있다.
CPU-bound: CPU 연산에 대부분의 시간을 사용하는 경우
I/O-bound: 상대적으로 느린 장치(네트워크, 하드 드라이브, 프린트 등)와 통신을 위해 대기하는 경우
Python에서 각각의 작업을 어떻게 처리하는게 효율적인지 알아보자.

I/O-bound

I/O-bound 작업 최적화의 핵심은 CPU가 대기하는 시간(idle time)을 줄이는 것이다.
I/O 시간을 임의로 줄일 수는 없기에 대기시간에 CPU가 쉬지 않고 다른 작업을 수행하게 하면 된다.

threading

I/O-bound 작업의 경우, 멀티 쓰레드를 사용하면 일반적인 절차형 프로그램 보다 훨씬 빠르게 수행된다.
task마다 발생하는 I/O 대기시간 동안 CPU가 기다리지 않고 동시성으로 다른 task를 수행하기 때문이다.
하지만 threading 방식은 위에 설명한 x = x + 1 문제와 같은 race condition(경합 조건)을 발생시킨다.
멀티 쓰레드에서 간헐적으로 발생하는 이런 종류의 버그는 재현하기 어려워 원인 파악이 매우 힘들다.

asyncio

asyncio를 사용하면 I/O-bound 작업에서 발생하는 대기시간을 낭비하지 않고 CPU를 사용할 수 있다.
asyncio는 싱글 쓰레드로 동작하여 race condition이 발생하지 않는다.
또 협동형 방식의 멀티 태스킹으로 동작하여 task switching을 프로그램이 제어한다.
asyncio의 동작을 간단하게 설명하면, event loop라는 오브젝트를 통해 어떤 task가 실행될 지를 관리한다.
event loop는 각각의 task가 어떤 상태인지 알기 때문에 상태에 따라 task를 관리한다.
따라서 특정 task가 I/O 대기 상태라면 프로그램 제어권을 실행 가능한 다른 task에게 넘겨준다.
task switching은 task에 의해 제어되기 때문에 task 실행 중간에 임의로 중단될 걱정을 하지 않아도 된다.
asyncio 사용에도 단점은 존재한다.
asyncio를 제대로 활용하려면 반드시 비동기 프로그래밍을 지원하는 라이브러리와 함께 사용해야 한다.
또 협동적 멀티 태스킹의 특성상 특정 task에서 제어권을 반환하지 않으면 다른 task가 실행되지 못한다.

multiprocessing

I/O-bound 작업에 multiprocessing을 사용하는 경우, 일반적인 절차형 프로그램 보다는 빨라진다.
하지만 threading이나 asyncio만큼의 성능 향상을 기대할 수는 없다.
multiprocessing은 애초에 I/O-bound 작업을 위한 라이브러리가 아니다.
Python에서 multiprocessing은 별도의 interpreter를 생성해 프로세스를 실행하는 것과 같다.
이는 멀티 쓰레드에서 새로운 thread를 만드는 것만큼 빠르게 동작하지 않는다.

CPU-bound

CPU-bound 최적화의 핵심은 주어진 데이터를 빠르게 처리하게 하는 것이다.
따라서 무거운 연산을 멀티 코어로 나누어 수행하게 하는 것이 전체 작업 시간을 단축시킨다.

threading / asyncio

CPU-bound 작업을 threading 또는 asyncio로 처리하면 어떻게 될까?
일반적인 절차형 프로그램으로 작성했을 때 보다 처리 속도가 빨라지지 않거나 오히려 느려진다.
asyncio는 애초에 싱글 쓰레드로 동작하기 때문에 멀티 코어를 활용할 수 없다.
일반적인 멀티 쓰레딩의 경우, 멀티 코어에서 실행하게 되면 다수의 쓰레드가 코어 상에서 병렬로 실행된다.
하지만 Python에는 GIL 때문에 멀티 코어에서 멀티 쓰레딩을 구현하더라도 쓰레드가 병렬 실행되지 않는다.
GIL = 인터프리터가 하나의 쓰레드만 실행할 수 있도록 하는 전역 락
따라서 threading으로 멀티 쓰레딩을 구현하더라도 실제로는 하나의 코어만 활용할 수 있다.
오히려 context switching과 같은 불필요한 작업으로 인해 전체 작업 시간이 더 증가하게 된다.

multiprocessing

따라서 multiprocessing으로 멀티 프로세싱을 구현했을 때 멀티 코어의 장점을 최대로 활용할 수 있다.

정리

CPU-bound 작업은 multiprocessing을 활용하여 빠르게 처리할 수 있다.
I/O-bound 작업은 asyncio를 활용하여 빠르게 처리할 수 있다.
I/O-bound 작업은 threading을 이용할 수도 있지만 thread-safety 문제가 발생할 수 있다.
아래와 같은 rule of thumb을 기억하자.
Use asyncio when you can, threading when you must.
한 가지 주의할 점은 멀티 쓰레딩이 무조건 I/O-bound 작업에 활용되는 것은 아니다.
멀티 쓰레딩을 이용하면 여러 계산 작업 간에 메모리를 공유하는 프로그램을 작성할 수 있다.
예를 들어 수치 라이브러리인 numpy는 모든 메모리를 공유하면서 멀티 코어를 활용해 행렬 연산을 가속한다.

References