きっかけ
- せっかく家の光回線の契約を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://qiita.com/TokyoMickey/items/cb51805a19dcee416151)
複数コネクションを確立するときはくれぐれも相手のサーバーに負荷をかけすぎないように
本題の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コネクションを使い続けるのはあまり良い判断とは言えないので、各コネクションを評価して性能の悪いコネクションは破棄し、新たなコネクションに置き換え、再度リクエストを送りなおす。
大雑把な流れ
- HTTP HEAD リクエストを送信してファイルサイズを確認(ここは既存のHTTPライブラリを使用)
- 総分割数や分割サイズなどを決定し、コネクションを確立
- 初期リクエストを送る
- 前述のselectorsでソケットを監視し、読み込み可能になったソケットから順次読み出してソケットごとに1次バッファに入れる
- 1次バッファの中身ががHTTPレスポンスとして処理できる長さになったらヘッダーとボディに分割
- ヘッダーからそのレスポンスがファイルのどの部分に当たるかを特定し、1次バッファから2次バッファへ移動
- 2次バッファの中で順番がそろったものからファイルに書き込んだ後、2次バッファから削除
- 各コネクションの評価値を更新し、性能が低いと判定されたコネクションは破棄し新たに確立したコネクションにリクエストを送りなおす
- ファイル全体がそろうまで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使用率が高いのは仕方ないのか...