「ファイルを読むという行為ってどういうことなんだ?」 大規模データをどうファイルに保存するべきかなんてことを調べていると、 そんな疑問が頭の中に湧いては消えていきます。
ファイルはOSが扱うデータの塊の1単位というぐらいの認識でしたが、 ある程度大きいファイルを途中や後ろから読んだりとか、 そういうことができるのかについては曖昧な印象しかありませんでした。
少し調べてみると、ファイルを読むというOSの基本的な機能として、 ファイルの末尾にseek位置を移動させるという機能があることがわかりました。 Pythonからでも簡単に利用できるようでしたので、 先頭からデータを読み捨てて行った場合と、 シークを使って後ろから読んだ場合の速度についてごく簡単な比較をしてみました。
検証環境
macOS Monterey 12.5.1
MacBook Air(M1, 2020)
メモリ 16GB
補助記憶装置(いわゆるディスク)はHDDではなくSSDです。一応。1
準備
1GBのファイルを作る
mkfile
コマンドで1GBのファイルを用意します。
このコマンドは指定容量分だけ\x00
で埋めたファイルを作成します。
その後dateの出力結果をそのファイルの末尾に追記します。
$ mkfile 1g large_file
$ date >> large_file
dateの出力は28バイトでしたので、1GB+28バイトのファイルが出来上がりました。
なお、ここでいう1GBは正確には1ギビバイトという量で、(1000*1000*1000
ではなく)1024*1024*1024
バイトです。
このファイルの中身の末尾の部分をバイナリエディタで見てみます。
$ xxd large_file | tail -3
3ffffff0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
40000000: e6b0 b420 2034 2031 3220 3039 3a30 313a ... 4 12 09:01:
40000010: 3239 204a 5354 2032 3032 330a 29 JST 2023.
途中まで\x00
で埋められ、最後に時刻情報が入っていることが確認できます。
これでファイルの準備は完了です。
確認してみる
準備したファイルをPythonで読んで処理速度を確認してみます。
Pythonファイル
import os
import time
def timer(prev=None):
# 前のタイミングからの経過時刻をprint
if not prev:
return time.time()
t = time.time()
print(t - prev)
return t
def read_from_tail():
# seekしてファイル末尾から28バイトをprint
with open("large_file", "rb") as f:
f.seek(-28, os.SEEK_END)
b = f.read(28)
print(f"tail: {b.hex()}")
def read_from_head():
# 先頭からreadで読み捨てて、最後の28バイトをprint
with open("large_file", "rb") as f:
# 1MBずつread(読み捨て)を1024回行う
for i in range(1024):
f.read(1024 * 1024)
b = f.read(28)
print(f"head: {b.hex()}")
prev = timer()
read_from_tail()
prev = timer(prev)
read_from_head()
prev = timer(prev)
read_from_tail()
prev = timer(prev)
read_from_head()
prev = timer(prev)
いくつか解説します。
with open("large_file", "rb") as f:
- バイナリモード(
b
)でファイルを開きます。
- バイナリモード(
f.seek(-28, os.SEEK_END)
seek
は、ファイルの中の読み出し位置を移動させます- 第一引数: 移動させるoffset値
- 第二引数: 移動させる起点
os.SEEK_END
は、そのファイルの後ろの末尾を指す- この時は第一引数はそこから戻るのでマイナスの値を入れる
os.SEEK_END
はバイナリモードでオープンした時しか使えない- テキストモードだと怒られる
io.UnsupportedOperation: can't do nonzero end-relative seeks
- テキストモードだと怒られる
結果
$ python measure.py
tail: e6b0b42020342031322030393a30313a3239204a535420323032330a
0.00022101402282714844
head: e6b0b42020342031322030393a30313a3239204a535420323032330a
0.0731058120727539
tail: e6b0b42020342031322030393a30313a3239204a535420323032330a
0.0003230571746826172
head: e6b0b42020342031322030393a30313a3239204a535420323032330a
0.06885409355163574
まずtail
とhead
の行を比べると、全く同じ出力になっていますので、
両者で同じ部分が読めていることがわかります。
次に所要時間を見てみると、
- tail(シークを使って後ろから読んだ場合)
- 1回目: 0.221ms
- 2回目: 0.323ms
- head(先頭からデータを読み捨てていった場合)
- 1回目: 73.1ms
- 2回目: 68.9ms
となっており、300倍程度の速度差が見受けられました。 何度か繰り返してみましたが、常にこのくらいの差はあり、 headについては、悪い時は1秒近くかかった時もありました。
なお、tailとheadを入れ替えたり繰り返し回数を増やしたりもしましたが、 何かしらのキャッシュが聞いて2回目以降すごく早くなったりなどはありませんでした。
まとめ
シークという概念についてなんとなくしか意識していなかったのですが、 実際にコードを書いて時間を計測してみることで、 ファイルの末尾だけを読む時の基本的な考え方が理解できました。
SEEK_END
を使うとファイルの末尾の場所に一気に飛べるということが大きいですね。
ちなみにファイルサイズの情報はinodeという
ファイルのメタ情報(ファイルの中身の情報ではなく、そのファイル自体の情報)に書かれていますので、
「実際にファイルを読んでみないと容量がわからない!」ということではなく、
「このファイルを読むぜ。容量は1GB+28バイトだな。」とわかっているわけです。
以上、誰かのお役に立てば嬉しいです。
参照情報
- ファイルの最後の方に出現する行を取得する - Tech random memoranda
- C言語 fseek 使い方 | C言語関数一覧~bituse~
- オペレーティングシステム
- lseek - システムコールの説明 - Linux コマンド集 一覧表
- HDD積んでるMacBook Air想像したらちょっと面白かった。 ↩