본문 바로가기

진리는어디에/Python

Python embedding 1 - Overview

'눈이 올것 같은 날씨군..' 이라고 생각하는 순간 신기하게도 눈이 내리는 군요. 그것도 아주 펑펑... 커플 분들에게는 정말 즐거운 날씨 일것이라는 생각이 듬과 동시에 어쩌면, 올해 크리스마스는 화이트 크리스마스가 될지도 모르겠다는 불길한 느낌이 오는군요.

제 느낌이 어쨌든, 내리는 눈이 너무 이뻐서 사진이라도 찍어 둘까하다가 관뒀습니다. 추억은 어디까지나 추억으로 남아야지, 기록으로 남겨진 추억은 나중에 감정이 사라져 버리고 나면 씁쓸함만이 남더군요. 헛소리를 하는 것을 보니 지병이 다시 도지는가 봅니다. '후천적 연말 크리스마스 우려 증후군'이라고 솔로 기간이 길어지다 보면 이런 병도 생깁니다.

이 글을 읽고 계시는 여러분들도 조심하시기 바랍니다. 언제 저 처럼 되 버릴지 모릅니다.ㅎㅎ

오늘은 파이썬을 C++에 임베딩(embedding) 시켜 사용하는 법을 알아 보도록 하겠습니다. 원문은 linux jurnal 에 기재된 Embedding Python in Multi-Threaded C/C++ Applications 이란 글입니다. CodeProject 에도 Embedding Python in C/C++ 라고 임베딩 관련하여 비슷한 글이 올라와 있지만, 제가 직접 그 코드를 사용해 본 결과 버그 투성이의 엉망진창 코드 였습니다. 하지만 linux jurnal의 코드 예제는 실전에 적용하기에는 너무 간단한 면이 없지 않아 있기 때문에, linux jurnal의 기사를 보시고 기본을 익히신 이후 CodeProject의 기사를 보시고 실전에 적용 하시는 것이 정신 건강에 도움이 되리라 생각합니다.

Introduction

일반적으로 C나 C++같은 언어는 빌드(컴파일과 링크)과정을 거치고 난 후에야 프로그램을 실행 할 수 있습니다. 만일 이미 빌드 된 프로그램의 로직을 변경 한다거나 하려면 수정을 하고, 다시 빌드 하는 과정을 거쳐야 하지요. 어찌 보면 별것 아닐 수 있지만 '빌드'라는 과정을 거친다는 것은 일단 런타임에 어떤 변화를 적용 할 수는 없다는 뜻이지요. 물론, 디자인 패턴 같을 적용하여 어느 정도 유연성(flexiblity)를 제공 할 수는 있지만, 그 역시 새로운 기능이나 로직을 추가 하려면 다시 빌드를 해야만 합니다. 그리고 작성하기도 까다롭지요. 메모리 누수나, 잘못된 메모리 참조, 변수 타입에 대한 신경도 써야 하고 이것 외에도 신경 써야 할 것들이 많이 있습니다.

스크립트 언어는 위의 불만 사항들을 해결 하는데 분명히 많은 도움이 될 것입니다. 일단 빌드라는 과정이 없지요. 코드를 작성하고, 실행만 하면 그만 입니다. 자료형에도 신경 쓸 필요 없습니다. 물론 메모리 할당과 해제는 스크립트 언어의 엔진이 다 알아서 해줍니다. 스크립트 언어의 가장 큰 장점 중에 하나는 코드의 작성이 빠르다는 것이지요. 하지만 성능이 C나 C++처럼 빠르지 못하다는 단점이 있습니다.

만일 여러분이 어떤 프로그램을 만든다고 가정 합시다. 게임이 좋겠군요. 여러분이 만드는 게임은 당연히 성능이 중요시 되므로 C++로 만들고 있을 겁니다. 그런데 그 중에 로직이 자주 변경 되면서, 그렇게 성능이 요구되지 않는 부분이 있습니다. NPC의 AI부분 정도로 하면 되겠군요. 아니면 여러분은 상당히 서비스 디펜던트한 서버를 만드는 프로그래머라고 합시다. 이 서버는 매주 사용자의 요구를 처리하는 로직이 변경 됩니다. 메시지를 받는 부분이나, 클라이언트와의 세션을 처리 하는 프레임워크가 되는 부분들은 C++로 만들면 성능도 좋고 안정적이고, 메모리도 적게 소모 하고 좋겠지요. 하지만 매주 변경하는 로직부분을 파이썬으로 만든다고 하면 어떨까요?

이런 부분에 스크립트 언어를 끼워 넣을 수 있다면 정말 환상적이지 않겠습니까? 파이썬에서는 임베딩이라고 하여 이런 기능을 제공하고 있습니다. 간단하게 말하자면 C++ 코드에서 파이썬 스크립트를 호출하고 실행 할 수 있는 기능이라고 정의 할 수 있겠습니다.

이 포스트에서는 파이썬을 어떻게 C/C++코드에서 호출 할 수 있으며, 멀티 쓰레드 프로그램에서 thread-safe하게 파이썬 스크립트를 실행 하는 방법에 대해서 다루도록 하겠습니다.

Overview of the Python C/C++ API

파이썬 API[각주:1]는 C와 C++에 대한 구분이 없습니다. 모든 파이썬 API들은 extern "C"를 이용해 선언되어져 있고, C++을 위한 어떠한 특별한 자료구조 같은 것도 제공 하지 않습니다. 그래서 C에서 임베딩을 하던, C++에서 임베딩을 하던 파이썬 API는 같은 형태를 유지 할 수가 있지요.

파이썬에서 C/C++에 제공하는 API는 두 가지 종류가 있습니다. 한 가지는 임베딩(embedding)을 위한것, 다른 한 가지는 확장(extending)을 위한 것이지요. 임베딩이라는 것은 C/C++ 코드에서 파이썬 스크립트를 읽고 실행 할 수 있도록 해주는 것이고요, 그와는 반대로 확장이라는 것은 파이썬 스크립트에서 C/C++의 라이브러리를 사용 할 수 있도록 해주는 것이지요. 확장에 관한 내용은 나중에 다른 포스트에서 좀 더 자세하게 알아 보도록 하고 이 포스트에서는 임베딩에 집중하도록 하겠습니다.

일반적으로 임베딩을 사용하면 C/C++ 어플리케이션은 파이썬 스크립트를 로드하고 실행 합니다. 이 때 어플리케이션은 파이썬 인터프리터 라이브러리와 링킹(linking) 되지요. 이 때 사용되는 라이브러리가 pythonXX.lib 파일 혹은 libpythonXX.a 파일인데요. 여기서 XX라는 것은 파이썬의 버젼을 나타냅니다. 만일 파이썬 2.4 버젼이라면 python24.lib 혹은 libpython24.a가 되겠지요.

Embedded Python

아래의 코드는 파이썬 인터프리터를 임베딩 하여 간단한 메시지를 출력 하는 코드 입니다.

#include <stdio.h>
#include <Python.h>
int main(int argc, char * argv[])
{
    // initialize the interpreter
    Py_Initialize();
    // evaluate some code
    PyRun_SimpleString("import sys\n");
    //ignore line wrap on following line
    PyRun_SimpleString("sys.stdout.write('Hello from an embedded Python Script\\n')\n");
    // shut down the interpreter
    Py_Finalize();
    return 0;
}

위의 예제에서 가장 먼저 나오는 Py_Initialize 함수는 파이썬 인터프리터 라이브러리를 초기화 하는 역할을 합니다. 파이썬 API를 사용하기 전에 반드시 이 함수가 가장 먼저 불려 져야 합니다. PyRun_SimpleString 은 간단한 파이썬 코드를 실행합니다. 예를 들어서 import sys 라인은 sys라는 파이썬 모듈을 로드 합니다. 각 PyRun_SimpleString에는 반드시 완벽한 파이썬 문장을 넘겨 줘야 합니다. Py_Finalize 함수는 파이썬 API 호출중 가장 마지막에 불려져야 합니다. 이 함수는 인터프리터를 종료하고, 그간 할당 되어 있던 자원들을 회수 하는 역할을 합니다. 이 함수는 반드시 모든 파이썬 인터프리터의 사용이 끝났다고 확신하는 시점에서 호출되어져야 합니다.

Python, C and Threads

C에서는 쉽게 쓰레드를 생성하고 사용합니다. 자세한 쓰레드의 생성과 사용에 대해서는 이 포스팅의 주제에 벗어 나므로 굳이 설명 하지 않도록 하겠습니다만 인터넷에서 조금만 검색하시면 쉽게 찾으실 수 있을 겁니다. 암튼 멀티 쓰레드를 지원하기 위해서 파이썬에서는 Global Interpreter Lock 이라는 뮤텍스를 사용하는데요, 모든쓰레드 내에서 파이썬 API를 이용하기 전에 이 Global Interpreter Lock을 획득해야 합니다. 그렇지 않으면 레이스 컨디션 상태에서 인터프리터가 정상적으로 동작 하지 않습니다. Lock을 잡고 풀어 주기 위해서 파이썬에서는  PyEval_AcquireLock 과 PyEval_ReleaseLock 두 함수를 제공 하고 있습니다.

그리고 파이썬은 각각 쓰레드의 상태를 유지하기 위해 각 쓰레드의 정보를 따로 저장 합니다. 각 쓰레드에 의존적인 정보들은 PyThreadState 객체에 저장 되는데요, C/C++쓰레드에서 파이썬 API를 호출 할 때 반드시 각 쓰레드 마다 PyThreadState 객체를 생성해서 가지고 있어야 합니다.

만일 여러분이 멀티 쓰레드 프로그래밍에 경험이 있으신 분이시라면 위에서 언급한 Global Interpreter Lock을 모든 파이썬 API를 호출 하기 전에 잡아 줘야 한다는 것에 상당한 거부감을 느끼실지 모르겠습니다. 모든 함수의 호출이 시리얼라이즈 된다는 것은 상당한 멀티 쓰레드의 장점을 버리는 것이고, CPU가 아무 것도 하지 않고 놀 수 있는 기회를 많이 만들게 된다는 의미니까요. 하지만 실질적으로 파이썬 스크립트를 실행하는 중에 인터프리터는 주기적으로 CPU를 다른 쓰레드에게 양보 해줍니다. 듣기로는 스크립트를 일정 바이트 수행하고 나면 다른 쓰레드에게 제어권을 넘긴다고 하더군요. 이것이 레이스 컨디션을 발생 시키지 않을까 우려를 했지만 실제 실행하는데 있어서 별다른 오류 상황을 만들지는 않았습니다. 아마도 내부적으로 어떤 메카니즘이 자동적으로 lock을 풀면서도 레이스 컨디션을 일으키지 않도록 하고 있다고 생각 됩니다만, 이에 대해서는 저도 정확히 잘 모르겠습니다.

Enabling Thread Support

멀티 쓰레드 어플리케이션에서 파이썬 API를 사용하기 전에 반드시 호출 되어야 하는 초기화 루틴들이 있습니다. 이 초기화 루틴들을 사용하기 위해서 파이썬은 컴파일 될 때 쓰레드를 지원한다는 옵션을 켜고 컴파일 되어야 합니다(그리고 보통은 기본으로 이렇게 되어 있습니다.). 일단 파이썬 인터프리터가 쓰레들를 지원하는 형태로 설치되어 있다면 스크립트가 멀티 쓰레드를 지원 할 지, 그렇게 하지 않을지 런타임에 결정할 수 있는 선택을 할 수 잇습니다.. 쓰레드를 지원하지 않는다고 선언하고(아무것도 하지 않고) 스크립트를 실행하면 lock을 잡는데 드는 오버헤드를 피할 수 있습니다. 만일 멀티 쓰레드 어플리케이션을 지원 한다는 옵션을 켜고자 한다면, 개인적으로 이 작업을 메인 쓰레드에서 할 것을 권장 합니다. 특히 어플리케이션이 시작하는 시점 말입니다.

// initialize Python
Py_Initialize();
// initialize thread support
PyEval_InitThreads();

 위의 예제는 두 가지 함수를 보여 주고 있습니다. 하나는 이전에 언급한 Py_Initialize 입니다. 이 함수는 인터프리터 라이브러리가 사용 할 전역 자원들을 할당 해 줍니다. 그리고 PyEval_InitThreads 함수는 런타임 쓰레드 지원 옵션을 켜줍니다. 이 함수는 파이썬의 내부 lock 메커니즘을 구동하지요. 이 함수에 주목할 점은 내부적으로 Global Interpreter Lock을 잡아버린다는 것입니다. 그래서 이 함수를 호출 하고 난 후에는 반드시 PyEval_ReleaseLock을 이용해 lock을 풀어 줘야 합니다. 하지만 lock을 풀기 전에 한 가지 더해야 할 일이 있습니다. 현재(메인) 쓰레드의 PyThreadState 객체를 얻어 오는 것이지요. 이 객체는 나중에 새로운 파이썬 쓰레드를 만들고 인터프리터를 정상적으로 종료 시키기 위해서 꼭 필요한 중요한 것입니다.

PyThreadState * mainThreadState = NULL;
// save a pointer to the main PyThreadState object
mainThreadState = PyThreadState_Get();
// release the lock
PyEval_ReleaseLock();

Creating a New Thread of Execution

각 쓰레드에서 파이썬 코드를 실행하기 위해서는 PyThreadState 객체가 필요 하다고 이미 말씀 드렸습니다. 인터프리터는 각 쓰레드 마다 인터프리팅에 필요한 데이터를 저장하고 이 PyThreadState객체를 이용하여 관리합니다. 이렇게 각 쓰레드의 정보들이 따로 관리 됨으로써 한 쓰레드의 액션이 다른 쓰레드까지 전파 되는 것이 방지 됩니다. 예를 들어서 한 쓰레드의 파이썬 코드에서 예외가 발생 한다고 하더라도, 다른 쓰레드의 파이썬 코드는 자신의 실행을 아무런 상관 없이 계속 할 수 있습니다.

PyThreadState는 각 쓰레드 내에서 직접 만들어 줘야 하는데, 이 때 모든 쓰레드에 관계된 데이터를 저장하고 있는 PyInterpreterState 라는 객체가 필요 합니다. 이 PyInterpreterState 객체는 파이썬 이 초기화 될 때 만들어 지며 메인쓰레드의 PyThreadState겍체에 저장 됩니다. 아래의 예제를 보도록 하겠습니다.

// 파이썬 API를 사용하기 전에는 항상 lock을 잡아야 합니다.
PyEval_AcquireLock();
// 메인쓰레드의 PyThreadState 객체에서 인터프리터의 레퍼런스를 얻어 옵니다.
PyInterpreterState * mainInterpreterState = mainThreadState->interp;
// 인터프리터 객체를 이용해 새로운 PyThreadState객체를 만들어 냅니다.
PyThreadState * myThreadState = PyThreadState_New(mainInterpreterState);
// 파이썬 API의 사용이 끝났으므로 lock을 해제 합니다.
PyEval_ReleaseLock();

Executing Python Code

지금 까지 PyThreadState 객체를 만들므로써 여러분의 C/C++ 어플리케이션은 파이썬 코드를 실행 할 수 있는 준비가 되었습니다. 이제 파이썬 스크립트를 실행하기 위해서 몇 가지 지켜야 할 규칙이 있습니다. 첫째, 파이썬 스크립트를 실행하기 위한 어떠한 작업이라도 그 전에 반드시 Global Interpreter Lock을 잡아야만 합니다. 둘째, 파이썬 스크립트를 실행하기 이전에 반드시 새로운 쓰레드에서 얻어온 PyThreadState 객체를 인터프리터에 로드 해야  합니다. 그리고 작업이 끝나고 나면 PyThreadState 객체를 인터프리터에서 언로드 하고, lock를 풀어 주는 것을 잊어서는 안 되겠지요. 잊지 마세요 "lock -> swap -> execute -> swap -> unlock"  순서 입니다.

// Global Interpreter Lock을 잡습니다
PyEval_AcquireLock();
// 현재 쓰레드의 PyThreadState 객체를 인터프리터에 로드 합니다.
PyThreadState_Swap(myThreadState);
// 파이썬 코드를 실행 합니다.
PyEval_SimpleString("import sys\n");
PyEval_SimpleString("sys.stdout.write('Hello from a C thread!\n')\n");
// NULL을 넣어 줌으로써 인터프리터에 로드 되는 PyThreadState를 언로드 시킵니다.2
PyThreadState_Swap(NULL);
// lock를 해제 합니다.
PyEval_ReleaseLock();

Cleaning Up a Thread

쓰레드 내의 모든 파이썬 스크립트의 사용이 끝나고 더 이상 쓰레드의 필요성이 없어지면, 지금 까지 할당 했던 자원들을 해제 해 주어야 합니다. 그 처음 과정으로 PyThreadState 객체를 지워 줍니다.

// Global Interpreter Lock을 잡습니다
PyEval_AcquireLock();
// PyThreadState 객체를 인터프리터에서 언로드 합니다.
PyThreadState_Swap(NULL);
// 다 쓴 PyThreadState 객체를 클리어 합니다.
PyThreadState_Clear(myThreadState);
// 그리고 지워버립니다.
PyThreadState_Delete(myThreadState);
// Lock를 해제 합니다.
PyEval_ReleaseLock();

위의 코드를 호출 하고 난뒤에 pthread_exit 함수나 TerminateThread 함수등으로 안전하게 쓰레드를 없앨 수 있습니다(하지만 가장 좋은 방법은 쓰레드 루틴에서 리턴하는 것입니다.).

Shutting Down the Interpreter

여러분의 어플리케이션이 더 이상 파이썬 인터프리터를 필요로 하지 않는다면, 이제 인터프리터를 종료 해야 겠지요. 사실 필요 없는 자원을 계속 유지 하고 있을 필요는 없잖아요? 아래의 코드는 파이썬 인터프리터를 해제 하는 과정을 보여 주고 있습니다.
// shut down the interpreter
PyEval_AcquireLock();
Py_Finalize();

Global Interpreter Lock를 해제 해주는 부분이 없다는 것이 이상하게 보일지도 모르겠습니다. 하지만 조금만 생각해 보면 lock을 해제 해야 할 필요성이 전혀 없고, 또 할 수도 없음을 알수 있을 겁니다. 파이썬 인터프리터는 이미 종료 되어버렸기 때문에 더 이상의 파이썬 API는 사용 할 수 없는 것입니다.

Conclusion

파이썬을 C/C++에 임베딩하여 쓴다는 것은 딱딱한 C/C++ 코드에 상당한 유연성을 제공 해 줌에 틀림이 없습니다. 스크립트 언어이기 때문에 성능면에서 약간의 믿음직스럽지 못한 면이 있긴하지만, 성능이 크리티컬하게 요구되지 않고, 로직의 변경이 잦은 부분이라면 충분히 적용해 볼 가치가 있다고 생각 됩니다. 이 포스트에서는 PyRun_SimpleString의 예제만 들어 복잡한 파이썬 스크립트 실행에 다소 적용이 어려워 보일지도 모르겠습니다만, 다음 포스팅에는 파이썬 스크립트 파일 자체를 로드하여 사용 하는 방법에 대해 다루도록 하겠습니다.

임베딩에 관련된 더 많고 정확한 정보를 얻고 싶으신 분들은 http://www.python.org/docs/api/ 의 Python C API를 살펴보시면 많은 도움이 될겁니다.

부록 1. 같이 읽으면 좋은 글

부록 2. 참고

  1. 파이선에서 C/C++에서 임베딩 할 수 있도록 제공하는 API들을 편의상 파이썬 API라고 부르겠습니다 [본문으로]
  2. 어떤 코드에서는 아래의 파라메터로 메인 쓰레드의 PyThreadState 객체를 넘겨 주기도 합니다. 정확하게 언급되어 있는 자료를 찾지는 못 했지만, 인터넷에서 찾아 볼 수 있는 다양한 임베딩 관련 코드를 살펴 보았을 때, NULL을 넘겨 준다는 것은 메인 쓰레드의 PyThreadState 객체를 다시 로드 한다는 의미 인것 같습니다. 메인 쓰레드 내에서 PyThreadState_Get 함수를 호출 한 것과 같은 의미라고 생각 하시면 될 것 같습니다(Enabling Thread Support 항목 참고). [본문으로]
유익한 글이었다면 공감(❤) 버튼 꾹!! 추가 문의 사항은 댓글로!!