[Python] GIL은 CPU-Bound I/O-Bound의 문제가 아니다.

GIL : Globla Interpreter Lock

GIL은 한번에 하나의 스레드만 수행할 수 있도록 인터프리터에 lock을 거는 기능입니다. 파이썬 객체는 GC를 위해, reference count를 가지고 있는데, 해당 객체를 참조할 때마다 reference count 값을 변경해야 합니다.

멀티 스레드를 실행하게 되면, 각 스레드가 공유하는 객체들에 대해 각각 lock을 걸어야하는데 이 때 성능상의 이슈와 deadlock의 위험이 존재합니다. 이를 위해서 인터프리터 레벨에서 한 시점에서 실행되는 스레드를 1개로 제한합니다. 따라서 멀티 CPU 환경이라 할지라도, 파이썬 스레드는 어느 시점에서나 1개의 스레드가 실행되는 단점이 있습니다.

이를 해결하려면 multiprocessing 라이브러리를 사용하면 됩니다. 이 경우 개별 프로세스가 생성되고, 프로세스 별로 인터프리터 락이 걸리기 때문에 동시 실행이 가능합니다.

GIL은 CPython에 존재하는 개념

CPython은 python code를 먼저 bytecode로 변환한 뒤, interpreting을 진행합니다. 따라서 CPython은 compiler의 역할과 interpreter의 역할을 모두 수행합니다.

Cpython은 메모리를 관리하는 방법이 thread-safe하지 않습니다. 그래서 CPython이 bytecode를 실행할 때 하나의 스레드만 파이썬 객체에 접근 할 수 있도록 lock을 겁니다.

CPython의 Reference Count란? (이하 RC)

cpp에서 shared_pointer가 RC를 사용하는 것처럼, python은 객체의 메모리를 관리할 때 RC를 사용합니다.

1
2
3
4
import sys
a = [] # 객체가 최초 생성될 때의 첫 번째 RC 생성
b = a # a가 가리키는 메모리를 b가 가리키게 하면서 두 번째 RC 생성
sys.getrefcount(a) # getrefcount()함수 내부에서 세 번째 RC 생성. 함수가 리턴되면 세 번째 RC는 소멸

CPython은 객체에 대한 RC가 0이 되면 해당 객체를 소멸시킵니다. 여러 쓰레드에서 RC를 생성/추가하기 위해서는 RC 정보를 변경할 때마다 lock을 걸어야하는데, 이렇게되면 성능 문제가 발생할 수 있는 것입니다. CPython 자체의 메모리 관리가 thread-safe하지 않기 때문에, 특정 멀티스레드 라이브러리를 사용하는 것은 이 문제를 해결할 수 없습니다. 그래서 동시 실행을 적용하기 위해서는 멀티프로세스 라이브러리를 사용합니다.

다시 이해하는 GIL

GIL은 Interpreter 자체적인 Lock으로 Python 바이트 코드를 실행하려면 Interpreter의 Lock을 획득해야 합니다. 이렇게 함으로써 Deadlock을 방지하고 성능 Overhead가 많이 발생하지 않습니다. 다만, 모든 CPU-Bound 프로그램을 단일 Thread로 만듭니다. (멀티 CPU 환경에서도 하나의 스레드만 실행된는 구조적 한계점을 가지게 됩니다.)

  • race condition : 2개의 Thread가 특정 값을 동시에 접근하여 변경하는 것
  • CPU-Bound :
  • GIL : Thread-safe 하기 위해 Thread를 하나만 사용하는 시스템
    • Python, Ruby
    • 몇몇 언어는 단일 Thread 성능 손실을 JIT 컴파일러와 같이 Boosting 기능을 붙여 보완
  • Garbage Collection : Thread-safe 메모리 관리 시스템
    • Java

아니 싱글스레드는 너무한거 아니오?

Python은 OS에서 Thread의 개념이 없을 때부터 있었습니다. 그런데 쉬운 언어를 표방하는 python에 대한 관심은 너무 뜨거워서, 이미 C를 기반으로 만들어진 python extension이 쏟아지고 있었습니다.(Python에 필요한 기능 확장을 C로 작성하던 시절) 추후에 Cpython 구현 자체를 memory-safe로 바꿀 수도 있었지만 그렇게되면 이미 만들어진 수 많은 extension을 수정해야하는 문제가 발생했습니다. (기존에 작성된 확장들은 interpreter level의 lock만 관리하면 되기 때문에 single thread 프로그램에서 높은 성능을 보였습니다. 동시에 통합하기도 쉬운 등 무시할 수 없는 장점이 있었습니다.)

이 특성은 python3k에 와서도 유지되고 있고 있습니다. GIL를 없애자는 주장과 없애면 지금까지 만들어진 모든 코드들의 수정이 필요하다는 논쟁 속에서 파이썬의 창시자 귀도 반 로썸BDFL로서 아래와 같이 말해서 논쟁을 종결시켰습니다.

1
2
I'd welcome a set of patches into Py3k only if the performance for a single-threaded program (and for a multi-threaded but I/O-bound program) does not decrease.
싱글 쓰레드 프로그램 성능에 영향이 없는 선에서 GIL 없앨 수 있으면 대!환!영!  

GIL이 영향을 미치는 범위

GIL은 CPU bound 와 I/O bound의 문제가 아닙니다. 수행되어야 하는 어떤 job이 python runtime과 상호작용을 하면 GIL의 영향을 받고, 상호작용을 하지 않으면 GIL의 영향을 받지 않습니다. 만약 대표적인 CPU bound인 image processing이 thread-safe한 C extension으로 구성되어 있고 python runtime과 상호작용을 하지 않는다면, GIL의 영향을 받지않고 multi-thread로 수행될 수 있습니다.

  • CPU bound
    • CPU가 빠르다면 더 빨라질 수 있는 작업 또는 프로그램을 말합니다.
      1
      
      A program is CPU bound if it would go faster if the CPU were faster, i.e. it spends the majority of its time simply using the CPU (doing calculations). A program that computes new digits of π will typically be CPU-bound, it's just crunching numbers.
      
  • IO bound란?
    • IO 서브 시스템이 빠르다면 더 빨라질 수 있는 작업 또는 프로그램을 말합니다. File, Database, Network 등이 포함됩니다.
      1
      
      A program is I/O bound if it would go faster if the I/O subsystem was faster. Which exact I/O system is meant can vary; I typically associate it with disk, but of course networking or communication in general is common too. A program that looks through a huge file for some data might become I/O bound, since the bottleneck is then the reading of the data from disk (actually, this example is perhaps kind of old-fashioned these days with hundreds of MB/s coming in from SSDs).
      

GIL의 영향을 받는 경우

1부터 50억까지 덧셈을 하는 프로그램은 python run-time과 상호작용하는 CPU-Bound 프로그램입니다. from threading import Thread를 사용해서 두 개의 쓰레드를 동시에 돌려서 덧셈을 하려고 해도, 소요 시간은 쓰레드를 한 개를 사용할 때와 크게 다르지 않습니다. GLI가 하나의 쓰레드만 수행되도록 강제하기 때문입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# single_threaded.py
import time
from threading import Thread

COUNT = 50000000

def countdown(n):
while n>0:
n -= 1

start = time.time()
countdown(COUNT)
end = time.time()

print('Time taken in seconds -', end - start)

출처: https://timewizhan.tistory.com/entry/Global-Interpreter-Lock-GIL [Small talks with something]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# multi_threaded.py
import time
from threading import Thread

COUNT = 50000000

def countdown(n):
while n>0:
n -= 1

t1 = Thread(target=countdown, args=(COUNT//2,))
t2 = Thread(target=countdown, args=(COUNT//2,))

start = time.time()
t1.start()
t2.start()
t1.join()
t2.join()
end = time.time()

print('Time taken in seconds -', end - start)

출처: https://timewizhan.tistory.com/entry/Global-Interpreter-Lock-GIL [Small talks with something]

GIL의 영향을 받지 않는 경우

그런데 I/O task는 여전히 CPU를 사용할텐데 어떻게 I/O Bound Job이 GIL을 피할 수 있을까요?

1
The python threading documentation states that "...threading is still an appropriate model if you want to run multiple I/O-bound tasks simultaneously", apparently because I/O-bound processes can avoid the GIL that prevents threads from concurrent execution in CPU-bound tasks.

간략한 답은 아래와 같습니다.

1
2
# https://scipy-cookbook.readthedocs.io/items/ParallelProgramming.html#Threads
while a thread is waiting for IO (for you to type something, say, or for something to come in the network) python releases the GIL so other threads can run. 

파이썬에서 아직 GIL은 사라지지 않았지만, 그럼에도 불구하고 여전이 thread는 I/O, image processing, and NumPy number crunching 등에서 여전히 유용하게 사용될 수 있습니다. 왜냐하면 이 작업들은 GIL 밖에서 python runtime과 상호작용 하지 않고 수행되기 때문입니다. 수행이 끝난 뒤에는 CPU와 상호작용을 합니다.

만약 동일한 기능을 수행하는 multi-threaded 작업들이 GIL 안에서 동작한다면 bottleneck 현상을 겪게됩니다.

1
The GIL is controversial because it prevents multithreaded CPython programs from taking full advantage of multiprocessor systems in certain situations. Note that potentially blocking or long-running operations, such as I/O, image processing, and NumPy number crunching, happen outside the GIL. Therefore it is only in multithreaded programs that spend a lot of time inside the GIL, interpreting CPython bytecode, that the GIL becomes a bottleneck.

그럼 Numpy는 Multithread를 지원하는가?

Numpy를 통해서 GIL을 피해 특정 기능을 MultiThread의 성능 향상을 충분히 이용하면서 수행하는 방법을 알아보겠습니다.

그런데 우선 정말로 MultiThread가 필요한지 고민을 먼저 해보는 것이 시간을 줄이는 방법입니다.

1
2
3
4
5
# Parallel Programming with numpy and scipy
numpy/scipy are not perfect in this area, but there are some things you can do. The best way to make use of a parallel processing system depend on the task you're doing and on the parallel system you're using. If you have a big complicated job or a cluster of machines, taking full advantage will require much thought. But many tasks can be parallelized in a fairly simple way.  

# Get your code working first
Get your code working first, before even thinking about parallelization. Then ask yourself whether your code actually needs to be any faster. Don't embark on the bug-strewn path of parallelization unless you have to.

Embarrassingly parallel한 경우

https://scipy-cookbook.readthedocs.io/items/ParallelProgramming.html

여러 개의 Job들이 서로 의존도가 없거나 의사소통할 필요가 없는 경우 Embarrassingly parallel하다고 합니다.

numpy code often releases the GIL while it is calculating, so that simple parallelism can speed up the code.

이 경우는 아래와 같이 수행할 수 있습니다.

1
dft = parallel_map(lambda f: sum(exp(2.j*pi*f*times)), frequencies)

The code implementing parallel_map is not too complicated, and is attached to this entry. Even simpler, if one doesn’t want to return values:

1
2
3
def compute(n):
    ...do something...
foreach(compute, range(100))

tasks need some kind of synchronization or communication

For example, you might have a list of jobs that can be run in parallel, but you need to gather all their results, do some summary calculation, then launch another batch of parallel jobs. There are two approaches to doing this in Python, using either multiple threads) or processes).

thread를 사용하는 경우

1
2
3
>>> print "%s %s %s %s and %s" %( ("spam",) *3 + ("eggs",) + ("spam",) )
>>> A = B + C
>>> print A

During the print operations and the % formatting operation, no other thread can execute. But during the A = B + C, another thread can run - and if you’ve written your code in a numpy style, much of the calculation will be done in a few array operations like A = B + C. Thus you can actually get a speedup from using multiple threads.

process를 사용하는 경우

https://scipy-cookbook.readthedocs.io/items/ParallelProgramming.html#Processes

멀티 프로세스 모듈을 사용해서 parallel을 지원합니다.

Reference

Leave a comment