Skip to content
Go back

Python並行処理: yt-dlpでの動画ダウンロードを高速化する

Published:  at  12:00 AM

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の並行化についてあまりわかっていなかったので、調べてみた。

少し古い2016年の記事だが、とてもわかりやすく、ほとんどの情報が今でも有効だと思われる。一読をおすすめする。

「並行処理」にまつわる言葉の定義として、次のように説明されている:

Sync(同期)と Async(非同期)の違い

Sync(同期)

Async(非同期)

Concurrency(並行処理) と Parallelism(並列処理)の違い

Concurrency(並行処理)

Parallelism(並列処理)

あくまで「並列処理」は「並行処理」の一部であり、並行処理の特殊なケースになる。

多くのケース(この記事を含め)では「並列処理」に限定せず「並行処理」を使ってどのように高速化するかを考えることが多いのではないだろうか。

並行処理の選択肢

Pythonではいくつかの方法で並行処理を行うことができる。まとめると次のようになる:

複数スレッドでの並行処理 (threading)複数プロセスでの並行処理 (multiprocessing)メインスレッドでの非同期処理 (asyncio)
主なモジュールthreading, concurrent.futuresmultiprocessing, concurrent.futuresasyncio
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について:

asyncioについて:

yt-dlpを並行化する

さて、yt-dlpをいくつかの方法で並行化し、パフォーマンスを比較してみる。

  1. sync: 通常の同期処理
  2. async: asyncioで単にyt-dlpを呼び出す(実質1と同じ)
  3. thread: 複数スレッドでyt-dlpを呼び出す
  4. 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を利用した。

パフォーマンス比較

結果を次のグラフに示す:

まとめ

yt-dlpでの動画ダウンロードを並行処理をするには、スレッドを使った並行処理が有効であることがわかった。

asyncioを使う場合は、yt-dlpではまだ非同期処理に対応していないため、asyncio.to_threadでスレッドを使って並行処理を行うことができる。実質的にはthreadingを利用する方法と変わらない。

ただしasyncioを使うことで統一的なインターフェースを提供することができる。yt-dlp以外の非同期処理と組み合わせたい場合は、asyncioを使うことでコードの統一性を保つことができそうだ。



Previous Post
Findyイベントアーカイブ
Next Post
llmをMacBook Air M2上で無料で動かす (mlxとggufを比較)