[Python] GIL(Global Interpreter Lock)

sing sang song·2021년 12월 23일
0
post-thumbnail

면접에서 대답하지 못했던,, GIL...
싱글스레드와 멀티스레드도 대답을 못했다.(반성하자 내 자신)

머리가 백지가 된 상태라 말 못하는 내 자신이 어이가 없었는데 덕분에 다시는 안 잊을것 같다..! 오히려 좋아~

싱글스레드와 멀티스레드 코드 속도 비교

  • 하나의 스레드가 두 개의 작업을 연속적으로 실행
  • 두 개의 스레드가 각각 하나의 작업을 실행
import random
import threading
import time


def working():
    max([random.random() for i in range(500000000)])


# 1 Thread
s_time = time.time()
working()
working()
e_time = time.time()
print(f'{e_time - s_time:.5f}')


# 2 Threads
s_time = time.time()
threads = []
for i in range(2):
    threads.append(threading.Thread(target=working))
    threads[-1].start()

for t in threads:
    t.join()

e_time = time.time()
print(f'{e_time - s_time:.5f}')

#single 129.12857
#multi 170.34252

CTO급 컴퓨터가 아니다 보니 좀 오래 걸려버렸다....

출처 : 링크

본래 멀티쓰레딩은 싱글스레드, 즉 혼자 할일을 여럿이 같이 함으로써 성능을 향상시키는 것에 목적이 있다.
그런데 왜 멀티스레드가 더 오래걸리는 결과가 나왔을까?

그 원인을 알기 위해서는 GIL에 대한 설명이 필요하다.

what is GIL?

위의 예시는 자바나 다른 언어처럼 완벽한 멀티쓰레드가 아닌 그런식으로 돌아가는 척(?)을 가능하게 하고 있는 것이다. 왜 그럴까?
파이썬은 싱글스레드의 언어이지만 꼭 멀티쓰레딩이 안되는 것은 아니다. 그 방법은 가장 마지막에

바로 GIL(Global Interpreter Lock, 전역 인터프리터 잠금)때문이다.

GIL(Global Interpreter Lock)이란? 파이썬 인터프리터가 한 스레드만 하나의 바이트코드를 실행 시킬 수 있도록 해주는 잠금기능(Lock)이다.

하나의 스레드가 작업을 끝낼때까지 모든 자원을 쓰게 두고 그 후에는 lock을 걸어서 다른 스레드는 실행되지 않도록 하는 것이다. 즉, 병렬 실행이 불가능하다.

그림을 보면 조금 더 이해하기 쉽다.

그럼 왜 락을 걸까?

In CPython, the global interpreter lock, or GIL, is a mutex that protects access to Python objects, preventing multiple threads from executing Python bytecodes at once. This lock is necessary mainly because CPython's memory management is not thread-safe.

출처 : 위키백과

GIL이 Python의 객체들에 대한 접근을 보호하는 일종의 뮤텍스(mutex)이기 때문이라고 한다.

뮤텍스(mutex)란, 멀티쓰레딩 환경에서 여러 개의 쓰레드가 어떠한 공유 자원에 접근할 때 그 공유자원에 접근하기 위해 가지고 있어야하는 일종의 열쇠다!

Python에서 모든 것은 객체(object)이다. 그리고 각 개체는 참조 횟수(Reference Count)를 저장하기 위한 필드를 갖고 있다.

참조 횟수란 그 객체를 가리키는 참조가 몇 개 존재하는지를 나타내는 것으로, Python에서의 GC(Garbage Collection)는 이러한 참조 횟수가 0이 되면 해당 객체를 메모리에서 삭제시키는 메커니즘으로 동작하고 있다.

그렇다면 이것이 GIL이랑 무슨 상관인 걸까? 참조 횟수에 기반하여 GC를 진행하는 Python의 특성상, 여러 개의 쓰레드가 Python 인터프리터를 동시에 실행하면 Race Condition이 발생할 수 있기 때문이다.

Race Condition이란, 하나의 값에 여러 쓰레드가 동시에 접근함으로써 값이 올바르지 않게 읽히거나 쓰일 수 있는 상태를 말한다. 이러한 상황을 보고 Thread-safe하지 않다고 표현하기도 한다.

즉, 여러 쓰레드가 Python 인터프리터를 동시에 실행할 수 있게 두면 각 객체의 참조 횟수가 올바르게 관리되지 못할 수도 있고, 이로 인해 GC가 제대로 동작하지 않을 수도 있다는 말이다. 물론 이러한 Race Condition은 뮤텍스(Mutex)를 이용하면 예방할 수 있다.

그런데 앞서 말했듯이, Python에서 모든 것은 객체이고, 객체는 모두 참조 횟수를 가진다. 따라서 GC의 올바른 동작을 보장하려면 결국 모든 객체에 대해 뮤텍스를 걸어줘야 한다는 말이 된다. 이는 굉장히 비효율적이며, 만약 이를 프로그래머에게 맡길 경우 상당히 많은 실수를 유발할 수도 있는 문제이다.

GIL의 존재 이유

그래서 결국 Python은 마음 편한 전략을 택하였다. 한 쓰레드가 Python 인터프리터를 실행하고 있을 때는 다른 쓰레드들이 Python 인터프리터를 실행하지 못하도록 막는 것이다. 이를 보고 "인터프리터를 잠갔다"라고 표현한다. 즉, Python 코드를 한 줄씩 읽어서 실행하는 행위가 동시에 일어날 수 없게 하는 것(하나의 Lock을 통해서 모든 객체들에 대한 reference count의 동기화 문제를 해결한 것)이다. 그러면 모든 객체의 참조 횟수에 대한 Race Condition을 고민할 필요도 없어진다. 뮤텍스를 일일이 걸어줄 필요도 없어지는 것이다. 이것이 GIL의 존재 이유이다.

python멀티스레딩은 무조건 느릴까?

위의 예시가 아닌 또 다른 예시가 있어서 가지고 와보았다.
바로 sleep을 쓰는 것이다

import random
import threading
import time


def working():
    time.sleep(0.1)
    max([random.random() for i in range(10000000)])
    time.sleep(0.1)
    max([random.random() for i in range(10000000)])
    time.sleep(0.1)
    max([random.random() for i in range(10000000)])
    time.sleep(0.1)
    max([random.random() for i in range(10000000)])
    time.sleep(0.1)
    max([random.random() for i in range(10000000)])
    time.sleep(0.1)


# 1 Thread
s_time = time.time()
working()
working()
e_time = time.time()
print(f'{e_time - s_time:.5f}')


# 2 Threads
s_time = time.time()
threads = []
for i in range(2):
    threads.append(threading.Thread(target=working))
    threads[-1].start()

for t in threads:
    t.join()

e_time = time.time()
print(f'{e_time - s_time:.5f}')

#1 = 11.66885
#2 = 10.76572

그 이유는 바로 sleep !

싱글스레드에서는 sleep으로 인해 아무런 동작도 취하지 못한 체 동작을 대기하는 반면에 멀티스레드에서는 sleep으로 멈춘 경우 다른 스레드로 context switching하여 효율이 개선된다.

지금은 설명하기 위해 sleep 함수를 사용하였지만 일반적인 경우 I/O(input,output:읽기,쓰기) 작업이 많아 스레드가 대기해야 할 경우 위에서 설명한 것과 같은 이유로 멀티스레드의 성능이 더 좋을 수 있다.


참고

profile
세상을 선명하게

0개의 댓글