
2026/03/25 5:29
macOS で kqueue を利用したファイル変更の検知方法
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
概要:
この記事では、著者が reload と呼ばれる軽量な Go ファイルウォッチャーを構築した方法について説明しています。このツールは、監視対象のファイルが変更されるたびに指定されたコマンドを自動的に再起動し、C コードの高速再コンパイルや静的サイトのリビルドに便利です。
Reload は 2 つのモードで動作します:
- コマンドラインで渡された明示的なファイル名を監視するモード
- 現在の作業ディレクトリ内のすべてのファイルを再帰的に監視するモード
この実装は macOS の
kqueue イベント通知システムを Go の fsnotify ライブラリ経由で利用しています。各監視対象ファイルは O_EVTONLY|O_CLOEXEC でオープンされ、EVFILT_VNODE | NOTE_WRITE(EV_ADD | EV_CLEAR で登録)に相当する kevent がキューに追加されます。イベントループは kevent() をブロックし、変更が発生するとそのパスを返します。
kqueue はオープンされたファイルの変更のみを報告するため、実装ではディレクトリツリーを走査してすべてのファイルを開きます。ウォッチャーはパスとファイル記述子(fds, fdPaths)のマッピングを保持し、すべての fds に対して O_CLOEXEC を設定して子プロセスへのリークを防ぎます。イベントがディレクトリから発生した場合、ウォッチャーはそのディレクトリを再走査して新しいファイルを追加します。削除されたファイルは削除されず、結果として記述子リークが発生する可能性がありますが、小規模プロジェクトでは許容範囲です。
大規模なディレクトリツリーの場合、
kqueue の per‑FD オーバーヘッドが問題になるため、著者はポーリングや macOS の FSEvents などの代替手段を提案しています。本文
2026‑03‑24
数か月前、私は自分用に小さなファイルウォッチャーを Go で書き、併せてブログ記事を書きました。
私が求めていたのは、開発サイクル中に実行しているコマンドの直前に置くだけで良いツールでした。
reload gcc main.c -o main && ./main reload make
このツールには 2 つのモードがあります。
- コマンドラインに 1 つ以上のファイル名が明示的に指定されている場合、そのファイルを監視します。
- ファイル名が指定されていない場合は、作業ディレクトリ内のすべてのファイルを監視します。
reload が知っておく必要があるのは「監視しているファイルのどれかが変更されたか」という点だけです。
もし変更が検出できたら、そのコマンドを再実行します。
うまく動作します! ただ、私にとって最も馴染みのない部分――ファイル変更の検知――は途中で諦めてしまいました。
私は fsnotify パッケージを使いました。これはクロスプラットフォームな Go ライブラリで、macOS と Linux の両方に対応していますが、自分用なので Linux への対応は気にしていません。
もっと重要なのは、fsnotify が内部で何を行っているのかを理解したかったことです。
macOS では kqueue イベント通知インタフェースを使用します。
この仕組みを見て、C コードを書いてテストし、最後に Go の reload プログラムに実装していきましょう。
kqueue データ構造
kqueue() 関数呼び出しは新しいカーネルイベントキュー(kqueue)を作成し、ファイルディスクリプタを返します。システムイベントの登録と待機には
kevent() を使い、kevent データ構造を利用します。この構造体は 5 フィールドからなります。
| フィールド | 意味 |
|---|---|
| イベントの発信源。ここでは監視したいファイルのファイルディスクリプタになります。 |
| カーネルフィルタ。 |
| イベントに対して行うアクション。 |
| フィルタ固有フラグ。 |
| 不透明なユーザデータ識別子。後で簡単にファイル名を取得できるようにします。 |
ファイル変更を監視するにはどのカーネルフィルタを使う?
9 つの可能性がありますが、EVFILT_VNODE を使用します。
EVFILT_VNODE ファイルディスクリプタを識別子として取り、fflags に指定したイベントを監視し、いずれかのイベントが発生すると返却する。
fflags には 10 種類のイベントがありますが、必要なのは NOTE_WRITE のみです。
| フラグ | 意味 |
|---|---|
| ファイルに書き込みが行われた。 |
最後に flags はイベント時に実行するアクションを定義します
10 種類のフラグがありますが、EV_ADD(kqueue にイベントを登録)と EV_CLEAR(配信後に状態をリセット)だけを使います。
EV_CLEAR を付けないと最初の変更から再び同じファイル変更を何度も受け取ってしまうので注意してください。
この 2 つの書き込みが kqueue に届くまでに複数回行われた場合は 1 件にまとめられます。
ただし、別々のファイルへ複数回書き込まれた場合はそれぞれ別個のイベントになります。
実際には reload で「同じタイミングに近いイベントが連続して来てもコマンドを再実行しない」ようなウィンドウを設けることになるでしょう。
以上を踏まえて、
kevent 構造体の初期化方法が分かります。
名前付きファイルを監視する
引数で渡されたすべてのファイルを監視するプログラムを書いてみます。
エラー処理は稀なケースだけ簡略化していますが、存在しないファイルを開こうとした場合などは必ず扱います。
まず
O_EVTONLY でファイルをオープンします。マニュアルページには「イベント専用モードは kqueue のようにファイルの変更監視にのみ使用される」とあります。
必要なヘッダ:
#include <sys/types.h> #include <sys/event.h> #include <sys/time.h>
kevent 構造体を使って、コマンドラインで指定されたファイルへの書き込みイベントを監視します。EV_SET マクロで構造体を初期化します。
// すべてのファイルを開いて変更イベントを設定 int nfiles = argc - 1; int *fds = malloc(nfiles * sizeof(int)); struct kevent *changes = malloc(nfiles * sizeof(struct kevent)); for (int i = 0; i < nfiles; i++) { fds[i] = open(argv[i + 1], O_EVTONLY); if (fds[i] == -1) { fprintf(stderr, "open(%s): \n", argv[i + 1]); exit(1); } EV_SET( &changes[i], fds[i], EVFILT_VNODE, EV_ADD | EV_CLEAR, NOTE_WRITE, 0, (void *)argv[i + 1] // udata: 不透明なユーザデータ。ここにファイル名を格納 ); }
次に
kevent() を呼び出してイベントを登録します。
// すべてのイベントを一括で登録 int kq = kqueue(); kevent( kq, // キュー changes, // 登録する kevent 配列 nfiles, // 配列長 NULL, // イベント情報を受け取る構造体(ここでは不要) 0, // 待機するイベント数 NULL // タイムアウト(不要なのでNULL) );
これでイベントループに入ってファイル変更を待ちます。
struct kevent event; while (1) { kevent( kq, // キュー NULL, // 登録するイベントはない 0, &event, // イベント情報が書き込まれる構造体 1, // 待機するイベント数(ここでは 1) NULL // タイムアウトなし。永遠に待つ ); if (event.fflags & NOTE_WRITE) { const char *name = (const char *)event.udata; printf("[%s] written\n", name); } }
完全なコードは GitHub Gist にあります。
ディレクトリを監視する
reload の 2 番目のモードでは、現在作業中のディレクトリ全体を監視します。
reload make
まずディレクトリ自体を開き、先ほどと同じように監視します。
int fd = open(directory, O_EVTONLY);
これで「新しいファイルが追加された」「既存のファイルが削除された」時にイベントが発火します。
しかし 既存ファイルへの変更 にはイベントは発生しません。
そのため、ディレクトリ内のすべてのファイルを個別に開いて監視する必要があります!
つまり「ファイルが作成されたら、そのファイルも監視対象に追加」する仕組みが必要です。
Go で reload を実装したので、次は Go コードを見ていきます。
Go での実装
kqueue の参照と開いているファイルディスクリプタを管理します。
またパスでファイルを参照できるようにします。
type watcher struct { kq int // kqueue ファイルディスクリプタ fds map[string]int // path -> file descriptor マッピング fdPaths map[int]string // file descriptor -> path(逆引き) }
kqueue を作成するときに CloseOnExec を呼びます。
reload がコマンドを再実行する際、Go の
exec パッケージは fork + exec パターンを使います。fork は親プロセスの開いているすべてのファイルディスクリプタ(kqueue と監視対象のファイル)を子にコピーしますが、子プロセスには不要です。
これらを残しておくとリソースリークや予期せぬ挙動につながります。
func newWatcher() (*watcher, error) { kq, err := unix.Kqueue() if err != nil { return nil, err } // 子プロセスに kqueue fd を継承させないようにする unix.CloseOnExec(kq) return &watcher{ kq: kq, fds: make(map[string]int), fdPaths: make(map[int]string), }, nil }
ファイルを監視対象に追加するときは、
O_EVTONLY | O_CLOEXEC で開き、kqueue に書き込みイベントを登録します。
func (w *watcher) Add(path string) error { // 既に監視している場合はスキップ if _, exists := w.fds[path]; exists { return nil } fd, err := unix.Open(path, unix.O_EVTONLY|unix.O_CLOEXEC, 0) if err != nil { return err } // ファイル/ディレクトリを登録 w.fds[path] = fd w.fdPaths[fd] = path _, err = unix.Kevent( w.kq, []unix.Kevent_t{{ Ident: uint64(fd), Filter: unix.EVFILT_VNODE, Flags: unix.EV_ADD | unix.EV_CLEAR, Fflags: uint32(unix.NOTE_WRITE), }}, nil, // 受け取るイベントはない nil, // タイムアウトなし ) if err != nil { unix.Close(fd) return err } return nil }
ディレクトリ内のすべてのファイルを追加するには、再帰的に走査して
Add を呼びます。
func (w *watcher) addRecursive(path string) error { info, err := os.Stat(path) if err != nil { return err } // ファイルなら直接監視 if !info.IsDir() { return w.Add(path) } // ディレクトリを走査し、すべてのエントリを追加 return filepath.WalkDir(path, func(walkPath string, d os.DirEntry, err error) error { if err != nil { // 走査失敗時はそのまま返却 return err } // ディレクトリもファイルも監視対象に追加 if err := w.Add(walkPath); err != nil { return err } return nil }) }
最後に kqueue を待ち受けるループを作ります。
イベントが来たらそのファイル名を返し、メインプログラムでコマンドを再実行します。
// 任意のファイル変更が来るまでブロックし、変更されたファイル名を返す func (w *watcher) Wait() (string, error) { events := make([]unix.Kevent_t, 1) for { n, err := unix.Kevent( w.kq, nil, // 登録はない events, // イベント情報を書き込む配列 nil, // タイムアウトなし ) if err != nil { if err == unix.EINTR { // シグナルで割り込みが来たら再試行 continue } return "", err } if n > 0 { path, ok := w.fdPaths[int(events[0].Ident)] if ok { return path, nil } } } }
ディレクトリ内に新しいファイルが作成された場合、そのファイルを監視対象に追加する必要があります。
そのため、イベントがディレクトリから来たときは再度ディレクトリ全体を走査します。
// ファイルイベントがディレクトリから来た場合、新規ファイルがあるかもしれないので再走査 info, _ := os.Stat(path) if info != nil && info.IsDir() { watcher.addRecursive(path) }
削除されたファイルを監視対象から外す処理は実装していません。
つまり「ファイルが削除されても fd を解放しない」ので、fd がリークします。
しかし私のユースケースではファイル削除が極めてまれで長時間動かさないため、この実装で十分です。
最後に
kqueue は初見だったものの、とても扱いやすく、macOS 上で多様な IPC 用途に使えることを知りました。
ファイル監視問題を解決する他の方法としては「ポーリング」で変更を検出する手段がありますが、kqueue には「監視対象ごとに fd を開く」という制約があるため、大規模なディレクトリツリーではスケールしません。
macOS でファイル監視を行う別の方法としては FSEvents があり、fd の枯渇問題はありません。
ぜひ GitHub 上の reload コードをチェックしてみてください。
Mac 上で試した感想や似たような実装経験があれば、お気軽に連絡してください。
最後に kqueue に関する詳細を知りたい方は、C で双方向ストリーミングサーバーを実装した記事とオリジナル論文をご覧ください。