LoginSignup
15
21

More than 5 years have passed since last update.

Pythonで作るHTTP分割ダウンロードするやつ

Last updated at Posted at 2017-09-15

きっかけ

  • せっかく家の光回線の契約を1Gbpsにしたのにいざ大きなファイル(LinuxのISO)などをHTTPでダウンロードしようとすると速度が出ない

なぜ?

  • 単一のTCPコネクションで出せるスループットには限界値が決まっている
  • Linuxの場合のTCP受信ウィンドウサイズの確認方法(正確にはカーネルのバッファサイズ)
$cat /proc/sys/net/ipv4/tcp_rmem
4096    87380   6291456

左から[min, default, max]

つまり最大スループットは

T_{max} = win / RTT

であるのでデフォルトの状態でRTT = 30msくらいのサーバーとの単一TCPコネクション通信ならば
およそ 87380[byte] * 8 / 30[ms] ≒ 23.3[Mbps]くらいしか出ない
実際は輻輳がなければウィンドウサイズがどんどん大きくなるのでもっと速度が出ます。

もちろん広帯域ネットワークに対応するためTCPにはウィンドウスケールオプションというものがあり、
1Gbyteまでウィンドウサイズを拡張できる(実際に利用されているかは不明)

複数コネクションなら理論的に言えばN本束ねれば帯域はN倍になる

既存のツール

コマンドラインから使えるツールとしてはwgetやcurlが有名
複数コネクション使えるものとしてはaria2などがある
curlやwgetの数倍速い 爆速ダウンローダー aria2を使う - Qiita
複数コネクションを確立するときはくれぐれも相手のサーバーに負荷をかけすぎないように

本題のHTTPクライアント作成

HTTPにはRange Requestというものがある
RFC 7233 — HTTP/1.1: Range Requests (日本語訳)
実装はとりあえず流行りのPythonで

Range Requestについて

  • 例えば1000バイトのファイルをリクエストすると仮定
    • GETリクエストを投げるときにヘッダーに'Range: bytes=0-499'とつけて送信すると、
    • レスポンスヘッダーに'Content-Range: bytes 0-499/1000'とつけてボディにはそのファイルの最初の500バイト分だけ入れて返してくれる
  • ステータスコードは'206 Partial Content'

ただしサーバーがRangeヘッダーを受け付けないようにしている場合もある

この機能を利用して複数のTCPコネクションに同時にファイルの別々の部分をリクエストする

多重化

Pythonにはselectシステムコールを更に高水準に扱うことができるselectorsというモジュールがある(標準ライブラリに!)
18.4. selectors — 高水準の I/O 多重化 — Python 3.6.1 ドキュメント
こいつで複数のソケットを監視して多重化する
こんな感じで使う

# AとBの2つのTCPエコーサーバとの接続をイメージしてください
import selectors
import socket

# 中略
sel = selectors.DefaultSelectors()
sock_A = socket.create_connection(address_A)
sock_B = socket.create_connection(address_B)

sel.resister(sock_A, selectors.EVENT_READ)
sel.resister(sock_B, selectors.EVENT_READ)


sock_B.sendall('Hello'.encode()) # send something to A
sock_B.sendall('Hello'.encode()) # send something to B

while True:
    events = sel.select()
    for key, mask in events:
        message = key.fileobj.recv(512)
        print(message.decode())

ポイント

  • 別々に返ってくるファイルの断片をすべてメモリ上に保持しておくわけにはいかないので順番がそろったところから順次ファイルに書き込む。
  • 性能の悪いTCPコネクションを使い続けるのはあまり良い判断とは言えないので、各コネクションを評価して性能の悪いコネクションは破棄し、新たなコネクションに置き換え、再度リクエストを送りなおす。

大雑把な流れ

  1. HTTP HEAD リクエストを送信してファイルサイズを確認(ここは既存のHTTPライブラリを使用)
  2. 総分割数や分割サイズなどを決定し、コネクションを確立
  3. 初期リクエストを送る
  4. 前述のselectorsでソケットを監視し、読み込み可能になったソケットから順次読み出してソケットごとに1次バッファに入れる
  5. 1次バッファの中身ががHTTPレスポンスとして処理できる長さになったらヘッダーとボディに分割
  6. ヘッダーからそのレスポンスがファイルのどの部分に当たるかを特定し、1次バッファから2次バッファへ移動
  7. 2次バッファの中で順番がそろったものからファイルに書き込んだ後、2次バッファから削除
  8. 各コネクションの評価値を更新し、性能が低いと判定されたコネクションは破棄し新たに確立したコネクションにリクエストを送りなおす
  9. ファイル全体がそろうまで4~8を繰り返す

実装したもの

https://github.com/johejo/rangedl
まだいくつかバグあり

使い方

環境 Python 3.6.1

$ pip install git+http://github.com/johejo/rangedl.git
$ rangedl [URL] -n [NUM_OF_CONNECTION] -s [SPLIT_SIZE_MB]
  • デフォルトだとtqdmでプログレスバーを表示。-pオプションをつけるとプログレスバーを表示しない。
  • 安全のためコネクションの数は10以上にはならない。
  • オプション指定の分割サイズが、'ファイルサイズ / コネクション数' の値よりも小さい場合は強制的に'ファイルサイズ / コネクション数' の値を分割サイズとする。

結果

  • 回線のご機嫌によりますが200Mbpsぐらいでダウンロードができた。
  • split_sizeを1MBにするとメモリの使用量も30~80MB程度になった。CPU使用率が高いのは仕方ないのか...
15
21
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
21