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
です。
なんでこんなことやっているのかもコメントに書いてありました。
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を使うことで、後続の読み込み操作が適切にアラインされることを意味しています。アラインメントを保つことで、効率的なファイルやソケットの読み書きを行うことができます。
システムコールを直接実行すれば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以上指定できない理由調べてみます。
MacOSのreadのリファレンスを見ると
read(int fildes, void *buf, size_t nbyte);
になっています。
MacOSのsize_t
はおそらく32bitで、符号あり32bit整数型の最大値である2147483647
が上限値ぽいです
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 byte
でMacよりも小さくなってます。
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)
}
}