Pythonのrequestsを非同期にしてiTunes APIに高速にリクエストを投げるには

32 min read読了の目安(約29200字

最初に

なんでか分からないけど、PythonのasyncはJavaScriptのasyncと比較しても使いづらい、そのため、threadに逃げてしまう。今回はその謎を探すために、PythonでITunes APIに非同期でリクエストを投げることを目標にasyncを勉強して行こうと思う。これは個人的なイメージだが、threadは実際にスレッドを2つ立て同時に動作させているが、asyncはたぶんスレッドは1つでも処理を中断して切り替える事(なんかJavsScriptのイベントループ)をやって非同期処理を実現してるからこっちのほうがリソースの消費が少ない気がする。

その辺を少しまとめてみた。

threading(マルチスレッド)

スレッドはプロセスの中にある処理単位で、同じプロセス内にあるスレッドはメモリを共有できる。

multiprocessing(マルチプロセス)

cpuの各コアに対してプロセスをあてる事ができる2コアなら2プロセスあてると効率的に処理を行える。

asyncio(ノンブロッキング)

一つのスレッドで複数の処理を切り替えながら行う。思った通りだった。

だからこんな感じの上下関係になる。

マルチプロセス > スレッド > ノンブロッキング

だからマルチスレッドでそのスレッド一つ一つにノンブロッキングで処理を行わせればかなり効率いい気がする。

今回はasyncio(ノンブロッキング)をPythonを用いてリクエストを非同期処理で送信する機能を実装するのが目標でそれを行うのに必要そうな用語等を周辺知識をまとめた。

学習コストの高い分野なので、個人的になんども挫折しそうになった。

使いこなせれば、普段のプログラムをさらに高速で動作させられるのでその価値はあると思う。

Futureについて

import asyncio
import time

#計算を処理する部分
def f(future):
    time.sleep(5) # 時間のかかる処理
    future.set_result("hello")
    return

future = asyncio.futures.Future()
#計算を実行する部分
f(future)

if future.done():
    res = future.result()
    print(res)

これだけではfutureの利点が生かされず、いまいち何がしたいのかわかりません。ただ future.done() を使用してfutureオブジェクトに処理結果があればそれを出力する、この後に出てくるイベントループでは "hello" がセットされたら処理を終えてイベントループを終了するような使い方をする。詳しくはイベントループの項目に書いてあります。

イベントループ

これは登録された関数を実行する機能を持ちます。JavaScriptみたいですね。もしかするとasyncはPythonの処理を一時的にJavaScriptの非同期処理みたいにしてくれるのかもしれません。

import asyncio
import time

def f(future):
    time.sleep(5) # 時間のかかる処理
    future.set_result("hello")
    return

loop = asyncio.get_event_loop()
future = asyncio.futures.Future()
loop.call_soon(f, future)
loop.run_forever()

上のコードでは、asyncio.get_event_loopを呼び出してBaseEventLoopオブジェクトを取得しています。そして、call_soonによって関数floopに登録しています。最後にloop.run_forever()でイベントループを実行しています。

これを実際実行すると、run_forever()で無限ループになっており永遠にプログラムが終了しません。代わりに、以下のように書くことで、関数f()の実行が終わった後に自動的にイベントループを停止することができます。

res = loop.run_until_complete(future)
print(res)

run_until_complete()はどのようにして関数f()の完了を知ることができるのでしょうか。これには、futureのコールバックという仕組みが用いられています。run_until_complete()ではまずfuture.add_done_callback()という関数を実行し、futureにコールバックを設定しています。その後run_forever()が呼ばれ関数f()が実行されます。その後関数f()内でfuture.set_result()によって値が設定されると、add_done_callback()によって設定されたコールバックが呼ばれます。run_until_complete()が設定するコールバック内では、loop.stop()によってイベントループの終了を予約する処理を行っているため、f()の実行終了後にイベントループが停止します。

注意点としては、future.set_result()が実行されて即座に関数f()の実行が終了するわけではないことです。あくまで終了が予約されるだけで、実際はreturnまで実行が継続されます。

イベントループを用いた複数の処理

イベントループは複数の処理を実行した際に効果を実感できます。

import asyncio
import time

def f(future, tag):
    for _ in range(3):
        time.sleep(1)
        print("waiting for f(%d)" % tag)
    future.set_result("hello %d" % tag)
    return

#イベントループを作成
loop = asyncio.get_event_loop()
futures = []
for tag in range(3):
		#futureオブジェクトを作成
    future = loop.create_future()
		#loop(イベントループ)に関数とfutureと関数の引数を登録する。
    loop.call_soon(f, future, tag)
		# futureの状態はfuturesに格納されて管理する。
    futures += [future]
#イベントループを実際に実行する。ここでgatherでfuturesを配列から*で展開している。
res = loop.run_until_complete(asyncio.gather(*futures))
print(res)

#実行結果
waiting for f(0)
waiting for f(0)
waiting for f(0)
waiting for f(1)
waiting for f(1)
waiting for f(1)
waiting for f(2)
waiting for f(2)
waiting for f(2)
['hello 0', 'hello 1', 'hello 2']

このコードでは3つの処理を登録しています。

先ほどは res = loop.run_until_complete(future) としていましたが、futureの数が増えて配列で渡す場合には asyncio.gather(*futures) が必要みたいです。いまいちどのように機能しているのかは分からない。ただ * で配列を展開しているので asyncio.gather(future, future, future...) と引数で渡しているのは分かる。

実行結果から

この結果からわかるようにf(0),f(1),f(2)は並列で実行されているわけではないことに注意してください。ライブラリのソースコードを見れば分かるように、loop.run_until_complete()内ではloop._readyに登録されたコールバックを順次実行しているだけなのです。

なのでまだ実際に非同期処理が行われていない事がわかります。

ジェネレータ

__iter__yield を使用するとイベントループを非同期で実行できるのだが、なぜ非同期と関係ないイテレータを使用する話になるのかというと __iter__ はイベントループに登録されるジェネレータオブジェクトに含まれるメソッドで、それと yield を使う事で処理を途中で中断して別の処理に移ったりするといった繊細な動作が出来るようになるのでここで説明に入ります。

詳しくはこの先で説明されています。

def generator():
    yield 1
    yield 2
    yield 3
    return "END"

gg = generator().__iter__()
print(gg.__next__())
print(gg.__next__())
print(gg.__next__())
try:
    print(gg.__next__())
except StopIteration as e:
    print(e.value)

ジェネレータは「イテレータを返す関数」つまりリスト等を生成する関数なのか?

違うイテレータは似ているがリストではない。イテレータの場合、配列から値が取り出された事を覚えている。

実行するとジェネレータオブジェクトが返される。ジェネレータオブジェクトはイテレータを表す関数 __iter__ を実装している。

たぶんPythonのメソッドである。 iter() と関係がある気がする。

iter()について深く見ていく

リストやタプルなどの複数の要素を持つデータに対して、イテレータに変換する事で要素を最初から順番に取り出す事ができる機能。

イテレータとイテラブル

そうなのかリスト自体がイテレータかと思っていたがどうやら違うようだ...てっきりforループで使える物イテレータつまりそれにはリストが含まれると考えていたが、それはイテレータではなくイテラブルだった。

ややこしい

リスト:順番が決められたデータの集団を表すデータ構造

イテレータ:データ集団に対し順番にアクセスしてくためのオブジェクト

たぶんリストを拡張(変換?)したのがイテレータな気がする。

イテレータ = iter(配列)
要素 = next(イテレータ)

このような使い方をするので、まぁ変換の方が正しいのかもしれない。拡張したところでリストと機能変わんないし。

イテレータに変換する事で、リストに値を取り出したら覚えている機能が追加される。

いまいち使い所は分からないが、一度取り出した値を再び取り出すミス等を防げる。

for 変数 in 配列 を使えば順番に要素を取り出せるので iter() をわざわざ使うことはないだろう。個人的に思ってしまう。

その for initer() を使って実装されている。

l = [0, 1, 2, 3]  # これはリスト
l_iter = iter(l)  # リストからlist_iteratorを取り出す
next(l_iter)
0
next(l_iter)
1
next(l_iter)
2
next(l_iter)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

このようにリストイテレータを順次 next(...) することで1つ1つリストの要素を取り出しています。 すべての要素が取り出されると、イテレーターは StopIteration を送出します。

???

なぜこのようになっているかというと、簡単に言えばforループの繰り返し処理を制御するためです。 リストをループするときに、ループがどこまで進んでいるかを管理するためにリストイテレーターが使われています。

もしリスト自身がイテレーターであれば、1度ループしただけでリストはループできなくなってしまいます(ループの進行状況が管理されるので)。 そのために、リスト自身ではなくリストイテレーターさん(つまりイテレーター)にループの状況を管理してもらっています。Pythonってすごい!

ここの表現が全く理解出来ない。スタックオーバーフローに助けを求めた。

イテレータはリストと違い値を排出した事を覚えているので、一度プログラムから__next__を用いて取り出された場合その値は再び取り出す事は出来ないため、一度ループで全て取り出した後、再びループで回してもイテレータの場合値を取り出すことが出来ないという事だ。正直それがすごいかどうか分からない...紛らわしい表現だ。

イテレーターは同時にイテラブルでもあります。 なので、イテレーターは for in ... に渡せます。この場合、イテレーターは iter(my_iterator) をすると my_iterator 自身を返します。

for in に似た機能をforなしで実装してみる。

また、for文は以下のようにも模式的に書けます。 この my_for 関数は、第一引数にイテラブルを、第二引数にforブロックの処理に相当する関数を渡します。

def my_for(iterable, iter_func):
     it = iter(iterable)
     try:
         while True:
             el = next(it)
             iter_func(el)
     except StopIteration:
         pass

def print_pow(el):
     print(el ** 2)

my_for([0, 1, 2, 3], print_pow)

#実行結果
0
1
4
9

イテレータの種類

  • イテレーターではない(イテラブル)
    • 文字列
    • リスト
    • タプル
    • rangeオブジェクト
    • 辞書、辞書.keys()、辞書.values()、辞書.items()
    • 集合
  • イテレーター(イテラブル)
    • リストとかタプルとかrangeとかを iter したら返ってくるやつ
    • ジェネレーター
    • ファイルとかのI/O

iter()

どうやらiter()とは少し使い方をみると違いがありそう。 __ で囲まれたメソッドを特殊メソッドと呼ばれる物で iter() の内部では __iter__ が呼ばれているみたい。違いはなかった...iter() = __iter__の認識で良さそう。

特殊メソッドとは

ここで少し特殊メソッドについて、学習していく。非同期処理と関係ないが毎回出てくる __iter__

気持ち悪かったので慣れるためにも深く調べてみた。特に気にならない人は __iter__の続き から読んで頂けたら幸いです。

Pythonのクラスでは「特殊メソッド」と呼ばれるメソッドを定義(オーバライド=上書き)できる。

特殊メソッドとは、各種の演算子や組み込み関数( iter()print() 等)などの操作の対象として、独自のクラスを利用できるようにするための仕組みだと考えられる。

クラスを自分で定義している時に、適切な名前の特殊メソッドを適切にオーバライドする事で、次のような処理が可能になる。

class Test():
	def __init__():
		#ここに色々処理を書き込めば初期化時に処理を追加出来る(オーバライド)
  • インスタンスの生成と初期化
  • インスタンス同士の比較
  • 他の型への変換
  • 数値として演算
  • 反復可能オブジェクト(コンテナ)的な動作
  • 関数的な動作

特殊メソッドの名前は、特定の処理を示す名前を、2つのアンダースコア「」で囲んだものになる。例えば、「init」は「インスタンスの初期化」を意味する「init」を「」で囲んだものになる。

インスタンスの生成と破壊に関係する特殊メソッド

__new__(cls, ...) :クラスのインスタンス生成時に呼び出される。

__init__(cls, ...) :クラスのインスタンス生成後に、それを初期化するために呼び出される。

__del__(cls, ...) :クラスのインスタンス破壊される時に呼び出される。

__new__メソッドは、クラスのインスタンス生成をカスタマイズする際に定義する。暗黙の第1パラメーターには「self」ではなく「cls」を指定する。インスタンスの生成自体は、親クラスの__new__メソッドを呼び出して、cls(とその他の引数)を指定する、つまり「 super().__new__(cls, .....) 」とするのが典型的なやり方、加えて、__new__メソッドでしか行えないインスタンスの初期化も行える。

__new__メソッドを定義した場合そこで作成されたインスタンスは__init__メソッドへ引き渡される。

__del__メソッドは、インスタンスが破壊されるタイミングで自動的に呼び出される。

特殊なリソースを破壊する必要がある際はこれを定義する必要がある。注意点はこのメソッドが呼び出されるタイミングだ。

「delインスタンス」を実行したタイミングで呼び出されるわけではない。

インスタンスが破壊されるのは、それに結び付けられている名前がなくなった時点なので注意が必要????

基底クラスで__del__メソッドが定義されているのであれば、それらを呼び出して、オブジェクトの破壊が確実に行われるようにする必要がある。

以下に例を示す

class Foo:
    def __new__(cls):
        print('__new__')
        self = super().__new__(cls)  # インスタンス生成を行う典型的なコード
        self.attr = 'set in __new__'  # ここでしかできない初期化処理を書いてもよい
        return self  # 生成したインスタンスを返す

    def __init__(self, name='foo'):
        print('__init__')
        self.name = name  # インスタンスの初期化処理

    def __del__(self):
        #super().__del__()  # 基底クラスに__del__メソッドがあれば必ず呼び出す
        print('__del__')  # インスタンスが破壊されるときに行う処理

foo = Foo()  # '__new__'と'__init__'が表示される
print('foo.attr:', foo.attr)  # 'foo.attr: set in __new__'
bar = foo
print('bar.name:', bar.name)  # 'bar.name: foo'
print('del foo')  # この時点ではまだ生成したインスタンスには別名がある
del foo
print('del bar')
del bar  # '__del__':この時点でインスタンスを束縛する名前がなくなる

この例では、__new__メソッドで「 super().__new__(cls) 」によりインスタンスを作成した後、「attr」という名前の属性(インスタンス変数)を定義して、それを return self で戻り値として返している。__init__メソッドでは、これを受け取り、それに対して「name」という名前の属性を設定している。これにより、インスタンスは2つの属性を持つことになる。

だから普段クラスを作成する時に__init__の引数にselfと書くのか!!!

class Test():
	def __init__(self):
		pass

この時に書かれてないけど

class Test():
	def __new__(cls):
		self = super().__new__(cls)
		return self

	def __init__(self):
		pass

こうなってるのか。

なぜこのように内部的に__new__と__init__分けているのかというと、__init__メソッドはインスタンスの初期化に使うが、「変更不可能なオブジェクトを初期化できない」事がある。tupleクラス等のPythonに元々組み込まれたクラスのオブジェクトを初期化することは __init__では難しい。

基本的に__init__より前に実行されるオブジェクトの初期化は出来ない。そのため__new__メソッドを用いてオブジェクトが生成される前に初期化を行う。

そもそもそんな場面そうそう出くわすことないしイメージが全然湧かない。

上記の文も自分で書いておきながらいまいちイメージが難しい。

クラスオブジェクトが生成される際に実行される特殊メソッドの順番

  1. new
  2. init

となっているので、initより手前で生成されたオブジェクトは初期化出来ないよね。という事を言いたいだけだと思う。

個人的にはinitで出来るようにすればいいじゃんと思ってしまう。

下記に__new__メソッドでtupleクラスのオブジェクトを初期化するコードを示した。

※クラス tupleはpythonに組み込まれているクラス class tuple([iterable])

Built-in Types - Python 3.9.1 documentation

class NumberedTuple(tuple):
    def __new__(cls, iterable):
				#引数が['foo','bar', 'baz']の場合
        tmp = [(idx, value) for idx, value in enumerate(iterable)]#こんな感じになる:[(0, 'foo'), (1, 'bar'), (2, 'baz')]
        self = super().__new__(cls, tmp)
        return self

nt = NumberedTuple(['foo','bar', 'baz'])
print(nt)  # ((0, 'foo'), (1, 'bar'), (2, 'baz'))

tupleクラスを継承して、反復可能なオブジェクトを受け取り、「(インデックス、要素)」で構成されるタプルを要素とするタプルを生成するクラス

通常のtupleクラスが配列 ['foo','bar', 'baz'] からタプル ('foo', 'bar', 'baz') を生成するのを

配列 ['foo','bar', 'baz'] から2次元のタプル ((0, 'foo'), (1, 'bar'), (2, 'baz')) を生成するクラスを作成している。

細かく見ていくと、enumerate関数はインデックスと要素を取り出しており返り値が2つになる。0, リスト[0] みたいな関係になっている。

それで作成したイテラブル(for inで回せる)なオブジェクトを親クラス(tupleクラス)の__new__メソッドに渡している。これにより、番号付きのタプルを生成できるようになる。このような変更不可能な型を基に派生クラスを定義するような際には、__new__メソッドでインスタンス生成と初期化の処理を独自に行う必要があるだろう。

ただこんな面倒な事しなくとも tuple([(0, 'foo'), (1, 'bar'), (2, 'baz')]) でいいような気もするけどよく分からない。

__iter__の続き

特殊メソッドはこのようにして使用する。そろそろ __iter__に戻ろうと思う。

今まで出てきた用語の特徴をみると

反復可能オブジェクト:要素を一度に1つずつ返せるオブジェクト。

反復可能オブジェクトは、それが内包するイテレータを返す__iter__メソッドを持つ。

イテレータ:リストと似てfor inで回せる(反復可能オブジェクト)しかし、イテレータでは値の取り出しを覚えているので、一度取り出した値を再び取り出す事は出来ない。そして自身を戻り値とする__iter__メソッド、次の要素を返す__next__メソッドを持つ。

イテラブル:反復可能なオブジェクト、リスト、イテレータ等が含まれる。

大体こんな感じの意味合いになる。

リストやタプル、辞書、集合は典型的な反復可能オブジェクトだ。これらは内部にイテレータを持ち、for文などで要素を反復的に処理する際には、python内部で自動的にそれが使われているようになっている。

なお、反復可能オブジェクトの条件は__iter__メソッドを持つ事。そのためイテレータも反復可能なオブジェクトに入る。

ここではrangeクラスのオブジェクトを参考に上記の用語を実際に見ていこうと思う。

r = range(3)
print(type(r))

#実行結果
<class 'range'>

次にrangeクラスに__iter__が含まれているか確認する。 dir() を使用する事でオブジェクトに含まれるメソッド・プロパティを確認する事が出来る。

r = range(3)
print(dir(r))

#実行結果
['__bool__', '__class__', '__contains__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getitem__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__iter__', '__le__', '__len__', '__lt__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__reversed__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'count', 'index', 'start', 'step', 'stop']

rangeクラスには__iter__は含まれるが、__next__メソッドはないことがわかる。

よってrange反復可能オブジェクトだが、イテレータではない事がわかる。

そしてイテレータを取り出すには、__iter__メソッドを呼び出すか、組み込みのiter関数に反復可能オブジェクトを渡す。iter関数に反復可能オブジェクトを渡すと、そこから__iter__メソッドが呼び出されてイテレータが返されるようになっている。そのため、下の2行は同じことになる。

r = range(3)

range_iter = r.__iter__()  # イテレータを取り出す
range_iter2 = iter(r)  # イテレータを取り出す

print(type(range_iter))
print(type(range_iter2))

#実行結果
<class 'range_iterator'>
<class 'range_iterator'>

ここでrangeクラスからイテレータのrange_iteratorを取り出す事で__next__メソッドが使えるようになる。

なんかややこしいね。rangeクラスはイテレータではなく__next__メソッドが使用出来ないが、その中に含まれるイテレータ range_iteratorを取り出すことでその中には__next__メソッドが含まれており、使用出来る。

range(__next__なし) > range_iterator(__next__あり)

勝手にイテレータの方が反復可能オブジェクトより便利な機能が追加される(値の取り出しを記憶する)から上位概念かと思ってたけど、そうではなさそう。

#さらに
print(range_iter.__next__())  # range_iterから次の値を取得
print(range_iter.__next__())  # range_iterから次の値を取得
print(next(range_iter2))  # range_iter2から次の値を取得
print(next(range_iter2))  # range_iter2から次の値を取得

#実行結果
0
1
0
1

#さらにrange_iterの値を全て取り出す
print(next(range_iter))  # range(3)で作成したので、要素はここで尽きる
print(next(range_iter))  # これ以降は例外が発生する

#実行結果
2
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

一つのrangeオブジェクトから複数のイテレータを作成して独自に値の取り出し具合を管理する事が出来る。こんな機能いつ使うのか知らんが...

__next__メソッドは全ての値を取り出しさらに値を取り出そうとするとStopIteration例外を発生させる。

次に実際にカウントアップするイテレータを作成してみる。特に意味はないが、__iter__や__next__等の特殊メソッドを自作クラスでオーバーライドして自分が追加したい機能をどのように使用するか、そして大体__iter__と__next__メソッドの中身はこんな感じで実装されているんだという事を確認出来る。

class CountUpIterator:
    def __init__(self, limit=5):
        self.limit = limit
        self.counter = -1
    def __iter__(self):
        print('__iter__ method called')
        return self
    def __next__(self):
        print('__next__ method called')
        self.counter += 1
        if self.counter >= self.limit:
            raise StopIteration()
        return self.counter

__iter__自体は呼び出されたら print() で分かるようにしたのと 自身を返すだけなのでそこまで難しくない。

__next__は取り出しの状態管理する self.counterself.conter の値が self.limit 以上になった際に例外 StopIteration が発生する。

上記のコードを実行してみる。

countup_iter = CountUpIterator(3)
countup_iter2 = iter(countup_iter)
print(countup_iter is countup_iter2)

#実行結果
__iter__method called
True

iter(countup_iter) で 特殊メソッド __iter__が呼び出されてcontup_iter自身が戻り値で返ってくる。

そのため counter_iterとcounter_iter2は同じになる。

次は__next__メソッドを呼び出してみる。

print(next(countup_iter))
print(countup_iter2.__next__())
print(next(countup_iter2))
print(countup_iter.__next__())

#実行結果
__next__method called
0
__next__method called
1
__next__method called
2
__next__method called
StopItaration

self.count-1 から始める事でカウントを0から行える。これで独自の__iter__と__next__メソッドを記述出来た。

ここまで来たら大体イテレータの意味が理解出来るようになったので、最初のジェネレータで出て来た文章に戻ろうと思う。

def generator():
    yield 1
    yield 2
    yield 3
    return "END"

gg = generator().__iter__()#generator関数からイテレータを生成する。
print(gg.__next__())
print(gg.__next__())
print(gg.__next__())

try:
    print(gg.__next__())
except StopIteration as e:
    print(e.value)

#実行結果
1
2
3
END

実行するとジェネレータオブジェクトが返される。ジェネレータオブジェクトはイテレータを表す関数 iter を実装している。

このたった2行を理解するのにとんでもない時間がかかったwww

generator関数のオブジェクトが返されてそれには__iter__メソッドが含まれているのでイテレータを取り出す事が出来るという事を言いたかったんだと思う。

yieldとは???

ジェネレータの中身の処理を一時的に止めると言われるがどういう事なのか??

これはreturnと比較する事で分かりやすくなります。

return:動作はシンプルで関数の処理を終了して、値を返す。

yeild:関数の処理をyeildが書かれた所で一旦停止して、値を返す。

比較したプログラムが下記になる。

def test():
	c = 100 + 200
	return c

def test2():
	for i in range(3):
		c = i + 200
		yield c

	c = 10 + 20
	yield c

test2 = test2() #generatorオブジェクト生成
print(dir(test2))#test2のオブジェクトには__next__メソッドがある事を確認する。

print('1回目')
print(test())
print(test2.__next__())#next(test2)でもいい

print('2回目')
print(test())
print(test2.__next__())#next(test2)でもいい

print('3回目')
print(test())
print(test2.__next__())#next(test2)でもいい

#実行結果
1回目
300
300 
2回目
300
30 #yieldの場合1回目と値が異なる。
3回目
300
Traceback (most recent call last):
  File "yieldIter.py", line 26, in <module>
    print(test2.__next__())#next(test2)でもいい
StopIteration

yieldの回数以上に__next__メソッドで呼び出すとStopIterationエラーを返す。

for文内でyieldが使われる場合

def test2():
    for i in range(3):
        c = i + 200
        yield c
        print(f'hello:{i}')

    c = 10 + 20
    yield c

test2 = test2() #generatorオブジェクト生成

print('1回目')
print(test2.__next__())#next(test2)でもいい

print('2回目')
print(test2.__next__())#next(test2)でもいい

print('3回目')
print(test2.__next__())#next(test2)でもいい

print('4回目')
print(test2.__next__())#next(test2)でもいい

#実行結果
1回目
200
2回目
hello:0
201
3回目
hello:1
202
4回目
hello:2
30

ループ文にyieldを入れるとループ回数分実行される。なのでループする回数と__next__での処理の進みがずれてややこしくなる。

イテレータがループした事覚えてるからyieldもそんな感じで実行された事を覚えてると勝手に思い込んでいたがどうや少し違うみたい。

1回目の__next__ではyieldの部分で一度ループを抜け処理を中断して、

2回目の__next__でprint()してまたyieldまで処理を進めてます。

__next__は2回目だけどprintが実行されたループのiの値が1回目の0のままだから本当に作業を中断した状態からの再開ってのがわかった。

yieldで記述するメリット

こうする事で一度に処理するのではなく少しずつ処理を行うことできる。メモリの使用量を節約できる。

記事で書かれていた例

単純な例ですが、たとえば 1GB の巨大なテキストファイルがあるとします。そして、この巨大なファイルを読み込み、データを渡してくれる関数を作るとします。これを普通にやろうとすると、受け渡し用のメモリが 1GB という巨大なサイズになってしまいます。ところが yield を使えば、少量、たとえば 1 行づつデータを読み込み、その都度 yield すればいいので、メモリの使用量はほんの僅かで済んでしまいます。

python の yield。サクッと理解するには return と比較

yield fromの使い方

def generator1():
    yield 'one'
 
def generator2():
    yield 'two'
 
def generator(g1, g2):
    yield from g1
    yield from g2
 
gen = generator(generator1(), generator2())
 
for x in gen:
    print(x)

#実行結果
one 
two

yieldを含む関数を引数に取ることで、 yield from 引数に取った関数 で別の関数で呼ぶ事ができる。

def generator2():
    yield 1
    yield 2
    yield 3
    return "END"

def generator():
    a = yield from generator2()
    return a

gg = generator().__iter__()
print(gg.__next__())
print(gg.__next__())
print(gg.__next__())
try:
    print(gg.__next__())
except StopIteration as e:
    print(e.value)

#実行結果
#実行結果
1
2
3
END

yieldの説明前のコードに戻るけど、 yield from を使用してyieldを含んだ関数を呼んで実行できる。実行結果は同じ。 yield from は他のイテレータを指定する。

yieldを使用して途中でイベントループを切り替える。(非同期処理ではない)

import asyncio
import time

def f(tag):
    for _ in range(3):
        yield
        time.sleep(1)
        print("waiting for f(%d)" % tag)
    return "hello %d" % tag

loop = asyncio.get_event_loop()
tasks = []
for tag in range(3):
    task = f(tag)#最初のyieldのおかげでタスクが3つ出来る。
    tasks += [task]
res = loop.run_until_complete(asyncio.gather(*tasks))#イベントループを処理が全て終了するまで実行
print(res)

#実行結果 1秒毎に結果が出力される。
# yield 出力はされないが最初はyieldまで実行されている
# yield
# yield
waiting for f(0)
waiting for f(1)
waiting for f(2)
waiting for f(0)
waiting for f(1)
waiting for f(2)
waiting for f(0)
waiting for f(1)
waiting for f(2)
['hello 0', 'hello 1', 'hello 2']

上記のコードではまだ非同期処理とは言えずyieldでイベントループをyieldの所で切り替えているだけ実行には9秒ほどかかる。

yieldによってどのような動作になっているのかより詳細に見ていくためにコードを追加した。


import asyncio
import time

def f(tag):
    for _ in range(3):
        print(f'yield:{tag}')
        print(f'_の中身:{_}')
        yield
        time.sleep(1)
        print("waiting for f(%d)" % tag)
    return "hello %d" % tag

loop = asyncio.get_event_loop()
tasks = []
for tag in range(3):
    task = f(tag)
    tasks += [task]
res = loop.run_until_complete(asyncio.gather(*tasks))
print(res)

#実行結果
yield0#ここが最初のyield3行
_の中身:0
yield1#ここが最初のyield3行
_の中身:0
yield2#ここが最初のyield3行
_の中身:0
#waiting for 手前までは一気に出力される。
#手前で1秒待ってから次の処理に移るその際イベントループ自体が止まるので非同期処理にならない。
waiting for f(0) #loop.run_until_completeによってタスクが実行される。

yield0
_の中身:1#ここでイベントループが0→1に切り替わる

waiting for f(1)#切り替わって2回目のforループのyieldまで処理を進める。この動作を繰り返す。
yield1
_の中身:1#ここで再びイベントループが1→2に切り替わる。

waiting for f(2)#f(2)の2回目のforループのyieldまで処理を進める。
yield2
_の中身:1

waiting for f(0)
yield0
_の中身:2

waiting for f(1)
yield1
_の中身:2

waiting for f(2)
yield2
_の中身:2

waiting for f(0)
waiting for f(1)
waiting for f(2)
['hello 0', 'hello 1', 'hello 2']

上記のイベントループ内の処理が複雑で理解出来ないから図にしてみた。処理は矢印の方向に向かって進む。

waiting for 手前までは一気に出力される。
手前で1秒待ってから次の処理に移るその際イベントループ自体が止まるので非同期処理にならない。

上記の処理だと複雑で分かりにく人のためにイベントループ内のタスクを減らして f(0) だけにしてみた。こっちの方がシンプルで流れが見やすいと思う。

import asyncio

def f(tag):
    for _ in range(3):
        print(f'yield:{tag} {_}')
        yield 
    return "hello %d" % tag

loop = asyncio.get_event_loop()
res = loop.run_until_complete(f(0))
print(res)

#実行結果
yield0 0
yield0 1
yield0 2
hello 0

上記の処理をイベントループ自体を止めずに、個別のイベント処理に待機処理を与えて待機させる事にした。

実際に待機中に他のイベントループを処理するには loop.call_later() 関数を使用する。指定秒数だけ待った後に与えられた関数を実行する。

これで例えば1秒後に処理を再開するとセットしておけばイベントループを止める事なく次の処理を行う事ができる。そしてその処理はすぐに別のイベント処理の一秒後に処理を再開するまで進みそれをf(0)~f(2)まで行う。この処理はとても早く行われ1秒が経過する前に終わる。そして1秒後のf(0)の処理が再開されるころにはf(1)、f(2)の待機処理の1秒も経過しておりほぼ同時に waiting for f(0)~f(2) が出力される。

これで3つの処理が同時に進んだ様に錯覚する。実際には1つのスレッドでイベントを切り替えて処理しているだけなので完全な並列ではない。

import asyncio
import time

def my_sleep(delay):
    def _cb_set_result(fut):
        fut.set_result(None)
    loop = asyncio.get_running_loop()#現在実行中のイベントループを返すなければエラーが発生
    future = loop.create_future()#futureオブジェクトを作成する。
    h = loop.call_later(delay, _cb_set_result, future)#delay分待ってから_cd_set_resultを実行する。
    #今回の場合は1秒後に_cd_set_resultが実行される。
		yield from future #futureインスタンスがイテレータなのでyield fromを使用する。

def f(tag):
    for i in range(3):
        yield from my_sleep(1)#なぜこれで1秒待つとまとめて3行出力されるのか分からん。
        print("waiting for f(%d)" % tag)
    return "hello %d" % tag

loop = asyncio.get_event_loop() #イベントループ作成
tasks = [f(n) for n in range(3)] #3つf()のタスクを作成
ret = loop.run_until_complete(asyncio.gather(*tasks))
print(ret)

#実行結果
waiting for f(0)
waiting for f(1)
waiting for f(2)
#1秒後
waiting for f(0)
waiting for f(1)
waiting for f(2)
#1秒後と間隔を開けて更新される。
waiting for f(0)
waiting for f(1)
waiting for f(2)
['hello 0', 'hello 1', 'hello 2']

それぞれの構文を見ていこうと思う。

asyncio.get_event_loop() :イベントループを作成する。カレントスレッドにカレントイベントループがなければ自動的にイベントループを作り,それをカレントイベントループに設定する。一度設定し,再度作っても同じカレントイベントループになる。

その証拠にloopとloop_2は全く同じものになる。

今は関係ないが、個別で別々のイベントループは作れないのだろうか???

import asyncio

loop = asyncio.get_event_loop()
loop_2 = asyncio.get_event_loop()
print(loop is loop_2) 

#実行結果
True

asyncio.new_event_loop() :これを使う事で個別のイベントループを作れそうだ。

import asyncio

loop = asyncio.get_event_loop()
loop_2 = asyncio.new_event_loop()
print(loop is loop_2) 

#実行結果
False

asyncio.get_running_loop() :現在実行中のイベントループを返す。イベントループがなければ例外が発生する。

loop.run_until_complete(future) :引数future(Futureのインスタンス)が完了するまで実行する。引数がコルーチンの場合は,asyncio.Taskとして実行するまで予約される。Futureの戻り値か例外を返す。

Futureインスタンス

結果を代入する箱と思われがちだが、実際にはイテレータそのため、 yield ではなく yield from を使用する。

class Future:
    #....
    def __iter__(self):
        yield self

こんな感じになってる。

正直最初の頃すぎて、忘れてしまっているが、ジェネレータを使わないfutureを引数に取ってイベントループを実行するバージョンでは、 loop.call_soon() を呼び出して関数 f() をイベントループに登録していましたが、ジェネレータでは tasks = [] にイベントを登録して run_until_complete() に渡していました。 run_until_complete() では渡された引数がジェネレータオブジェクト(yieldが含まれた関数)の場合Taskインスタンスを生成して、それを生成時に内部で loop.call_soon() を実行する。

async、awaitキーワードの登場

やっとここで難しいジェネレータを抜け出し先ほどの分かりにくいコードをasync, awaitを使って書き換えていく。

前回はジェネレータオブジェクトをイベントループに登録していたが、今回はそのジェネレータがコルーチンに変わる。

コルーチン:いったん処理を中断した後、続きから処理を再開できる。先ほどやってきたyieldをもつジェネレータオブジェクトと変わりないです。

import asyncio
import time

async def f(tag):
    for i in range(3):
        await asyncio.sleep(1)#ここで処理が中断して1秒後に再開する。
        print("waiting for f(%d)" % tag)
    return "hello %d" % tag

loop = asyncio.get_event_loop()
tasks = [f(n) for n in range(3)]
ret = loop.run_until_complete(asyncio.gather(*tasks))
print(ret)

先ほどよりだいぶシンプルになった。

async を前に付けた関数はコルーチンになる。そしてそれを先ほどのジェネレータのように tasks = [] に格納できる。

iTunesのapiに非同期でリクエスト投げる。

ここからrequestsの処理を非同期にしていきたいと思う。

import time
import json
import asyncio
import requests

async def f(url):
	res = await loop.run_in_executor(None, requests.get, url)
	return res.json()

songName = '愛のままに feat. 唾奇'

urls = [f'https://itunes.apple.com/search?term={songName}&media=music&entity=song&country=jp&lang=ja_jp&limit=10', f'https://itunes.apple.com/search?term={songName}&media=music&entity=song&limit=10']
loop = asyncio.get_event_loop()
tasks = [f(url) for url in urls]
start = time.time()
ret = loop.run_until_complete(asyncio.gather(*tasks))
print(ret)
end = time.time() - start
print(f'実行時間:{end}')

#実行結果
[{'resultCount': 1, 'results': [{'wrapperType': 'track', 'kind': 'song', 'artistId': 370539771, 'collectionId': 1468067871, 'trackId': 1468068067, 'artistName': 'BASI', 'collectionName': '切愛', 'trackName': '愛のままに feat. 唾奇', 'collectionCensoredName': '切愛', 'trackCensoredName': '愛のままに feat. 唾奇', 'artistViewUrl': 'https://music.apple.com/jp/artist/basi/370539771?uo=4', 'collectionViewUrl': 'https://music.apple.com/jp/album/%E6%84%9B%E3%81%AE%E3%81%BE%E3%81%BE%E3%81%AB-feat-%E5%94%BE%E5%A5%87/1468067871?i=1468068067&uo=4', 'trackViewUrl': 'https://music.apple.com/jp/album/%E6%84%9B%E3%81%AE%E3%81%BE%E3%81%BE%E3%81%AB-feat-%E5%94%BE%E5%A5%87/1468067871?i=1468068067&uo=4', 'previewUrl': 'https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview123/v4/1a/7a/28/1a7a288f-5107-f412-9772-81481d3bf921/mzaf_4891237580224207650.plus.aac.p.m4a', 'artworkUrl30': 'https://is2-ssl.mzstatic.com/image/thumb/Music123/v4/0f/93/e2/0f93e249-6b57-6d55-8550-9f8a997ccd4a/source/30x30bb.jpg', 'artworkUrl60': 'https://is2-ssl.mzstatic.com/image/thumb/Music123/v4/0f/93/e2/0f93e249-6b57-6d55-8550-9f8a997ccd4a/source/60x60bb.jpg', 'artworkUrl100': 'https://is2-ssl.mzstatic.com/image/thumb/Music123/v4/0f/93/e2/0f93e249-6b57-6d55-8550-9f8a997ccd4a/source/100x100bb.jpg', 'collectionPrice': 2037.0, 'trackPrice': 255.0, 'releaseDate': '2019-06-26T07:00:00Z', 'collectionExplicitness': 'notExplicit', 'trackExplicitness': 'notExplicit', 'discCount': 1, 'discNumber': 1, 'trackCount': 12, 'trackNumber': 11, 'trackTimeMillis': 266333, 'country': 'JPN', 'currency': 'JPY', 'primaryGenreName': 'ヒップホップ/ラップ', 'isStreamable': True}]}, {'resultCount': 1, 'results': [{'wrapperType': 'track', 'kind': 'song', 'artistId': 370539771, 'collectionId': 1468067871, 'trackId': 1468068067, 'artistName': 'BASI', 'collectionName': 'Setsuai', 'trackName': 'Ainomamani (feat. Tsubaki)', 'collectionCensoredName': 'Setsuai', 'trackCensoredName': 'Ainomamani (feat. Tsubaki)', 'artistViewUrl': 'https://music.apple.com/us/artist/basi/370539771?uo=4', 'collectionViewUrl': 'https://music.apple.com/us/album/ainomamani-feat-tsubaki/1468067871?i=1468068067&uo=4', 'trackViewUrl': 'https://music.apple.com/us/album/ainomamani-feat-tsubaki/1468067871?i=1468068067&uo=4', 'previewUrl': 'https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview123/v4/1a/7a/28/1a7a288f-5107-f412-9772-81481d3bf921/mzaf_4891237580224207650.plus.aac.p.m4a', 'artworkUrl30': 'https://is2-ssl.mzstatic.com/image/thumb/Music123/v4/0f/93/e2/0f93e249-6b57-6d55-8550-9f8a997ccd4a/source/30x30bb.jpg', 'artworkUrl60': 'https://is2-ssl.mzstatic.com/image/thumb/Music123/v4/0f/93/e2/0f93e249-6b57-6d55-8550-9f8a997ccd4a/source/60x60bb.jpg', 'artworkUrl100': 'https://is2-ssl.mzstatic.com/image/thumb/Music123/v4/0f/93/e2/0f93e249-6b57-6d55-8550-9f8a997ccd4a/source/100x100bb.jpg', 'collectionPrice': 12.99, 'trackPrice': 1.29, 'releaseDate': '2019-06-26T07:00:00Z', 'collectionExplicitness': 'notExplicit', 'trackExplicitness': 'notExplicit', 'discCount': 1, 'discNumber': 1, 'trackCount': 12, 'trackNumber': 11, 'trackTimeMillis': 266333, 'country': 'USA', 'currency': 'USD', 'primaryGenreName': 'Hip-Hop/Rap', 'isStreamable': True}]}]
実行時間:0.16942214965820312

試しにリスト内のurlの数を2つ増やして2倍にしてみたが実行時間は 実行時間:0.17414307594299316 と変わらず、さらに2倍の8個にしてみたが実行時間は 実行時間:0.20545411109924316 と変わらず非同期処理になっている事がわかった。

最後に

正直requestsの非同期処理をするためだけに、ジェネレータやイテレータのくだりが必要だったのかと疑問は残るが、イベントループ内に登録された、イベント処理のどこで作業を中断して別のイベントに切り替えて処理を実行していくのかイメージ出来るようになったのでよかったと思う。さらに普段のプログラムも高速に出来て嬉しい限りである。

記事に関するコメント等は

🕊:Twitter
📺:Youtube
📸:Instagram
👨🏻‍💻:Github
😥:Stackoverflow

でも受け付けています。どこかにはいます。

参照

リストとイテレータの違いについて|teratail

Pythonにおけるiter()の利用方法を現役エンジニアが解説【初心者向け】

[Pythonチートシート]特殊メソッド編

Pythonのリストはイテレーターでない。わかりやすい(はずの)イテレーターとイテラブルの説明 - Make組ブログ

Python の dir() 関数

[Python入門]イテレータとは

python の yield。サクッと理解するには return と比較

【Python入門】yield文の基本的な使い方を解説 | 侍エンジニアブログ

【Python】asyncio(非同期I/O)のイベントループをこねくり回す - Qiita

Pythonの非同期処理 ~async, awaitを完全に理解する~ - Qiita

Pythonにおける非同期処理: asyncio逆引きリファレンス - Qiita