macOS で kqueue を利用したファイル変更の検知方法

2026/03/25 5:29

macOS で kqueue を利用したファイル変更の検知方法

RSS: https://news.ycombinator.com/rss

要約

Japanese Translation:

概要:
この記事では、著者が reload と呼ばれる軽量な Go ファイルウォッチャーを構築した方法について説明しています。このツールは、監視対象のファイルが変更されるたびに指定されたコマンドを自動的に再起動し、C コードの高速再コンパイルや静的サイトのリビルドに便利です。
Reload は 2 つのモードで動作します:

  1. コマンドラインで渡された明示的なファイル名を監視するモード
  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 フィールドからなります。

フィールド意味
ident
イベントの発信源。ここでは監視したいファイルのファイルディスクリプタになります。
filter
カーネルフィルタ。
flags
イベントに対して行うアクション。
fflags
フィルタ固有フラグ。
udata
不透明なユーザデータ識別子。後で簡単にファイル名を取得できるようにします。

ファイル変更を監視するにはどのカーネルフィルタを使う?

9 つの可能性がありますが、EVFILT_VNODE を使用します。

EVFILT_VNODE   ファイルディスクリプタを識別子として取り、fflags に指定したイベントを監視し、いずれかのイベントが発生すると返却する。

fflags
には 10 種類のイベントがありますが、必要なのは NOTE_WRITE のみです。

フラグ意味
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 で双方向ストリーミングサーバーを実装した記事とオリジナル論文をご覧ください。

同じ日のほかのニュース

一覧に戻る →

2026/03/29 2:39

GitLab の創業者は、会社を立ち上げることでがんと闘う

## Japanese Translation: **概要** 著者は、上部脊柱のT5椎骨に位置する腫瘍性骨肉腫との個人的な闘いを語ります。標準治療オプションを試みたものの適切な臨床試験が見つからない中で、著者は自身の状態に合わせた新しい診断手法と並行治療プロトコルを開発しました。また、「癌ジャーニーデッキ」と埋め込み型OpenAIフォーラムプレゼンテーションを作成し、この経験を記録しています。著者のアプローチはevenone.venturesに掲載されている企業によって支援され、さらにエリオット・ハーシュバーグによる著者の旅路についての包括的な記事や、ルクサンドラ氏が執筆した「The bureaucracy blocking the chance(機会を阻む官僚主義)」という患者優先医療実践を批判する作品も広い文脈に含まれます。治療データと詳細なタイムラインは、https://osteosarc.com/ で公開されており、データ概要ドキュメントや25 TBの読み取り可能なGoogle Cloudバケットが含まれています。著者は読者にメールリストへの登録を促し、更新情報を受け取れるよう案内しています。また、`cancer@sytse.com` で連絡を取ることもできます。

2026/03/29 5:39

CSSは終焉を迎える運命にあります。

## Japanese Translation: この記事は、CSSのみでレンダリングを行い、ロジックには最小限のJavaScriptしか使用しない完全にプレイ可能なDOOM風ゲームをウェブブラウザ上で動かす方法を紹介しています。壁・床・天井・スプライト・弾道などを表現するために数千もの `<div>` 要素が生成され、各要素はカスタムプロパティとして生のDoom座標を保持し、CSS が `hypot()`(距離)や `atan2()`(角度)といった関数で幾何学を計算します。ワールドはプレイヤーの動きに逆行するように `translate3d` と `rotateY` で移動されますが、CSS にはカメラオブジェクトがないためです。 床は `rotateX(90deg)` で回転し、`clip-path`(または新しい `shape()` 関数)を使って任意の多角形や穴に切り取られます。テクスチャタイルはセクター全体にわたって背景位置をワールド座標に合わせて (`background-position: calc(var(--min-x)*-1px) …`) 配置されます。ドア、リフト、その他の動的要素はカスタムプロパティ上で CSS トランジションによってアニメーションし、JavaScript が状態属性を更新します。スプライトは `rotateY` でカメラに向き、`scaleX` で鏡像化したビルボードです。スプライトのアニメーションは CSS の `steps()` キーフレームで行い、攻撃・死亡フレーム用のデータ状態は JavaScript が供給します。弾道は CSS アニメーションで移動し、衝突検出はまだ JavaScript で処理されます。 照明はセクターごとに `filter: brightness(var(--light))` を使って全体的に適用され、ちらつくライトは `@property --light` を通じてアニメーションします。プロジェクトではアンカー位置決め、`@property`、および「ハッキー」な CSS‑のみのカリング手法(オフスクリーン要素を隠すために負の遅延でアニメーションを一時停止)といった実験的機能が採用されています。 数千もの 3D 転送された要素によるパフォーマンスは課題となり、著者は JavaScript で手動フラスタムカリングを実装し、条件付き `if()` のサポートが登場すれば将来的に純粋 CSS ソリューションへ移行する計画です。記事では Safari のビュー遷移による 3D フラット化、background‑image 再ラスター化の問題、コンポジタ不安定性などブラウザバグも文書化し、インラインスタイルやバグ報告といった回避策を紹介しています。 著者はより多くのロジックを純粋 CSS に移すことで JavaScript を完全に排除できる可能性があり、パフォーマンスをさらに向上させることを想定しています。成功すれば、このアプローチは軽量なブラウザベースゲームを刺激し、高度な CSS グラフィックス機能のサポートを促進し、重いエンジンを必要としない効率的なレンダリングが求められる開発者に利益をもたらすでしょう。

2026/03/27 23:39

オープンブースト・オン・モトローラ 88000プロセッサー

## Japanese Translation: (欠落している詳細を補完しつつ明瞭さを保つ)** ``` モトローラ 68000 ファミリーは、1990年代中頃のワークステーション(Apple、Amiga、Atari ST、Sun、HP、NeXT)や多くの産業用ボードで普及していました。 その RISC 後継機種である 88000(m88k)は、68k と PowerPC の間に導入されましたが、約 1994 年頃に期待された性能を提供できず廃止されました。m88k は二世代存在しました: • 88100 – 第1世代 CPU で、オプションの外部 88200 CMMU チップを搭載し、MVME180(20 MHz、2 本の CMMU)と MVME181 に使用されました。 • 88110 – 第2世代 CPU で、統合キャッシュ/MMU を備え、50 MHz を想定していましたが実際には約 40 MHz で販売されました。MVME187(25 MHz、デュアル CMMU、最大 64 MB)、MVME188(SMP、最大 4 CPU と 8 CMMU)、および MVME197 系列(セカンダリキャッシュ)に搭載されました。 VME バスは 32‑bit アドレス/データラインを備えたパッシブバックプレーンであり、複数ボードサポート、割り込みベクタ、オプションのスレーブマッピング、および終端要件があります。 OpenBSD のポートは 1995 年に MVME187 上で開始されました。Nivas Madhur、Steve Murphree、Marc Espie らの貢献は CVS マージ競合、アカウント停止(Theo de Raadt の関与)、GCC‑2.95 互換性問題、カーネルパニック(「align & align‑1」アサーション)および MVME188 上の不完全な SMP サポートに直面しました。ポートは 3.1‑beta スナップショットまで達成しましたが、ハードウェアエラー(VME バスロックアップ、DCAM2 コンフリクト、I²C フェイル)が未解決のまま残っています。 m88k アーキテクチャに関するドキュメントは、モトローラ AT&T System III/V、Data General DG/UX、Omron UniOS などのプロプライエタリ Unix バリアントと無料 CMU Mach コードから取得されました。メンテナー間の個人メール交換は協力、衝突解決、およびニッチなポートの保守課題を示しています。 MVME VME ボードおよび他の m88k システムのユーザーは、この OpenBSD ポートに安全性と安定性を依存しています。継続的なサポートがない場合、利用可能な OS を失うリスクがあり、新しいアーキテクチャへの移行が必要になるかもしれません。 ``` *改善された要約はすべての主要ポイントを反映し、不適切な推測を回避し、主旨を明確に提示し、曖昧または混乱を招く表現を排除しています。

macOS で kqueue を利用したファイル変更の検知方法 | そっか~ニュース