Programming/Python

왜 Python에는 GIL이 있는가

동건 2018. 6. 2. 17:56

Python 사용자라면 한 번 쯤은 들어봤을 (안 들어봤다 해도 괜찮아요) 악명 높은 GIL (Global Interpreter Lock)에 대해 정리해본다.


Global Interpreter Lock

그래서 GIL은 무엇인가? Python Wiki에서는 이렇게 말한다.


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.


간단하게 우리 말로 옮겨보자면,

CPython에서의 GIL은 Python 코드(bytecode)를 실행할 때에 여러 thread를 사용할 경우, 단 하나의 thread만이 Python object에 접근할 수 있도록 제한하는 mutex 이다. 그리고 이 lock이 필요한 이유는 CPython이 메모리를 관리하는 방법이 thread-safe하지 않기 때문이다.


뒤늦게 밝히는 바인데, 이 글은 Python의 표준 implementation인 CPython에 대한 이야기임을 확실하게 말해둔다. 여기서 내가 모르는

  • mutex, thread-safe의 개념
  • CPython이 메모리를 관리하는 방법

을 알아보고 돌아오려고 한다.



C에서의 thread-safeness와 mutex

CPython은 C로 만들었으므로, 위에서 말하는 mutex와 thread-safeness의 개념을 C 언어의 바탕에서 찾아보는 것이 좋을 것 같다. computing.llcl.gov에서 제공하는 POSIX thread에 대한 튜토리얼을 읽어보시길 추천한다. 아래에 간략하게 그림과 코드 위주로 그 내용을 정리해본다.


Process와 thread


UNIX processUNIX processThread within a UNIX processUNIX process 안의 thread


운영체제가 생성하는 작업 단위를 process라고 한다. 이 process 안에서 공유되는 메모리를 바탕으로 여러 작업을 또 생성할 수 있는데, 이 때의 작업 단위를 thread라고 한다. 따라서 각 thread 마다 할당된 개인적인 메모리가 있으면서, thread가 속한 process가 가지는 메모리에도 접근할 수 있다.


Shared memory modelShared memory model


C 언어에서는 pthread.h에서 thread와 관련된 기능을 제공한다. 아래는 thread를 생성해서 일을 시키는 기본적인 코드 예시이다.

/******************************************************************************
* FILE: hello.c
* DESCRIPTION:
*   A "hello world" Pthreads program.  Demonstrates thread creation and
*   termination.
* AUTHOR: Blaise Barney
* LAST REVISED: 08/09/11
******************************************************************************/
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#define NUM_THREADS	5

void *PrintHello(void *threadid) {
    long tid;
    tid = (long) threadid;
    printf("Hello World! It's me, thread #%ld!\n", tid);
    pthread_exit(NULL);
}

int main(int argc, char *argv[]) {
    pthread_t threads[NUM_THREADS];
    int rc;
    long t;
    for (t = 0; t < num_threads; t++) {
        printf("in main: creating thread %ld\n" t);
        rc = pthread_create(&threads[t], null, printhello, (void *)t);
        if (rc) {
            printf("ERROR; return code from pthread_create() is %d\n", rc);
            exit(-1);
        }

    /* last thing that main() should do */
    pthread_exit(NULL);
}



Thread-safeness

Thread unsafe, race conditionThread-unsafe의 경우


"Thread-safe하지 않다"는 것은 무슨 의미인지부터 알아보겠다. 위에서 thread들은 process가 공유하는 메모리에 접근할 수 있다고 언급했었는데, 이로 인해 참사가 발생할 수 있다. 이번엔 Python 코드 예제를 통해 참사를 경험해보자.

import threading

x = 0  # A shared value

def foo(): global x for i in range(100000000): x += 1 def bar(): global x for i in range(100000000): x -= 1 t1 = threading.Thread(target=foo) t2 = threading.Thread(target=bar) t1.start() t2.start() t1.join() t2.join() # Wait for completion

print(x)


print(x)의 결과가 0으로 나오는 게 내가 생각하기엔 정상적으로 작동한 것이리라 생각이 든다. 하지만 실제 계산을 해보면 x의 값은 전혀 이상한 숫자가 된다. 왜 그럴까? 전역 변수 x에 두 개의 thread가 동시에 접근해서 각자의 작업을 하면서 어느 한 쪽의 작업 결과가 반영이 되지 않기 때문이다 ("씹혔다"라고 표현할 수도 있겠다). 이렇게 여러 thread가 공유된 데이터를 변경함으로써 발생하는 문제를 race condition이라고도 부른다. 훨씬 자세한 설명을 듣고 싶다면 원문을 보시길 바란다.


따라서 "thread-safe하다"는 것은 thread들이 race condition을 발생시키지 않으면서 각자의 일을 수행한다는 뜻임을 알 수 있다.



mutex

Thread-safe한 코드를 만들기 위해서 사용하는 것 중 하나가 mutex (mutual exclusion)이다. 위에서 본 참사를 막기 위해서, 공유되는 메모리의 데이터를 여러 thread가 동시에 사용할 수 없도록 잠그는 일을 mutex가 맡는다.

Stackoverflow에서 찾은 mutex에 대한 굉장히 좋은 비유를 여기에 우리 말로 옮겨놓는다.

휴대폰이 없던 시절에는 공중 전화를 주로 이용했었다. 거리의 모든 남자들은 각자의 아내에게 전화를 너무나 걸고 싶어한다.

어떤 한 남자가 처음으로 공중 전화 부스에 들어가서 그의 사랑하는 아내에게 전화를 걸었다면, 그는 꼭 전화 부스의 문을 꼭 잡고 있어야 한다. 왜냐하면 사랑에 눈이 먼 다른 남자들이 전화를 걸기 위해 시도때도 없이 달려들고 있기 때문이다. 줄 서는 질서 문화 따위는 없다. 심지어 그 문을 놓친다면, 전화 부스에 들이닥친 남자들이 수화기를 뺏어 당신의 아내에게 애정 표현을 할 지도 모른다.

아내와의 즐거운 통화를 무사히 마쳤다면, 이제 문을 잡고 있던 손을 놓고 부스 밖으로 나가면 된다. 그러면 공중 전화를 쓰기 위해 달려드는 다른 남자들 중 제일 빠른 한 명이 부스에 들어가서 똑같이 문을 꼭 잡고 그의 아내와 통화할 수 있다.

이 재밌는 이야기를 아래와 같이 thread 개념에 하나씩 대응시킬 수 있다.

  • thread: 각 남자들
  • mutex: 공중 전화 부스의 문
  • lock: 그 문을 잡고 있는 남자의 손
  • resource: 공중 전화


이제 C가 mutex를 다루는 코드 예제를 보겠다. 가장 중요한 부분만 다시 살펴 볼 것이므로, 길고 눈 아파서 읽기 싫다면 넘어가도 좋다.

/*****************************************************************************
* FILE: dotprod_mutex.c
* DESCRIPTION:
*   This example program illustrates the use of mutex variables 
*   in a threads program. This version was obtained by modifying the
*   serial version of the program (dotprod_serial.c) which performs a 
*   dot product. The main data is made available to all threads through 
*   a globally accessible  structure. Each thread works on a different 
*   part of the data. The main thread waits for all the threads to complete 
*   their computations, and then it prints the resulting sum.
* SOURCE: Vijay Sonnad, IBM
* LAST REVISED: 01/29/09 Blaise Barney
******************************************************************************/
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

/*   
The following structure contains the necessary information  
to allow the function "dotprod" to access its input data and 
place its output into the structure.  This structure is 
unchanged from the sequential version.
*/

typedef struct {
   double *a;
   double *b;
   double sum;
   int veclen;
} DOTDATA;

/* Define globally accessible variables and a mutex */
#define NUMTHRDS 4
#define VECLEN 100000
DOTDATA dotstr;
pthread_t callThd[NUMTHRDS];
pthread_mutex_t mutexsum;

/*
The function dotprod is activated when the thread is created.
As before, all input to this routine is obtained from a structure 
of type DOTDATA and all output from this function is written into
this structure. The benefit of this approach is apparent for the 
multi-threaded program: when a thread is created we pass a single
argument to the activated function - typically this argument
is a thread number. All  the other information required by the 
function is accessed from the globally accessible structure. 
*/

void *dotprod(void *arg) {
/* Define and use local variables for convenience */
   int i, start, end, len;
   long offset;
   double mysum, *x, *y;
   offset = (long) arg;
     
   len = dotstr.veclen;
   start = offset*len;
   end = start + len;
   x = dotstr.a;
   y = dotstr.b;

/*
Perform the dot product and assign result
to the appropriate variable in the structure. 
*/
   mysum = 0;
   for (i = start; i < end; i++) {
      mysum += (x[i] * y[i]);
   }

/*
Lock a mutex prior to updating the value in the shared
structure, and unlock it upon updating.
*/
   pthread_mutex_lock(&mutexsum);
   dotstr.sum += mysum;
   printf("Thread %ld did %d to %d:  mysum=%f global sum=%f\n",offset,start,end,mysum,dotstr.sum);
   pthread_mutex_unlock(&mutexsum);

   pthread_exit((void*) 0);
}

/* 
The main program creates threads which do all the work and then 
print out result upon completion. Before creating the threads,
The input data is created. Since all threads update a shared structure, we
need a mutex for mutual exclusion. The main thread needs to wait for
all threads to complete, it waits for each one of the threads. We specify
a thread attribute value that allow the main thread to join with the
threads it creates. Note also that we free up handles  when they are
no longer needed.
*/

int main (int argc, char *argv[]) {
   long i;
   double *a, *b;
   void *status;
   pthread_attr_t attr;

/* Assign storage and initialize values */
   a = (double*) malloc (NUMTHRDS*VECLEN*sizeof(double));
   b = (double*) malloc (NUMTHRDS*VECLEN*sizeof(double));
  
   for (i = 0; i < veclen*numthrds; i++) {
      a[i]=1;
      b[i]=a[i];
   }

   dotstr.veclen = VECLEN;
   dotstr.a = a;
   dotstr.b = b;
   dotstr.sum = 0;

   pthread_mutex_init(&mutexsum, NULL);

/* create threads to perform the dotproduct */
   pthread_attr_init(&attr);
   pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_JOINABLE);

   for (i = 0; i < NUMTHRDS; i++) {
/* Each thread works on a different set of data.
 * The offset is specified by 'i'. The size of
 * the data for each thread is indicated by VECLEN.
 */
      pthread_create(&callThd[i], &attr, dotprod, (void *)i); 
   }

   pthread_attr_destroy(&attr);

/* Wait on the other threads */
   for (i = 0; i < NUMTHRDS; i++) {
      pthread_join(callThd[i], &status);
   }

/* After joining, print out the results and cleanup */
   printf ("Sum =  %f \n", dotstr.sum);
   free (a);
   free (b);
   pthread_mutex_destroy(&mutexsum);
   pthread_exit(NULL);
}


위 예제에서 가장 중요한 코드만 다시 보겠다. 이 부분은, thread에서 실행하게 되는 dotprod 함수의 일부분이다.

void *dotprod(void *arg) {
   // ...
   pthread_mutex_lock(&mutexsum);
   dotstr.sum += mysum;
   pthread_mutex_unlock(&mutexsum);
   // ...
}


어느 한 thread가 최초로 mutex를 가져갔다면 (pthread_mutex_lock을 성공했다면), 그 thread는 그 다음 코드를 계속 진행할 수 있다. 반면, 그 순간 이후로 다른 thread가 mutex를 가져가려고 한다면, 첫 번째로 mutex를 가져간 thread가 그 잠금을 풀 때까지 (pthread_mutex_unlock를 실행할 때까지) 기다려야 한다. 그렇게 mutex의 잠금이 해제되면, 이제서야 두 번째 thread가 mutex를 받아서 다음 코드를 진행할 수 있게 된다.

이렇게 mutex가 보호하고자 하는 변수는 dotstr.sum으로, thread들이 각자의 합을 계산해서 모두 합치는 자리인데, 여기서 race condition이 발생한다면 제대로 총합이 더해질 수 없기 때문에, mutex를 이용해서 이 변수에 동시적인 접근을 막는 것이다.

POSIX thread에 대한 정리는 여기서 그만한다. 원문에서는 훨씬 많은 내용을 굉장히 쉽게 잘 설명하고 있으므로, 다시 한번 강추.



CPython의 메모리 관리: Reference counting

CPython은 reference의 개수를 세는 방법으로 메모리를 관리한다. sys.getrefcount라는 함수를 이용해서 Python이 세고 있는 object의 reference 개수를 확인할 수 있다.

# 출처: realpython.com
>>> import sys
>>> a = []
>>> b = a
>>> sys.getrefcount(a)
3
  • a가 처음 만들어졌을 때의 reference 개수가 하나,
  • ba의 reference를 할당했으므로, 그 개수가 하나 늘어나서 두 개,
  • sys.getrefcount 함수에 argument로 a가 들어가서, 이 함수 내부에서 a의 reference 개수를 하나 늘리므로 세 개 (그리고 이 함수가 끝날 때 다시 reference 개수를 하나 줄일 것이다),

따라서 최종 출력으로 나오는 a의 reference 개수는 세 개가 된다. 그리고 이 개수가 0이 되면 CPython이 알아서 메모리를 회수한다고 생각할 수 있다.


한 발짝 더 들어가보자. CPython의 PyObject의 구조체는 reference 개수와 그 타입을 가지도록 선언한다.

// 출처: Include/object.h
typedef struct _object {
    _PyObject_HEAD_EXTRA
    Py_ssize_t ob_refcnt;
    struct _typeobject *ob_type;
} PyObject;


그리고 PyObject의 reference 개수를 의미하는 ob_refcnt를 올리고 내리는 매크로도 찾아 볼 수 있다. Py_DECREF는 reference 개수가 0이 되면 메모리 할당을 해제하는 것을 볼 수 있다.

#define Py_INCREF(op) (                         \
    _Py_INC_REFTOTAL  _Py_REF_DEBUG_COMMA       \
    ((PyObject *)(op))->ob_refcnt++)

#define Py_DECREF(op)                                   \
    do {                                                \
        PyObject *_py_decref_tmp = (PyObject *)(op);    \
        if (_Py_DEC_REFTOTAL  _Py_REF_DEBUG_COMMA       \
        --(_py_decref_tmp)->ob_refcnt != 0)             \
            _Py_CHECK_REFCNT(_py_decref_tmp)            \
        else                                            \
            _Py_Dealloc(_py_decref_tmp);                \
    } while (0)


지금까지 확인한 CPython의 내용을 바탕으로 Hello World를 찍어보는 예제만 보고, 다시 GIL을 생각해보자.

// 출처: https://pythonextensionpatterns.readthedocs.io
#include "Python.h"

void print_hello_world(void) {
    PyObject *pObj = NULL;

    pObj = PyBytes_FromString("Hello world\n"); /* Object 생성했으므로, ref count = 1. */
    PyObject_Print(pLast, stdout, 0);
    Py_DECREF(pObj);    /* ref count가 0으로 줄었으므로, object는 메모리에서 제거된다.
                         * 이 단계를 까먹으면, 메모리에서 남아있지만 object는 죽었으므로 memory leak! */
}



다시, GIL

지금까지 알아본 것들을 헤아려 보면 크게 두 가지다.

  • C에서 thread를 사용할 때에 race condition이 일어나지 않도록 하는 것은 순전히 사용자의 몫이다.
  • CPython은 생성되는 개체의 reference를 세어가면서 메모리 관리를 한다.

그러므로 우리는 자연스럽게 CPython이 reference counting을 하는 과정에서의 대참사가 일어날 수 있음을 생각해볼 수 있다. Reference counting 중에 race condition이 일어난다면, 그 결과는 결국 메모리 유실 (memory leak)일 것이다 (반대로 아직 살아있어야 할 object를 죽여버릴 수도 있다).

이를 해결하기 위해서는 mutex를 이용하면 된다고 언급했었다. 위에서 봤던 예제처럼 생각해보자면, ob_refcnt 변수를 바꾸는 지점에서 mutex를 잠가야 될 것 같다. 그러면 object 하나 하나마다에 대응하는 mutex가 필요하게 된다. 여러 개의 mutex를 사용하는 것은 성능적으로도 많은 손해를 볼 뿐 아니라, deadlock이라는 치명적인 위험 상황을 불러들이게 되어 좋지 않은 결정이 된다.

CPython의 결정은 이렇다. mutex를 통해 모든 reference 개수를 일일이 보호하지 말고, Python interpreter 자체를 잠그기로 한 것이다. 이거 하나만 mutex로 보호하면 그동안 우려했던 문제를 해결할 수 있다. 하지만, 얼마나 많은 thread를 사용하던지에 상관없이 오직 한 thread만이 Python code를 실행할 수 있다는 의미이기도 하다. 사실상 한 process 안에서 여러 thread를 이용한 병렬 처리를 막은 것이라고 생각할 수도 있겠다. 한 thread가 Python bytecode를 실행하기 위해서는 공중 전화 부스에 들어가서 interpreter lock을 잡아야 하는 것이다.

그래서 Global Interpreter Lock이다.


왜 GIL을 선택해야 했나

이에 대해서는 역사적인 이유를 여기에서 찾을 수 있었다.

Python이 태동하던 시기에는 thread라는 개념이 없었을 당시였고, 쉽고 간결한 언어를 표방했던 Python에 많은 사용자들이 모여들고 있었다. 수 많은 C extension들이 이미 만들어졌는데, 시간이 지나서 thread 개념으로 인한 문제를 해결하기 위해서 가장 현실적인 방안은 GIL이었다. 거대한 커뮤니티에서 만들어낸 C extension들을 새로운 메모리 관리 방법에 맞춰서 모두 바꾸는 것은 불가능하다. 대신 Python이 GIL을 도입하면 C extension들을 바꾸지 않아도 됐던 것이다.

이렇게 초장기에 만들어진 CPython의 GIL은 현재 Python 3가 되도록 크게 변하지 않은 부분이라고 한다. BDFL은 GIL에 대한 개선을 하고 싶은 사람들에게 이렇게 말했다.

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.
단일 thread 프로그램에서의 성능을 저하시키지 않고 GIL의 문제점을 개선할 수 있다면, 나는 그 개선안을 기꺼이 받아들일 것이다.

그리고 지금까지 그렇게 해서 받아들여진 개선안은 없다고 한다.


마치며

기본적인 Python 사용자 입장에서 GIL로 인해 불편을 느낄 가능성은 거의 없다.

  • 일단 단일 thread일 때는 아무런 문제가 없다.
  • CPU가 바쁘게 계산하는 일들은 numpy/scipy에서 GIL 바깥에서 굉장히 효율적인 C 코드로 연산할 수 있다.
  • 병렬 처리에 관해서는 굳이 thread가 아니더라도 multiprocessing이나 asyncio 등의 많은 선택지가 있다.
  • 굳이 thread 간의 동시적인 처리가 필요하다면 다른 Python implementation을 고려해봐도 된다. Jython, IronPython, Stackless Python, PyPy 등이 있다.

하지만 기본적으로 CPython이 내부적으로 어떤 역사가 있는 지를 아는 것만으로도 충분히 의미있다고 생각한다. 또한 당신이 CPython API를 이용한 C extension을 작성하고자 한다면, Python이 메모리를 관리하는 방식과 GIL의 존재를 모르고는 할 수 없을 것이다.


지금까지 정리한 내용을 바탕으로 Python에서 할 수 있는 동시적 처리 방법(concurrency)에 대해 이야기가 이어질 수 있는데, 한 글에 담기엔 너무 길어져서 나중을 기약한다.



참조 링크

POSIX thread, C API


GIL


Python의 reference counting



반응형