😸

llsとcachectlから学ぶ、Goでシステムプログラミングをする方法

2023/03/19に公開

YAPC::Kyoto 2023発表資料

自己紹介

  • 本名:金子達哉
  • 株式会社PR TIMES開発本部長CTO
    • 2021/4入社
    • 今回のYAPCのGold Sponsorsやっています
    • 会社のことを聞きたい方はぜひ声をかけてください!
  • 達人が教えるWebパフォーマンスチューニング〜ISUCONから学ぶ高速化の実践(技術評論社)(通称:ISUCON本)の著者の1人
    • ISUCON9予選・ISUCON6本選運営
  • catatsuyというIDで各種SNSをやっています
    • かたついと呼ばれることが多いです

はじめに

  • PerlのCPANモジュールのSys::PageCacheがLinux上のページキャッシュを調整するために使われてきた
    • Sys::PageCacheはXS(PerlからC言語のコードを呼び出せる仕組み)を使って実装されているのでpureなPerl実装ではない
    • C言語を介してシステムコールを呼び出している
  • cubicdaiya/cachectlはSys::PageCacheと同様の機能を持つGoのツール
    • 元々cgo実装だったが、pure Go実装に自分が書き換えた
    • Goはシステムコールを呼ぶためにC言語を使う必要がない
  • 自作のllsもGoから直接システムコールを呼び出すことで、pure Goで実装
  • システムプログラミングをGoでやる方法があまり知られていない気もするので今回紹介

対象読者

  • Goの基本文法を理解している
  • Goでアプリケーションを作ったことがある
  • Goの標準ライブラリやGoコンパイラについて一定程度の理解がある
  • Linuxシステムプログラミングに興味がある

参考文献

Cとシステムコール

システムプログラミングの入口に相当するものがシステムコールです
(Linuxシステムプログラミング 1.1.1.1 システムコールの発行(コール))

  • LinuxのシステムプログラミングはLinuxのシステムコールを実行するプログラム
    • ユーザ空間のアプリケーションはセキュリティや信頼性の理由から、例えばファイルの読み書きなどの操作はできない
    • ユーザアプリケーションはカーネルにしか許可されていない操作を行う場合、システムコールを呼び出してカーネルに代わりにやってもらう
    • システムコールの呼び出し方法はカーネルによって定義されていて、アーキテクチャによっても異なる
    • 各システムコールには一意の番号が割り振られている
  • システムコールを実行できる言語はC言語が主に利用されている
    • システムコールの呼び出し方法はOSやアーキテクチャによって異なるため、通常はlibcを利用して呼び出される(後述)

他のアーキテクチャではシステムコールの発行手順は異なりますが、本質は変わりません。システムプログラマは通常、カーネルがシステムコールをどのように受け付けるかを把握しておく必要はありません。この手順はアーキテクチャにより定められており、コンパイラとCライブラリにより自動的に処理されます。
(Linuxシステムプログラミング 1.1.1.1 システムコールの発行(コール))

コラム:manの使い方

番号 内容
1 Linux上のユーザが実行できるコマンド
2 システムコール
3 C言語で実装されたライブラリ
  • manは上の章番号を指定することができる
  • システムコールのopenのドキュメントを読みたい場合はman 2 openする

libcについて

Cライブラリ(libc)はUnixアプリケーションの心臓部分です。C言語以外でプログラミングした場合でも、上位ライブラリにより隠蔽されているかもしれませんが、コアサービスやシステムコールを利用するためにCライブラリは使用されています。LinuxシステムのCライブラリはGNU libcです。glibc(ジーリブシー)と短縮して呼ばれます
(Linuxシステムプログラミング 1.1.2 Cライブラリ)

  • 通常LinuxはGNU libc (glibc) を使用するが、Alpine Linuxはlibcとしてmusl libcを利用している
  • musl libcの方がソースコードがシンプルと言われているので、libcのソースコードを見たいときはmusl libcを見るのがおすすめ
    • openのシステムコールの番号は5が多いが、アーキテクチャによって異なる(下記参照)
    • アーキテクチャ毎の差異を吸収するのがlibc
arch/arm/bits/syscall.h.in:#define __NR_open 5
arch/i386/bits/syscall.h.in:#define __NR_open 5
arch/m68k/bits/syscall.h.in:#define __NR_open 5
arch/microblaze/bits/syscall.h.in:#define __NR_open 5
arch/mips/bits/syscall.h.in:#define __NR_open 4005
arch/mips64/bits/syscall.h.in:#define __NR_open 5002
arch/mipsn32/bits/syscall.h.in:#define __NR_open 6002
arch/powerpc/bits/syscall.h.in:#define __NR_open 5
arch/powerpc64/bits/syscall.h.in:#define __NR_open 5
arch/s390x/bits/syscall.h.in:#define __NR_open 5
arch/sh/bits/syscall.h.in:#define __NR_open 5
arch/x32/bits/syscall.h.in:#define __NR_open (0x40000000 + 2)
arch/x86_64/bits/syscall.h.in:#define __NR_open 2

Goでシステムプログラミング

Go is a general-purpose language designed with systems programming in mind

The Go Programming Language Specification

  • GoはC言語に頼らずにシステムコールを呼び出すことができる
    • もちろん得手不得手はあるが、Linux上で動かすツール類を作りたいならかなり便利
  • GoにはcgoというC言語のライブラリ・関数を呼び出せる機能があるので、cgo経由でシステムコールを呼び出すことができる
    • ただしcgoを使うとクロスコンパイルが難しくなる
    • CからGoにメモリコピーをする関係上、パフォーマンスが出しにくかったり、GCの対象外になるなどデメリットが大きい
    • 今回紹介するのはcgoを使わずにGoのみで実装する方法
      • Go Proverbsの格言で "Cgo is not Go." というのがあり、cgoは避けるべき
  • Goではシステムコールを呼び出すだけならcgoを使う必要はなく、syscallパッケージ・golang.org/x/sys/unixを利用できる
    • 実はシステムプログラミングをかなりやりやすい言語
    • ただしGoが標準ライブラリが隠蔽している部分を直接使うことになるので、一般的なGoの作法とは違うところが多い
  • Linuxにしかないシステムコールは当然Linuxにしか関数・定数が存在していないので、mac上だと存在しない関数を呼び出している扱いになる
    • エディタの設定次第ではエディタ上でエラーになるが、無視するしかない
    • 当然mac上ではコンパイルもできない(Linux用バイナリをクロスコンパイルすることはできる)

Goとlibc

  • Goはlibcを使用していない
    • アーキテクチャ毎の差異をGo自身が吸収する必要がある
  • Goにはbuild tagの仕様があり、OS・アーキテクチャ・Goのバージョンなどで特定のファイルをbuild時に読み込むかどうか判断できる
    • 現在では新しい//go:build記法が使えるようになっており、条件式を書くことができる
      • OS:Linux Arch:amd64の場合//go:build amd64 && linux
    • 例えばopenのシステムコールに利用する番号はファイル毎に定数が定義されている(下記参照)
  • 標準ライブラリのsyscallパッケージにOS毎にシステムコール呼び出しが定義されている
    • GoはC言語の力を使わずにシステムコールを呼び出すことができる
    • ただしsyscallパッケージはこれ以上機能追加はしない方針で、機能追加は golang.org/x/sys/unix に行われている
    • https://golang.org/s/go1.4-syscall
syscall/zsysnum_darwin_amd64.go:  SYS_OPEN = 5
syscall/zsysnum_darwin_arm64.go:  SYS_OPEN = 5
syscall/zsysnum_dragonfly_amd64.go: SYS_OPEN = 5
syscall/zsysnum_freebsd_386.go: SYS_OPEN = 5
syscall/zsysnum_freebsd_amd64.go: SYS_OPEN = 5
syscall/zsysnum_freebsd_arm.go: SYS_OPEN = 5
syscall/zsysnum_freebsd_arm64.go: SYS_OPEN = 5
syscall/zsysnum_freebsd_riscv64.go: SYS_OPEN = 5
syscall/zsysnum_linux_386.go: SYS_OPEN = 5
syscall/zsysnum_linux_amd64.go: SYS_OPEN = 2
syscall/zsysnum_linux_arm.go: SYS_OPEN = 5
syscall/zsysnum_linux_mips.go:  SYS_OPEN = 4005
syscall/zsysnum_linux_mips64.go:  SYS_OPEN = 5002
syscall/zsysnum_linux_mips64le.go:  SYS_OPEN = 5002
syscall/zsysnum_linux_mipsle.go:  SYS_OPEN = 4005
syscall/zsysnum_linux_ppc64.go: SYS_OPEN = 5
syscall/zsysnum_linux_ppc64le.go: SYS_OPEN = 5
syscall/zsysnum_linux_s390x.go: SYS_OPEN = 5
syscall/zsysnum_netbsd_386.go:  SYS_OPEN = 5
syscall/zsysnum_netbsd_amd64.go:  SYS_OPEN = 5
syscall/zsysnum_netbsd_arm.go:  SYS_OPEN = 5
syscall/zsysnum_netbsd_arm64.go:  SYS_OPEN = 5
syscall/zsysnum_openbsd_386.go: SYS_OPEN = 5
syscall/zsysnum_openbsd_amd64.go: SYS_OPEN = 5
syscall/zsysnum_openbsd_arm.go: SYS_OPEN = 5
syscall/zsysnum_openbsd_arm64.go: SYS_OPEN = 5
syscall/zsysnum_openbsd_mips64.go:  SYS_OPEN = 5
syscall/zsysnum_plan9.go: SYS_OPEN = 14

コラム:Goのbootstrap問題

  • Goコンパイラは1.4までCで作られており、1.5からGoで作られている
    • self-hosting
  • Goはビルド済みバイナリが配布されているので、通常ビルド済みバイナリをダウンロードするだけ
    • Go 1.19以下を自分でビルドする場合は、Go 1.4をCコンパイラでコンパイル→Go 1.4を使って最新のGoをコンパイルで入手可能
    • Go 1.20からGo 1.17をbootstrap toolchainとして採用したため、Go 1.20は、Go 1.4をCコンパイラでコンパイル→Go 1.4を使ってGo 1.17をコンパイル→Go 1.17でGo 1.20をコンパイルで入手可能
    • Goは1年で2回リリースされるサイクルになっているが、現在毎年bootstrap toolchainとして使うGoのバージョンを1年前にリリースされたGoに更新していくことが提案されており、Go 1.22でGo 1.20にbootstrap toolchainを更新することが提案されている
    • これが実現すればgenericsの利用が本格化する可能性がある
  • Go 1.17になったのはGo 1.17で導入された新しいbuild tagの記法の//go:buildを全面的に導入できるようにするためと、Go 1.18はgenericsが導入された初めてのバージョンで変更量が多く、バグが多い可能性があるため

https://github.com/golang/go/issues/44505
https://github.com/golang/go/issues/54265

Goの配列とslice

Go Slices: usage and internals - The Go Programming Languageおよびプログラミング言語Go 第4章を参照

  • この後配列とsliceに関する知識が必要になるので解説
  • Goの配列は要素数を含めて型
    • [3]int[4]intは違う型
  • Goの配列は値であり、例えば関数の引数に渡せばcopyされる
    • Cでは配列の変数名を渡すと先頭要素へのポインタになるのでGoとは違う
  • sliceは実体としての配列の要素へのポインタ・len(sliceの長さ)・cap(実体の配列の要素数)を持っている
    • 配列の要素へのポインタを持っているので、sliceは実質的に参照型となる
    • 配列の要素へのポインタは配列の先頭要素である必要はない

Goの標準ライブラリと配列

  • Goでは通常配列ではなくsliceを利用する
  • 一部の標準ライブラリは配列を返すが、それ以外は基本的に配列のことを忘れても問題ない
    • crypto/md5.Sum(data []byte)[16]byteを返すなど
  • 大きい配列を関数の引数に渡すとcopyになるため、パフォーマンス的にも配列を使うのは好ましくない可能性が高い
  • 配列は要素数が固定で柔軟性がない上に、要素数が型に含まれるため特定の要素数専用の関数を作るしかない

ここから実際のプログラムを紹介していきます!

llsについて

  • 大量のファイルが置かれているディレクトリはlsできない
    • lsすると固まったり、途中で異常終了したりする
    • 原因の1つは結果をsortしていることなのでsortを無効にする手法がよく知られている
      • それでもダメなケースがある(あった)
  • 原因はlibcのreaddirが内部で利用しているバッファのサイズが固定なので、あまりにも大量のファイルがある場合は大量のシステムコールを実行する必要があることにある
    • バッファのサイズを変える方法はないため、自分で直接getdents64システムコールを呼び出すしかない
  • PR TIMESで最終的に約3100万ファイルのリストを出力することに成功しています

llsの使用例

llsは大量のファイルを扱う場合に適したlsの代替として使用することが可能

# 基本的にlsと同じだが、大量のファイルのリストが出力されるはずなので、出力をファイルにリダイレクトする方が良い
lls > output.txt

# もちろんディレクトリを指定することも可能
lls / > output.txt

llsの実装(抜粋)

Goのsyscall/ztypes_linux_amd64.go

syscall/ztypes_linux_amd64.go
package syscall

type Dirent struct {
  Ino       uint64
  Off       int64
  Reclen    uint16
  Type      uint8
  Name      [256]int8
  Pad_cgo_0 [5]byte
}

以下のlibcの実装に相当

struct dirent {
  ino_t          d_ino;       /* Inode number */
  off_t          d_off;       /* Not an offset; see below */
  unsigned short d_reclen;    /* Length of this record */
  unsigned char  d_type;      /* Type of file; not supported
                                 by all filesystem types */
  char           d_name[256]; /* Null-terminated filename */
};

llsの実装:

  // getdents64に渡すバッファを作成
  buf := make([]byte, bufSize)

  // getdents64は複数回実行すると残りのファイルのリストが取得できるので最後まで実行する
  for {
    // getdents64に作成したバッファを渡して実行
    // 返り値はバッファに入っているデータのバイト数とエラー
    n, err := syscall.Getdents(int(f.Fd()), buf)
    if err != nil {
      fmt.Fprintln(c.errStream, err)
      return ExitCodeFail
    }

    // 0だった場合は全てのファイルを取得し終えているのでforを終了
    if n == 0 {
      break
    }

    // バッファにはCの構造体(struct dirent)のデータが複数入っている
    // 1つ1つGoの構造体にmappingしていく
    for bufp := 0; bufp < n; {
      // Cの構造体をGoの構造体にキャストする
      dirent := (*syscall.Dirent)(unsafe.Pointer(&buf[bufp]))
      // Reclenにはこの構造体のバイト数が入っているので、バイト数分先に進める
      bufp += int(dirent.Reclen)

      // InoはInode numberで0はファイルの実体が存在していない→削除されたファイル
      // Inode numberがファイルの実体を表す
      if dirent.Ino == 0 {
        continue
      }

      // Nameにはファイル名が入っている
      // Cだとchar[256]でGoだと[256]int8に対応しているが、文字列に変換したいので一旦[256]byteにキャスト
      // Goだとtype byte = uint8で同じではないのでキャストが必要
      // Linuxのファイル名の最大長は255バイトなのはここがchar[256]で最後がNULL文字になっているため
      bb := (*[256]byte)(unsafe.Pointer(&dirent.Name))
      // 配列に対しても[n:m]の記法は利用できるがsliceが返ってくる
      // stringにキャストしたいので[]byteである必要がある
      // ここはCの文字列で終端はNULL文字になっているので、NULL文字が入っているバイト列目までの[]byteを渡せば文字列になる
      name := string(bb[0:blen(*bb)])

      // すべてのディレクトリは.と..の2つのディレクトリを含んでいるため除去する
      if name == "." || name == ".." {
        // ignore
        continue
      }
      fmt.Fprintln(c.outStream, name)
    }
  }

// NULL文字の場所を返す関数
func blen(b [256]byte) int {
  for i := 0; i < len(b); i++ {

    // NULL文字は0と同じになる
    if b[i] == 0 {
      return i
    }
  }
  // 本当はここに来ることはない
  return len(b)
}

コラム:getdentsとgetdents64

man 2 getdents

getdents64()
    The  original  Linux  getdents()  system  call  did  not handle large filesystems and large file offsets.  Consequently, Linux 2.4 added dents64(), with wider types for the d_ino and d_off
    fields.  In addition, getdents64() supports an explicit d_type field.
    The getdents64() system call is like getdents(), except that its second argument is a pointer to a buffer containing structures of the lowing type:
        struct linux_dirent64 {
            ino64_t        d_ino;    /* 64-bit inode number */
            off64_t        d_off;    /* 64-bit offset to next structure */
            unsigned short d_reclen; /* Size of this dirent */
            unsigned char  d_type;   /* File type */
            char           d_name[]; /* Filename (null-terminated) */
        };
  • getdentsは古い仕組みなので使うメリットはない
    • 何も考えずにgetdents64を使えば良い
  • Goの場合、アーキテクチャによってはSYS_GETDENTSSYS_GETDENTS64がsyscallパッケージに定義されている
    • ただし関数(今回利用したsyscall.Getdents)が用意されているのはSYS_GETDENTS64のみなので、どうしてもSYS_GETDENTSを呼び出したい場合はsyscall.Syscallなどを使って自分で呼び出すしかない

コラム:musl libcのreaddir実装

  • readdir関数は内部でgetdents64システムコールを呼び出しているが、バッファサイズは固定になっている
readdir.c
struct dirent *readdir(DIR *dir)
{
  struct dirent *de;

  if (dir->buf_pos >= dir->buf_end) {
    int len = __syscall(SYS_getdents, dir->fd, dir->buf, sizeof dir->buf);
    if (len <= 0) {
      if (len < 0 && len != -ENOENT) errno = -len;
      return 0;
    }
    dir->buf_end = len;
    dir->buf_pos = 0;
  }
  de = (void *)(dir->buf + dir->buf_pos);
  dir->buf_pos += de->d_reclen;
  dir->tell = de->d_off;
  return de;
}
__dirent.h
struct __dirstream
{
  off_t tell;
  int fd;
  int buf_pos;
  int buf_end;
  volatile int lock[1];
  /* Any changes to this struct must preserve the property:
   * offsetof(struct __dirent, buf) % sizeof(off_t) == 0 */
  char buf[2048];
};
dirent.h
typedef struct __dirstream DIR;

コラム:unsafe.Stringについて

  • Go 1.20から[]byteからstringへのキャストを高速に行うunsafe.String関数が追加になった
    • stringへの変換にほぼ時間がかからなくなる
  • llsのuse caseだと自分の環境だと大体30nsほどの高速化になりそうだったが、3000万ファイルあったとして大体1sほどの高速化にしかならない
    • llsの場合、ディスクに大量のファイルが置かれている→HDDの読み込み速度がボトルネック
    • 可読性を損なうほどのメリットがllsに関してはなさそう

use unsafe.String to optimize for speed by catatsuy · Pull Request #28 · catatsuy/lls

cachectlについて

  • cubicdaiya/cachectlは元々cgoを使ってシステムコールをC言語経由で実行していた
    • pure Go実装に自分が書き変えた
  • CPANモジュールのSys::PageCacheとできることは基本同じ
    • Linux上のファイルのページキャッシュの利用状況を確認できたり、ページキャッシュからpurgeできたりする
  • deamonとして起動できるcachectldもあるが、今回は紹介しない

cachectlの使用例

cachectlはLinux上のページキャッシュを調整するために使用することができる

# ページキャッシュの状況を確認する
cachectl -f /var/log/access_log

# ページキャッシュから指定したファイルをpurgeする
cachectl -op purge -f /var/log/access_log

# -rで比率を指定することができる
cachectl -op purge -f /var/log/access_log -r 0.9

cachectlの実装(抜粋)

  • 元のC実装だとエラー時のファイルのclose処理をgoto errorで何とかしていたが、Goはdeferだけで良い
cachectl/purge.go
import (
  "fmt"
  "log"
  "os"

  "golang.org/x/sys/unix"
)

// purgeする処理
func purgePages(fpath string, fsize int64, rate float64) error {
  if rate < 0.0 || rate > 1.0 {
    return fmt.Errorf("%.1f: rate should be over 0.0 and less than 1.0\n", rate)
  }

  f, err := os.Open(fpath)
  if err != nil {
    return err
  }
  defer f.Close()

  // Cの以下のコードに相当
  // l = (off_t)(st.st_size * r)
  // posix_fadvise(fd, 0, l, POSIX_FADV_DONTNEED)
  // ファイルデータのアクセスパターンをあらかじめ宣言する
  // POSIX_FADV_DONTNEEDは指定されたデータは近い将来アクセスされないことを宣言する
  err = unix.Fadvise(int(f.Fd()), 0, int64(float64(fsize)*rate), unix.FADV_DONTNEED)
  if err != nil {
    return fmt.Errorf("failed to purge page cache for %s", fpath)
  }

  return nil
}
cachectl/activepages.go
import (
  "os"
  "unsafe"

  "golang.org/x/sys/unix"
)

// ページキャッシュの状況を取得する
func activePages(path string) (int, error) {
  f, err := os.Open(path)
  if err != nil {
    return 0, err
  }
  defer f.Close()

  fi, err := f.Stat()
  if err != nil {
    return 0, err
  }
  fsize := fi.Size()

  if fsize == 0 {
    return 0, nil
  }

  // Cの以下のコードに相当
  // m = mmap(NULL, st.st_size, PROT_NONE, MAP_SHARED, fd, 0);
  // 引数の順番は異なっているので注意
  mmap, err := unix.Mmap(int(f.Fd()), 0, int(fsize), unix.PROT_NONE, unix.MAP_SHARED)
  if err != nil {
    return 0, err
  }
  // Cの以下のコードに相当
  // munmap(m, st.st_size);
  defer unix.Munmap(mmap)

  pagesize := int64(os.Getpagesize())
  pages := (fsize + pagesize - 1) / pagesize
  pageinfo := make([]byte, pages)

  mmapPtr := uintptr(unsafe.Pointer(&mmap[0]))
  sizePtr := uintptr(fsize)
  pageinfoPtr := uintptr(unsafe.Pointer(&pageinfo[0]))

  // ページがメモリー内にあるかどうかを判定する
  // Cの以下のコードに相当
  // mincore(m, st.st_size, pageinfo)
  // 定数定義のみで関数は用意されていないので、unix.Syscallで自分で呼び出す
  // 成功した場合は0、エラーの場合は-1を返してくる
  // byteのスライスの変数であるpageinfoの先頭要素のポインタを渡していて、pageinfoに必要な情報が代入される
  ret, _, err := unix.Syscall(unix.SYS_MINCORE, mmapPtr, sizePtr, pageinfoPtr)
  if ret != 0 {
    return 0, err
  }

  result := 0

  for _, p := range pageinfo {
    // 最下位bitがsetされているかどうかでメモリ内にファイルが存在しているか判定している
    // それ以外のbitは現在未定義だが、今後追加される可能性があるのでビット演算している
    if p&1 == 1 {
      result++
    }
  }

  return result, nil
}

CとGoの比較

  • Goはdeferが使えるので関数終了時に必ず実行したい処理を忘れることがない
    • ファイルのClose処理など
  • Cは多値を返せないので、libcの関数やシステムコールのエラー時は返り値で-1(もちろん関数によって異なる)を返した上で、グローバル変数errnoにエラー変数がsetされる仕様がほとんど
    • Goはerrを見るだけで良い
  • Go側は基本ドキュメントがないので、libcやシステムコールのドキュメントを読む必要がある
    • 基本はそのまま呼び出しているだけなので、そちらを読めば何とかなる
  • システムコールは基本的にCの世界なので、Go上では扱う場合もそのことを意識する必要がある
    • 返ってくる値はCの構造体や配列だったり、返り値のルールもGoのルールとは異なることがある
  • 今回紹介したコード以外は見慣れたGoなので、今回紹介した関数以外はGoの知識が活かせる
    • システムコールを呼び出す関数の中でシステムコールのことは極力隠蔽することで、Goのプログラムとして自然なプログラムにできる
    • システムコールの呼び出し部分さえ何とかすれば、他は普通のGoなので実は結構書きやすい
    • Goの標準パッケージはこういう面倒な部分を隠蔽してくれているので、標準パッケージがやってくれていることを自分でやる感じ

最後に

  • Linuxシステムプログラミングとプログラミング言語Goは必読
  • Goでシステムプログラミングをするの楽しいのでおすすめです

Discussion