먹고 기도하고 코딩하라

[인프런 리프 2기] 4주 - 파이썬 병행성 본문

일상

[인프런 리프 2기] 4주 - 파이썬 병행성

2G Dev 2021. 4. 2. 21:17
728x90
728x90

이전 글 보기

 

[인프런 리프 2기] 3주 - 파이썬 일급 함수

오늘은 인프런 리프 3주차 미션인 섹션 5를 정리해 보겠습니다. 제가 수강하는 강의는 [우리를 위한 프로그래밍], 파이썬 중급 과정 인프런 오리지널 강의입니다. 섹션 5 : 파이썬 일급 함수 시

dev-dain.tistory.com

 

 

오늘은 인프런 리프 4주차 미션인 섹션 6를 정리해 보겠습니다.

 

 

제가 수강하는 강의는 [우리를 위한 프로그래밍], 파이썬 중급 과정 인프런 오리지널 강의입니다.

 

 

 

 

섹션 6 : 파이썬 병행성

저번 주 내용인 클로저와 데코레이터를 겨우겨우 이해할 정도로 이론을 다시 봤는데 보자마자 더 헤비하고 무서운 내용이 나오네요. 병행성과 병렬성 파트입니다. 프로세스와 스레드 개념을 안다면 이해하기 쉽겠지만, 솔직히 알아도 단번에 이해하기는 좀 어려운 내용이라는 생각이 드네요.

섹션 6에서는 다음과 같은 내용을 다룹니다:

(1) 이터레이터 패턴과 제너레이터 패턴의 차이점 살펴보기
(2) 병행성과 병렬성의 차이
(3) 병행성 : 제너레이터 구현
(4) 병행성 : 코루틴 이해와 구현
(5) 병렬성 : 비동기 작업을 위한 위한 futures

이번 포스팅은 (4)까지만 다룹니다.

 

 

 

 

6-1. 이터레이터 패턴과 제너레이터 패턴의 차이점 살펴보기

제너레이터는 이터레이터를 생성하는 함수입니다. 그리고 모든 제너레이터는 이터레이터입니다. 그 반대는 성립하지 않지만요.

이터레이터와 비슷하지만, 제너레이터는 yield 키워드를 사용하고 이터레이터와는 메모리를 적재하는 방식이 다릅니다.

이터러블(iterable) 객체는 반복할 수 있는 객체죠. collections, str, list, dict, set, tuple 등 여러 가지 자료형이 이터러블 가능한 자료형에 해당됩니다.

어떤 객체나 타입이 이터러블한지 알아보려면 dir을 찍었을 때 __iter__가 있는지 확인하면 됩니다.

 

t = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'	# str

w = iter(t) # w에는 __next__가 있음
print(type(w))	# <class 'str_iterator'>
print(w)	# <str_iterator object at 0x0000023A79295EB0>

t에 A부터 Z까지 순차적으로 문자를 담은 문자열을 할당하고, w에 iter(t)를 합니다. 이터러블 가능한 객체를 iter() 하게 되면 이터레이터를 반환합니다. 그래서 w는 str_iterator 타입이 되는 거죠. w를 그대로 쓰려고 해도 실행이 잘 되지 않습니다. 하나씩 next로 받아야 합니다.

 

print(next(w))	# A
print(next(w))	# B
print(next(w))	# C
print(next(w))	# D
# code

이런 식으로 next(iterator)를 하게 되면 토큰 단위로 나뉜 각 요소들이 순서대로 나옵니다. 이터레이터는 다음에 나올 순서를 기억하고 있습니다. 이터레이터가 가장 마지막 요소까지 반환해서 더이상 반복이 불가능하면 StopIteration 에러를 내고 실행을 종료하게 됩니다.

이를 막기 위해 다음과 같은 코드를 작성할 수 있습니다. 다음과 같이 작성하면 except 문에서 에러를 잡아서 프로그램이 강제 종료되지 않고, 안전히 진행할 수 있습니다.

 

while True:
  try:
    print(next(w))
  except StopIteration:
    break

어떤 객체가 이터러블 객체인지 확인하는 방법은 3가지가 있습니다.

1번째는 dir을 확인해서 __iter__가 있는지 보는 방법입니다. 가장 쉽지만, __dir__은 때로 많은 메소드와 변수를 담고 있어서 한눈에 확인하기 어렵다는 단점이 있습니다.

2번째는 hasattr(object, '__iter__')로 확인하는 것입니다. 타입이나 객체를 첫 번째 인자로 주고, 두 번째에 확인하려는 속성인 '__iter__'을 넣어 주는 것입니다. 말 그대로 object에게 __iter__가 있는지 확인하는 것입니다. 이 경우 이터러블 객체라면 True를 반환하게 됩니다.

3번째는 collections의 abc 모듈을 import해서 확인하는 방법입니다. isinstance(object, abc.Iterable)을 합니다. Iterable 클래스를 object가 상속받았는지, 즉 이터러블 객체가 맞는지 확인합니다. 이 역시 이터러블 객체라면 True를 반환하게 됩니다.

 

from collections import abc

print(dir(t))
print(hasattr(t, '__iter__'))
print(isinstance(t, abc.Iterable))

이제 이터레이터 패턴과 제너레이터 패턴을 이용해서 이터레이터처럼 동작하는 객체를 만들어 보겠습니다. 예제에서 클래스를 만들고 있는데, 코드 일부만 발췌해 보겠습니다.

 

class WordSpliter:
  def __init__(self, text):
    self._idx = 0
    self._text = text.split(' ')

  # 클래스 인스턴스지만 이터러블하게 사용 가능
  def __next__(self):
    print('Called __next__')  # 호출됐는지 확인
    try:
      word = self._text[self._idx]
    except IndexError:
      raise StopIteration('Stopped Iteration.')
    self._idx += 1
    return word
    
wi = WordSpliter('Done is better than perfect')
print(hasattr(wi, '__iter__'))	# False

print(next(wi))	# 'Called __next__' 'Done'
print(next(wi))	# 'Called __next__' 'is'

클래스 내에 __next__ 메소드를 구현하고, wi라는 변수를 만들어 WordSpliter 인스턴스를 할당했습니다. 이렇게 된다면, wi는 실제로는 이터러블 객체는 아니지만 마치 이터레이터처럼 사용이 가능합니다. 실제로 __iter__가 있는지 확인해 보면 wi는 이터러블 객체는 아닙니다. 다만, __next__를 구현했기 때문에 next(wi)와 같이, 즉 진짜 이터레이터와 같이 사용할 수 있게 된 거죠.

__next__ 내부에서는 인스턴스를 만들 때 받은 문자열을 split해서 _text라는 리스트로 갖고 있습니다. IndexError가 일어나지 않는 한 word 변수에 해당 리스트의 인덱스 위치의 요소를 반환받아 (예를 들어 첫 번째 next라면 ['Done', 'is', 'better', 'than', 'perfect']이기 때문에 self._text[0] == 'Done') 그것을 return합니다. idx는 1을 증가해 이미 반환한 요소 다음을 가리키도록 하고요. 이터레이터와 정말 비슷하게 동작하는 겁니다.

 

이것을 제너레이터 패턴으로 바꿀 수도 있습니다. 똑같이 클래스를 만들어서 확인해 보겠습니다. 

 

class WordSplitGenerator:
  def __init__(self, text):
    self._text = text.split(' ')

  # 위와 다르게 __next__ 대신 __iter__ 만들기
  def __iter__(self):
    for word in self._text:
      # 다음에 반환될 값의 위치 정보를 기억하고 있음
      yield word  # 제너레이터 yield. return도 필요없음
      
wg = WordSplitGenerator('Done is better than perfect')
wt = iter(wg)
print(wt)	# <generator object WordSplitGenerator.__iter__ at 0x0000019F7FB51D60>

print(next(wt))	# 'Done'
print(next(wt))	# 'is'

어떤가요? 위에서 __next__ 함수를 구현했던 것과 달리 WordSplitGenerator에서는 __iter__ 함수를 구현했습니다. 뿐만 아니라 __next__ 함수와 달리(사실 __next__ 정도도 간결하지만 😅), 매우 간결합니다. for 문을 돌리고 그 안에서 yield를 할 뿐입니다. iter(wg)를 해서 꺼내서 쓴다는 것을 제외하면 next()로 이터레이터처럼 쓴다는 점에서는 WordSpliter와 WordSplitGenerator가 아주 똑같이 동작해서 비교하기가 어렵습니다. 더 자세한 내용은 아래 포스팅을 참고하면 더 좋겠습니다.

[Python 3.7] 파이썬 이터레이터, 제너레이터 개념과 차이점 (iterator, generator, yield, yield from)

[번역] 이터레이터와 제너레이터

 

 

 

 

6-2. 병행성과 병렬성의 차이

병행성과 병렬성은 한국말이 비슷해서 언뜻 잘못 보면 헷갈리기 쉬운 개념입니다. 컴퓨터개론이나 운영체제 과목을 들을 때 나오는 개념인데 한 번 정리해 보겠습니다. 편의상 '컴퓨터'라고 지칭하지만, Single Core와 Multi Core 정도로 보면 되겠습니다.

 

  • 병행성 : Concurrency. 동시성이라고 부르기도 합니다.
    • 한 컴퓨터가 여러 일을 동시에 수행하는 것처럼 보입니다. 단, 실제로는 시분할 시스템으로 여러 가지 일에 사람이 느끼지 못할 정도로 짧은 시간을 분배하면서 돌아가기 때문에 멀티 태스킹이 가능한 것처럼 보이지만, 실제로는 하나의 시간에는 하나의 일만 합니다.
    • Time interupt로 다음 작업으로 넘어갈 때, 작업을 중단했던 마지막 지점을 알고 있습니다.
    • 싱글 코어로 멀티 스레드를 동작시키는 방식입니다.
  • 병렬성 : Parallelism.
    • 여러 컴퓨터가 여러 작업을 동시에 수행합니다.
    • 멀티 코어에서 멀티 스레드를 동작시키는 방법입니다.

 

 

 

6-3. 병행성 : 제너레이터 구현

제너레이터를 구현하는 방법은 여러 가지가 있습니다. 첫 번째로, 함수를 작성하되 yield가 들어간 것은 제너레이터 취급을 받게 되어 있습니다. 간단한 제너레이터 함수를 만들어 보겠습니다.

 

def generator_ex1():
  print('Start')
  yield 'A Point'
  print('Continue')
  yield 'B Point'
  print('End')
  
temp = generator_ex1()
print(temp)	# <generator object generator_ex1 at 0x000002A0115EDAC0>

print(next(temp))	# Start A Point
print(next(temp))	# Continue B Point
print(next(temp))	# End StopIteration Error

temp 변수에 generator_ex1을 실행한 결과를 할당합니다. 그냥 generator_ex1을 할당하면 제너레이터가 아니라 함수를 할당한 것이 되기 때문에 주의해야 합니다. 

next(temp)로 yield되는 부분까지만 실행하고 멈춘 부분을 기억했다가 다시 시작하는 것을 볼 수 있습니다. 하지만 3번째 next를 하면 더 이상 yield할 것도 없고 끝까지 갔기 때문에 StopIteration 에러가 나는데요. 이것을 막으려면 for문 안에 간단히 집어넣으면 됩니다.

 

for v in generator_ex1():
  print(v)

그런데 문득 궁금해집니다. 왜 temp에 제너레이터 함수를 호출한 결과를 할당하는 걸까요? 그냥 이렇게 쓰면 안 되는 걸까요?

 

print(next(generator_ex1()))
print(next(generator_ex1()))

안 됩니다. 실제로 실행해 보면 이에 대한 결과는 'Start A Point', 'Start A Point'인 것을 볼 수 있습니다. 즉, 전에 어디까지 yield했는지 전혀 기억하지 못합니다. 그렇기 때문에 변수에 제너레이터 함수를 호출한 결과인 제너레이터 객체를 할당해주고, 그것으로 next를 해야 yield한 위치를 기억할 수 있습니다.

 

제너레이터를 생성하는 두 번째 방법은 컴프리헨션을 이용하는 것입니다. 컴프리헨션 자체는 어려운 개념은 아니지만 결과를 예상하기가 약간 어려울 수 있습니다. generator_ex1 함수를 재활용해 봅시다.

 

temp1 = [x * 3 for x in generator_ex1()]
temp2 = (x * 3 for x in generator_ex1())

temp1은 평범한 리스트 컴프리헨션입니다. generator_ex1을 실행한 return값(여기서는 yield겠죠)을 3번 곱해 리스트에 넣습니다. yield를 2번(A Point, B Point) 한다는 것을 염두에 두고 실행 결과를 예상해 보세요.

temp2는 언뜻 보면 튜플 컴프리헨션 같기도 합니다. 리스트 컴프리헨션처럼 동작하는데 튜플로 나오나? 하지만 그렇지 않습니다. print를 한 번 찍어 보죠.

 

print('temp1:', temp1)  # yield된 것을 3번 반복한 결과의 리스트 (A Point, B Point)
# temp1: ['A PointA PointA Point', 'B PointB PointB Point']

print('temp2:', temp2)  # 제너레이터 객체가 return됨
# temp2: <generator object <genexpr> at 0x000001DED16ADAC0>

temp1은 예상대로 리스트가 나옵니다. yield된 각 요소가 3번씩 곱해진 것이 하나의 요소로 자리매김하고 있죠. temp2의 경우에는 뜻밖에도 제너레이터 객체가 return됩니다. 이것이 제너레이터 객체를 만드는 2번째 방법입니다. 리스트 컴프리헨션처럼 사용하되, ()로 감싸주는 것입니다.

for문을 찍어서 실제로 뭐가 나오는지 확인해 봅시다. 

 

for i in temp1:
  print(i)
  
# A PointA PointA Point
# B PointB PointB Point

for i in temp2:
  print(i)
  
# Start
# A PointA PointA Point
# Continue
# B PointB PointB Point
# End

사실 temp1이야 결과가 뻔하죠? 리스트이기 때문에 그냥 리스트 요소 그대로 출력됩니다.

하지만 temp2는 제너레이터 객체입니다. 실제로 제너레이터 함수를 실행하는 것처럼 실행됩니다. 단, yield된 x를 3번 곱했기 때문에 매 요소를 3번 곱한 문자열이 결과로 나오게 됩니다.

좀 까다롭네요. -_- 컴프리헨션으로 만드는 제너레이터 예제는 더 쉬운 예제도 있으니 참고하시면 좋겠습니다. 

 

제너레이터를 만드는 3번째 방법은 itertools 라이브러리의 여러 가지 모듈을 사용하는 방법입니다.

일단 itertools 라이브러리를 import 해옵니다.

 

import itertools

무한대로 늘어나는 홀수를 반환하는 제너레이터를 만든다고 상상해 봅시다. 어떻게 만들 수 있을까요?

함수라면 아마 다음과 같이 만들 수 있을 것 같습니다.

 

def odd_num():
  num = 1
  while True:
    yield num
    num += 2
    
odd = odd_num()
print(next(odd))	# 1
print(next(odd))	# 3
print(next(odd))	# 5
print(next(odd))	# 7
print(next(odd))	# 9
print(next(odd))	# 11
print(next(odd))	# 13
# code...

하지만 함수 안 만들고도 더 쉬운 방법이 있습니다. itertools.count(시작수, 증가수)를 사용하는 것입니다. itertools.count는 시작 수부터 증가 수만큼 무한대로 증가하는 제너레이터를 만들기에 안성맞춤입니다. 이미 있는 모듈은 굳이 새로 만들 거 없이 그냥 쓰는 게 좋겠죠?

 

odd_gen = itertools.count(1, 2)

print(next(odd_gen))	# 1
print(next(odd_gen))	# 3
print(next(odd_gen))	# 5
print(next(odd_gen))	# 7
# code

숫자놀이를 하다 보니 누적도 해 보고 싶습니다. 1부터 100까지 더한 값을 볼 뿐만 아니라, 1+2, 1+2+3, 1+2+3+4, ... 이런 식으로 1씩 늘려 더한 값을 계속 반환하는 제너레이터를 만들고 싶습니다. 이럴 때는 어떻게 해야 할까요? 역시 itertools 라이브러리를 사용하면 됩니다. 이번에는 accumulate(list) 모듈을 씁니다.

 

gen = itertools.accumulate([x for x in range(1, 101)])

for v in gen:
  print(v)

이렇게 하면 1, 3, 6, 10, 15, 21, 28, ... 이런 식으로 1부터 숫자를 계속 더해나가면서 100까지 더해 5050까지 나오는 결과를 볼 수 있습니다.

 

그 외에도 itertools 라이브러리에는 쓸만한 모듈이 굉장히 많습니다. 가령 필터를 걸어서 반대되는 것만 찾으려면 filterfalse, 체이닝을 하려면 chain 등... 여러 가지 모듈이 있으니 강의에서 직접 확인해 보고 사용법을 익히시면 좋겠습니다. 물론 문서를 봐도 좋고요!

 

 

 

 

6-4. 병행성 : 코루틴 이해와 구현

지금까지도 내용이 참 많죠? 😅 하지만 코루틴(coroutine)을 꼭 알고 들어가는 게 좋겠습니다.

제너레이터는 yield를 통해 중간에 함수 작업을 중단하고 메인 루틴으로 돌아갈 수 있었죠? 제너레이터에서 파생된 코루틴 역시 yield를 사용해서 메인 루틴과 서브 루틴 간 상호작용이 가능합니다. 이 때 서브 루틴은 메인 루틴에 의해 호출되는 함수, 서브 모듈 등을 의미합니다. 

스레드 프로그래밍의 경우, 싱글 스레드와 멀티 스레드 프로그래밍은 사용하는 자원의 동기화 등이 복잡합니다. 특히 race condition 처럼 자원을 두고 여러 스레드들이 싸우는 교착 상태에 빠지거나 하는 것을 막기 위해 코딩해야 합니다. 또한, context switching, 문맥 교환 비용이 발생하며 자원 소비 가능성도 증가하게 됩니다. 디버깅이 어렵다는 단점도 있죠.

코루틴은 스레드 프로그래밍과 달리 단일 스레드에서 하나의 루틴만 실행하기 때문에 deadlock 등을 걱정할 필요가 없습니다. 코루틴은 싱글 스레드 환경에서 스택을 기반으로 동작하는 비동기 작업입니다. 스레드 프로그래밍에 비해 오버헤드가 적고, 단일 스레드 환경에서도 동시성 프로그래밍을 가능하게 해 줍니다. 언뜻 보면 제너레이터와 비슷하게 보이지만 그 안을 들여다봐야 코루틴인지 알게 됩니다.

여기서 제너레이터는 몇 가지 상태를 가집니다. GEN_CREATED(최초 대기 상태), GEN_RUNNING(실행 상태), GEN_SUSPENDED(yield 대기 상태), GEN_CLOSED(실행 완료 상태)가 바로 그 상태들인데요. inspect 라이브러리의 getgeneratorstate 메소드에 제너레이터 객체를 매개변수로 넣어 현재 상태를 확인해 볼 수 있다는 점 참고하시기 바랍니다. 자세한 사용법은 검색이나 강의를 참고해 보세요.

 

그럼 예시를 한 번 살펴 보겠습니다.

 

# 코루틴 Ex1
def coroutine1():
  print('>>> coroutine started. ')
  i = yield
  print('>>> coroutine received : {}'.format(i))

cr1 = coroutine1()
print(cr1, type(cr1))	# <generator object coroutine1 at 0x00000179E090D9E0> <class 'generator'>

# yield 지점까지 서브루틴 수행
next(cr1)	# >>> coroutine started.

# send에 아무것도 넣지 않으면 기본 전달값 None
# 값 전송
cr1.send(100)	# >>> coroutine received : 100 StopIteration

coroutine1이라는 함수를 하나 만들었습니다. 이 때 yield 오른쪽에는 아무것도 없는데, i = yield라고 되어 있는 부분을 주목하셔야 합니다. yield를 i에 할당한다니 이게 무슨 뜻일까요?

일단 next(cr1)을 해서 yield 전까지는 실행을 했습니다. 현재 yield 오른쪽에 아무것도 없어 실질적으로 yield되는 값은 없습니다. 그런데 next 다음에 제너레이터 객체.send(value)를 해서 값을 보내 주죠. 그리고 send를 하는 순간 coroutine1 함수의 마지막 문장이 실행되는데, 여기서 send로 보내준 100이라는 값이 i로 들어가는 것을 볼 수 있습니다.

즉, i = yield 라는 것은 제너레이터 객체가 여기서 일단 실행을 멈추고, send로 값을 받아서 i에 할당해준다는 뜻입니다.

주의할 점은 제너레이터 객체를 만들자마자 바로 send를 할 수는 없다는 것입니다. 만들고 최소 1회의 next 다음에 send가 가능합니다. 

다른 예시를 살펴 보겠습니다.

 

def coroutine2(x):
  print('>>> coroutine started : {}'.format(x))
  y = yield x # 왼쪽에 있으면 받는 거고 오른쪽에 있으면 주는 거
  print('>>> coroutine received : {}'.format(y))
  # 메인 루틴(함수 밖)에서 서브 루틴(coroutine2)로 넘긴 값은 z
  # 서브 루틴에서 메인 루틴으로 주는 값은 x + y
  z = yield x + y
  print('>>> coroutine received : {}'.format(z))

cr2 = coroutine2(10)

print(next(cr2))	# >>> coroutine started : 10	10
print(cr2.send(100))	# >>> coroutine received : 100	110

이건 아까 것보다는 약간 복잡해서 주석을 추가했습니다. coroutine2는 최초에 매개변수 하나를 받아서 만들게 되어 있으므로 10을 줍니다. next(cr2)를 했을 때, coroutine started 라고 되어 있는 print문이 실행되고, x가 yield되므로 10이 출력됩니다. 이 다음에 cr2.send(100)을 해서 100을 넣어주면, y에 100이 할당되죠. 그러면 coroutine received 라고 되어 있는 실행문이 출력되는데 이 때 y 값은 100입니다. 그러므로 received는 100이지만, yield는 x + y이므로 110을 yield하게 되는 겁니다.

이해가 되시나요? ㅎㅎ

 

코루틴은 중첩 처리도 가능합니다. 예를 들어, 아래 두 함수는 완전히 똑같은 일을 합니다.

def generator1():
  for x in 'AB':
    yield x
  for y in range(1, 4):
    yield y
    
t1 = generator1()
print(next(t1))	# A
print(next(t1))	# B
print(next(t1))	# 1
print(next(t1))	# 2
print(next(t1))	# 3
def generator2():
  yield from 'AB'
  yield from range(1, 4)
  
t3 = generator2()
print(next(t3))	# A
print(next(t3))	# B
print(next(t3))	# 1
print(next(t3))	# 2
print(next(t3))	# 3

만약 yield해야 하는 값이 여러 개, 그리고 이터러블한 객체라면 yield from을 쓰는 것이 더 효율적이고 보기도 좋습니다. 만약 yield from이 아니라 그냥 yield를 했다면 'AB', range(1, 2, 3) 이렇게 2번만 yield할 수 있었겠죠? yield from을 썼을 때는 각 이터러블 객체의 len을 다 더한만큼 next를 할 수 있습니다.

 


 

병렬성은 내용이 따로 많기도 하고, 이미 이 포스팅에 복잡하고 어려운 내용들을 많이 담았으므로 이쯤에서 잠시 끊어 가도록 하겠습니다.

저도 정리하면서 다시 공부하게 되네요. ㅎㅎ 어렵지만 보람 있습니다.

 

나머지 자세한 내용은 강의에서 확인해 보시기 바랍니다. ㅎㅎ

 

 

이 포스팅은 '인프런 리프' 활동의 일환으로, <우리를 위한 프로그래밍 : 파이썬 중급> 강의를 무료로 제공받고 수강한 후기를 담고 있습니다.

 

 

 

 

References

 

 

다음 글 보기

 

[인프런 리프 2기] 4주 - 파이썬 병렬성과 AsyncIO

오늘은 인프런 리프 4주차 미션인 섹션 6, 7을 정리해 보겠습니다. 제가 수강하는 강의는 [우리를 위한 프로그래밍], 파이썬 중급 과정 인프런 오리지널 강의입니다. 섹션 7 : 최종 실습 이 포스

dev-dain.tistory.com

 

728x90
반응형
0 Comments
댓글쓰기 폼