kawasin73のブログ

技術記事とかいろんなことをかくブログです

Go 言語でタイムアウト付きのファイルロックを実現する

言語の壁をぶっ壊す。どうも、かわしんです。

プロセス間の待ち合わせの手法としてファイルロックがあります。このファイルロックをタイムアウトでキャンセルすることを可能にするために以下のライブラリを作ったのでその解説をしたいと思います。

github.com

対象

今回の対象は以下の環境とします

ファイルロックを実現するシステムコール

Linux では、fcntlflock というシステムコールでファイルロックができます。

この 2 つで獲得されるロックはカーネル内では別のものとして扱われますが、 FLOCK の 注意 にもある通り、一部のシステムでは flockfcntl が影響を与える可能性があるため、単一のプログラムの中ではどちらかのロックのみを使うようにした方が良さそうです。(プログラムの複雑性も軽減されます)

今回の話は、fcntlflock のどちらでも共通の話なので、fcntl を使ってデモを行います。

FCNTL

fcntl は、ファイルディスクリプタを操作するシステムコールです。

Man page of FCNTL

#include <unistd.h>
#include <fcntl.h>

int fcntl(int fd, int cmd, ... /* arg */ );

以下のコマンドを指定して、引数に flock 構造体を渡すとファイルロックができます。

ロックはファイルのバイト単位で範囲を指定してロックします。

基本はアドバイザリーロックで、頑張ると強制ロックも可能です。

これらは POSIX で標準化されています。

  • F_SETLK : ロックの獲得をノンブロッキングで行います。ロックの獲得に失敗した場合は EACCES または EAGAIN エラーが返ります。
  • F_SETLKW : ロックの獲得を行います。ロックの獲得に失敗した場合はロックの獲得ができるまで処理をブロックします。
  • F_GETLK : ロックの情報を取得します。

ロックの種類は F_RDLCKF_WRLCK があり flock 構造体で指定します。go でいう sync.RWMutex みたいな感じですね。F_UNLCK を指定するとロックを解除します。

FLOCK

次に flock は、名前の通りファイルをロックするシステムコールです。

Man page of FLOCK

flockBSD 系由来のシステムコールPOSIX には含まれませんが、シンプルなインターフェイスです。

ロックの範囲はファイル全体のみで、fcntl のように特定の範囲のみをロックすることはできません。

ロックはアドバイザリーロックになります。

#include <sys/file.h>

int flock(int fd, int operation);  

operation に以下のオペレーションを指定してファイルをロックします。

  • LOCK_SH : 共有ロック。fcntl でいう F_RDLCK みたいな
  • LOCK_EX : 排他ロック。fcntl でいう F_WRLCK みたいな
  • LOCK_UN : ロックの解除

また、operation論理和LOCK_NB を指定するとノンブロッキングでロックを獲得し、指定しない場合はロックを獲得できるまで処理をブロックします。

Go 言語におけるファイルロック

Go では syscall パッケージに syscall.FcntlFlock()syscall.Flock() が用意されています。

// fcntl のインターフェイス
func FcntlFlock(fd uintptr, cmd int, lk *Flock_t) error

// flock のインターフェイス
func Flock(fd int, how int) (err error)

これらはあくまでもシステムコールをラップしただけのものなのでタイムアウトの機能を追加する必要があります。

githubfile lockタイムアウト付きのファイルロックの Go の実装を調べていると以下の2つのライブラリを探すことができました。

github.com/gofrs/flock

GitHub - gofrs/flock: Thread-safe file locking library in Go (originally github.com/theckman/go-flock)

gofrs/flock には、TryLockContext というメソッドがあり context.Context を利用してタイムアウトやロックの中断を実現できます。

func (f *Flock) TryLockContext(ctx context.Context, retryDelay time.Duration) (bool, error)

しかし、その内部実装は syscall.Flock をノンブロッキングモードで呼び出し、成功するまで for 文で retryDelay で設定された間隔で呼び出し続けるものになっています。

これでは、カーネル内部のロックキュー に入らないため、 複数プロセス間のロックの順序が崩れてしまいます 。(参考 : linux - flock locking order? - Stack Overflow)

また、ロックの獲得まで 最大で retryDelay の分の遅延が発生する ことになります。

github.com/jviney/go-filelock

GitHub - jviney/go-filelock

jviney/go-filelock では、Obtain 関数にタイムアウトの時間を渡してタイムアウト付きのファイルロックができます。

func Obtain(path string, timeout time.Duration) (lock *Lock)

しかし、その内部実装は syscall.Flockブロッキングモードで実行する goroutine を立ち上げて、その終了とタイムアウトObtain 関数内で select 文で待つものでした。

タイムアウトするとさらに新しい goroutine を立ち上げ、その goroutine 内でロックが獲得できるまで待って即座にロックを開放するようにしています。

      // We hit the timeout without successfully getting the lock.
      // The goroutine blocked on syscall.Flock() is still running
      // and will eventually return at some point in the future.
      // If the lock is eventually obtained, it needs to be released.
      go func() {
        if err := <- flockChan; err == nil {
          releaseFlock(file)
        }
      }()

https://github.com/jviney/go-filelock/blob/ec38a70cdf163d7c9f50a224f4c422f8a1847d7a/filelock.go#L48-L56

ロックが獲得できなかった場合、 ロック開放用とロックを取得する 2 つの goroutine が残り続けます

また、ロックを獲得中のファイルディスクリプタを閉じるとロックが獲得するまでブロックします。 ファイルディスクリプタを解放することができない ためファイルディスクリプタも残り続けます。

これではリソース管理の面からガバガバです。

どうやってファイルロックを中断するか

上の 2 つのライブラリはファイルロックの直接的な中断ができなかったためにこのような課題が残っていました。

この点を解決するために、ここでどうやってブロックしているファイルロックを中断するかを考える必要があります。

答えは シグナル です。

ブロッキングするシステムコールはシグナルを受信することによってブロックが解除され EINTR エラーを返すようになっています。(タイムアウトという文脈では alarm を使うことが多そうです)

Timeouts for system calls are done with signals. Most blocking system calls return with EINTR when a signal happens, so you can use alarm to implement timeouts.

https://stackoverflow.com/questions/5255220/fcntl-flock-how-to-implement-a-timeout#answer-5255473

そのためシグナルを送信してロック獲得処理に EINTR を発生させることでファイルロックのタイムアウトや中断を実現できます。

シグナルによる割り込みを Go でも実現させる

シグナルを自分のプロセスに送れば解決するはずなのですが、Go では以下の 2 つの障壁があります。

  • SA_RESTART によって EINTR がそもそも発生しない
  • シグナルを pthread_kill 使って送信しないといけない

それぞれ順をおって解説します。

SA_RESTART によって EINTR がそもそも発生しない

SA_RESTARTsigaction に設定するフラグで、このフラグが設定されると、ブロッキングしているシステムコールがシグナルによって中断された場合でも EINTR を返さずにブロッキングを続行するようになります。

参考 : シグナルハンドラーによるシステムコールやライブラリ関数への割り込み

Go ではランタイムによって全てのシグナルに対して SA_RESTART が設定されています。これにより標準ライブラリは EINTR のエラーハンドリングをしなくてよくなります。(確かに標準ライブラリを読んでいると EINTR のハンドリングをしていないことに気づきます)

Also, the Go standard library expects that any signal handlers will use the SA_RESTART flag. Failing to do so may cause some library calls to return "interrupted system call" errors.

https://golang.org/pkg/os/signal/#hdr-Go_programs_that_use_cgo_or_SWIG

これは Go のランタイムの問題なのでどうしようもありません。Go 言語のレイヤーでは解決できません。

ここで、「 言語の壁をぶっ壊す 」CGO の出番です。Go 言語を壊していきます。

CGO によって直接 C 言語で sigaction を実行しシグナルハンドラを設定することで Go のランタイムが設定した SA_RESTART を上書きします。

この時点で Windows とかの可搬性は捨ててます。すみません。

void sighandler(int sig){
    // empty handler
}
static struct sigaction oact;

static int setup_signal(int sig) {
    struct sigaction act;
    // setup sigaction
    act.sa_handler = sighandler;
    act.sa_flags = 0;
    sigemptyset(&act.sa_mask);
    // set sigaction and cache old sigaction to oact
    if(sigaction(sig, &act, &oact) != 0){
        return errno;
    }
    return 0;
}

シグナルを pthread_kill 使って送信しないといけない

さて、SA_RESTART を上書きした上で自身のプロセスにシグナルを送るとブロッキングしているファイルロックを中断できる場合とできない場合が出てきます。

それは、事前に signal パッケージの signal.Ignore()signal.Notify() などを呼び出した時です。

これらのメソッドを呼び出した時に Go のランタイムは signal mask thread を立ち上げそのスレッドが全ての sigaction の登録を行います。(具体的には ensureSigM という関数)

go/signal_unix.go at 2c5363d9c1cf51457d6d2466a63e6576e80327f8 · golang/go · GitHub

これによって プロセススコープに送られたシグナル はランタイムの signal mask thread に送られてしまいます。その場合は別のスレッドでファイルロックをブロックしているためシグナルが届かず EINTR も発生しません。

この解決策は スレッドスコープでシグナルを送る ことです。

残念ながら Go 言語は OS のスレッド意識させないような作りになっており、スレッドに対して直接シグナルを送ることはできません。

そこで、「 言語の壁をぶっ壊す 」CGO の出番です。どんどん Go 言語を壊していきます。

C では、pthread_kill という関数があり特定のスレッドにシグナルを送ることができます。

static pthread_t tid;

static int setup_signal(int sig) {
    ...
    // set self thread id
    tid = pthread_self();
    ...
}

static int kill_thread(int sig) {
    // send signal to thread
    if (pthread_kill(tid, sig) == -1) {
        return errno;
    }
    return 0;
}

これでブロックしているファイルロックを中断させることができるようになりました。

github.com/kawasin73/gointr を作った

これまでの知見を元にブロッキングしている処理を中断させる処理をライブラリ化しました。

github.com

使い方としては

  1. 中断に使うシグナルを指定して gointr.Intruptter を作成する (gointr.New(syscall.SIGUSR1))
  2. ブロッキングする処理を行う goroutine を立ち上げて intr.Setup() を実行してから、ブロッキング処理を行う
  3. ブロッキング処理が終わったら intr.Close() する
  4. もし、中断する場合は intr.Signal() を呼び出す

となります。中断された場合は EINTRブロッキングするシステムコールから返ってきます。

注意点

注意点としては、以下のことが挙げられます。気をつけてください。

  • グローバル変数を内部で使っているため 複数のブロッキング処理の中断に対応していない
  • signal パッケージの関数を呼び出すと上書きした sigaction が上書きされ直されるので ブロッキング処理中は signal パッケージの関数を呼んではいけない
  • CGO を使っている

Example

package main

import (
    "fmt"
    "io"
    "log"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/kawasin73/gointr"
)

// lock locks file using FCNTL.
func lock(file *os.File) error {
    // write lock whole file
    flock := syscall.Flock_t{
        Start:  0,
        Len:    0,
        Type:   syscall.F_WRLCK,
        Whence: io.SeekStart,
    }

    if err := syscall.FcntlFlock(file.Fd(), syscall.F_SETLKW, &flock); err != nil {
        // FCNTL returns EINTR if interrupted by signal on blocking mode
        if err == syscall.EINTR {
            return fmt.Errorf("file lock timeout for %q", file.Name())
        }
        return &os.PathError{Op: "fcntl", Path: file.Name(), Err: err}
    }
    return nil
}

func main() {
    signal.Ignore()

    file, err := os.Create("./.lock")
    if err != nil {
        log.Panic(err)
    }

    // init pthread
    intr := gointr.New(syscall.SIGUSR1)

    // init error channel
    chErr := make(chan error, 1)

    // setup timer
    timer := time.NewTimer(3 * time.Second)

    go func() {
        // setup the thread signal settings
        if terr := intr.Setup(); terr != nil {
            chErr <- terr
            return
        }
        defer func() {
            // reset signal settings
            if terr := intr.Close(); terr != nil {
                // if failed to reset sigaction, go runtime will be broken.
                // terr occurs on C memory error which does not happen.
                panic(terr)
            }
        }()
        // lock file blocking
        chErr <- lock(file)
    }()

    for {
        select {
        case err = <-chErr:
            timer.Stop()
            if err == nil {
                log.Println("lock success")
            } else {
                log.Println("lock fail err", err)
            }
            // break loop
            return

        case <-timer.C:
            log.Println("timeout")

            // send signal to the thread locking file and unblock the lock with EINTR
            err := intr.Signal()
            log.Println("signal")
            if err != nil {
                log.Panic("failed to kill thread", err)
            }
            // wait for lock result from chErr
        }
    }
}

最後に

以上です。ありがとうございました。

ぶっちゃけ、ここまで厳密にするなら Go を使わなくていいと思うし、多分自分が使うとしたら github.com/gofrs/flock を使う。