Go言語のReadでは1GB以上のサイズを読むことができない

Goでファイルを分割してアップロードするAPIのクラアントのコードを作成していたときにしったことです。

2GBのバッファを使ってReadする

Go で以下のようなコードで5GBファイルから1回のReadで2GB読み込もうとすると、bufferに1GB分のデータしか格納されませんでした。
エラー発生しない限りはbufferのサイズ分読み込んでもらえるものだと思っていたので、原因探って行きます。

package main

import (
    "flag"
    "log"
    "os"
)

func main() {
    fname := flag.String("f", "", "file name")
    flag.Parse()

    buf := make([]byte, 2*1024*1024*1024)
    f, err := os.Open(*fname)
    defer f.Close()
    if err != nil {
        log.Println("can't open file: ", err)
    }

    for {
        n, err := f.Read(buf)
        if n > 0 {
            log.Printf("read %d bytes", n)
        }
        if err != nil {
            break
        }
    }
}

5GBのファイルを読み込んだ時の出力結果です。
1 * 1024 * 1024 * 1024 = 1073741824 byte = 1 GiBなので1回のReadで1GBしか読み込めていません。

go run main.go -f 5G.bin
2023/11/23 15:46:45 read 1073741824 bytes
2023/11/23 15:46:45 read 1073741824 bytes
2023/11/23 15:46:45 read 1073741824 bytes
2023/11/23 15:46:45 read 1073741824 bytes
2023/11/23 15:46:46 read 1073741824 bytes

MAX1GBまでしか読み込めない制限がありそうです。

原因

標準パッケージの中身を追っていくとfd.Readで以下の制限を設けていました。 1 << 30でビットシフトしています。
2^30 = 1073741824です。

なんでこんなことやっているのかもコメントに書いてありました。

// Darwin and FreeBSD can't read or write 2GB+ files at a time,
// even on 64-bit systems.
// The same is true of socket implementations on many systems.
// See golang.org/issue/7812 and golang.org/issue/16266.
// Use 1GB instead of, say, 2GB-1, to keep subsequent reads aligned.
const maxRW = 1 << 30

ChatGPTさんにこのコメントを説明してもらった結果は以下です

このコメントは、Go言語の標準パッケージに含まれるファイルディスクリプタに関連するUnixシステム用のReadメソッドについてです。

内容を解説すると、まず、Darwin(MacOSのカーネル)やFreeBSDなどの一部のUnix系システムでは、64ビットシステムであっても2GB以上のファイルを一度に読み書きすることができないと述べています。

また、多くのシステムにおけるソケットの実装でも同様の制限があることを指摘しています。

この制限についての詳細は、golang.orgの問題トラッカーである"issue/7812"や"issue/16266"で報告されています。これらの問題は、大容量のファイルやソケットに対する読み書き操作において、特定のシステムで制限があることを示しています。

そして、このコメントは、2GBよりもやや小さいサイズの1GBを使用することを推奨しています。これは、例えば2GB-1といった上限値ではなく、1GBを使うことで、後続の読み込み操作が適切にアラインされることを意味しています。アラインメントを保つことで、効率的なファイルやソケットの読み書きを行うことができます。

システムコールのreadを実行してみる

システムコールを直接実行すれば1GB以上のReadできそうなので試してみます。
コメント通りに2GB-1が上限でした。2GB以上指定するとinvalid argumentになります。

package main

import (
    "flag"
    "log"
    "os"
    "syscall"
)

func main() {
    fname := flag.String("f", "", "file name")
    flag.Parse()

    buf := make([]byte, 2*1024*1024*1024-1)
    f, err := os.Open(*fname)
    if err != nil {
        log.Println("can't open file: ", err)
    }

    for {
        n, err := syscall.Read(int(f.Fd()), buf)
        if n > 0 {
            log.Printf("read %d bytes", n)
        } else {
            break
        }
        if err != nil {
            log.Println(err)
            break
        }
    }
}
go run main.go -f 5G.bin
2023/11/28 00:14:49 read 2147483647 bytes
2023/11/28 00:14:50 read 2147483647 bytes
2023/11/28 00:14:50 read 1073741826 bytes

2GB以上指定できない理由

2GB以上指定できない理由調べてみます。
MacOSreadのリファレンスを見ると
read(int fildes, void *buf, size_t nbyte); になっています。
MacOSsize_tはおそらく32bitで、符号あり32bit整数型の最大値である2147483647が上限値ぽいです

Linuxで試してみる

Linuxだと5GBを一度で読み込めそうなので試してみます。 GoのDockerの公式イメージdebianがベースみたいなのでそれでやってみます。

FROM golang:1.21

WORKDIR /usr/src/app

RUN dd if=/dev/zero of=5G.bin bs=1M count=5120
COPY main.go .
RUN go build -v -o /usr/local/bin/app ./main.go

CMD ["app", "-f", "5G.bin"]

ソースコードのバッファサイズを2GBに変更します。 そしてDocker buildしてrunした結果がこれです。

docker run test       
2023/11/27 16:00:02 read 2147479552 bytes
2023/11/27 16:00:05 read 2147479552 bytes
2023/11/27 16:00:07 read 1073750016 bytes

予想とは異なり、2147479552 byteMacよりも小さくなってます。 Macの方が4095 byte大きいです。

どうやらLinuxにも制限あるみたいでした。

stackoverflow.com

1GB以上Readする方法

io.ReadFullを使えばこの問題回避できます。
これを使えばバッファサイズ分きちんと読み込んでくれます。

読み込んだサイズよりもバッファの方が大きい場合はErrUnexpectedEOFが返ってきます。

コードはこんな感じになります。

package main

import (
    "errors"
    "flag"
    "io"
    "log"
    "os"
)

func main() {
    fname := flag.String("f", "", "file name")
    flag.Parse()

    buf := make([]byte, 2*1024*1024*1024)
    f, err := os.Open(*fname)
    if err != nil {
        log.Println("can't open file: ", err)
    }

    for {
        n, err := io.ReadFull(f, buf)
        if err != nil {
            if errors.Is(err, io.ErrUnexpectedEOF) {
                log.Printf("read %d bytes", n)
                break
            } else if errors.Is(err, io.EOF) {
                break
            } else {
                log.Fatalf("read error: %v", err)
            }
        }
        log.Printf("read %d bytes", n)
    }
}