C#のasync/awaitはOSでどう動く?
Linux epoll/io_uringとWindows IOCPで見る非同期I/Oの仕組み
薄く、軽く理解していたC#ですが、本格的に学習する必要が出てきました。
普通に初心者向け学習コースを辿るのはつまらないので、表題にある内容をベースにC#を学び直してみようと考えました。(若干、現実逃避気味)
目次
- 1. async/await の「待たない」仕組み
- 2. Windowsでの実践:C#で非同期サーバを動かす
- 3. Linux epoll──I/O多重化の礎
- 4. Linux io_uring──epollの限界を超える新世代I/O
- 5. epoll と io_uring の比較
- 6. .NETランタイムとOSの接続点(再掲)
- 補足:環境とビルドトラブル対処(Linux)
- まとめ
C# の async/await は、単なる「便利な構文」ではありません。
その背後では、OSカーネルの非同期I/O機構(Linuxの epoll / io_uring、Windowsの IOCP)が密接に連携しています。
この記事では、
- .NET の
async/awaitが OS レベルでどのように動いているのか - Windows での実践(C#で非同期ソケットサーバを構築)
- Linux における
epollとio_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戦略を選択する。