C#でファイルを読み取りする場合は、StreamReaderクラスやFileStreamクラス、Fileクラスを使います。
このクラスにはファイル内のテキストを読み取りするメソッドが用意されています。
| クラス | メソッド | 
| StreamReader | ReadLine()、ReadToEnd() | 
| FileStream | Read()、ReadByte() | 
| File | ReadAllLine()、ReadAllText() | 
これらのメソッドを使って、ファイルの末尾を読み取りする方法はいくつかありますが、使用するメソッド・使い方によって処理速度が異なります。
今回は Benchmark.NET を利用して、ファイルの末尾を読み取りするまでの処理速度を測定して、高速で行える手法を検証してみたいと思います。

どのメソッドを使うか迷っている。。。
高速で最後の行を読み取りしたい方はもちろん、上述したようにファイルのテキストを読み取りする方法は何種類かあるので、どれを使おうか迷っているという方にも参考になる記事になっています。
ぜひ本記事を最後まで読んでみてください。

オススメの参考書
C#の使い方が丁寧に解説しており、「基礎からしっかりと学びたい」という初心者の方にオススメの一冊です。サンプルコードも記載してあり、各章の最後に復習問題があるので理解度を確認しながら読み進めることができます。新しい C# のバージョンにも対応している書籍です。
Benchmark.NETで測定する

今回は Benchmark.NET を利用して、ファイルの末尾を読み取りするまでの処理速度を測定します。
Benchmark.NET について
Benchmark.NET とは、C# や F#、VB など利用できるベンチマークテストを行う為のツールです。
このツールを使うことで、パフォーマンスの測定、比較、分析を簡単に行う事ができます。
BenchmarkDotNetは、メソッドをベンチマークに変換し、そのパフォーマンスを追跡し、再現性のある測定実験を共有するのに役立ちます。
GitHub:BenchmarkDotNet
Benchmark.NET のインストール
Benchmark.NET は、NuGet から簡単にプロジェクトへインストールできます。
手順は次の通りです。
お使いのパソコンにインストールされている Visual Studio 2022 で、プロジェクトを開きます。
統合開発環境である Visual Studio のインストールがまだの方は、次の記事を参考にしてインストールします。

メニューバーから [ツール] -> [NuGet パッケージ マネージャー] -> [ソリューションの NuGet パッケージの管理] の順に選択します。
検索欄に「Benchmark.NET」を入力して、検索結果の一覧から「BenchmarkDotNet」をインストールします。(2023年5月現在、バージョンは0.13.5)

検証で使用するファイルを作成する

今回、検証で使用するファイルは、 1~ 100までの数値をカンマ区切りした行が1万行ある csv ファイルを用意します。
 ファイルの中身(イメージ)
1,2,3,4,5,6,7,8,9,10,11, … ,100
1,2,3,4,5,6,7,8,9,10,11, … ,100
1,2,3,4,5,6,7,8,9,10,11, … ,100
使用するファイルのサイズは、画像にあるように 28,614KB です。


独自メソッドを作成する

C#でファイルの読み取りをする際に使用されるメソッドを使って、ファイルの末尾の行を取得する独自メソッドを何種類か作成して検証を行います。
| 独自メソッド名 | 備考 | 
| Read | FileStream クラス の Read()を使用する。 | 
| ReadLine | StreamReader クラスの ReadLine()を使用する。 | 
| ReadToEnd | StreamReader クラスの ReadToEnd()を使用する。 | 
| ReadLines | File クラスの ReadLines()を使用する。 | 
| ReadLinesLinq | File クラスの ReadLines()を使用する。末尾の行を取得する際に Linq を使用する。 | 
| ReadAllLines | File クラスの ReadAllLines()を使用する。 | 
| ReadAllLinesLinq | File クラスの ReadAllLines()を使用する。末尾の行を取得する際に Linq を使用する。 | 
| ReadAllText | File クラスの ReadAllText()を使用する。 | 
以下に作成した独自メソッドのコードを紹介しています。
Read メソッド
このメソッドは、FileStream クラス のRead()を使用しています。
 ファイルの読み取り開始位置を末尾に移動して、Read()でファイルの末尾からバイトの配列データを取得します。取得した配列のデータを文字列へ変換し、改行コードを区切り文字として分割します。
分割した要素の1つ目が末尾の行になります。
コードを表示(ここをクリックしてください)
[Benchmark]
public void Read()
{
    using (FileStream fs = new FileStream(fileName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
    {
        var bufferSize = 1024;
        var buffer = new byte[bufferSize];
        var lastLine = string.Empty;
        var position = fs.Seek(0, SeekOrigin.End);
        while (position > 0)
        {
            var offset = Math.Min(bufferSize, (int)position);
            fs.Seek(-offset, SeekOrigin.Current);
            var bytesRead = fs.Read(buffer, 0, offset);
            var text = encording.GetString(buffer, 0, bytesRead);
            var lines = text.Split(new[] { Environment.NewLine }, StringSplitOptions.None);
            if (lines.Length > 1)
            {
                lastLine = lines[lines.Length - 1];
                break;
            }
            position -= offset;
            fs.Seek(-offset, SeekOrigin.Current);
        }
        Console.WriteLine($"ファイルの末尾の行: {lastLine}");
    }
}| メソッド名 | 説明 | 
| Seek | このストリームの現在位置を特定の値に設定します。 | 
| Read | ストリームからバイトのブロックを読み取り、そのデータを特定のバッファーに書き込みます。 | 
| 構造体名 | 説明 | 
| SeekOrigin | シークに使用するストリームの場所を指定します。 ・SeekOrigin.Begin → 先頭 ・SeekOrigin.Current → 現在の位置 ・SeekOrigin.End → 末尾 | 
ReadLine メソッド
StreamReader クラスのReadLine()を使用します。
Peek()で読み取り対象の文字列があるかどうかを監視しながら、ReadLine()で1行分の文字列を読み取りします。これをファイルの末尾に達するまで実行します。
最後に読み取りした文字列が末尾の行になります。
コードを表示(ここをクリックしてください)
[Benchmark]
public void ReadLine()
{
    using (StreamReader sr = new(fileName, encording))
    {
        var lastLine = string.Empty;
        while (0 <= sr.Peek())
        {
            lastLine = sr.ReadLine();
        }
        Console.WriteLine($"ファイルの末尾の行: {lastLine}");
    }
}| メソッド名 | 説明 | 
| Peek | 読み取り対象の文字列がある場合は整数を返します。読み取り対象の文字列がない場合は-1を返します。 | 
| ReadLine | 現在のストリームから 1 行分の文字を読み取り、そのデータを文字列として返します。 | 

ReadToEnd メソッド
StreamReader クラスのReadToEnd()を使用します。
ReadToEnd()でファイルの先頭から末尾までの全ての行を読み取りします。改行コードを区切り文字として取得した文字列を分割します。
分割した要素の最後が末尾の行になります。
コードを表示(ここをクリックしてください)
[Benchmark]
public void ReadToEnd()
{
    using (StreamReader sr = new(fileName, encording))
    {
        var lastLine = string.Empty;
        var text = sr.ReadToEnd();
        var lines = text.Split(new[] { Environment.NewLine }, StringSplitOptions.None);
        if (lines.Length > 1)
        {
            lastLine = lines[lines.Length - 1];
        }
        Console.WriteLine($"ファイルの末尾の行: {lastLine}");
    }
}| メソッド名 | 説明 | 
| ReadToEnd | ストリームの現在位置から末尾までのすべての文字を読み込みます。 | 
ReadLines メソッド
File クラスのReadLines()を使用します。
ReadLines()はファイルの全ての行を取得するので、foreachで1行ずつ呼び出します。
最後に取得した行が末尾の行になります。
コードを表示(ここをクリックしてください)
[Benchmark]
public void ReadLines()
{
    var lastLine = string.Empty;
    var lines = File.ReadLines(fileName, encording);
    foreach (var line in lines)
    {
        lastLine = line;
    }
    Console.WriteLine($"ファイルの末尾の行: {lastLine}");
}| メソッド名 | 説明 | 
| ReadLines | ファイルの行を読み取ります。 | 
ReadLinesLinq メソッド
File クラスのReadLines()を使用します。
ReadLines()はファイルの全ての行を取得するので、Linq のLastOrDefault()で最後の行を取得します。
コードを表示(ここをクリックしてください)
[Benchmark]
public void ReadLinesLinq()
{
    var lastLine = File.ReadLines(fileName, encording).LastOrDefault();
    Console.WriteLine($"ファイルの末尾の行: {lastLine}");
}| メソッド名 | 説明 | 
| ReadLines | ファイルの行を読み取ります。 | 
| LastOrDefault | シーケンスの最後の要素を返します。要素が見つからない場合は既定値を返します。 | 
ReadAllLines メソッド
File クラスのReadAllLines()を使用します。
ReadAllLines()はファイルの全ての行を配列で取得します。要素の最後が末尾の行になります。
コードを表示(ここをクリックしてください)
[Benchmark]
public void ReadAllLines()
{
    var lastLine = string.Empty;
    var lines = File.ReadAllLines(fileName, encording);
    if (lines.Length > 1)
    {
        lastLine = lines[lines.Length - 1];
    }
    Console.WriteLine($"ファイルの末尾の行: {lastLine}");
}| メソッド名 | 説明 | 
| ReadAllLines | テキスト ファイルを開き、ファイルのすべての行を文字列配列に読み取った後、ファイルを閉じます。 | 
ReadAllLinesLinq メソッド
File クラスのReadAllLines()を使用します。
ReadAllLines()はファイルの全ての行を取得するので、Linq のLastOrDefault()で最後の行を取得します。
コードを表示(ここをクリックしてください)
[Benchmark]
public void ReadAllLinesLinq()
{
    var lastLine = File.ReadAllLines(fileName, encording).LastOrDefault();
    Console.WriteLine($"ファイルの末尾の行: {lastLine}");
}| メソッド名 | 説明 | 
| ReadLines | テキスト ファイルを開き、ファイルのすべての行を文字列配列に読み取った後、ファイルを閉じます。 | 
| LastOrDefault | シーケンスの最後の要素を返します。要素が見つからない場合は既定値を返します。 | 
ReadAllText メソッド
File クラスのReadAllText()を使用します。
ReadAllText()でファイルの先頭から末尾までの全ての行を読み取りします。改行コードを区切り文字として取得した文字列を分割します。
分割した要素の最後が末尾の行になります。
コードを表示(ここをクリックしてください)
[Benchmark]
public void ReadAllText()
{
    var lastLine = string.Empty;
    var text = File.ReadAllText(fileName, encording);
    var lines = text.Split(new[] { Environment.NewLine }, StringSplitOptions.None);
    if (lines.Length > 1)
    {
        lastLine = lines[lines.Length - 1];
    }
    Console.WriteLine($"ファイルの末尾の行: {lastLine}")| メソッド名 | 説明 | 
| ReadAllText | テキスト ファイルを開き、そのファイル内のすべてのテキストを文字列に読み取った後、ファイルを閉じます。 | 
測定結果

上述したメソッドの処理時間を測定した結果が次の通りです。処理速度が速い順に並べています。
| 順位 | 独自メソッド名 | 処理速度(msec) | 
| 1 | Read | 0.332 | 
| 2 | ReadLine | 6.690 | 
| 3 | ReadLines | 6.963 | 
| 4 | ReadLinesLinq | 7.212 | 
| 5 | ReadAllLinesLinq | 18.318 | 
| 6 | ReadAllLines | 18.327 | 
| 7 | ReadToEnd | 27.720 | 
| 8 | ReadAllText | 28.538 | 
この結果から、独自メソッドのRead()が圧倒的に高速であることが判断できます。
他のメソッドは全ての行を読み取りしていますが、このメソッドは末尾の行からファイルの読み込みをしているので、無駄の行は読み取りしていない点がやはり処理速度に大きな差の要因になっているのでしょう。
下図は Benchmark.NET の出力結果です。

まとめ

この記事ではファイルの最後の行を取得する処理を測定して、高速に読み取りをする方法について紹介しました。
結論としては、先頭から全てのテキストを取得するより、ファイルの最後の行から読み取りした方が遥かに高速であることが分かりました。
大容量のファイルから最後の行だけ高速に読み込みたい場合には、参考になると思います。
以上、最後まで読んで頂きありがとうございました。



付録
Benchmark.NET で測定した時のコードを記載しておきます。
using BenchmarkDotNet.Running;
using ConsoleApp1;
public class Program
{
    public static void Main(string[] args)
    {
        //Benchmark.NETによる測定処理
        var summary = BenchmarkRunner.Run<FileEndLineTest>();
    }
}using BenchmarkDotNet.Attributes;
using System.Text;
namespace ConsoleApp1
{
    [ShortRunJob]
    public class FileEndLineTest
    {
        private string fileName = @"C:\Sample.csv";
        private Encoding encording = Encoding.GetEncoding("utf-8");
        [Benchmark]
        public void Read()
        {
            // 記事内のコードを参照ください。
        }
        [Benchmark]
        public void ReadLine()
        {
            // 記事内のコードを参照ください。
        }
        [Benchmark]
        public void ReadToEnd()
        {
            // 記事内のコードを参照ください。
        }
        [Benchmark]
        public void ReadLines()
        {
            // 記事内のコードを参照ください。
        }
        [Benchmark]
        public void ReadLinesLinq()
        {
            // 記事内のコードを参照ください。
        }
        [Benchmark]
        public void ReadAllLines()
        {
            // 記事内のコードを参照ください。
        }
        [Benchmark]
        public void ReadAllLinesLinq()
        {
            // 記事内のコードを参照ください。
        }
        [Benchmark]
        public void ReadAllText()
        {
            // 記事内のコードを参照ください。
        }
    }
}

 
					