言語の壁をぶっ壊す。どうも、かわしんです。
プロセス間の待ち合わせの手法としてファイルロックがあります。このファイルロックをタイムアウトでキャンセルすることを可能にするために以下のライブラリを作ったのでその解説をしたいと思います。
対象
今回の対象は以下の環境とします
ファイルロックを実現するシステムコール
Linux では、fcntl
と flock
というシステムコールでファイルロックができます。
この 2 つで獲得されるロックはカーネル内では別のものとして扱われますが、 FLOCK の 注意 にもある通り、一部のシステムでは flock
と fcntl
が影響を与える可能性があるため、単一のプログラムの中ではどちらかのロックのみを使うようにした方が良さそうです。(プログラムの複雑性も軽減されます)
今回の話は、fcntl
と flock
のどちらでも共通の話なので、fcntl
を使ってデモを行います。
FCNTL
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_RDLCK
と F_WRLCK
があり flock
構造体で指定します。go でいう sync.RWMutex
みたいな感じですね。F_UNLCK
を指定するとロックを解除します。
FLOCK
次に flock
は、名前の通りファイルをロックするシステムコールです。
flock
は BSD 系由来のシステムコールで 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)
これらはあくまでもシステムコールをラップしただけのものなのでタイムアウトの機能を追加する必要があります。
github で file lock
でタイムアウト付きのファイルロックの Go の実装を調べていると以下の2つのライブラリを探すことができました。
github.com/gofrs/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
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) } }()
ロックが獲得できなかった場合、 ロック開放用とロックを取得する 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_RESTART
は sigaction
に設定するフラグで、このフラグが設定されると、ブロッキングしているシステムコールがシグナルによって中断された場合でも 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
を作った
これまでの知見を元にブロッキングしている処理を中断させる処理をライブラリ化しました。
使い方としては
- 中断に使うシグナルを指定して
gointr.Intruptter
を作成する (gointr.New(syscall.SIGUSR1)
) - ブロッキングする処理を行う goroutine を立ち上げて
intr.Setup()
を実行してから、ブロッキング処理を行う - ブロッキング処理が終わったら
intr.Close()
する - もし、中断する場合は
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 を使う。