このページの本文へ

Goならわかるシステムプログラミング 第12回

ファイルシステムと、その上のGo言語の関数たち(3)

2017年02月22日 22時00分更新

文● 渋川よしき、編集● 鹿野桂一郎

  • この記事をはてなブックマークに追加
  • 本文印刷

Go言語でシステムプログラミングの世界を覗くこの連載では、前々回からファイルシステムに関係する話題を扱ってきました。 今回の記事では、その総まとめとして、アプリケーションから見たファイルシステム周りの最深部を辿っていきます。 扱う話題は次の4つです。

  • ファイルロック
  • ファイルのメモリへのマッピング
  • 同期・非同期とブロッキング・ノンブロッキング
  • select属のシステムコールによるI/O多重化

ファイルのロック(syscall.Flock()

ファイルのロックは、複数のプロセス間で同じリソースを同時に変更しないようにするために「いま使用していますよ」と他のプロセスに伝える手法のひとつです。

ファイルロックの最も単純な方法は、リソースが使用中であることをファイル(ロックファイル)によって示すというものでしょう。 たとえば、古いプログラマ(30代以上?)にはお馴染みのCGI(かつて動的なウェブサイトの実現手段としてメジャーだった仕組み)では、たいていロックファイルが利用されていました。

ロックファイルはポータブルな仕組みですが、確実性という面では劣ります1。 より確実なのは、ファイルロックのためのシステムコールを利用して、 すでに存在するファイルに対してロックをかける方法です。 このシステムコールによってロックをかけられたファイルに対し、他のプロセスがロックをかけようとすると、ブロックします。

Go言語では、POSIX系OSの場合、このロックのためのsyscall.Flock()というシステコールが利用できます。 ただし、syscall.Flock()によるロック状態は、通常のファイル入出力のためのシステムコールによっては確認されません。 そのため、ロックをまじめに確認しないプロセスが1つでもあると、自由に上書きされてしまう可能性があります。 このような強制力のないロックのことを、アドバイザリーロック勧告ロック)と呼びます。

このsyscall.Flock()によるロックは、Windowsでは利用できません。 Windowsでは、ファイルロックにLockFileEx()という関数を使います。 こちらは、syscall.Flock()とは違い、他のプロセスもブロックする強制ロックです。 (ちなみに、GoだけでなくJavaやPHPでも、ファイルロックのためのAPIについてはPOSIXとWindowsとで同じ違いがあります。)

syscall.Flock()によるPOSIX系OSでのファイルロック

まずはPOSIX系OSでsyscall.Flock()システムコールを使ってファイルをロックする方法を見ていきましょう。 以下のコードは、Go言語本体のコード2から一部を拝借してシンプルにしたものです。

// +build darwin dragonfly freebsd linux netbsd openbsd
 
package main
 
import (
  "sync"
  "syscall"
)
 
type FileLock struct {
  l  sync.Mutex
  fd int
}
 
func NewFileLock(filename string) *FileLock {
  if filename == "" {
    panic("filename needed")
  }
  fd, err := syscall.Open(filename, syscall.O_CREAT|syscall.O_RDONLY, 0750)
  if err != nil {
    panic(err)
  }
  return &FileLock{fd: fd}
}
 
func (m *FileLock) Lock() {
  m.l.Lock()
  if err := syscall.Flock(m.fd, syscall.LOCK_EX); err != nil {
    panic(err)
  }
}
 
func (m *FileLock) Unlock() {
  if err := syscall.Flock(m.fd, syscall.LOCK_UN); err != nil {
    panic(err)
  }
  m.l.Unlock()
}

syscall.Flock()は引数を2つ取ります。 1つは、ロックしたい対象のファイルのディスクリプタです。 もう1つは、ロックのモードを指示するフラグです。フラグには次の4種類があります(上記のコードでは排他ロックとロックの解除のみを使っています)。

フラグ 説明
LOCK_SH 共有ロック。他のプロセスからも共有ロックなら可能だが、排他ロックは同時には行えない。
LOCK_EX 排他ロック。他のプロセスからは共有ロックも排他ロックも行えない。
LOCK_UN ロック解除。ファイルをクローズしても解除になる。
LOCK_NB ノンブロッキングモード

ファイルのようなリソースのロックには、共有ロック排他ロックという区別があります。 共有ロックは、複数のプロセスから同じリソースに対していくつも同時にかけられます。 一方、排他ロックでは他のプロセスからの共有ロックがブロックされます。 この区別により、「読み込み(共有ロック)は並行アクセスを許すが、書き込み(排他ロック)は1プロセスのみ許可する」、といったことが可能です。

syscall.Flock()によるロックでは、すでにロックされているファイルに対してロックをかけようとすると、最初のロックが外れるまでずっと待たされます。 そのため、定期的に何度もアクセスしてロックが取得できるかトライする、といったことができません。 これを可能にするのがノンブロッキングモードです(この記事の後半で少し詳しく説明します)。

ただ、ノンブロッキングモードはスレッドの利用が大掛かりになってしまう言語だと必要になりますが、並行処理が簡単にかけるGo言語の場合はブロッキングモードだけが用意されることもよくあります。

POSIXの他のロック関連のシステムコール

POSIXでは、syscall.Flock()によるロックのほかにも、fcntllockfといったシステムコールを使ったロックがあります (Linuxでは、lockffcntlへのエイリアスとなっています3)。

fcntlシステムコールは、ファイルディスクリプタのメタデータを操作するシステムコールで、多くのタスクが行えます4fcntlシステムコールを使えば、強制力のないアドバイザリーロックや、強制ロック、範囲指定ロックなどが行えます。

LockFileEx()によるWindowsでのファイルロック

次は、WindowsのLockFileEx()によるファイルロックの使い方です。

package main
 
import (
  "sync"
  "syscall"
  "unsafe"
)
 
var (
  modkernel32      = syscall.NewLazyDLL("kernel32.dll")
  procLockFileEx   = modkernel32.NewProc("LockFileEx")
  procUnlockFileEx = modkernel32.NewProc("UnlockFileEx")
)
 
type FileLock struct {
  m  sync.Mutex
  fd syscall.Handle
}
 
func NewFileLock(filename string) *FileLock {
  if filename == "" {
    panic("filename needed")
  }
  fd, err
    := syscall.CreateFile(
         &(syscall.StringToUTF16(filename)[0]),
         syscall.GENERIC_READ|syscall.GENERIC_WRITE,
         syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE,
         nil,
         syscall.OPEN_ALWAYS,
         syscall.FILE_ATTRIBUTE_NORMAL,
         0)
  if err != nil {
    panic(err)
  }
  return &FileLock{fd: fd}
}
 
func (m *FileLock) Lock() {
  m.m.Lock()
  var ol syscall.Overlapped
  r1, _, e1
   := syscall.Syscall6(
        procLockFileEx.Addr(),
        6,
        uintptr(m.fd),
        uintptr(LOCKFILE_EXCLUSIVE_LOCK),
        uintptr(0),
        uintptr(1),
        uintptr(0),
        uintptr(unsafe.Pointer(ol)))
  if r1 == 0 {
    if e1 != 0 {
      panic(error(e1))
    } else {
      panic(syscall.EINVAL)
    }
  }
}
 
func (m *FileLock) Unlock() {
  var ol syscall.Overlapped
  r1, _, e1
   := syscall.Syscall6(
        procUnlockFileEx.Addr(),
        5,
        uintptr(m.fd),
        uintptr(0),
        uintptr(1),
        uintptr(0),
        uintptr(unsafe.Pointer(ol)),
        0)
  if r1 == 0 {
    if e1 != 0 {
      panic(error(e1))
    } else {
      panic(syscall.EINVAL)
    }
  }
  m.m.Unlock()
}

LockFileEx()でも、排他ロックと共有ロックをフラグで使い分けます。 上記の例では、排他ロックのためのオプション(LOCKFILE_EXCLUSIVE_LOCK)を指定しています。 このオプションを渡さないと共有ロックモードになります。

Windowsでは、ロックの解除はUnlockFileEx()という別のAPIになっています。 上記のコードでは使っていませんが、ロックするファイルの範囲を指定することもできます。

FileLock構造体の使い方

上記で示したPOSIXとWindowsのそれぞれのファイルロックのサンプルコードは、同じAPIをそれぞれの環境用に実装したものです。 これらのコードで定義したFileLock構造体は、下記のような使い方で利用します。

package main
 
import (
    "fmt"
    "time"
)
 
func main() {
    l := NewFileLock("main.go")
    fmt.Println("try  locking...")
    l.Lock()
    fmt.Println("locked!")
    time.Sleep(10 * time.Second)
    l.Unlock()
    fmt.Println("unlock")
}

このコードを実行すると、指定したファイル(上記の例ではmain.goそのもの)のロックを取得して10秒後に解放します。 10秒以内に他のコンソールから同じプログラムを実行すると、最初のプロセスが終了するまで、他のプロセスが待たされることがわかります。

Go言語でマルチプラットフォームを実現するための手段

Go言語には、マルチプラットフォームを実現する方法が、大きく分けて2つあります。

1つめは、Build Constraintsと呼ばれるもので、ビルド対象のプラットフォームを指定する方法です。 具体的には、コード先頭に// +buildに続けてビルド対象のプラットフォームを列挙したり、 ファイル名に _windows.go のようなサフィックスを付けます。

上記のコード例では、POSIX用には// +buildを指定しています。 対象をWindowsに限定する場合は、ファイル名にサフィックスを付けるのが一般的です (そのため上記のWindows用のコードには// +buildを指定していません)。

Go言語でマルチプラットフォームを実現するもう1つの手段は、 runtime.GOOS定数を使って実行時に処理を分岐するという方法です。 ただし、この方法は、今回のようにAPI自体がプラットフォームによって異なる場合にはリンクエラーが発生してしまいます。 そのため、上記のコードでは前者の手段を使っています。

ファイルのメモリへのマッピング(syscall.Mmap

これまでの記事でファイルを読み込むときは、os.Fileを使っていました。 この構造体はシークできるので、ランダムアクセスは可能ですが、それにはいちいち読み込み位置を移動しなければなりません。

そこで登場するのがsyscall.Mmap()システムコールです。 このシステムコールを使うと、ファイルの中身をそのままメモリ上に展開できます。 WindowsでもCreateFileMapping()というAPIと、MapViewOfFile()というAPIの組で同じことが実現できます。

syscall.Mmap()をラップして、クロスプラットフォームで使えるパッケージも何種類かあります。 なかでもシンプルなのは、Go言語の実験的パッケージのひとつであるmmapです。 このパッケージでは、1バイト単位にランダムアクセスするためのAt()メソッドや、ブロック単位で読み出すReadAt()メソッドなどが提供されています。 後者のメソッドをサポートしているため、このパッケージは、連載の第4回の記事で登場したio.ReaderAtインタフェースを満たしています。

今回の解説で使うのは、より柔軟でPOSIXに近いgithub.com/edsrzf/mmap-goパッケージです。 こちらのパッケージ(mmap-go)は、マッピングしたメモリを表すスライスをそのまま返すので、Go言語の文法を自由に使ってデータにアクセスできます。

まずは次のようにしてパッケージをインストールしてください。

$ go get github.com/edsrzf/mmap-go

下記は、ファイルをメモリにマッピングしてから修正してファイルに書き戻す、というサンプルです。 最後に、元データとメモリの内容、それに最初に元データを格納したファイルの内容を読み込んで出力しています。

package main
 
import (
    "github.com/edsrzf/mmap-go"
    "os"
    "io/ioutil"
    "path/filepath"
    "fmt"
)
 
func main() {
    // テストデータを書き込み
    var testData = []byte("0123456789ABCDEF")
    var testPath = filepath.Join(os.TempDir(), "testdata")
    err := ioutil.WriteFile(testPath, testData, 0644)
    if err != nil {
        panic(err)
    }
 
    // メモリにマッピング
    // mは[]byteのエイリアスなので添字アクセス可能
    f, err := os.OpenFile(testPath, os.O_RDWR, 0644)
    if err != nil {
        panic(err)
    }
    defer f.Close()
    m, err := mmap.Map(f, mmap.RDWR, 0)
    if err != nil {
        panic(err)
    }
    defer m.Unmap()
 
    // メモリ上のデータを修正して書き込む
    m[9] = 'X'
    m.Flush()
 
    // 読み込んでみる
    fileData, err := ioutil.ReadAll(f)
    if err != nil {
        panic(err)
    }
    fmt.Printf("original: %s\n", testData)
    fmt.Printf("mmap: %s\n", m)
    fmt.Printf("file: %s\n", fileData)
}

mmapパッケージ(mmpa-go)には次のような関数が用意されています。

  • mmap.Map(): 指定したファイルの内容をメモリ上に展開
  • mmap.Unmap(): メモリ上に展開された内容を削除して閉じる
  • mmap.Flush(): 書きかけの内容をファイルに保存する
  • mmap.Lock(): 開いているメモリ領域をロックする
  • mmap.Unlock(): メモリ領域をアンロックする

上記の例では、まずファイルを読み書きフラグつきでos.OpenFileによってオープンし、その結果をmmap.Map()関数に渡して読み書きモードでメモリ上に展開し、 そこで内容を書き換え(数字の0のところをXに書き換え)、Flush()メソッドを使ってそれをファイルに書き戻しています。 最終的なファイルが書き換わるので、最後にioutil.ReadAll()で読み込んだ内容も元データとは異なります。 実行結果を下記に示します。

original: 0123456789ABCDEF
mmap: 012345678XABCDEF
file: 012345678XABCDEF

C言語のmmap()システムコールには、ファイルを読み込む位置とサイズも指定しなければなりませんが、 mmapパッケージ(mmap-go)ではオフセットとして先頭が、サイズとして最大の長さがデフォルトで指定されるので、 mmap.Map()関数により、1つめの引数に指定されたファイルの全内容がメモリにマップされます。 オフセットとサイズを調整して一部だけを読み込みたい場合は、本来のmmap()システムコールに近い挙動のmmap.MapRegion()を使ってください。

mmap.Map()関数の3つめの引数は、特殊なフラグです。このフラグにmmap.ANONを渡すと、ファイルをマッピングせずに、メモリ領域だけを確保します。

mmap.Map()関数の2つめの引数には、メモリ領域に対して許可する操作を設定します。 許可したい操作に応じて次のような値を指定します。

  • mmap.RDONLY: 読み込み専用
  • mmap.RDWR: 読み書き可能
  • mmap.COPY: コピー・オン・ライト
  • mmap.EXEC: 実行可能にする

コピー・オン・ライトがちょっとわかりにくいかもしれません。 上記のコードで、mmap.Map()の第二引数をmmap.COPYにするとどうなるでしょうか?

original: 0123456789ABCDEF
mmap: 012345678XABCDEF
file: 0123456789ABCDEF

コピー・オン・ライト時は、通常通りメモリ領域にファイルをマッピングしますが、メモリ領域で書き換えが発生するとその領域がまるごとコピーされます。 そのため、元のファイルには変更が反映されません。 不思議な挙動ですが、書き換えが発生するまでは複数バリエーションの状態を保持する必要がないので、メモリを節約できます。

mmapの実行速度

通常のFile.Read()メソッドのシステムコールと比べて、mmapのほうが実行速度が出そうですが、実はケースバイケースです。

この話題は、奥一穂さんのブログでも取り上げられています5。 前から順番に読み込んで逐次処理するのであれば、通常の処理のFile.Read()でも十分に速いでしょう。 データベースのファイルなど、全部を一度にメモリ上に読み込んで処理する必要があって、その上でランダムアクセスが必要なケースでは、mmapのほうが有利なこともあり、使いやすいと思います。 しかし、一度に多くのメモリを確保しなければならないため、ファイルサイズが大きくなるとI/O待ちが長くなる可能性があります。 もちろん、コピー・オン・ライト機能を使う場合や、確保したメモリの領域にアセンブリ命令が格納されていて実行を許可する必要がある場合には、mmap一択です。

mmapは、メモリ共有の仕組みとしても使えたりもします。ファイルI/O以外の話なので、メモリの紹介をするときに改めて取り上げたいと思います。

同期・非同期 / ブロッキング・ノンブロッキング

第10回の記事でも紹介しましたが、ファイルもネットワークも、CPU内部の処理に比べると劇的に遅いタスクです6。 そのため、これらのデータ入出力が関係するプログラミングにおいては、「重い処理」に引きづられてプログラム全体が遅くならないようにする仕組みが必要になります。

そのための仕組みを、OSのシステムコールにおいて整備するためのモデルとなるのが、同期非同期、そしてブロッキングノンブロッキングという分類です。 なお、この分類はIBM developerWorksの「Boost application performance using asynchronous I/O」7で紹介されているものです。 日本語の解説記事としては、松本亮介さんのブログエントリー「非同期I/OやノンブロッキングI/O及びI/Oの多重化について」8が参考になるでしょう。

同期と非同期には次のような区別があります。

  • 同期: OSに仕事を投げて、入出力の準備ができたらアプリケーションに処理が返ってくる
  • 非同期: OSに仕事を投げて、入出力が完了したら通知をもらう

ブロッキングとノンブロッキングは、次のような区別です。

  • ブロッキング: お願いした仕事が完了するまで待つ
  • ノンブロッキング: お願いした仕事が完了するのを待たない

したがって、OSのシステムコールも、同期か非同期か、そしてブロッキングかノンブロッキングかによって、全部で以下のような4つの種類に分かれます。

これらのシステムコールの分類は「シングルスレッドで動作するプログラム」で「効率よくI/Oをさばくための」の指標ですので、クライアント側がマルチプロセスやマルチスレッドで動作するときのことはひとまず忘れてください。

同期・ブロッキング

同期・ブロッキングの処理は、読み込み・書き込み処理が完了するまでの間、何もせずに待ちます。 重い処理があると、そこですべての処理が止まってしまいます。 実行時のパフォーマンスは良くないのですが、コードはもっともシンプルで理解しやすいという特徴があります。

同期・ノンブロッキング

同期・ノンブロッキングは、いわゆるポーリングです。 ファイルオープン時にノンブロッキングのフラグを付与することで実現できます。 APIを呼ぶと、「まだ完了していないか」どうかのフラグと、現在得られるデータが得られます。 クライアントは、完了が返ってくるまで何度もAPIを呼びます。

非同期・ブロッキング

非同期・ブロッキングは、I/O多重化(I/Oマルチプレクサー)とも呼ばれます。 準備が完了したものがあれば通知してもらう、というイベント駆動モデルです。 そのための通知には、select属9と呼ばれるシステムコールを使います。 POSIX共通のAPIはselectシステムコールですが、 実行効率に難があるため、移植性が落ちても各OSで効率の良い方法(Linuxのepoll、BSD系OSのkqueue、WindowsのI/O Completion Port)が使われます。

Go言語でI/O多重化を実現する方法は次節で紹介します。

非同期・ノンブロッキング

メインプロセスのスレッドとは完全に別のスレッドでタスクを行い、完了したらその通知だけを受け取るという処理です。 APIとしては、POSIXのAPIで定義されている非同期I/O(aio_*)インタフェースが有名です。

ただし、現在のaio_*はLinuxではユーザー空間でスレッドを使って実装されており、実装が成熟していない10とmanに書かれています。 これとは別にLinuxカーネルレベルAIO11というものもありますが、これもあまり使われていません。 Go言語のsyscallパッケージでも機能が提供されていません。

Node.jsでも、Linuxのaioの採用が何度か検討されましたが、「2016年でもまだ安定していない」「Linux作者のリーナスも興味がなく、改善される見込みがない」などのいくつかの理由によって却下されています12。 高速な性能がうりのNginxも、当初はaioを使っていましたが、マルチスレッドを使うことでスループットがさらに9倍になったと報告されています13

Go言語でさまざまなI/Oモデルを扱う手法

並行処理が得意なGo言語ですが、ベースとなるファイルI/OやネットワークI/Oは、シンプルな同期・ブロッキングインタフェースになっています。 同期・ブロッキングのAPIを並行処理するだけでも、重い処理で全体が止まることがなくなるため効率が改善できます。

もちろん、Go言語のプログラムで同期・非同期、ブロッキング・ノンブロッキングのモデルを使いこなすことに意味がないわけではありません。 Go言語で同期・ブロッキング以外のI/Oモデルを実現するには、ベースの言語機能(goroutine、チャネル、select)をアプリケーションで組み合わせます。

  • goroutineをたくさん実行し、それぞれに同期・ブロッキングI/Oを担当させると、非同期・ノンブロッキングとなります
  • goroutineで並行化させたI/Oの入出力でチャネルを使い、他のgoroutineとのやりとりする個所のみの同期が行えます
  • このチャネルにバッファがあれば書き込み側も、ノンブロッキングとなります
  • これらのチャネルでselect構文を使うことで非同期・ブロッキングのI/O多重化が行えます
  • select構文にdefault節があると、読み込みをノンブロッキングで行えるようになり、aio化が行えます。

このように、基本シンタックスの組み合わせで、同期で実装したコードをラップして、さまざまなI/Oのスタイルが実現できる点がGo言語の強みです。 他の言語だと、非同期を後から足そうとするとプログラムの多くの箇所で修正が必要になることがあります。 特に、非同期・ブロッキング(select属)の追加では、プログラムが構造から変わってくることすらあります。

非同期やノンブロッキングを扱う場合でも、それらを統括するメインロジック・メインループは基本的に逐次処理となるでしょう。 Go言語では基本構文だけで非同期やノンブロッキングをまとめあげることができます。 Go言語プログラマ間では見慣れたイディオムとして知識が共有されているため、コードが読みやすくなります。

select属のシステムコールによるI/O多重化

前節で説明したように、非同期・ブロッキングは1スレッドでたくさんの入出力を効率よく扱うための手法であり、I/O多重化とも呼ばれます。 それを効率よく実現するAPIのことをselect属と総称します。

前述のように、並行処理を使えば小さい規模のI/Oの効率化は十分に行えますが、select属はC10K問題と呼ばれる、万の単位の入出力を効率よく扱うため手法として有効です。 ネットワークについては、すでにselect属のシステムコールがGo言語のランタイム内部に組み込まれており、サーバーを実装したときに効率よくスケジューラが働くようになっています。 第11回のファイルシステムの監視などたくさんのファイルを同時に扱うときに使えます。

POSIXには、select属のシステムコールとして、selectpollがあります。 しかし、selectは、扱うディスクリプタの数が増えたときの性能に問題があります。 pollも、多少はましですが、移植性に欠けます。 select属のAPIを使う目的は、大量のI/Oをさばくときのパフォーマンスを向上させることなので、中途半端なAPIでは使う意味がありません。 そのため、パフォーマンスも移植性も落ちるPoll()は、Go言語のsyscallパッケージにも含まれていません。

現在では、多重I/Oを実現する場合、各OSが提供している効率の良いシステムコールを使うのが定石となっています。 以下の表に、Go言語のruntimeパッケージ内で利用されているselect属のAPIをまとめます。

種類 Go言語のランタイム使用OS
select 使わず
poll 使わず(Goもサポートせず)
epoll Linux
kqueue BSD系OS(macOSも含む)
Event Ports Solaris
I/O Completion Port Windows
サポートせず NaCL、Plan 9

以下は、これらのselect属のAPIのうち、macOSのkqueueの使い方を示すサンプルコードです14./testフォルダ内の変更を監視してイベントを待つという動作を実装したものです。

package main
 
import (
  "fmt"
  "syscall"
)
 
func main() {
  kq, err := syscall.Kqueue()
  if err != nil {
    panic(err)
  }
  // 監視対象のファイルディスクリプタを取得
  fd, err := syscall.Open("./test", syscall.O_RDONLY, 0)
  if err != nil {
    panic(err)
  }
  // 監視したいイベントの構造体を作成
  ev1 := syscall.Kevent_t{
    Ident:  uint64(fd),
    Filter: syscall.EVFILT_VNODE,
    Flags:  syscall.EV_ADD | syscall.EV_ENABLE | syscall.EV_ONESHOT,
    Fflags: syscall.NOTE_DELETE | syscall.NOTE_WRITE,
    Data:   0,
    Udata:  nil,
  }
  // イベント待ちの無限ループ
  for {
    // kevent作成
    events := make([]syscall.Kevent_t, 10)
    nev, err := syscall.Kevent(kq, []syscall.Kevent_t{ev1}, events, nil)
    if err != nil {
      panic(err)
    }
    // イベントを確認
    for i := 0; i < nev; i++ {
      fmt.Printf("Event [%d] -> %+v\n", i, events[i])
    }
  }
}

上記のコードでは、監視対象のイベント詳細が格納された構造体(syscall.Kevent_t )を作り、それをsyscall.Kevent()に渡しています。 すると、発生したイベントのリストがsyscall.Kevent()の3つめの引数に入ります。 syscall.Kevent()の4つめの引数は、その際のタイムアウトで、タイムアウトを設定しなければ結果が返ってくるまでブロックし続けます。 これにより、非同期・ブロッキングのイベント待ち処理が実現できます。

逆に、下記のようにタイムアウトをゼロにすれば即座に処理が返ってくるため、ノンブロッキングAPIとして使うことも可能です。

timeout := syscall.Timespec{
  Sec:  0,
  Nsec: 0,
}
:
nev, err := syscall.Kevent(kq, []syscall.Kevent_t{ev1}, events, &timeout)

ここではmacOSの例だけを紹介しましたが、他のOSで用意されているselect属のシステムコールも基本的には同様に使えます。 なるべく多くの例を紹介したいところですが、環境も、サポートされるイベントやオプションの種類も膨大なので、本連載ではイメージだけを伝えるにとどめます。

Linuxカーネルだと、timerfd()signalfd()eventfd()などのシステムコールを使うことで、タイマー、シグナル、プロセス間通信もすべてファイルディスクリプタにより扱えます。 したがって、select属のシステムコールでタイマーやシグナルも同時に扱えるようになっています。

ただし、Go言語ではこれらの機能をファイルディスクリプタにする関数が提供されていないので、同じことはできません。 その代わりに、I/Oモデルの中で紹介したように、Go言語であればチャネルを使うことで非同期・ブロッキングをシミュレートできます。 Go言語のタイマーやシグナルはチャネルで扱えるため、アプリケーションレイヤーでLinuxのシステムコールと同じことは実現できます。

まとめと次回予告

今回は、アプリケーションから見たファイルシステム周りの最深部を辿りました。

正直なところ、Go言語は基本のパフォーマンスが高いため、ファイルロックを別にすればそこまでのパフォーマンス・チューニングが必要になることはあまりありません。 筆者も、C言語で書かれたコードをGo言語に移植したときに同じシステムコールを利用した程度です。 しかし、Go言語であれば、いざ必要になったときでもC言語による拡張ライブラリなどを実装せずにそこそこのコード量でこれらの機能が使えるという安心感はあります。

次回からはプロセスまわりの話を紹介していきます。

脚注

  1. とほほのCGI入門 > ファイルのロックに関する基礎知識: http://www.tohoho-web.com/wwwcgi8.htm
  2. https://github.com/golang/build/tree/master/cmd/builder
  3. https://linuxjm.osdn.jp/html/LDP_man-pages/man3/lockf.3.html
  4. https://linuxjm.osdn.jp/html/LDP_man-pages/man2/fcntl.2.html
  5. mmapのほうがreadより速いという迷信について: http://d.hatena.ne.jp/kazuhooku/20131010/1381403041
  6. もちろん、ソケット通信の中でもUnixドメインソケットは高速ですし、ファイルもすべてバッファに載っていれば高速ですが、これらは例外です。
  7. http://www.ibm.com/developerworks/linux/library/l-async/
  8. http://blog.matsumoto-r.jp/?p=2030
  9. select属という総称は、とちぎRubyの咳さんによる用語です。
  10. https://linuxjm.osdn.jp/html/LDP_man-pages/man7/aio.7.html
  11. http://www.oreilly.co.jp/community/blog/2010/09/buffer-cache-and-aio-part3.html
  12. https://github.com/libuv/libuv/issues/461
  13. https://www.nginx.com/blog/thread-pools-boost-performance-9x/
  14. 参考: https://gist.github.com/nbari/386af0fa667ae03daf3fbc80e3838ab0

カテゴリートップへ

この連載の記事
ピックアップ