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)
    }
}

Go言語でスマートメーターから電力量取得してみる

なんか最近電気代高くなってきたのでスマートメーターから電力量を取得してみた。
いまどきアプリとかで電気代見ることできるのであんまりやる意味はないと思う・・・
リアルタイム性と瞬時電力量が取れるのが一番のメリットだと思うがNeture Remote Lite Eを使えばUIもいい感じで表示されるし値段もそんな変わらないのでそっち買うのがおすすめ。

準備するもの

  • BP35A1
    • Wi-SUNモジュール
  • BP35A7A
    • ピッチ変換アダプタ
  • BP35A7-accessories
    • ピンヘッダー
  • USBシリアル変換
    • なんか家にあったやつを使った
  • BルートのIDとパスワード

Wi-SUN系のものはコアスタッフってところで買った。
トータルで1万円ぐらいだった。

ドキュメント

参考にした記事

BP35A1のセットアップ

cuコマンドでシリアル通信を行う。
sudo cu -l cu.usbserial-AI06IYRS -s 115200

デフォルト設定だとスマートメータからデータがバイナリで表示されるので16進数に変更しておく
WOPT 1

瞬時電力量の取得方法

送信コマンドの組み立て方

ミドルウェア仕様書のとおりにECHONETLiteデータフレームを組み立てていく。

  • EHD1
    • 0x10
    • ECHONET Lite規格
  • EHD2
    • 0x81
    • 形式1
  • TID
    • 0x01
    • 2Byteであればなんでもいいが今回は0x01で固定している
  • SEOJ
    • 0x05FF01
    • 送信元
    • コントローラークラス規定
  • DEOJ
    • 0x028801
    • 送信先
    • 低圧スマート電力メータクラス規定
  • ESV
    • 0x62
    • 読み出し要求
  • OPC
  • 0x01
  • 1プロパティしか読み込まないため
  • EPC
    • 0xE7
    • 瞬時電力計測値
  • PDC
    • 0x00
    • 読み出し要求の場合は0x00
  • EDT
    • PDCが0の場合はたぶんいらない

こららを全部結合すると\x10\x81\x00\x01\x05\xFF\x01\x02\x88\x01\x62\x01\xE7\x00になるのでSKSENDTOで送信する。

受信データ読み方

瞬時電力量のコマンドを送信すると以下みたいなERXUDPから始まるデータのレスポンスがくる。

ERXUDP FE80:0000:0000:0000:0280:xxxx:xxxx:xxxx FE80:0000:0000:0000:021D:xxxx:xxxx:xxxxx 0E1A 0E1A 008087003010D9C1 1 0012 1081000102880105FF017201E704000000DE

空白区切りの最後の部分が瞬時電力計測値が含まれているデータ(ECHONETLiteデータフレーム)である。
DEOJまでは送信元と受信元が逆になったぐらい違いしかない。

  • ESV
    • `0x72
    • 応答
  • OPC
  • 0x01
  • 要求時と同じ値
  • EPC
    • 0xE7
    • 要求時と同じ値
  • PDC
    • 0x04
    • EDTのサイズ
  • EDT
    • 0x000000DE
    • 16 * 13 + 14 = 222W

ソースコード

Goで書いてみたが、結構長くなったのでGithubのリンク貼っておく。積算電力量も取得できるようにしている。

github.com

エラーハンドリングは結構適当なので長時間動かす時は手加えないといけない。

serial通信のパッケージにgo-serialを使ってみたが、タイムアウトを設定してもエラーが返ってこないのでio.ReadLine()で読み込もうとすると永遠にブロックされてしまう。
issueは上がっているが破壊的変更なのでv2がでるまで改善されてないみたいなので、自分でそこの部分は実装した。
このパッケージでラップすればいけるみたいなコメントもみた。

PostgreSQL, MySQLで重複したデータがあるカラムでorder byするときの注意点

業務でソートのキーとlimitとoffsetを指定できるList APIで,offset間でデータが重複するバグがありそのときに知ったRDBorder by句の注意点についてまとめる。

検証

PostgreSQLにテーブルとデータを準備する。

create table test
(
    id integer primary key,
    val integer
);

INSERT INTO test (id, val) VALUES
    (1, 1),
    (2, 1),
    (3, 1),
    (4, 1),
    (5, 1),
    (6, 1),
    (7, 1),
    (8, 1),
    (9, 1),
    (10, 1);

そして以下のクエリを実行する。

select * from test order by val limit 2 offset 0;
select * from test order by val limit 2 offset 2;
select * from test order by val limit 2 offset 4;
select * from test order by val limit 2 offset 6;
select * from test order by val limit 2 offset 8;

すると以下のような結果になる。 id = 1の行がoffset 0とoffset 2に重複し、id = 3の行が取れていない。

select * from test order by val limit 2 offset 0;
 id | val 
----+-----
  2 |   1
  1 |   1
(2 rows)

select * from test order by val limit 2 offset 2;
 id | val 
----+-----
  4 |   1
  1 |   1
(2 rows)

select * from test order by val limit 2 offset 4;
 id | val 
----+-----
  5 |   1
  6 |   1
(2 rows)

select * from test order by val limit 2 offset 6;
 id | val 
----+-----
  7 |   1
  8 |   1
(2 rows)

select * from test order by val limit 2 offset 8;
 id | val 
----+-----
  9 |   1
 10 |   1
(2 rows)

原因

ORDER BYとLIMIT, OFFSETの組み合わせには注意しように詳しく書いてあるが、ユニークなカラムでない場合の並び順は順序不定になっている。リンク先のブログはMySQLになっているがPostgeSQLでもチュートリアルに順序不定であることが書かれている。

この例では、ソート順は十分に指定されていません。 ですので、San Franciscoの行は順序が異なるかも知れません。

対策

  • ユニークなカラムをorder byで指定する
  • order byで指定するカラムを2つにする
    • ただし2つめのカラムはユニークなものにする