30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

io_uring がもたらす並行性とデータ競合

io_uring を使うと、 I/O 処理をカーネルに依頼する際に、「依頼だけを行い、その完了を待たずに自分の処理に戻る」ことができるようになる。
依頼だけを行い「自分の仕事」に戻り、それと並行してカーネルが I/O 処理を進めてくれる。
そして自分のタイミングで結果を確認し取得すればいいが、そのために別途システムコールを発行する必要はなく、スレッドとカーネルが共有しているメモリ領域を操作すればよい。
この並行性やメモリ共有という仕組みは効率性をもたらすが、それは同時にデータ競合の可能性を生む。
この記事では、 io_uring が持つこういった特徴について、具体例を示しながら見ていく。

動作確認は以下の環境で行った。

$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 24.04.4 LTS
Release:    24.04
Codename:   noble
$ rustc --version
rustc 1.97.1 (8bab26f4f 2026-07-14)

$ cargo --version
cargo 1.97.1 (c980f4866 2026-06-30)

Rust の Edition は2024

使っているクレートとそのバージョンは以下。

  • libc@0.2.186

「依頼」「実行」「回収」を分離できない同期システムコール

read()のような同期システムコールでカーネルに I/O 処理を依頼すると、その I/O 処理の結果を受け取るまで、待機しないといけない。つまり、「依頼」だけを行って処理に戻ることはできず、「実行」を待ち、そしてその結果を「回収」しなければならない。
そのため「待ち時間」が発生する。完了するまでに1秒かかる場合、システムコールを呼んだスレッドは、1秒間ただ待機しなければならない。
それを再現したのが以下のコード。read()によるデータの読み取り処理を行っている。

use std::io;
use std::process;
use std::sync::atomic::{AtomicU64, Ordering};
use std::thread;
use std::time::Duration;

static WORK_COUNT: AtomicU64 = AtomicU64::new(0);

fn do_work() {
    thread::sleep(Duration::from_millis(100));
    WORK_COUNT.fetch_add(1, Ordering::Relaxed);
}

fn start_timer(budget: Duration) {
    thread::spawn(move || {
        thread::sleep(budget);
        let count = WORK_COUNT.load(Ordering::Relaxed);
        println!("time is up: work done = {count}");
        process::exit(0);
    });
}

fn main() -> io::Result<()> {
    let mut pipe_fds = [0i32; 2];
    if unsafe { libc::pipe(pipe_fds.as_mut_ptr()) } < 0 {
        return Err(io::Error::last_os_error());
    }
    let read_fd = pipe_fds[0];
    let write_fd = pipe_fds[1];

    let mut buf = vec![0u8; 256];

    thread::spawn(move || {
        thread::sleep(Duration::from_secs(1));
        let data = b"hello from pipe";
        unsafe {
            libc::write(write_fd, data.as_ptr() as *const libc::c_void, data.len());
        }
    });

    start_timer(Duration::from_secs(3));

    let n = unsafe { libc::read(read_fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
    if n < 0 {
        return Err(io::Error::last_os_error());
    }
    println!("completed: {}", String::from_utf8_lossy(&buf[..n as usize]));

    println!("starting work...");
    loop {
        do_work();
    }
}

main()のなかでタイマーを起動しているが(start_timer(Duration::from_secs(3)))、このタイマーは3秒経過したあと、プログラム全体を終了させる。
タイマーを呼び出したあとread()でパイプからデータを読み取り、それからloopに入る。loopのなかでdo_work()を繰り返しているが、これは、100ミリ秒待機してからWORK_COUNTをインクリメントする関数。
タイマーは終了する直前にWORK_COUNTの値を出力する。これで、タイマーを呼び出してから終了するまでの3秒間でどれくらいdo_work()という「仕事」を処理できたかを、計測できる。

このプログラムを実行すると、約1秒後に以下のように表示される。

completed: hello from pipe
starting work...

さらにその2秒後、以下のように表示され、プログラムが終了する。

time is up: work done = 19

上述したようにまずタイマーを起動させ、その後にread()を呼び出しているが、read()が処理を終えるのに約1秒かかるようにしてある。
そのため、read()を終えてloopに入る時点(つまりstarting work...が出力されるタイミング)で既に、タイマーの3秒のうち1秒以上が経過してしまっている。
そのため、「仕事」に使えたのは約2秒となり、do_work()を完了させることができた回数は19になる。

io_uring を使えば「依頼」を分離できる

io_uring を使うと、 I/O 処理の「依頼」だけを行い、「自分の仕事」に戻ることができる。カーネルが I/O 処理を行うのと並行して、スレッドは「自分の仕事」を進めることができる。

それを表現したのが以下のコード。

github.com

全文は長いので、main()の一部のみを載せる。

fn main() -> io::Result<()> {
    // 省略

    let mut pipe_fds = [0i32; 2];
    if unsafe { libc::pipe(pipe_fds.as_mut_ptr()) } < 0 {
        return Err(io::Error::last_os_error());
    }
    let read_fd = pipe_fds[0];
    let write_fd = pipe_fds[1];

    let mut buf = vec![0u8; 256];

    let sqe = unsafe { sqes_base.add(0) };
    unsafe {
        ptr::write_bytes(sqe, 0, 1);
        (*sqe).opcode = IORING_OP_READ;
        (*sqe).fd = read_fd;
        (*sqe).off = u64::MAX;
        (*sqe).addr = buf.as_mut_ptr() as u64;
        (*sqe).len = buf.len() as u32;
        (*sqe).user_data = 0x01;
    }

    thread::spawn(move || {
        thread::sleep(Duration::from_secs(1));
        let data = b"hello from pipe";
        unsafe {
            libc::write(write_fd, data.as_ptr() as *const libc::c_void, data.len());
        }
    });

    start_timer(Duration::from_secs(3));

    submit(ring_fd, &sq, 0)?;

    println!("starting work...");
    loop {
        do_work();

        if let Some(cqe) = peek_completion(&cq)? {
            let n = cqe.res as usize;
            println!("completed: {}", String::from_utf8_lossy(&buf[..n]));
        }
    }
}

start_timer()do_work()WORK_COUNTは何も変わっていない。変わったのは、read()ではなくsubmit()peek_completion()を使っていること。
実装の詳細は先程のリンク先を見て欲しいが、submit()では、 io_uring を使ってデータの読み込み処理を依頼している。
そしてpeek_completion()は依頼した処理が終わっているかを確認し、終わっている場合はその結果を返す。

タイマーをスタートさせたあとsubmit()で処理を依頼し、その後loopに入る。loopではdo_work()を終える度にpeek_completion()を呼び出し、 I/O 処理の結果が返ってきたらそれを表示する。
つまり「依頼」「実行」「回収」を分離している。スレッドはまず「依頼」だけを行い、カーネルが「実行」している間、自分は他の仕事を行う。そして自分が必要になったタイミングで結果の「回収」を行う。

このプログラムを実行するとすぐに以下が表示される。

starting work...

その1秒後に以下が表示される。

completed: hello from pipe

さらにその2秒後に以下が表示される。

time is up: work done = 29

read()と異なり、submit()はすぐに終わる。そのためすぐにloopに入りdo_work()の実行に取り掛かる。
その結果、do_work()29回実行できている。つまり、タイマーの3秒のうちほとんどの時間をdo_work()の実行に充てることができている。読み取り処理には1秒かかるわけだが、それはカーネルが並行して行ってくれている。

もちろん I/O 処理の結果を出力することもできており、これはpeek_completion()によって実現できているが、peek_completion()のなかではシステムコールは発行していない。
io_uring ではスレッドとカーネルが同じメモリ領域を操作し、それを使って情報のやり取りを行う。システムコールを発行せずに「回収」を行えるため、 I/O 処理が頻発するようなワークロードではそれがパフォーマンス上の大きな優位性となることがある。

このように、 io_uring を上手く使えれば、より効率的に I/O 処理を扱えるようになる。

データ競合

しかし、並行性やメモリ共有といった特徴は効率性を生むだけでなく、データ競合という問題も生み出す。

カーネルに I/O 処理を依頼する際、依頼内容によっては、カーネルがデータを読み書きするためのメモリ領域を用意し、そのメモリアドレスを渡す必要がある。カーネルはそのメモリ領域にアクセスし、必要な処理を行う。
しかし、スレッドとカーネルが並行して処理を進めるため、今はカーネルが使うはずのそのメモリ領域に対してスレッドが操作を行ってしまうことが、仕組み上あり得る。読み書きをしたり、メモリ領域を解放してしまったり。これがデータ競合であり、バグの要因になる。

先程のコードを一部書き換え、わざとデータ競合を起こしたのが以下。

github.com

let mut buf = vec![0u8; 256];

let sqe = unsafe { sqes_base.add(0) };
unsafe {
    ptr::write_bytes(sqe, 0, 1);
    (*sqe).opcode = IORING_OP_READ;
    (*sqe).fd = read_fd;
    (*sqe).off = u64::MAX;
    (*sqe).addr = buf.as_mut_ptr() as u64;
    (*sqe).len = buf.len() as u32;
    (*sqe).user_data = 0x01;
}

// 中略

submit(ring_fd, &sq, 0)?;

let mut account_name = buf;
account_name[..5].copy_from_slice(b"alice");
println!(
    "account_name: {}",
    String::from_utf8_lossy(&account_name[..15])
);

println!("starting work...");
loop {
    do_work();

    if WORK_COUNT.load(Ordering::Relaxed) == 20 {
        println!(
            "account_name: {}",
            String::from_utf8_lossy(&account_name[..15])
        );
    }

    if let Some(cqe) = peek_completion(&cq)? {
        println!("received message: {} bytes", cqe.res);
    }
}

変数bufはカーネルが使うために用意した領域で、カーネルはIORING_OP_READ、つまり読み込み処理を行った結果をここに書き込む。
しかしこのコードを書いた開発者は、bufはもう使い終わったと勘違いし、別の用途で使い回してしまっている。それが以下のコード。

let mut account_name = buf;
account_name[..5].copy_from_slice(b"alice");

account_nameという名前に変え、その値をaliceという文字列にしている。

しかし名前を変えたところで、この変数が使っているメモリ領域が変わるわけではない。
カーネルはIORING_OP_READした結果をそこに書き込む。その結果、account_nameの値はhello from pipeになってしまう。開発者が意図したものとは違う値になってしまう。

カーネルはただ、sqeaddrフィールドに書かれているメモリアドレスに対してIORING_OP_READした結果を書き込むだけであり、呼び出し元のスレッドにおいてそのメモリアドレスがどの変数と結びついているのか、そのメモリアドレスが解放されているのか、などは知りようがない。
そのため、 io_uring を利用する側が気を付けなければならない。カーネルによる操作が終わるまでは当該メモリ領域を操作してはいけない、という規律を守らなければならない。そうしないとデータ競合が発生してしまう。

Rust の所有権を活用してデータ競合を防止する

開発者がミスをせず規律を守り続ける限りデータ競合は発生しないが、それは現実的ではない。何らかの仕組みによって防止することが望ましい。
その方法のひとつが、 Rust の所有権を利用した仕組み。

簡易的な実装例を用意した。

github.com

この実装で、先程と同じようにbufを操作しようとすると、コンパイルエラーになる。もちろんbufへの不正な操作をやめればコンパイルが通り、動作する。

error[E0382]: use of moved value: `buf`
   --> src/main.rs:278:28
    |
252 |     let mut buf = vec![0u8; 256];
    |         ------- move occurs because `buf` has type `Vec<u8>`, which does not implement the `Copy` trait
...
276 |     submit(ring_fd, &sq, 0, buf, &mut in_flight)?;
    |                             --- value moved here
277 |
278 |     let mut account_name = buf;
    |                            ^^^ value used here after move
    |

変えたのはsubmit()peek_completion()

submit()のインターフェースを変え、bufを渡すことを必須とした。
そうすることで、submit()の呼び出し以降は、main()bufを触ることはできなくなる。bufの所有権がsubmit()に移るため。main()bufを操作しようとすると、上記したようにコンパイルエラーになる。
しかし、ただ単にbufの所有権をsubmit()に移すだけだと、submit()の呼び出しが終了したタイミングでbufは解放されてしまう。bufはこれからカーネルが使うのだから、それでは困る。
そこで、in_flightという変数を用意し、それにbufを持たせる。in_flightmain()で宣言しsubmit()に参照を渡すだけなので、in_flightの所有権はmain()が持ち続ける。そしてそのin_flightbufを持つ。そうすることで、submit()のスコープが終了してもbufは解放されない。

fn submit(
    ring_fd: i32,
    sq: &SqRing,
    sqe_index: u32,
    buf: Vec<u8>,
    in_flight: &mut Option<Vec<u8>>,
) -> io::Result<()> {
    let tail = unsafe { (*sq.tail).load(Ordering::Relaxed) };
    unsafe {
        ptr::write(sq.array.add((tail & sq.ring_mask) as usize), sqe_index);
        (*sq.tail).store(tail.wrapping_add(1), Ordering::Release);
    }

    let ret = unsafe { io_uring_enter(ring_fd, 1, 0, 0) };
    if ret < 0 {
        return Err(io::Error::last_os_error());
    }

    // in_flight に buf を持たせる
    *in_flight = Some(buf);

    Ok(())
}
fn main() -> io::Result<()> {
    // 省略

    let mut buf = vec![0u8; 256];

    let sqe = unsafe { sqes_base.add(0) };
    unsafe {
        ptr::write_bytes(sqe, 0, 1);
        (*sqe).opcode = IORING_OP_READ;
        (*sqe).fd = read_fd;
        (*sqe).off = u64::MAX;
        (*sqe).addr = buf.as_mut_ptr() as u64;
        (*sqe).len = buf.len() as u32;
        (*sqe).user_data = 0x01;
    }

    // 中略

    let mut in_flight: Option<Vec<u8>> = None;

    // in_flight は参照を渡しているだけなので、 in_flight の所有権は引き続き main() が持っている
    submit(ring_fd, &sq, 0, buf, &mut in_flight)?;

    // 省略
}

peek_completion()のインターフェースも変えている。引数としてin_flightの参照を受け取る。
そして、カーネルから結果が返ってきておりそれを取り出す時に、結果(cqe)だけでなくin_flightが持っていたbufも返すようにする。そうすることで、bufの所有権がmain()に戻ってくる。これ以降はmain()bufを触ってもコンパイルエラーにならない。

fn peek_completion(
    cq: &CqRing,
    in_flight: &mut Option<Vec<u8>>,
) -> io::Result<Option<(IoUringCqe, Vec<u8>)>> {
    let head = unsafe { (*cq.head).load(Ordering::Relaxed) };
    let tail = unsafe { (*cq.tail).load(Ordering::Acquire) };

    if head == tail {
        return Ok(None);
    }

    let cqe = unsafe { ptr::read(cq.cqes.add((head & cq.ring_mask) as usize)) };
    unsafe { (*cq.head).store(head.wrapping_add(1), Ordering::Release) };

    if cqe.res < 0 {
        return Err(io::Error::from_raw_os_error(-cqe.res));
    }

    let buf = in_flight
        .take()
        .expect("completion without in-flight buffer");

    Ok(Some((cqe, buf)))
}

submit()したら CQE を回収するまでbufを他の用途で使ってはいけない」という、規律、制約をコードで表現できている。型によって保証されている。そのため、開発者の注意力に頼らずにデータ競合を防げる。

この「防止策」はあくまでも、先に示したデータ競合を防ぐためのものでしかない。あのデータ競合をコンパイルエラーにできる、という話でしかなく、実用性は乏しい。
しかし、 Rust の所有権を使った考え方やアプローチ自体には汎用性があるため、例として示した。

Datastream で Amazon Aurora から BigQuery にストリーミング転送する

Datastream は Google Cloud が提供しているサービスのひとつで、データのストリーミング転送を実現できる。
バッチ処理などに比べてデータの同期をリアルタイム性高く行うことができる。

この記事では、細かい設定方法などについては扱わず、 Datastream の考え方や仕組み、ストリーミング転送を有効にした際の挙動などについて述べていく。

Datastream は様々な転送元、転送先を選べるが、今回は Amazon Aurora の PostgreSQL から BigQuery への転送を行う。

Aurora インスタンスと Datastream の疎通

まずは Aurora と Datastream の疎通について述べる。
それぞれ別のクラウドサービスが提供するサービスなので、両者を疎通させる経路をインターネット上に作る必要がある。
そのための方法を Datastream は複数用意しているが、この記事では、 AWS に踏み台サーバを用意しそれを経由して疎通する方法を採用することにする。以降、それを前提とした説明となる。

まず、踏み台サーバとして EC2 インスタンスを用意する。そして踏み台サーバから Aurora インスタンスにアクセスできるようにしておく。そして、インターネットゲートウェイが設置された VPC ネットワークに踏み台サーバを配置することで、外部から踏み台サーバに、そしてそれを経由して Aurora インスタンスにアクセスできるようにする。
Datastream は SSH で踏み台サーバにアクセスする。そのため、セキュリティグループの設定で、 Datastream が使う IP アドレスが TCP のポート22(SSH で使うポート)に接続できるようにしておく必要がある。Datastream が使う IP アドレスは以下のページに掲載されている。
https://docs.cloud.google.com/datastream/docs/ip-allowlists-and-regions

これで、 Aurora インスタンスと Datastream が情報をやり取りできるようになる。

Aurora 側の設定

PostgreSQL には「論理レプリケーション」という仕組みがあり、これを使うことでストリーミング転送できるようになる。
論理レプリケーションとは、「このテーブルにこの内容で INSERT した」「このテーブルのこのレコードを DELETE した」という変更内容を記録し、記録した内容を他のソフトウェアに配信する仕組みのこと。
変更内容を受け取るソフトウェアを「コンシューマ」と呼ぶ。この記事の文脈でいうと、 Datastream がコンシューマとなる。

Amazon Aurora の PostgreSQL の場合、rds.logical_replicationというパラメータを1にすると論理レプリケーションが有効になる。

しかしこれだけでは不十分で、論理レプリケーションを動かすためのオブジェクトを用意する必要がある。
まず、 Publication 。これは、どのテーブルの変更を配信するのかを定義するオブジェクト。
そして、 Replication Slot 。これは、コンシューマの情報を管理するためのオブジェクト。このオブジェクトに、コンシューマがどこまで変更内容を受け取ったのかを記録する。そうすることで、まだ処理されていない変更内容が失われないようにする。
この 2 つを用意しなければならない。

そして、コンシューマ(今回のケースだと Datastream )が使用する、 PostgreSQL のユーザーも用意する必要がある。そのユーザーは当然、必要な権限を持っている必要がある。

Datastream 側の設定

「接続プロファイル」と「ストリーム」という、 2 種類のリソースを作成する。

接続プロファイルはその名の通り、対象とどのように接続するのかを管理するリソースであり、転送先と転送元それぞれに用意する。
今回のケースでは転送先は BigQuery だが、その場合、転送先を表現する接続プロファイルには、これといって書くことはない。Datastream も BigQuery も Google Cloud のサービスであるため、 Google Cloud が内部でよしなにしてくれる。利用者が何かを意識する必要がない。
一方、転送元である Aurora インスタンスとの接続を表現する接続プロファイルには、様々な情報を書く必要がある。対象のインスタンスのエンドポイントやポート番号、踏み台サーバの IP アドレスやポート番号、使用する PostgreSQL ユーザー、など。踏み台サーバに SSH 接続するための秘密鍵の情報も、接続プロファイルに登録する。

最後に、ストリームというリソースを作成し、それを有効にする。
ストリームには、 2 つの接続プロファイルの他、使用する Publication や Replication Slot の名前など、ストリーミング転送そのものの設定を記述する。また、転送対象のテーブルも指定する。ストリームで指定されており、かつ Publication に含まれているテーブルが、転送対象となる。
ストリームに設定するパラメータのうち、特に重要なもののひとつがdata_freshness。この値が、転送先の各テーブルのmax_stalenessの初期値になる。後述するが、max_stalenessの値によって、転送先にデータが反映される頻度が変わる。

ストリームが適切に作成され有効になると、 Datastream が論理レプリケーションのコンシューマとなり、論理レプリケーションが動作し始める。
Datastream を利用する、というのは、ストリームを作成しそれを有効にする、ということであり、ここまでの準備はストリームを作るための準備であった、と整理できるかもしれない。

ストリームを有効にした際の挙動

ストリームの設定で「バックフィル」というものを有効にすると、ストリームを有効にした時点で転送元にあった全てのレコードが転送先のテーブルに作られる。
そしてそのあとは、転送元のテーブルに対して INSERT, UPDATE, DELETE が行われる度に、その内容が転送先のテーブルにも反映される。

ではどの程度のタイミング、頻度で反映されるのか。それに影響を与えるのが、先程軽く触れたmax_stalenessである。
これは BigQuery の各テーブルが持つパラメータで、例えばこの値が「15分」である場合、少なくとも 15 分前までの変更内容がクエリ結果に反映されることが保証される。逆に言えば、転送元での変更がクエリ結果に現れるまでに、最大で 15 分程度の遅れが生じ得る、ということである。反映のタイミングを厳密に指定したり、任意のタイミングで手動で反映させたり、といったことはできない。
この値を小さくするほどデータが新鮮になるが、その分 BigQuery 側で変更を反映するための処理が頻繁に実行されるようになり、金銭的コストが増える。

レコードに対する操作ではなく、ALTER TABLEによるカラムの追加や削除を行うとどうなるのか。

追加にしろ削除にしろ、ALTER TABLEしただけでは、その変更は BigQuery には反映されない。

例えばordersテーブルにnoteカラムを追加したとする。

id status amount note
1 active 1200 NULL
2 pending 800 NULL
3 cancelled 0 NULL

しかし BigQuery 側にはnoteカラムは存在しないままである。

id status amount
1 active 1200
2 pending 800
3 cancelled 0

このあとに INSERT, UPDATE, DELETE などを行ったときに初めて、 BigQuery 側にも反映される。
例えば転送元のテーブルで、id1のレコードと2のレコードのnoteに値を入れる UPDATE を実行したとする。
それによって転送元のテーブルが以下の状態になった場合、 BigQuery 側のテーブルも同じ内容になる。

id status amount note
1 active 1200 Initial registration
2 pending 800 Awaiting payment
3 cancelled 0 NULL

カラム削除については、 BigQuery に反映されることはない。
例えば、転送元でnoteカラムを削除したとする。

id status amount
1 active 1200
2 pending 800
3 cancelled 0

しかし BigQuery 側は、変わらず以下の状態である。

id status amount note
1 active 1200 Initial registration
2 pending 800 Awaiting payment
3 cancelled 0 NULL

では転送元でレコードの編集を行うとどうなるか。
id2のレコードを UPDATE して、以下のようにしたとする(amount800から999にしている)。

id status amount
1 active 1200
2 pending 999
3 cancelled 0

そうすると BigQuery 側は以下のようになる。

id status amount note
1 active 1200 Initial registration
2 pending 999 NULL
3 cancelled 0 NULL

id2のレコードが更新され、そのレコードのnoteNULLになる。
noteというカラムは残り続け、変更があったレコード以外のレコードについては値は変わらない。上記の例だとid1のレコードのnoteカラムの値は、このレコードに対する操作が発生しない限りInitial registrationのまま変化しない。