인터프리터와 pypy
컴파일러는 소스코드 전체를 재구성하여, 컴퓨터 프로세서가 실행 가능한 기계어로 바로 변환한다. 파이썬의 인터프리터는 고레벨 언어와 기계어의 중간 형태인 바이트 코드로 우선 컴파일 하고, 이를 한 줄씩 기계어로 번역을 한다. 일반적으로 인터프리터는 하나하나 해석을 해야하기 때문에 실행 속도가 느리다. 하지만 코드를 수정하는 즉시 컴파일 없이 실행할 수 있으며, 해석하는 과정에서 에러가 보고되면 이후의 코드를 읽지 않기 때문에 보안에 유리하다. (순수한 인터프리터 언어는 cmd.exe 나 bash 와 같은 쉘이 있다. CPU 가 실행할 수 있는 명령은 CPU 인스트럭션 뿐인데, 만약 고레벨 언어를 한 행씩 해석해서 실행해야한다면 해석 비용이 과도하게 들게 된다. )
pypy 는 JIT(Just-In-Time) 컴파일을 도입하여 실행속도를 향상시켰다. 즉, 프로그램을 실행하기 전에 바이트 코드로 컴파일 하는 대신, 실행 시점에 필요한 부분을 즉석으로 컴파일한다. 실행 중에 생성되고 사용되는 객체 정보를 확인한 후 이때 자주 쓰이는 코드를 캐싱하는 등의 방법으로 인터프리터 언어의 느린 실행속도를 개선한다.
- java: 소스코드를 바이트코드로 컴파일 한 후, Java VM 실행환경에서 돌아간다. VM 만 있다면 모든 플랫폼에서 실행할 수 있다
GIL 과 가비지 컬렉션
파이썬에서는 모든 객체들이 상속하는 PyObject 가 있는데, ob_refcnt 와 ob_type 필드를 갖는 C 언어의 struct 로 구현되어있다. ob_refcnt 는 가비지 컬렉션을 위해 필요하고, ob_type 는 다른 struct 를 가르키는 포인터로 어떤 파이썬 객체 타입인지 설명한다.
각 객체 타입마다 메모리 allocator 과 deallocator 를 가지는데, 메모리는 공유되는 자원이기 때문에, 한 곳에 두 객체가 접근하면 문제가 생긴다. 따라서 GIL 을 통해 인터프리터 전체에 lock 을 걸고, 오직 하나의 스레드만 메모리에 접근하여 객체를 할당하게 한다.객체를 할당하거나, 사용할 때 reference count 가 증가하는데, 사용하지 않아 0이 된 객체는 자동으로 메모리에서 해제하는 가비지 컬렉터를 지원한다.
- 메모리관리: https://realpython.com/python-memory-management/
멀티스레드 vs 멀티프로세스
멀티프로세스는 각 프로세스에 독립적인 메모리가 할당되어 별도로 실행되지만, 멀티스레드는 한 프로세스 내에서 메모리를 공유한다. 즉, 스택 영역을 제외한 나머지 영역을 공유하기 때문에 메모리 공간 소모가 줄고, 문맥 교환(context switching)도 빠르게 이뤄진다.
파이썬에서의 멀티스레딩은 reference count 때문에 복잡해진다. 만약 두 스레드가 동시에 카운트에 접근하는 경쟁 상태(race condition)가 발생한다면, 사용하지 않는 객체에 대한 메모리 누수가 발생하거나(ref count 가 과도 증가) 참조되고 있는 객체가 해제될 수도 있다(ref count 가 과도 감소). 따라서 reference count 는 lock 을 걸어 동시에 접근이 불가능하게 해야한다. 하지만 모든 객체가 lock 을 걸면서 사용해야하는 것은 비효율적이기도 하고, dead lock 이 생겨 자원을 사용하지 못하고 무한히 대기할 수도 있게 된다. 따라서 GIL 정책으로, 스레드가 파이썬 바이트코드를 실행하려면 인터프리터 전체에 lock 을 걸고 사용하게끔한다.
전체에 lock 을 걸어버리면 싱글스레드와 다른 점이 무엇일까? 파이썬의 멀티스레딩은 CPU 처리보다 I/O 를 동시적(concurrent)으로 처리할 때 효과가 좋다. 스레드들이 I/O 를 기다리는 동안 lock 은 점유되지 않기 때문에, 한 스레드가 기다리는 동안 다른 스레드가 lock 을 선점하여 CPU 연산을 진행할 수 있다.
그렇다면 파이썬에서는 멀티프로세스를 사용하는 것이 좋을까? 만약 CPU 처리를 병렬화(parallel)하기 위해서는 multiprocessing 모듈을 사용하여 속도를 높일 수 밖에 없다. 하지만 그만큼의 메모리 자원이 소모되므로 프로젝트에 따라 판단해야할 것이다.
-
힙, 스택: 힙 영역의 변수는 사용자가 직접 할당하고 해제한다. 스택 영역은 함수의 호출과 함께 할당되고 소멸되는 함수의 지역 변수와 매개변수가 저장되는 영역이다.
-
thread safe, 스레드 안전: 어떤 자원(함수, 변수, 객체)에 여러 스레드가 동시에 접근해도 프로그램의 실행에 문제가 없음을 뜻한다. 상호배제(mutual exclusion) 방법을 사용하여 한 스레드가 임계구역(critical section)에 진입해 있다면 다른 스레드의 접근을 막아 스레드의 연산을 직렬화(serialize)한다.
제너레이터
제너레이터는 이터레이터를 생성해주는 함수이다. 제너레이터 함수를 정의하고 호출하여 객체를 생성할 경우, 이 객체는 이터레이터가 되어 제너레이터 함수에 설정된 값을 전달한다. yield 또는 yield from 구문을 사용하여 값을 전달하는데, 제너레이터 함수는 현재 순서를 기억하고 있어야 하기 때문에 바깥에서 호출이 종료되어도 대기 상태로 기다린다. 즉, 바깥 함수에서는 next(generator_obj) 를 호출하고 값을 전달받을 때까지 대기하며, my_generator() 는 yield 를 전달하고 다시 호출을 받을때까지 대기한다. 이는 더 이상 yield 할 객체가 없을 때까지 진행되어 마지막에 StopIteration 예외로 마무리한다.
(i for i in range(50) if i % 2 == 0) 와 같이 괄호로 표현식을 묶으면 제너레이터 객체가 된다. 이는 필요할 때마다 요소를 만들기 때문에 리스트에 비해 메모리를 절약할 수 있다.
def my_generator(new_value): # 파라미터를 넘길 수 있다
# 하나의 값을 발생할 경우
yield "value" + new_value
# 반복가능한 객체(이터레이터, 제너레이터도 가능) 호출할 경우
yield from ["v","a","l","u","e"] + [new_value]
ge = my_generator("hi")
print(next(ge))
for i in my_generator("hi"): # next 호출하여 yield 실행
print(i)
제너레이터 기반 코루틴은 deprecated 되었지만(3.11 부터 없어짐), 코루틴 진행 방식을 이해할 때 도움이 된다. 코루틴은 특정 시점에 상대방의 코드를 실행하는 방식이고, 대기상태에 놓이게 되는 제너레이터의 yield 를 활용하면 만들 수 있다. 제너레이터는 내부에 있던 값을 전달하는 것이지만(파라미터 변경 불가), 코루틴은 호출 시점마다 다른 값을 보내고, 처리된 값을 받아올 수 있다. next 혹은 send 로 코루틴을 호출하면(두 함수는 값을 반환받는지 여부가 다르다) yield 로 값을 받아 할당하고, 다음 행의 함수를 실행하고 다시 yield 에서 대기상태로 기다린다.
def sub_coroutine():
result = 0
while True:
value = (yield result)
if value is None:
print("end sub_coroutine")
return result
result += value
def my_coroutine():
try:
while True:
result = yield from sub_coroutine() # 하위 코루틴까지 send 로 값 전달
print(result) # sub_coroutine 이 끝나고 최종 결과
except GeneratorExit: # 코루틴이 종료될 때 발생하는 예외
print("end my_coroutine")
co = my_coroutine()
# 최초로 코루틴 함수를 실행. yield 를 만나 대기상태에 머물러 있게 한다
next(co) # co.send(None) 과 같음
for i in range(11):
res = co.send(i) # res = co.send(i) 는 None 을 반환한다
print("i", res)
co.send(None) # sub_coroutine 종료. 해당 객체로 새로운 누적을 시작할 수 있다
co.close() # my_coroutine 종료. 파이썬 코드가 끝나면 자동으로 종료된다
- 이터레이터: 값이 필요한 시점이 되었을 때 값을 만든다. next 와 getitem(index) 로 반복 가능한 객체를 접근한다.
코루틴
코루틴은 자신의 실행 상태를 저장해 놓고 프로그램 제어권을 다른 루틴과 공유하면서 실행하는 함수이다. 따라서 I/O 요청을 진행하는 함수를 코루틴으로 제작하면 I/O 가 한 코루틴에서 진행되는 동안 다른 코루틴을 수행할 수 있는 비동기적인 프로그램 제작이 가능하다.
코루틴 A, B 가 있을 때 A 에서는 test.com 의 정보를 가져오고, B 에서는 test2.com 의 정보를 가져온다고 하자. 기존 동기적인 프로그램은 A, B 를 실행하면 A 가 종료될 때까지(test.com 의 정보를 전부 가져올때까지) B 가 실행되지 않는다. 하지만 코루틴의 경우 A 가 호출되어 정보를 가져올 때까지 기다려야한다면, 기다리는 동안 코루틴 B 를 호출해 test2.com 의 정보를 요청할 수 있도록 한다. A 가 정보를 다 가져오는 순간 A 로 돌아와 함수를 진행하고, B 도 마찬가지로 마무리한다(B 가 먼저 끝날 수도 있다). 즉, A B 가 각각 실행될 동안 제어권을 바꿔가면서 다른 작업을 하기 때문에 non-blocking 하다.
스레드도 코루틴과 마찬가지로 동시성을 활요하기 위한 방법이다. 하지만 멀티스레드는 각각 스택 메모리가 필요하고, OS 에서 스케쥴링을 진행하는데 비해, 코루틴은 싱글스레드이기 때문에 객체간의 문맥교환 비용도 적으며, 힙 메모리 공유에 따른 lock 처리를 하지 않아도 된다.
코루틴은 스레드에 설정되어있는 이벤트 루프(무한루프를 돌며 태스크가 전부 종료될때까지 하나씩 실행)를 통해 태스크의 제어권을 교환한다. 코루틴 객체는 호출하더라도 실행이 되지 않기 때문에, 태스크 객체를 만드는 동시에 이벤트 루프에 태스크를 실행할 것을 예약해야한다. 처음에 이벤트 루프가 예약한 태스크들 중 A 를 실행하는데, A 실행중 I/O 를 만나서 대기를 해야하면 A 는 이벤트 루프에게 제어를 넘긴다. 이벤트 루프는 B 를 선택하고, B 실행 중 A 의 대기가 종료되면 A 는 이벤트 루프에 실행을 다시 예약하고 대기한다.
이벤트 루프는 실행할 태스크가 없으면, 소켓을 통해 코루틴의 I/O 대기가 종료되었는지 찾는다(polling). 코루틴은 I/O 를 기다려야할때, 소켓을 등록하고, 해당 소켓에 바인딩된 Future 객체를 생성하여 퓨처->코루틴->태스크 까지 연결되도록 한다. 데이터가 들어오면 Future 객체가 완료 상태가 되고, 이벤트 루프에 콜백함수를 등록한다(Future를 해제하고, 자기 자신을 실행재개하는 함수를 전달).
async def say_after(delay,what):
print(f"typing {what} in {delay}s...")
await asyncio.sleep(delay)
print(what)
async def main():
# 코루틴을 각각 실행하면 5초가 걸린다
# await say_after(3, 'hello')
# await say_after(2, 'world')
# 태스크 객체를 생성하는 동시에 실행 예약을 한다
task1 = asyncio.create_task(say_after(3,'hello'))
task2 = asyncio.create_task(say_after(2,'world'))
# world 출력 1초후 hello 출력. 3초가 걸린다
await task1
await task2
# 모두 완료될 때까지 기다려야 한다면
# await asyncio.gather(task1, task2)
# 최초 코루틴 실행하는 엔트리 포인트
asyncio.run(main())
-
동기 + 논블로킹의 경우: 함수 A 를 호출했는데 return 값이 없어 매번 A 를 호출하면 이는 동기적이면서(함수 A 의 ‘종료’를 기다림) 논블로킹(함수 A 를 매번 ‘호출’함 - 기다리는 동안 다른 일을 함) 인 것이다. polling 참고.
-
참고: https://it-eldorado.tistory.com/159