今回はLinuxのプロセス生成をio_uringでやってみたというパッチを見つけたので、それについて調べた話になります。
パッチはこちら: [PATCH RFC 0/9] Launching processes with io_uring
まだRFCの状態です。2022年にもio_uring_spawnという同じような提案がありましたが、そちらは流れてしまったので今回もどうなるかは不明という感じ。
目次
現状の課題
Linuxでは新規プロセスを作成する際に fork/clone システムコールで既存のプロセスを複製した後、execシステムコールで上書きを行います。
この手法において、forkの部分が無駄なコストだというのは大昔からよく言われる点です。
マルチプロセスで同じプログラムを動かしたいならまだしも、完全に新規プロセスを作りたいのにまずは既存のプロセスをコピーする必要があるためです。
カーネル側もそれを理解しているので、メモリ空間のコピーオンライト1など、大半の処理がオンデマンド型にされています。
とはいえ、fork+execはカーネル/ユーザー空間で行き来が複数回発生したり、execの前にforkでコピーしたプロセス情報の変更処理が入ったり、親プロセスもforkが終わるまで待たされたりと、改善点は多いです。
提案手法
io_uring経由でプロセスの生成を行えるように変更します。
これによりプロセスの生成が非同期で行うことができ、性能向上が見込めます。
io_uringについて
io_uringはもともと単発の非同期I/Oを実行できる仕組みです。
従来のI/Oは処理が完了するまでwaitが発生したり、逐一システムコールを発行してカーネル空間のデータをユーザー空間にコピーする必要がありました。
io_uringではユーザーがリングバッファにリクエストだけ書き込んで、先に他のタスクを行います。カーネルスレッドは定期的にリングバッファを確認して2、リクエストがあったらそれを実行して、結果をバッファに書き込みます。
このリングバッファはユーザーとカーネルが共通してアクセスできるメモリ空間となっており、io_uring以外にもカーネル/ユーザー間でデータの受け渡しに使えるのでは?と最近注目されています。
ただし、本来の用途と異なる利用を際限なく許すとコントロール不能になるので、慎重に検討するべきという意見もあります。
実装
io_uringに存在するリクエストのリンク機能を利用します。
https://unixism.net/loti/tutorial/link_liburing.html
通常、io_uringに入ったリクエストについて実行順は担保されていませんが、リンク機能を使うことリクエストの依存関係を示すことができます。
これを利用してfork+execの仕組みを実装しようという発想です。
// ヘルパー関数、copy_process()を利用してfork/clone()と同じ処理を行う
struct task_struct *create_io_uring_spawn_task(int (*fn)(void *), void *arg)
{
unsigned long flags = CLONE_CLEAR_SIGHAND;
struct kernel_clone_args args = {
.flags = ((lower_32_bits(flags) | CLONE_VM |
CLONE_UNTRACED) & ~CSIGNAL),
.exit_signal = (lower_32_bits(flags) & CSIGNAL),
.fn = fn,
.fn_arg = arg,
};
return copy_process(NULL, 0, NUMA_NO_NODE, &args);
}
// ヘルパー関数、上記のcreate_io_uring_spawn_task()の第一引数に渡す関数
// → kernel_clone_args.fn に渡される関数
// リンクを辿って io_issue_sqe() にリクエストを投げる
static int io_uring_spawn_task(void *data)
{
struct io_kiocb *head = data;
struct io_clone *c = io_kiocb_to_cmd(head, struct io_clone);
struct io_ring_ctx *ctx = head->ctx;
struct io_kiocb *req, *next;
int err;
set_task_comm(current, "iou-spawn");
mutex_lock(&ctx->uring_lock);
for (req = c->link; req; req = next) {
int hardlink = req->flags & REQ_F_HARDLINK;
next = req->link;
req->link = NULL;
req->flags &= ~(REQ_F_HARDLINK | REQ_F_LINK);
if (!(req->flags & REQ_F_FAIL)) {
err = io_issue_sqe(req, IO_URING_F_COMPLETE_DEFER);
...
}
// 実体関数。上記のcreate_io_uring_spawn_task() と io_uring_spawn_task()を利用して
// プロセスを生成し task_struct を生成、wake_upする。
int io_clone(struct io_kiocb *req, unsigned int issue_flags)
{
struct io_clone *c = io_kiocb_to_cmd(req, struct io_clone);
struct task_struct *tsk;
...
tsk = create_io_uring_spawn_task(io_uring_spawn_task, req);
...
c->link = req->link;
req->flags &= ~(REQ_F_HARDLINK | REQ_F_LINK);
req->link = NULL;
wake_up_new_task(tsk);
return IOU_OK;
}
実装の詳細はこちらのパッチを参照
[PATCH RFC 5/9] kernel/fork: Add helper to fork from io_uring
[PATCH RFC 7/9] io_uring: Introduce IORING_OP_CLONE
恩恵
すぐに終了するプロセスを頻繁に呼んでいるようなプログラムで特に速度改善が見込まれると思います。また、実装も fork/execveat の内部関数を再利用しているだけなので楽な方だと思います。
今後の課題
fork/execの処理をリンクで繋げて再現しているだけなので保守が大変という問題があります。
特にプロセス生成というカーネルの根幹的な部分に関する処理なので、実装するならABIとしてサポートする対象になります。
今後、プロセス生成まわりで変更があったさいにこちらも変更しないといけないのでメンテナが不足している現状で長期保守できるのか?という問題があります。
また、パッチに寄せられたコメントでは「普通にcloneシステムコールを拡張してexecできるようにすればいいのでは?」という最もなツッコミも寄せられています。
https://lwn.net/ml/all/fd219866-b0d3-418b-aee2-f9d1815bfde0@gmail.com/
この時点で、そもそも io_uring インフラを使う必要があるのか疑問が生じます。それが本当に役に立っているとは思えません。例えば、リンクを使うのではなく、カスタムフォーマットの操作リストとして実装する方法も考えられます。その場合、単一の io_uring リクエストで実行するか、あるいは通常のシステムコールとして処理できるのではないでしょうか。
struct clone_op ops = { { CLONE },
{ SET_CRED, cred_id }, ...,
{ EXEC, path }};
この設計を見ていると、別の処理方法を考えるべきではないかと思えます。例えば、なぜ(最終的な exec を除いて)作成されたタスクのコンテキストで実行する必要があるのでしょうか?リクエストを通常の方法で元のタスクから実行し、各リクエストが「半作成状態でまだ起動されていないタスク」を引数として受け取り、それを変更し、最終的な exec で起動するという方式ではダメなのでしょうか?
感想
まだRFCの状態で、本人もメンテナから意見が欲しいと言っている段階なのでこのまま来ることは無いと思います。
どちらかというとio_uringの可能性を見れたという意味で、面白い挑戦だと思いました。もっとも、本来の目的からそれた使い方を良しとするのかという問題はありますが…