ファイル末尾の情報をSEEK_ENDを使って読んでみる

「ファイルを読むという行為ってどういうことなんだ?」 大規模データをどうファイルに保存するべきかなんてことを調べていると、 そんな疑問が頭の中に湧いては消えていきます。
2023.04.13

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

「ファイルを読むという行為ってどういうことなんだ?」 大規模データをどうファイルに保存するべきかなんてことを調べていると、 そんな疑問が頭の中に湧いては消えていきます。

ファイルは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

まずtailheadの行を比べると、全く同じ出力になっていますので、 両者で同じ部分が読めていることがわかります。

次に所要時間を見てみると、

  • 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バイトだな。」とわかっているわけです。

以上、誰かのお役に立てば嬉しいです。

参照情報


  1. HDD積んでるMacBook Air想像したらちょっと面白かった。