Pythonの動画ダウンローダーyt-dlpを使って、複数の動画をダウンロードする際、処理を並行化して高速化する方法を調べてみた。
例えば次のコードは、複数のURLから動画をダウンロードするが、直列に同期的に処理されるため、ダウンロードに時間がかかる:
import yt_dlp
urls = [
"https://www.youtube.com/watch?v=...",
"https://www.youtube.com/watch?v=...",
"https://www.youtube.com/watch?v=...",
...
]
with yt_dlp.YoutubeDL(get_options(file_path)) as ydl:
for url in urls:
ydl.download([url])
1つ1つの動画をダウンロードする直列処理ではなく、並行処理を行いたい。
Pythonの並行処理
そもそもPythonの並行化についてあまりわかっていなかったので、調べてみた。
- Async Python: The Different Forms of Concurrency, Abu Ashraf Masnun
少し古い2016年の記事だが、とてもわかりやすく、ほとんどの情報が今でも有効だと思われる。一読をおすすめする。
「並行処理」にまつわる言葉の定義として、次のように説明されている:
Sync(同期)と Async(非同期)の違い
Sync(同期)
- ブロッキング操作
- タスクは1つずつ進む
Async(非同期)
- ノンブロッキング操作
- タスクはそれぞれ独立に開始/終了する
Concurrency(並行処理) と Parallelism(並列処理)の違い
Concurrency(並行処理)
- 複数のタスクを同時に進めること
Parallelism(並列処理)
- Concurrencyの一種で
- 複数のタスクを同時に実行すること(同時に開始すること)
あくまで「並列処理」は「並行処理」の一部であり、並行処理の特殊なケースになる。
多くのケース(この記事を含め)では「並列処理」に限定せず「並行処理」を使ってどのように高速化するかを考えることが多いのではないだろうか。
並行処理の選択肢
Pythonではいくつかの方法で並行処理を行うことができる。まとめると次のようになる:
複数スレッドでの並行処理 (threading) | 複数プロセスでの並行処理 (multiprocessing) | メインスレッドでの非同期処理 (asyncio) | |
---|---|---|---|
主なモジュール | threading, concurrent.futures | multiprocessing, concurrent.futures | asyncio |
I/Oバウンドタスク | 向いている | 向かない | 向いている |
CPUバウンドタスク | 向かない | 向いている | 向かない |
GILによる制限 | 受ける | 受けない | 受ける |
メモリー共有 | 共有する | 共有しない | 共有しない |
まずGILとは、Global Interpreter Lockの略で、Pythonのバイトコードを、1つのスレッドでしか実行できないようにする機構のこと。各選択肢の特徴を理解する上で重要な存在だ。
CPUバウンドタスクは、プロセスを使った並行処理が適している。 GILの制限はスレッドに対する制限で、プロセスは制限されない。並行してPythonのバイトコードを実行できる。一方でプロセス間ではメモリー共有ができない(ないし難しい)ため、データのやり取りやメモリ使用量に注意が必要となる。
I/Oバウンドタスクはスレッドを使った並行処理が適している。 I/O処理はGILの制限を受けないため、スレッドを使って並行処理を行うことができ、プロセスよりも軽量に生成できる。一方で、GILの存在により、スレッドはCPUバウンドタスクの並行化には向かない。
*python3.13以降では、実験的にGILを無効化したバージョンが提供されているので、今後並行化の方法も大きく変わっていくかもしれない。
asyncioは、非同期処理のためのモジュールでメインスレッドでイベントループを使って非同期処理を行う。あくまで1つのスレッドでコードを実行するため、GILの制限を受けることになる。そのためスレッドと同様、CPUバウンドタスクには向かないが、I/Oバウンドタスクには向いている。
asyncioモジュールからスレッドやプロセスを生成することもできるので、個人的にはasyncioが統一的なインターフェースとして一番有用なのではないかと感じている。
そのほかの参考資料
上記はかなり要約した情報であって、Pythonの並行処理についての事情は、なかなか複雑になっている。
個人的には以下の資料がわかりやすかったので、参考に記載しておく。
GILについて:
- Understanding the Python GIL, David Beazley
- threading vs multiprocessing in python, Dave’s Space
- スレッドとプロセスの並行化の比較, 動画で解説されているのでわかりやすい
asyncioについて:
- PEP492
- 正式なasyncioの導入に関するPEP
- Combining asyncio and threads in the same application, Marc-Andre Lemburg, PyCon JP 2020
- asyncio中心に解説したPyConJP 2020でのセッション
- Build Your Own Async, David Beazley
- 長いのでまだ全ては見れてないが、asyncioの基本的な仕組みを理解するのに良さそう
yt-dlpを並行化する
さて、yt-dlpをいくつかの方法で並行化し、パフォーマンスを比較してみる。
- sync: 通常の同期処理
- async: asyncioで単にyt-dlpを呼び出す(実質1と同じ)
- thread: 複数スレッドでyt-dlpを呼び出す
- async-thread: asyncioでスレッドを利用してyt-dlpを呼び出す(実質2と同じ)
動画のダウンロード自体がI/Oバウンドタスクであるため、プロセスを使った方法は省略した。
2. async
は、単にasync def
の関数(コルーチン)を使ってyt-dlpを呼び出す:
async def async_download_video(video_id: str, file_path: Path, max_workers=None):
loop = asyncio.get_running_loop()
loop.set_default_executor(ThreadPoolExecutor(max_workers=max_workers))
with yt_dlp.YoutubeDL(get_options(file_path)) as ydl:
ydl.download([video_id])
yt-dlpが非同期処理に対応していないため、無理やりawait
することはできない:
# これはできない
with yt_dlp.YoutubeDL(get_options(file_path)) as ydl:
await ydl.download([video_id])
つまり、ライブラリ側でasync対応がなされていない限り、await
を使うことはできない。
上記コードをasyncioで実行しても、実際には同期処理が行われる。
ただし、asyncio.to_thread
でスレッドを生成し、その結果をawait
することができる。これを利用して、yt-dlpを2
と同様にスレッドで呼び出すことができる。
4. async-thread
は、asyncio.to_thread
を使ってyt-dlpを呼び出す:
async def async_download_video_thread(video_id: str, file_path: Path, max_workers=None):
loop = asyncio.get_running_loop()
loop.set_default_executor(ThreadPoolExecutor(max_workers=max_workers))
with yt_dlp.YoutubeDL(get_options(file_path)) as ydl:
await asyncio.to_thread(ydl.download, [video_id])
これはasyncioを利用してはいるが、実際は3. thread
と同様の複数スレッドでの処理を行っている。
実際のコードはgistに置いている。パフォーマンス比較にはhyperfineを利用した。
パフォーマンス比較
- タスク: 動画を3回ダウンロードする
- worst qualityかつ音声なしで608KB
- スレッド数: 3
- python: 3.13
- 手元のM2 Macbook Air, 自宅のWi-Fi環境で実行
結果を次のグラフに示す:
- 想定通り
1.sync
と2.async
はほぼ同じ結果で、12秒程度 - 想定通り
3.thread
と4.async-thread
はほぼ同じ結果で、6秒程度 3.thread
と4.async-thread
は1.sync
と2.async
に比べて約2倍の速度でダウンロードできた
まとめ
yt-dlpでの動画ダウンロードを並行処理をするには、スレッドを使った並行処理が有効であることがわかった。
asyncioを使う場合は、yt-dlpではまだ非同期処理に対応していないため、asyncio.to_thread
でスレッドを使って並行処理を行うことができる。実質的にはthreading
を利用する方法と変わらない。
ただしasyncioを使うことで統一的なインターフェースを提供することができる。yt-dlp以外の非同期処理と組み合わせたい場合は、asyncioを使うことでコードの統一性を保つことができそうだ。