言語の壁をぶっ壊す。どうも、かわしんです。
プロセス間の待ち合わせの手法としてファイルロックがあります。このファイルロックをタイムアウトでキャンセルすることを可能にするために以下のライブラリを作ったのでその解説をしたいと思います。
対象
今回の対象は以下の環境とします
ファイルロックを実現するシステムコール
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 を使う。