3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

C#のasync/awaitはOSでどう動く? Linux epoll/io_uringとWindows IOCPで見る非同期I/Oの仕組み

Posted at

C#のasync/awaitはOSでどう動く?
Linux epoll/io_uringとWindows IOCPで見る非同期I/Oの仕組み

薄く、軽く理解していたC#ですが、本格的に学習する必要が出てきました。
普通に初心者向け学習コースを辿るのはつまらないので、表題にある内容をベースにC#を学び直してみようと考えました。(若干、現実逃避気味)

目次

C# の async/await は、単なる「便利な構文」ではありません。
その背後では、OSカーネルの非同期I/O機構(Linuxの epoll / io_uring、Windowsの IOCP)が密接に連携しています。

この記事では、

  • .NET の async/await が OS レベルでどのように動いているのか
  • Windows での実践(C#で非同期ソケットサーバを構築)
  • Linux における epollio_uring の考え方とサンプル
  • それぞれのビルド・実行・テスト方法

を通して「待たないI/O」の実像を追います。


1. async/await の「待たない」仕組み

C# の非同期処理は「同時に複数動かす」ためではなく、「待っている間にスレッドを占有しない」ための仕組みです。
await によって、I/O完了を待つ間スレッドを解放し、他の処理に再利用します。

C#サンプル:async/awaitによる非同期読み込み

ファイル名: async_sample.cs

using System;
using System.IO;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        Console.WriteLine("Start");
        string text = await File.ReadAllTextAsync("data.txt");
        Console.WriteLine("Done: " + text.Length + " bytes");
    }
}

実行方法(PowerShell/cmd):

dotnet new console -n AsyncTest
cd AsyncTest
# 上記コードを Program.cs に貼り付け
dotnet run

このコードでは、File.ReadAllTextAsync が OS の非同期I/Oを呼び出します。
Windows では IOCP、Linux では epoll(将来的には io_uring)を使用して動作します。

2. Windowsでの実践:C#で非同期サーバを動かす

2.1 高水準サンプル:TcpListener + NetworkStream.ReadAsync

最短で動く Echo サーバです。内部待機は await によりスレッドを占有しません。Windows 下では IOCP による完了通知モデルが使われます。

ファイル名: AsyncTcpEcho.cs

using System;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Threading.Tasks;

class Program
{
    static async Task HandleClientAsync(TcpClient client)
    {
        using var stream = client.GetStream();
        var buf = new byte[1024];
        while (true)
        {
            int n = await stream.ReadAsync(buf, 0, buf.Length); // 非同期読み取り
            if (n == 0) break; // 切断
            await stream.WriteAsync(buf, 0, n);                 // 非同期書き込み(エコー)
        }
        client.Close();
    }

    static async Task Main()
    {
        var listener = new TcpListener(IPAddress.Any, 8080);
        listener.Start();
        Console.WriteLine("Listening on :8080");

        while (true)
        {
            var client = await listener.AcceptTcpClientAsync(); // 非同期Accept
            _ = Task.Run(() => HandleClientAsync(client));      // 並行処理
        }
    }
}

プロジェクト作成と実行(PowerShell/cmd):

dotnet new console -n AsyncEcho
cd AsyncEcho
# Program.cs に上記コードを貼り付け
dotnet run

動作確認:

telnet localhost 8080

入力がそのまま返れば成功です。

2.2 低水準サンプル:SocketAsyncEventArgs(IOCPイベント駆動)

IOCP の「完了通知」モデルに近いイベント駆動APIです。大量接続の土台に向きます。

ファイル名: SaeaEchoServer.cs

using System;
using System.Net;
using System.Net.Sockets;

class SaeaEchoServer
{
    private readonly Socket _listen;
    public SaeaEchoServer(IPEndPoint ep)
    {
        _listen = new Socket(ep.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
        _listen.Bind(ep);
        _listen.Listen(512);
    }

    public void Start()
    {
        Console.WriteLine("Listening on " + _listen.LocalEndPoint);
        StartAccept();
    }

    private void StartAccept()
    {
        var args = new SocketAsyncEventArgs();
        args.Completed += AcceptCompleted;
        bool pending = _listen.AcceptAsync(args);
        if (!pending) AcceptCompleted(this, args);
    }

    private void AcceptCompleted(object? sender, SocketAsyncEventArgs e)
    {
        if (e.SocketError != SocketError.Success || e.AcceptSocket == null)
        {
            e.Dispose();
            StartAccept();
            return;
        }

        var client = e.AcceptSocket;
        e.Dispose();
        StartAccept();

        var recvArgs = new SocketAsyncEventArgs();
        var buffer = new byte[1024];
        recvArgs.SetBuffer(buffer, 0, buffer.Length);
        recvArgs.UserToken = client;
        recvArgs.Completed += IOCompleted;

        StartReceive(recvArgs);
    }

    private void StartReceive(SocketAsyncEventArgs e)
    {
        var client = (Socket)e.UserToken!;
        bool pending = client.ReceiveAsync(e);
        if (!pending) IOCompleted(this, e);
    }

    private void IOCompleted(object? sender, SocketAsyncEventArgs e)
    {
        var client = (Socket)e.UserToken!;
        if (e.LastOperation == SocketAsyncOperation.Receive)
        {
            if (e.BytesTransferred <= 0 || e.SocketError != SocketError.Success)
            {
                Cleanup(client, e);
                return;
            }
            e.SetBuffer(0, e.BytesTransferred);
            e.Completed -= IOCompleted;
            e.Completed += SendCompleted;
            bool pending = client.SendAsync(e);
            if (!pending) SendCompleted(this, e);
        }
        else
        {
            Cleanup(client, e);
        }
    }

    private void SendCompleted(object? sender, SocketAsyncEventArgs e)
    {
        var client = (Socket)e.UserToken!;
        if (e.SocketError != SocketError.Success)
        {
            Cleanup(client, e);
            return;
        }
        e.Completed -= SendCompleted;
        e.Completed += IOCompleted;
        e.SetBuffer(0, e.Buffer!.Length);
        StartReceive(e);
    }

    private void Cleanup(Socket client, SocketAsyncEventArgs e)
    {
        try { client.Shutdown(SocketShutdown.Both); } catch { }
        try { client.Close(); } catch { }
        e.Dispose();
    }

    public static void Main()
    {
        var server = new SaeaEchoServer(new IPEndPoint(IPAddress.Any, 8080));
        server.Start();
        Console.WriteLine("Press Ctrl+C to exit.");
        System.Threading.Thread.Sleep(System.Threading.Timeout.Infinite);
    }
}

ビルドと実行(PowerShell/cmd):

dotnet new console -n SaeaEcho
cd SaeaEcho
# Program.cs に上記コードを貼り付け
dotnet run

動作確認:

telnet localhost 8080

複数のクライアントから同時接続しても効率よく動作します(IOCPによる完了通知モデル)。

3. Linux epoll──I/O多重化の礎

epoll は Linux における I/O 多重化の中核です。モデルは readiness-based(「I/Oが可能になったら通知」)。
アプリ側は通知を受けてから read() / write() を実行します。

概念整理

  • epoll_create1 で監視オブジェクトを作成
  • epoll_ctl(ADD/MOD/DEL) で監視対象FDを登録・更新・削除
  • epoll_wait でイベント待機
  • 既定は レベルトリガ(LT)エッジトリガ(ET) を使う場合は非ブロッキングFDとループ読みが必須

サンプル:レベルトリガのEchoサーバ(簡潔版)

ファイル名: epoll_echo.c

#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>

static int set_nonblock(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

int main() {
    int server = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(server, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in addr = {0};
    addr.sin_family = AF_INET;
    addr.sin_port = htons(8080);
    addr.sin_addr.s_addr = INADDR_ANY;
    bind(server, (struct sockaddr*)&addr, sizeof(addr));
    listen(server, SOMAXCONN);
    set_nonblock(server);

    int epfd = epoll_create1(0);
    struct epoll_event ev = { .events = EPOLLIN, .data.fd = server };
    epoll_ctl(epfd, EPOLL_CTL_ADD, server, &ev);

    struct epoll_event events[64];
    printf("epoll echo on :8080
");

    while (1) {
        int n = epoll_wait(epfd, events, 64, -1);
        for (int i = 0; i < n; i++) {
            int fd = events[i].data.fd;
            if (fd == server) {
                // 複数同時acceptに対応(ノンブロッキングのためループ)
                while (1) {
                    int client = accept(server, NULL, NULL);
                    if (client < 0) {
                        if (errno == EAGAIN || errno == EWOULDBLOCK) break;
                        perror("accept");
                        break;
                    }
                    set_nonblock(client);
                    struct epoll_event cev = { .events = EPOLLIN, .data.fd = client };
                    epoll_ctl(epfd, EPOLL_CTL_ADD, client, &cev);
                }
            } else {
                // 読み取り
                char buf[4096];
                while (1) {
                    ssize_t r = read(fd, buf, sizeof(buf));
                    if (r == 0) { close(fd); break; }      // 切断
                    if (r < 0) {
                        if (errno == EAGAIN || errno == EWOULDBLOCK) break; // もう読むものがない
                        perror("read"); close(fd); break;
                    }
                    // エコー
                    ssize_t off = 0;
                    while (off < r) {
                        ssize_t w = write(fd, buf + off, r - off);
                        if (w < 0) { perror("write"); close(fd); break; }
                        off += w;
                    }
                }
            }
        }
    }
}

ビルド(Linux, bash):

gcc -O2 -Wall -o epoll_echo epoll_echo.c

実行:

./epoll_echo

テスト:

nc localhost 8080
# 入力がそのまま返れば成功(複数端末から同時接続で確認)

ET(エッジトリガ)利用の注意

  • EPOLLET を使う場合は 必ず非ブロッキング にして、read()EAGAIN になるまでループ すること。
  • 書き込みも同様に EAGAIN を考慮して送信キューを用意するのが実用的です。

4. Linux io_uring──epollの限界を超える新世代I/O

io_uring は Linux 5.1 以降で導入された completion-based モデルです。
ユーザー空間とカーネル空間で Submission Queue(SQ)Completion Queue(CQ) を共有し、システムコール回数・コピーを最小化します。

4.1 基本サンプル:ファイル読み取り

ファイル名: io_uring_read.c

#include <liburing.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>

int main() {
    struct io_uring ring;
    io_uring_queue_init(8, &ring, 0);

    int fd = open("data.txt", O_RDONLY);
    if (fd < 0) { perror("open"); return 1; }

    char buf[1024];
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_read(sqe, fd, buf, sizeof(buf), 0);
    io_uring_submit(&ring);

    struct io_uring_cqe *cqe;
    io_uring_wait_cqe(&ring, &cqe);

    if (cqe->res < 0) {
        fprintf(stderr, "read failed: %d
", cqe->res);
    } else {
        printf("Read %d bytes: %.*s
", cqe->res, cqe->res, buf);
    }

    io_uring_cqe_seen(&ring, cqe);
    close(fd);
    io_uring_queue_exit(&ring);
    return 0;
}

依存パッケージ(Ubuntu/Debian):

sudo apt update && sudo apt install -y liburing-dev

ビルド:

gcc -O2 -Wall -o io_uring_read io_uring_read.c -luring

実行:

echo "Hello io_uring" > data.txt
./io_uring_read

4.2 最小ネットワーク例:1回のaccept→recv→send

io_uring でもソケット I/O を扱えます。以下は最小構成で、1クライアントに対して1回だけ受信・送信を行う例です(学習目的)。

ファイル名: io_uring_socket_echo_min.c

#include <liburing.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>

int main() {
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in addr = {0};
    addr.sin_family = AF_INET;
    addr.sin_port = htons(8080);
    addr.sin_addr.s_addr = INADDR_ANY;
    bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr));
    listen(listen_fd, SOMAXCONN);

    struct io_uring ring;
    io_uring_queue_init(64, &ring, 0);

    // 1) accept を投げる
    struct sockaddr_in caddr;
    socklen_t caddr_len = sizeof(caddr);
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_accept(sqe, listen_fd, (struct sockaddr*)&caddr, &caddr_len, 0);
    io_uring_submit(&ring);

    // 2) accept 完了を待つ
    struct io_uring_cqe *cqe;
    io_uring_wait_cqe(&ring, &cqe);
    int client_fd = cqe->res;
    io_uring_cqe_seen(&ring, cqe);
    if (client_fd < 0) { fprintf(stderr, "accept failed: %d
", client_fd); return 1; }

    // 3) 受信を投げる
    char buf[1024];
    sqe = io_uring_get_sqe(&ring);
    io_uring_prep_recv(sqe, client_fd, buf, sizeof(buf), 0);
    io_uring_submit(&ring);

    io_uring_wait_cqe(&ring, &cqe);
    int n = cqe->res;
    io_uring_cqe_seen(&ring, cqe);
    if (n <= 0) { close(client_fd); io_uring_queue_exit(&ring); return 0; }

    // 4) 受け取ったデータをそのまま送信
    sqe = io_uring_get_sqe(&ring);
    io_uring_prep_send(sqe, client_fd, buf, n, 0);
    io_uring_submit(&ring);

    io_uring_wait_cqe(&ring, &cqe);
    io_uring_cqe_seen(&ring, cqe);

    close(client_fd);
    io_uring_queue_exit(&ring);
    close(listen_fd);
    return 0;
}

ビルド:

gcc -O2 -Wall -o io_uring_socket_echo_min io_uring_socket_echo_min.c -luring

実行・テスト:

./io_uring_socket_echo_min &
sleep 1
printf "hello
" | nc localhost 8080

注意: 上記は学習用の極小サンプルです。実運用では、複数の同時接続・複数回の send/recv を扱うために、複数のSQE/CQE管理やユーザデータ(sqe->user_data)による状態管理、送信キュー、エラー処理の強化が必要です。


5. epoll と io_uring の比較

観点 epoll io_uring
モデル Readiness-based(I/O可能通知) Completion-based(I/O完了通知)
システムコール回数 多め(wait→read/write) 少なめ(バッチ送信・共有リング)
データコピー ユーザー空間とカーネル間で発生 共有リングにより最小化
主な適用 ソケットI/O中心 ファイル・ソケットを統一的に扱う
導入時期 Linux 2.5 Linux 5.1
学習/実装の難易度 低〜中 中〜高

io_uring は IOCP に近い完了通知モデルで、C# の async/await との親和性が高い一方、運用では監視・セキュリティ設定(コンテナの seccomp 等)にも配慮が必要です。


6. .NETランタイムとOSの接続点(再掲)

C# の await は .NET ランタイムを経由して、Windows では IOCP、Linux では epoll(将来的に io_uring)を使用します。
アプリケーションは同じ C# コードで運用でき、OSごとの非同期I/O機構の違いはランタイムが吸収します。


補足:環境とビルドトラブル対処(Linux)

  • io_uring は Linux 5.1 以上が前提。uname -r で確認
  • Ubuntu/Debian 系でヘッダ未検出エラーが出たら sudo apt install liburing-dev
  • ビルド時に -luring の付け忘れに注意
  • コンテナでは seccomp が io_uring 系システムコールをブロックする場合があるため、実行ポリシーを見直す

まとめ

  • Windows では C# の async/await が IOCP ベースの完了通知モデルを活用し、少数スレッドで多接続を処理可能。
  • Linux では epoll(readiness)と io_uring(completion)があり、後者はより低オーバーヘッドで将来性が高い。
  • C# アプリは OS 差異を意識せず同じコードで非同期I/Oを享受でき、ランタイムが最適なI/O戦略を選択する。
3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?