속도¶
파이썬의 가장 일반적으로 사용되는 구현체인 CPython은 CPU 바운드 작업에서 느립니다. PyPy 는 빠릅니다.
David Beazley의 CPU 바운드 테스트 코드를 약간 수정한 버전(여러 번의 테스트를 위해 루프 추가)을 사용하여, CPython과 PyPy의 처리 차이를 볼 수 있습니다.
# PyPy
$ ./pypy -V
Python 2.7.1 (7773f8fc4223, Nov 18 2011, 18:47:10)
[PyPy 1.7.0 with GCC 4.4.3]
$ ./pypy measure2.py
0.0683999061584
0.0483210086823
0.0388588905334
0.0440690517426
0.0695300102234
# CPython
$ ./python -V
Python 2.7.1
$ ./python measure2.py
1.06774401665
1.45412397385
1.51485204697
1.54693889618
1.60109114647
배경¶
GIL¶
`GIL`_ (Global Interpreter Lock)은 파이썬이 여러 스레드가 동시에 동작하도록 허용하는 방식입니다. 파이썬의 메모리 관리는 완전히 스레드 안전하지 않기 때문에, 여러 스레드가 동시에 같은 파이썬 코드를 실행하는 것을 막기 위해 GIL이 필요합니다.
David Beazley는 GIL이 어떻게 동작하는지에 대한 훌륭한 가이드 를 가지고 있습니다. 그는 또한 파이썬 3.2의 새로운 GIL 도 다룹니다. 그의 결과는 파이썬 응용에서 성능을 극대화하려면 GIL에 대한 강한 이해, 그것이 특정 응용에 어떻게 영향을 미치는지, 코어가 몇 개인지, 그리고 응용의 병목이 어디인지에 대한 강한 이해가 필요함을 보여줍니다.
C 확장¶
GIL¶
C 확장을 작성할 때는 스레드를 인터프리터에 등록하도록 각별한 주의 를 기울여야 합니다.
C 확장¶
Cython¶
Cython 은 파이썬 언어의 상위 집합(superset)을 구현하여, 그것으로 파이썬을 위한 C 및 C++ 모듈을 작성할 수 있게 해줍니다. Cython은 또한 컴파일된 C 라이브러리의 함수를 호출할 수 있게 해줍니다. Cython을 사용하면 파이썬의 변수와 연산에 대한 강타입(strong typing)의 이점을 누릴 수 있습니다.
다음은 Cython으로 강타입을 사용하는 예시입니다:
def primes(int kmax):
"""Calculation of prime numbers with additional
Cython keywords"""
cdef int n, k, i
cdef int p[1000]
result = []
if kmax > 1000:
kmax = 1000
k = 0
n = 2
while k < kmax:
i = 0
while i < k and n % p[i] != 0:
i = i + 1
if i == k:
p[k] = n
k = k + 1
result.append(n)
n = n + 1
return result
소수를 찾는 알고리즘의 이 구현은 순수 파이썬으로 구현된 다음 것과 비교했을 때 몇 가지 추가 키워드를 가지고 있습니다:
def primes(kmax):
"""Calculation of prime numbers in standard Python syntax"""
p = range(1000)
result = []
if kmax > 1000:
kmax = 1000
k = 0
n = 2
while k < kmax:
i = 0
while i < k and n % p[i] != 0:
i = i + 1
if i == k:
p[k] = n
k = k + 1
result.append(n)
n = n + 1
return result
Cython 버전에서는 파이썬 리스트를 만드는 동시에, C 타입으로 컴파일될 정수와 정수 배열을 선언하는 것을 눈여겨보세요:
def primes(int kmax):
"""Calculation of prime numbers with additional
Cython keywords"""
cdef int n, k, i
cdef int p[1000]
result = []
def primes(kmax):
"""Calculation of prime numbers in standard Python syntax"""
p = range(1000)
result = []
차이가 무엇일까요? 위의 Cython 버전에서는 표준 C와 유사한 방식으로 변수 타입과 정수 배열의 선언을 볼 수 있습니다. 예를 들어 3번째 줄의 cdef int n,k,i 같은 것입니다. 이러한 추가 타입 선언(즉, 정수) 덕분에 Cython 컴파일러는 두 번째 버전으로부터 더 효율적인 C 코드를 생성할 수 있습니다. 표준 파이썬 코드가 *.py 파일에 저장되는 반면, Cython 코드는 *.pyx 파일에 저장됩니다.
속도 차이는 어떨까요? 시도해봅시다!
import time
# Activate pyx compiler
import pyximport
pyximport.install()
import primesCy # primes implemented with Cython
import primes # primes implemented with Python
print("Cython:")
t1 = time.time()
print(primesCy.primes(500))
t2 = time.time()
print("Cython time: %s" % (t2 - t1))
print("")
print("Python")
t1 = time.time()
print(primes.primes(500))
t2 = time.time()
print("Python time: %s" % (t2 - t1))
이 두 줄은 모두 설명이 필요합니다:
import pyximport
pyximport.install()
pyximport 모듈은 *.pyx 파일(예: primesCy.pyx)을 Cython으로 컴파일된 버전의 primes 함수와 함께 임포트할 수 있게 해줍니다. pyximport.install() 명령은 파이썬 인터프리터가 Cython 컴파일러를 직접 시작하여 C 코드를 생성하게 하고, 그 C 코드는 자동으로 *.so C 라이브러리로 컴파일됩니다. 그러면 Cython은 여러분의 파이썬 코드에서 이 라이브러리를 쉽고 효율적으로 임포트할 수 있게 해줍니다. time.time() 함수를 사용하면 500개의 소수를 찾기 위한 이 두 가지 다른 호출 사이의 시간을 비교할 수 있습니다. 표준 노트북(듀얼 코어 AMD E-450 1.6 GHz)에서 측정된 값은 다음과 같습니다:
Cython time: 0.0054 seconds
Python time: 0.0566 seconds
그리고 다음은 임베디드 ARM beaglebone 머신의 출력입니다:
Cython time: 0.0196 seconds
Python time: 0.3302 seconds
Pyrex¶
Shedskin?¶
동시성¶
Concurrent.futures¶
concurrent.futures 모듈은 “콜러블을 비동기적으로 실행하기 위한 고수준 인터페이스”를 제공하는 표준 라이브러리의 모듈입니다. 동시성을 위해 여러 스레드나 프로세스를 사용하는 더 복잡한 세부 사항을 많이 추상화하여, 사용자가 당면한 작업을 완수하는 데 집중할 수 있게 해줍니다.
concurrent.futures 모듈은 두 가지 주요 클래스, ThreadPoolExecutor 와 ProcessPoolExecutor 를 노출합니다. ThreadPoolExecutor는 사용자가 작업을 제출할 수 있는 워커 스레드 풀을 만듭니다. 이러한 작업은 다음 워커 스레드를 사용할 수 있게 되면 다른 스레드에서 실행됩니다.
ProcessPoolExecutor도 같은 방식으로 동작하지만, 워커로 여러 스레드 대신 여러 프로세스를 사용한다는 점이 다릅니다. 이로써 GIL을 우회할 수 있습니다. 다만 워커 프로세스로 전달되는 방식 때문에, picklable한 객체만 실행하고 반환할 수 있습니다.
GIL의 동작 방식 때문에, 좋은 경험 법칙은 실행하는 작업이 블로킹을 많이 동반할 때(예: 네트워크 요청)는 ThreadPoolExecutor를 사용하고, 계산이 무거운 작업일 때는 ProcessPoolExecutor를 사용하는 것입니다.
두 Executor를 사용하여 병렬로 작업을 실행하는 주요 방법은 두 가지가 있습니다. 한 가지는 map(func, iterables) 메소드를 사용하는 것입니다. 이는 내장 map() 함수와 거의 동일하게 동작하지만, 모든 것을 병렬로 실행한다는 점이 다릅니다.
from concurrent.futures import ThreadPoolExecutor
import requests
def get_webpage(url):
page = requests.get(url)
return page
pool = ThreadPoolExecutor(max_workers=5)
my_urls = ['http://google.com/']*10 # Create a list of urls
for page in pool.map(get_webpage, my_urls):
# Do something with the result
print(page.text)
더 세밀한 제어를 위해, submit(func, *args, **kwargs) 메소드는 콜러블을 실행하도록 스케줄링하고(func(*args, **kwargs) 형태로), 콜러블의 실행을 나타내는 Future 객체를 반환합니다.
Future 객체는 스케줄링된 콜러블의 진행 상황을 확인하는 데 사용할 수 있는 다양한 메소드를 제공합니다. 다음과 같은 것들이 있습니다:
- cancel()
호출을 취소하려고 시도합니다.
- cancelled()
호출이 성공적으로 취소되었다면 True를 반환합니다.
- running()
호출이 현재 실행 중이고 취소할 수 없다면 True를 반환합니다.
- done()
호출이 성공적으로 취소되었거나 실행이 끝났다면 True를 반환합니다.
- result()
호출이 반환한 값을 반환합니다. 기본적으로 이 호출은 스케줄링된 콜러블이 반환할 때까지 블로킹된다는 점에 유의하세요.
- exception()
호출이 발생시킨 예외를 반환합니다. 예외가 발생하지 않았다면 None을 반환합니다. result() 와 마찬가지로 이 호출도 블로킹된다는 점에 유의하세요.
- add_done_callback(fn)
스케줄링된 콜러블이 반환할 때 (fn(future) 형태로) 실행될 콜백 함수를 부착합니다.
from concurrent.futures import ProcessPoolExecutor, as_completed
def is_prime(n):
if n % 2 == 0:
return n, False
sqrt_n = int(n**0.5)
for i in range(3, sqrt_n + 1, 2):
if n % i == 0:
return n, False
return n, True
PRIMES = [
112272535095293,
112582705942171,
112272535095293,
115280095190773,
115797848077099,
1099726899285419]
futures = []
with ProcessPoolExecutor(max_workers=4) as pool:
# Schedule the ProcessPoolExecutor to check if a number is prime
# and add the returned Future to our list of futures
for p in PRIMES:
fut = pool.submit(is_prime, p)
futures.append(fut)
# As the jobs are completed, print out the results
for number, result in as_completed(futures):
if result:
print("{} is prime".format(number))
else:
print("{} is not prime".format(number))
concurrent.futures 모듈에는 Future를 다루기 위한 두 가지 헬퍼 함수가 들어 있습니다. as_completed(futures) 함수는 future 리스트에 대한 이터레이터를 반환하며, future가 완료되는 대로 yield합니다.
wait(futures) 함수는 단순히 제공된 future 리스트의 모든 future가 완료될 때까지 블로킹됩니다.
concurrent.futures 모듈 사용에 대한 더 많은 정보는 공식 문서를 참고하세요.
threading¶
표준 라이브러리에는 사용자가 여러 스레드를 수동으로 다룰 수 있게 해주는 threading 모듈이 함께 제공됩니다.
다른 스레드에서 함수를 실행하는 것은 콜러블과 그 인자를 Thread 생성자에 전달한 다음 start() 를 호출하기만 하면 될 정도로 간단합니다:
from threading import Thread
import requests
def get_webpage(url):
page = requests.get(url)
return page
some_thread = Thread(get_webpage, 'http://google.com/')
some_thread.start()
스레드가 종료될 때까지 기다리려면, join() 을 호출하세요:
some_thread.join()
join() 을 호출한 다음에는 (join 호출이 타임아웃되었기 때문일 수 있으므로) 스레드가 여전히 살아 있는지 확인하는 것이 항상 좋은 생각입니다:
if some_thread.is_alive():
print("join() must have timed out.")
else:
print("Our thread has terminated.")
여러 스레드가 메모리의 같은 영역에 접근하기 때문에, 두 개 이상의 스레드가 동시에 같은 리소스에 쓰려고 하거나, 출력이 특정 이벤트의 순서나 타이밍에 의존하는 상황이 발생할 수 있습니다. 이를 데이터 레이스 또는 경합 상황(race condition)이라고 합니다. 이런 일이 발생하면 출력이 엉망이 되거나 디버깅하기 어려운 문제에 부딪힐 수 있습니다. 좋은 예시는 이 Stack Overflow 글 입니다.
이를 피하는 방법은 각 스레드가 공유 리소스에 쓰기 전에 획득해야 하는 Lock 을 사용하는 것입니다. 락은 contextmanager 프로토콜(with 구문)을 통해 또는 acquire() 와 release() 를 직접 사용하여 획득하고 해제할 수 있습니다. 다음은 (다소 인위적인) 예시입니다:
from threading import Lock, Thread
file_lock = Lock()
def log(msg):
with file_lock:
open('website_changes.log', 'w') as f:
f.write(changes)
def monitor_website(some_website):
"""
Monitor a website and then if there are any changes,
log them to disk.
"""
while True:
changes = check_for_changes(some_website)
if changes:
log(changes)
websites = ['http://google.com/', ... ]
for website in websites:
t = Thread(monitor_website, website)
t.start()
여기서는 여러 스레드가 사이트 목록의 변경을 확인하고, 변경 사항이 있을 때마다 log(changes) 를 호출하여 그 변경을 파일에 쓰려고 합니다. log() 가 호출되면, with file_lock: 으로 락을 획득할 때까지 기다립니다. 이로써 어느 시점에서든 오직 하나의 스레드만 파일에 쓰도록 보장됩니다.
