C# NetworkStreamの使い方を基礎から解説|TCP通信の読み書き・非同期処理・エラー対策

はじめに

C#でTCP通信を実装するとき、よく使われるのがNetworkStreamです。NetworkStreamを使うと、TCP接続を「読み書きできるストリーム」として扱えるため、ファイルを読み書きするような感覚でネットワーク通信を実装できます。

ただし、NetworkStreamは便利な反面、TCP通信特有の注意点もあります。たとえば「1回のReadで送信したデータがすべて読めるとは限らない」「文字列はそのまま送れずバイト列に変換する必要がある」「メッセージの区切りは自分で決める必要がある」といった点です。

この記事では、C#のNetworkStreamの基本から、TcpClientTcpListenerとの関係、同期処理・非同期処理、エラー対策、実用的なプロトコル設計までを順番に解説します。

1. C#のNetworkStreamとは

1-1. NetworkStreamの役割とTCP通信で使われる場面

NetworkStreamは、ネットワーク上の接続をストリームとして扱うためのクラスです。主にTCP通信で、接続済みのソケットに対してデータを読み書きするために使われます。

TCP通信では、クライアントとサーバーの間に接続を確立し、その接続上でバイト列を送受信します。NetworkStreamを使うと、この送受信処理をReadWriteといったストリーム操作として扱えます。

Microsoftの公式ドキュメントでも、NetworkStreamはネットワークアクセス用の基礎となるデータストリームを提供し、同期・非同期のデータ転送に使用できるクラスとして説明されています。Microsoft Learn

1-2. TcpClient・TcpListener・Socketとの関係

C#でTCP通信を実装するときは、主に次のクラスを組み合わせます。

TcpClientは、TCPサーバーへ接続するクライアント側の処理を簡単に扱うためのクラスです。接続後にGetStream()を呼び出すと、データ送受信用のNetworkStreamを取得できます。Microsoft Learn

TcpListenerは、サーバー側でクライアントからの接続を待ち受けるためのクラスです。Start()で待ち受けを開始し、AcceptTcpClient()で接続してきたクライアントを受け入れます。Microsoft Learn

Socketは、より低レベルな通信制御を行うためのクラスです。TcpClientTcpListenerは内部的にSocketを利用しており、通常のTCP通信であればTcpClientTcpListenerを使う方がシンプルに実装できます。Microsoft Learn

1-3. NetworkStreamでできること・できないこと

NetworkStreamでできることは、TCP接続上でのバイト列の読み書きです。具体的には、次のような処理ができます。

C#
stream.Write(buffer, 0, buffer.Length);
int bytesRead = stream.Read(buffer, 0, buffer.Length);

一方で、NetworkStreamだけで通信プロトコル全体を自動的に管理してくれるわけではありません。たとえば、次のような処理は開発者が自分で設計する必要があります。

メッセージの区切りを決めること、文字コードを統一すること、データ長を管理すること、切断やタイムアウトに対応すること、大きなデータを分割して読み取ることなどです。

また、NetworkStreamはネットワークデータに対するランダムアクセスをサポートしていません。CanSeekは常にfalseで、PositionLengthSeekのような操作は利用できません。Microsoft Learn

1-4. Streamクラスを継承していることによる特徴

NetworkStreamSystem.IO.Streamを継承しています。そのため、ReadWriteReadAsyncWriteAsyncCanReadCanWriteCanTimeoutなど、ストリーム共通のメソッドやプロパティを使えます。

この特徴により、StreamReaderStreamWriterBinaryReaderBinaryWriterなど、ストリームを扱う既存のクラスと組み合わせることもできます。

ただし、ファイルストリームとは違い、ネットワーク通信では相手の送信タイミングや切断状態に左右されます。そのため、単に「読み込めるはず」と考えるのではなく、戻り値や例外を確認しながら処理することが重要です。

2. NetworkStreamを使う前に知っておきたいTCP通信の基礎

2-1. TCP通信の基本的な流れ

TCP通信の基本的な流れは、次のようになります。

サーバー側は特定のIPアドレスとポート番号で待ち受けを開始します。クライアント側は、そのサーバーのホスト名またはIPアドレスとポート番号を指定して接続します。接続が確立すると、クライアントとサーバーは同じ接続上で双方向にデータを送受信できます。

TCPは、送信されたデータが順序どおりに届くように制御するプロトコルです。MicrosoftのTCPクラス解説でも、TCPはリモートエンドポイントとの接続を確立し、その接続を使ってデータを送受信し、到着時には正しい順序に組み立てると説明されています。Microsoft Learn

2-2. クライアント側とサーバー側の違い

クライアント側は、基本的に「接続しに行く側」です。C#ではTcpClientを使い、次のようにサーバーへ接続します。

C#
using TcpClient client = new TcpClient();
client.Connect("127.0.0.1", 5000);

サーバー側は、基本的に「接続を待ち受ける側」です。C#ではTcpListenerを使い、次のように待ち受けます。

C#
TcpListener listener = new TcpListener(IPAddress.Any, 5000);
listener.Start();

using TcpClient client = listener.AcceptTcpClient();

接続後は、クライアント側もサーバー側もTcpClient.GetStream()からNetworkStreamを取得し、同じように読み書きできます。

2-3. バイト列で送受信する仕組み

NetworkStreamで送受信するデータは、基本的にbyte[]です。文字列、JSON、画像、ファイルなども、最終的にはバイト列に変換して送信します。

たとえば、文字列を送る場合でも、直接"Hello"を送るのではなく、次のようにEncoding.UTF8.GetBytesでバイト配列に変換します。

C#
string message = "Hello";
byte[] data = Encoding.UTF8.GetBytes(message);

stream.Write(data, 0, data.Length);

受信側では、読み取ったバイト列を同じ文字コードで文字列に戻します。

C#
string received = Encoding.UTF8.GetString(buffer, 0, bytesRead);

2-4. 文字列を送るときにEncodingが必要な理由

コンピューター間で送られるデータはバイト列です。一方、C#の文字列は内部的には文字として扱われます。そのため、文字列をネットワークで送るには、文字列をどのルールでバイト列に変換するかを決める必要があります。

この変換ルールがEncodingです。

送信側がUTF-8でエンコードしたのに、受信側がShift_JISやASCIIとしてデコードすると、文字化けが起きる可能性があります。特に日本語を扱う場合は、送信側と受信側でEncoding.UTF8に統一するのが一般的です。

3. C#でNetworkStreamを取得する基本手順

3-1. TcpClientからGetStreamでNetworkStreamを取得する

NetworkStreamを使う最も一般的な方法は、TcpClientからGetStream()を呼び出すことです。

C#
using System.Net.Sockets;

using TcpClient client = new TcpClient();
client.Connect("127.0.0.1", 5000);

using NetworkStream stream = client.GetStream();

GetStream()は、データの送受信に使えるNetworkStreamを返します。ただし、先にConnectしていない状態でGetStream()を呼ぶとInvalidOperationExceptionが発生します。Microsoft Learn

3-2. using文でTcpClientとNetworkStreamを安全に破棄する

TcpClientNetworkStreamは通信リソースを扱うため、使い終わったら破棄する必要があります。C#ではusing文を使うと、例外が発生した場合でも安全にDisposeできます。

C#
using TcpClient client = new TcpClient();
client.Connect("127.0.0.1", 5000);

using NetworkStream stream = client.GetStream();

// 通信処理

TcpClient.Close()TcpClientを破棄し、関連するTCP接続を閉じるよう要求します。また、作成済みの関連NetworkStreamも閉じられます。Microsoft Learn

実務では、usingを使ってTcpClientNetworkStreamの両方を明示的に管理すると、リソースの解放漏れを防ぎやすくなります。

3-3. CanRead・CanWrite・CanTimeoutの確認方法

NetworkStreamが読み取り可能か、書き込み可能か、タイムアウトをサポートしているかは、次のプロパティで確認できます。

C#
Console.WriteLine(stream.CanRead);
Console.WriteLine(stream.CanWrite);
Console.WriteLine(stream.CanTimeout);

読み取りを行う前にCanRead、書き込みを行う前にCanWriteを確認しておくと、意図しない例外を避けやすくなります。

C#
if (stream.CanWrite)
{
byte[] data = Encoding.UTF8.GetBytes("Hello");
stream.Write(data, 0, data.Length);
}

if (stream.CanRead)
{
byte[] buffer = new byte[1024];
int bytesRead = stream.Read(buffer, 0, buffer.Length);
}

3-4. 接続先ホスト名・IPアドレス・ポート番号の指定方法

クライアント側では、接続先をホスト名またはIPアドレスとポート番号で指定します。

C#
client.Connect("example.com", 5000);
client.Connect("127.0.0.1", 5000);

サーバー側では、待ち受けるIPアドレスとポート番号を指定します。

C#
TcpListener listener = new TcpListener(IPAddress.Any, 5000);
listener.Start();

IPAddress.Anyを指定すると、利用可能なすべてのネットワークインターフェースで待ち受けます。ローカル環境だけでテストする場合は、IPAddress.Loopbackを指定することもあります。

C#
TcpListener listener = new TcpListener(IPAddress.Loopback, 5000);

4. NetworkStreamでデータを書き込む方法

4-1. Writeメソッドでバイト配列を送信する基本コード

NetworkStreamでデータを送信するには、Writeメソッドを使います。

C#
byte[] data = { 0x01, 0x02, 0x03 };

stream.Write(data, 0, data.Length);

第1引数は送信するバイト配列、第2引数は開始位置、第3引数は送信するバイト数です。

実際のアプリケーションでは、文字列やJSONなどをバイト配列に変換してから送信することが多いです。

4-2. 文字列をUTF-8でバイト配列に変換して送る

文字列を送信する場合は、Encoding.UTF8.GetBytesを使います。

C#
using System.Text;

string message = "こんにちは、NetworkStream";
byte[] data = Encoding.UTF8.GetBytes(message);

stream.Write(data, 0, data.Length);

受信側もUTF-8で復元します。

C#
string received = Encoding.UTF8.GetString(buffer, 0, bytesRead);

送信側と受信側で文字コードを統一することが、文字化け防止の基本です。

4-3. Flushは必要か

NetworkStream自体はバッファリングされないため、NetworkStream.Flush()を呼んでもネットワークストリームには効果がありません。公式ドキュメントでも、NetworkStreamはバッファリングされていないため、Flushはネットワークストリームに対して効果を持たないと説明されています。Microsoft Learn

C#
stream.Write(data, 0, data.Length);
stream.Flush(); // NetworkStream自体では基本的に不要

ただし、StreamWriterBufferedStreamなど、NetworkStreamの上にバッファを持つクラスを重ねている場合は別です。その場合は、上位のバッファを送出するためにFlushAutoFlushが必要になることがあります。

C#
using StreamWriter writer = new StreamWriter(stream, Encoding.UTF8)
{
AutoFlush = true
};

writer.WriteLine("Hello");

4-4. 送信時に発生しやすい例外と対処法

送信時には、主に次のような例外が発生します。

IOExceptionは、通信中にソケットアクセスエラーやネットワーク障害が発生した場合に起きることがあります。ObjectDisposedExceptionは、すでに閉じたNetworkStreamに対して書き込もうとした場合に発生します。SocketExceptionは、接続失敗や通信エラーなど、ソケットレベルの問題で発生します。

基本的な対策は、try-catchで例外を捕捉し、ログを残したうえで接続を閉じることです。

C#
try
{
byte[] data = Encoding.UTF8.GetBytes("Hello");
stream.Write(data, 0, data.Length);
}
catch (IOException ex)
{
Console.WriteLine($"送信中にI/Oエラーが発生しました: {ex.Message}");
}
catch (ObjectDisposedException)
{
Console.WriteLine("すでにストリームが閉じられています。");
}

5. NetworkStreamでデータを読み込む方法

5-1. Readメソッドで受信データを読み取る基本コード

NetworkStreamでデータを受信するには、Readメソッドを使います。

C#
byte[] buffer = new byte[1024];

int bytesRead = stream.Read(buffer, 0, buffer.Length);

Readは、受信したデータを指定したバッファに格納し、実際に読み取ったバイト数を返します。NetworkStream.Readは、使用可能なデータをバッファに読み込み、成功した読み取りバイト数を返すメソッドです。Microsoft Learn

5-2. Readの戻り値が意味する読み取りバイト数

Readの戻り値は、読み取ったバイト数です。バッファサイズが1024バイトでも、戻り値が常に1024になるわけではありません。

C#
int bytesRead = stream.Read(buffer, 0, buffer.Length);

if (bytesRead > 0)
{
string message = Encoding.UTF8.GetString(buffer, 0, bytesRead);
Console.WriteLine(message);
}

重要なのは、buffer.LengthではなくbytesReadを使って処理することです。

C#
// 悪い例: バッファ全体を文字列化してしまう
string wrong = Encoding.UTF8.GetString(buffer);

// 良い例: 実際に読んだ範囲だけを文字列化する
string correct = Encoding.UTF8.GetString(buffer, 0, bytesRead);

5-3. 受信バッファサイズの決め方

受信バッファサイズは、扱うデータの性質によって決めます。短いテキストメッセージなら1024バイトや4096バイト程度で十分なことが多いです。大きなファイルや画像を扱う場合は、8192バイトやそれ以上のバッファを使い、ループで分割して読み取ります。

ただし、バッファサイズを大きくしても「1回のReadで1メッセージが必ず読める」わけではありません。TCPはバイトストリームなので、アプリケーション側でメッセージの境界を判断する必要があります。

5-4. 受信したバイト列を文字列に変換する方法

受信したバイト列を文字列に変換するには、送信時と同じEncodingを使います。

C#
byte[] buffer = new byte[1024];
int bytesRead = stream.Read(buffer, 0, buffer.Length);

string message = Encoding.UTF8.GetString(buffer, 0, bytesRead);
Console.WriteLine(message);

UTF-8で送ったデータはUTF-8で読む、Shift_JISで送ったデータはShift_JISで読む、というように送受信で文字コードを一致させることが重要です。

5-5. Readが0を返すケースと接続終了の判定

Read0を返した場合は、基本的に相手側が正常に接続を終了したと判断できます。公式ドキュメントでも、Readが0を返すのは、要求バイト数が0の場合、または相手側ソケットが正常なシャットダウンを行い、利用可能なデータがなくなった場合とされています。Microsoft Learn

C#
int bytesRead = stream.Read(buffer, 0, buffer.Length);

if (bytesRead == 0)
{
Console.WriteLine("相手側が接続を終了しました。");
}

接続終了を検知したら、ループを抜けてNetworkStreamTcpClientを破棄します。

6. TCPクライアントの実装例

6-1. サーバーへ接続するサンプルコード

まずは、サーバーへ接続する基本コードです。

C#
using System.Net.Sockets;

string host = "127.0.0.1";
int port = 5000;

using TcpClient client = new TcpClient();
client.Connect(host, port);

Console.WriteLine("サーバーに接続しました。");

hostにはサーバーのホスト名またはIPアドレス、portにはサーバーが待ち受けているポート番号を指定します。

6-2. NetworkStreamでメッセージを送信する

接続後、GetStream()NetworkStreamを取得し、メッセージを送信します。

C#
using NetworkStream stream = client.GetStream();

string message = "Hello Server";
byte[] sendBuffer = Encoding.UTF8.GetBytes(message);

stream.Write(sendBuffer, 0, sendBuffer.Length);

この時点で、指定した文字列がUTF-8のバイト列としてサーバーへ送信されます。

6-3. サーバーからの応答を受信する

サーバーからの応答を読むには、Readを使います。

C#
byte[] receiveBuffer = new byte[1024];
int bytesRead = stream.Read(receiveBuffer, 0, receiveBuffer.Length);

string response = Encoding.UTF8.GetString(receiveBuffer, 0, bytesRead);
Console.WriteLine($"サーバーからの応答: {response}");

bytesReadが0の場合は、サーバーが接続を終了したと判断します。

6-4. クライアント処理全体の完成コード

以下は、サーバーへ接続し、メッセージを送信し、応答を受信するTCPクライアントの完成例です。

C#
using System;
using System.IO;
using System.Net.Sockets;
using System.Text;

class Program
{
static void Main()
{
string host = "127.0.0.1";
int port = 5000;

try
{
using TcpClient client = new TcpClient();
client.Connect(host, port);

using NetworkStream stream = client.GetStream();

string message = "Hello Server";
byte[] sendBuffer = Encoding.UTF8.GetBytes(message);

stream.Write(sendBuffer, 0, sendBuffer.Length);
Console.WriteLine($"送信: {message}");

byte[] receiveBuffer = new byte[1024];
int bytesRead = stream.Read(receiveBuffer, 0, receiveBuffer.Length);

if (bytesRead == 0)
{
Console.WriteLine("サーバーが接続を終了しました。");
return;
}

string response = Encoding.UTF8.GetString(receiveBuffer, 0, bytesRead);
Console.WriteLine($"受信: {response}");
}
catch (SocketException ex)
{
Console.WriteLine($"接続エラー: {ex.Message}");
}
catch (IOException ex)
{
Console.WriteLine($"通信エラー: {ex.Message}");
}
}
}

7. TCPサーバーの実装例

7-1. TcpListenerで接続待ち受けを開始する

サーバー側では、TcpListenerを使って指定ポートで待ち受けます。

C#
using System.Net;
using System.Net.Sockets;

TcpListener listener = new TcpListener(IPAddress.Any, 5000);
listener.Start();

Console.WriteLine("サーバーを開始しました。");

Start()を呼び出すと、クライアントからの接続要求を受け付けられる状態になります。TcpListener.Startは接続要求をキューに入れ、AcceptTcpClientなどで取り出すまで待ち受けます。Microsoft Learn

7-2. AcceptTcpClientでクライアント接続を受け入れる

接続してきたクライアントを受け入れるには、AcceptTcpClient()を使います。

C#
using TcpClient client = listener.AcceptTcpClient();
Console.WriteLine("クライアントが接続しました。");

AcceptTcpClient()はブロッキングメソッドです。つまり、クライアントが接続してくるまで処理がそこで止まります。

7-3. NetworkStreamでクライアントからのデータを読む

接続を受け入れたら、GetStream()NetworkStreamを取得し、クライアントからのデータを読み取ります。

C#
using NetworkStream stream = client.GetStream();

byte[] buffer = new byte[1024];
int bytesRead = stream.Read(buffer, 0, buffer.Length);

string received = Encoding.UTF8.GetString(buffer, 0, bytesRead);
Console.WriteLine($"受信: {received}");

7-4. クライアントへ応答を書き込む

クライアントへ応答を返すには、同じNetworkStreamに対してWriteを呼び出します。

C#
string response = $"Echo: {received}";
byte[] responseBytes = Encoding.UTF8.GetBytes(response);

stream.Write(responseBytes, 0, responseBytes.Length);

TCP接続は双方向なので、同じ接続上で読み取りと書き込みの両方ができます。

7-5. シンプルなエコーサーバーの完成コード

以下は、クライアントから受け取った文字列をそのまま返すエコーサーバーの例です。

C#
using System;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Text;

class Program
{
static void Main()
{
int port = 5000;
TcpListener listener = new TcpListener(IPAddress.Any, port);

try
{
listener.Start();
Console.WriteLine($"エコーサーバーを開始しました。Port: {port}");

while (true)
{
using TcpClient client = listener.AcceptTcpClient();
Console.WriteLine("クライアントが接続しました。");

using NetworkStream stream = client.GetStream();

byte[] buffer = new byte[1024];
int bytesRead = stream.Read(buffer, 0, buffer.Length);

if (bytesRead == 0)
{
Console.WriteLine("クライアントが切断しました。");
continue;
}

string received = Encoding.UTF8.GetString(buffer, 0, bytesRead);
Console.WriteLine($"受信: {received}");

string response = $"Echo: {received}";
byte[] responseBytes = Encoding.UTF8.GetBytes(response);

stream.Write(responseBytes, 0, responseBytes.Length);
Console.WriteLine($"送信: {response}");
}
}
catch (SocketException ex)
{
Console.WriteLine($"ソケットエラー: {ex.Message}");
}
catch (IOException ex)
{
Console.WriteLine($"通信エラー: {ex.Message}");
}
finally
{
listener.Stop();
}
}
}

このサンプルは1接続ずつ処理するシンプルな構成です。複数クライアントを同時に扱う場合は、非同期処理やタスクを使って接続ごとに処理を分けます。

8. NetworkStreamの非同期処理

8-1. ReadAsyncとWriteAsyncを使うメリット

NetworkStreamには、同期メソッドのReadWriteだけでなく、非同期メソッドのReadAsyncWriteAsyncもあります。公式ドキュメントでも、非同期I/Oを処理したい場合はReadAsyncWriteAsyncの利用が案内されています。Microsoft Learn

非同期処理を使うメリットは、通信待ちの間にスレッドを占有しにくいことです。特に、サーバーで複数クライアントを扱う場合や、GUIアプリで画面を固めたくない場合に有効です。

8-2. async・awaitで非同期TCPクライアントを実装する

非同期クライアントは、ConnectAsyncWriteAsyncReadAsyncを組み合わせて実装します。

C#
using System;
using System.IO;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

class Program
{
static async Task Main()
{
using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));

try
{
using TcpClient client = new TcpClient();
await client.ConnectAsync("127.0.0.1", 5000, cts.Token);

using NetworkStream stream = client.GetStream();

string message = "Hello Async Server";
byte[] sendBuffer = Encoding.UTF8.GetBytes(message);

await stream.WriteAsync(sendBuffer, cts.Token);
Console.WriteLine($"送信: {message}");

byte[] receiveBuffer = new byte[1024];
int bytesRead = await stream.ReadAsync(receiveBuffer, cts.Token);

if (bytesRead == 0)
{
Console.WriteLine("サーバーが接続を終了しました。");
return;
}

string response = Encoding.UTF8.GetString(receiveBuffer, 0, bytesRead);
Console.WriteLine($"受信: {response}");
}
catch (OperationCanceledException)
{
Console.WriteLine("処理がタイムアウトまたはキャンセルされました。");
}
catch (SocketException ex)
{
Console.WriteLine($"接続エラー: {ex.Message}");
}
catch (IOException ex)
{
Console.WriteLine($"通信エラー: {ex.Message}");
}
}
}

非同期メソッドを使うことで、通信待ちの間もアプリケーション全体が停止しにくくなります。

8-3. 非同期TCPサーバーで複数接続を扱う考え方

複数のクライアントを扱うサーバーでは、接続を受け付ける処理と、各クライアントとの通信処理を分けます。基本的な考え方は、AcceptTcpClientAsyncで接続を受け取り、クライアントごとの処理を別タスクで実行することです。

C#
using System.Net;
using System.Net.Sockets;
using System.Text;

class Program
{
static async Task Main()
{
using CancellationTokenSource cts = new CancellationTokenSource();

TcpListener listener = new TcpListener(IPAddress.Any, 5000);
listener.Start();

Console.WriteLine("非同期サーバーを開始しました。");

try
{
while (!cts.Token.IsCancellationRequested)
{
TcpClient client = await listener.AcceptTcpClientAsync(cts.Token);

_ = Task.Run(() => HandleClientAsync(client, cts.Token));
}
}
finally
{
listener.Stop();
}
}

static async Task HandleClientAsync(TcpClient client, CancellationToken cancellationToken)
{
using (client)
using (NetworkStream stream = client.GetStream())
{
byte[] buffer = new byte[1024];

while (!cancellationToken.IsCancellationRequested)
{
int bytesRead = await stream.ReadAsync(buffer, cancellationToken);

if (bytesRead == 0)
{
Console.WriteLine("クライアントが切断しました。");
break;
}

string received = Encoding.UTF8.GetString(buffer, 0, bytesRead);
Console.WriteLine($"受信: {received}");

byte[] response = Encoding.UTF8.GetBytes($"Echo: {received}");
await stream.WriteAsync(response, cancellationToken);
}
}
}
}

このように、接続ごとに処理を分けると、複数のクライアントを同時に扱いやすくなります。

8-4. CancellationTokenでキャンセルに対応する

非同期処理では、CancellationTokenを使ってキャンセルに対応できます。たとえば、一定時間で処理を打ち切る場合はCancellationTokenSourceにタイムアウトを設定します。

C#
using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));

int bytesRead = await stream.ReadAsync(buffer, cts.Token);

キャンセルされた場合は、OperationCanceledExceptionが発生するため、catchで処理します。

C#
try
{
int bytesRead = await stream.ReadAsync(buffer, cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("読み取りがキャンセルされました。");
}

サーバー停止や画面操作による中断など、実用的なアプリケーションではキャンセル対応を入れておくと安全です。

8-5. 同期処理と非同期処理の使い分け

同期処理は、実装がシンプルで理解しやすいのがメリットです。学習用サンプル、小規模なツール、1接続だけを扱う簡単な処理であれば、同期処理でも十分です。

一方、非同期処理は、複数接続を扱うサーバー、UIアプリ、長時間待機する通信処理に向いています。通信待ちでスレッドを占有しにくく、アプリケーションの応答性を保ちやすいためです。

実務では、最初は同期処理で仕組みを理解し、その後にasyncawaitを使った非同期処理へ移行すると理解しやすくなります。

9. タイムアウトと接続エラーへの対策

9-1. ReadTimeoutとWriteTimeoutの使い方

NetworkStreamには、読み取りタイムアウトを設定するReadTimeoutと、書き込みタイムアウトを設定するWriteTimeoutがあります。ReadTimeoutは、読み取り操作がデータ待ちでブロックする時間を設定するためのプロパティです。Microsoft Learn

C#
stream.ReadTimeout = 5000;
stream.WriteTimeout = 5000;

単位はミリ秒です。上の例では、5秒でタイムアウトします。

C#
try
{
byte[] buffer = new byte[1024];
int bytesRead = stream.Read(buffer, 0, buffer.Length);
}
catch (IOException ex)
{
Console.WriteLine($"読み取りタイムアウトまたは通信エラー: {ex.Message}");
}

タイムアウトは、相手からデータが来ないまま永久に待ち続ける問題を防ぐために重要です。

9-2. 接続できない場合のSocketException対策

サーバーに接続できない場合、SocketExceptionが発生することがあります。原因としては、ホスト名が間違っている、ポート番号が違う、サーバーが起動していない、ファイアウォールで拒否されている、ネットワークが到達不能などが考えられます。

C#
try
{
using TcpClient client = new TcpClient();
client.Connect("127.0.0.1", 5000);
}
catch (SocketException ex)
{
Console.WriteLine($"接続できませんでした: {ex.Message}");
Console.WriteLine($"エラーコード: {ex.ErrorCode}");
}

本番環境では、エラーメッセージだけでなく、接続先ホスト、ポート番号、エラーコードもログに残すと原因調査がしやすくなります。

9-3. IOExceptionが発生する主な原因

IOExceptionは、NetworkStreamで読み書きしている途中に発生しやすい例外です。主な原因は、通信相手の切断、ネットワーク障害、ソケットアクセスエラー、タイムアウトなどです。

公式ドキュメントでも、NetworkStream.Readではソケットアクセス時のエラーやネットワーク読み取り失敗時にIOExceptionが発生すると説明されています。Microsoft Learn

C#
try
{
int bytesRead = stream.Read(buffer, 0, buffer.Length);
}
catch (IOException ex)
{
Console.WriteLine($"通信中にエラーが発生しました: {ex.Message}");

if (ex.InnerException is SocketException socketEx)
{
Console.WriteLine($"Socket error: {socketEx.ErrorCode}");
}
}

IOException.InnerExceptionSocketExceptionが入っていることもあるため、必要に応じて確認します。

9-4. 通信中に相手が切断した場合の処理

通信中に相手が正常に切断した場合、Readは0を返します。これはエラーではなく、相手が接続を閉じたことを表します。

C#
int bytesRead = stream.Read(buffer, 0, buffer.Length);

if (bytesRead == 0)
{
Console.WriteLine("相手が接続を終了しました。");
return;
}

一方、突然のネットワーク断や異常終了では、IOExceptionSocketExceptionとして検知されることがあります。そのため、Readの戻り値チェックと例外処理の両方が必要です。

9-5. try-catch-finallyで安全にリソースを解放する

通信処理では、どこで例外が発生しても接続を閉じられるようにする必要があります。usingを使うのが基本ですが、TcpListenerのように明示的なStopが必要な場合はfinallyも有効です。

C#
TcpListener listener = new TcpListener(IPAddress.Any, 5000);

try
{
listener.Start();

using TcpClient client = listener.AcceptTcpClient();
using NetworkStream stream = client.GetStream();

// 通信処理
}
catch (SocketException ex)
{
Console.WriteLine($"ソケットエラー: {ex.Message}");
}
catch (IOException ex)
{
Console.WriteLine($"I/Oエラー: {ex.Message}");
}
finally
{
listener.Stop();
}

例外処理とリソース解放をセットで考えることが、安定したTCP通信の基本です。

10. NetworkStreamでよくあるつまずきポイント

10-1. 1回のReadで全データが読めるとは限らない

NetworkStreamで最もよくある誤解は、「1回Writeしたデータは、1回のReadでそのまま受け取れる」と考えてしまうことです。

TCPはメッセージ単位ではなくバイトストリームです。そのため、送信側が1回で1000バイト送っても、受信側では300バイトと700バイトに分かれて読めることがあります。逆に、複数回送ったデータがまとめて読めることもあります。

そのため、受信側では必要な長さに達するまでループして読み取る設計が必要です。

C#
static int ReadExact(NetworkStream stream, byte[] buffer, int size)
{
int totalRead = 0;

while (totalRead < size)
{
int read = stream.Read(buffer, totalRead, size - totalRead);

if (read == 0)
{
throw new EndOfStreamException("接続が終了しました。");
}

totalRead += read;
}

return totalRead;
}

10-2. メッセージの区切りを自分で決める必要がある

TCPには、アプリケーション上の「1メッセージ」という概念がありません。そのため、どこからどこまでが1つのメッセージなのかを自分で決める必要があります。

代表的な方法は、次の2つです。

改行区切りでは、1行を1メッセージとして扱います。チャットや簡単なコマンド通信に向いています。

データ長付き形式では、先頭に本文のバイト数を付けてから本文を送ります。JSON、バイナリ、ファイル送信などに向いています。

10-3. 文字化けが起きる原因とEncodingの統一

文字化けの多くは、送信側と受信側でEncodingが一致していないことが原因です。

C#
// 送信側
byte[] data = Encoding.UTF8.GetBytes("こんにちは");

// 受信側
string text = Encoding.UTF8.GetString(buffer, 0, bytesRead);

日本語を含む通信では、基本的にUTF-8に統一するのがおすすめです。また、StreamReaderStreamWriterを使う場合も、明示的にUTF-8を指定すると安全です。

C#
using StreamReader reader = new StreamReader(stream, Encoding.UTF8);
using StreamWriter writer = new StreamWriter(stream, Encoding.UTF8)
{
AutoFlush = true
};

10-4. DisposeやCloseの呼び忘れによる問題

TcpClientNetworkStreamを閉じ忘れると、ソケットやポートなどのリソースが不要に残る可能性があります。短時間で大量の接続を作るアプリケーションでは、リソース不足や接続不安定の原因になることがあります。

基本的にはusingを使います。

C#
using TcpClient client = new TcpClient();
client.Connect("127.0.0.1", 5000);

using NetworkStream stream = client.GetStream();

// 通信処理

NetworkStream.Closeは関連リソースを解放し、TcpClientに関連付けられたNetworkStreamの場合はTCP接続を閉じますが、関連するTcpClient自体を破棄するわけではありません。Microsoft Learn

10-5. UIアプリで同期Readを使うと固まる理由

Windows FormsやWPFなどのUIアプリで、メインスレッド上で同期Readを呼ぶと、データが届くまで画面操作が止まることがあります。

C#
// UIスレッドで実行すると画面が固まる可能性がある
int bytesRead = stream.Read(buffer, 0, buffer.Length);

UIアプリでは、ReadAsyncを使うか、バックグラウンドタスクで通信処理を行うのが基本です。

C#
int bytesRead = await stream.ReadAsync(buffer, cancellationToken);

非同期処理にすることで、通信待ちの間もUIスレッドをブロックせずに済みます。

11. 実用的なプロトコル設計の考え方

11-1. 改行区切りでメッセージを送る方法

短いテキストメッセージを送る場合は、改行区切りが簡単です。StreamReaderStreamWriterを使うと、1行単位で読み書きできます。

送信側はWriteLineを使います。

C#
using StreamWriter writer = new StreamWriter(stream, Encoding.UTF8)
{
AutoFlush = true
};

writer.WriteLine("LOGIN user password");

受信側はReadLineを使います。

C#
using StreamReader reader = new StreamReader(stream, Encoding.UTF8);

string? line = reader.ReadLine();

if (line != null)
{
Console.WriteLine($"受信: {line}");
}

改行区切りはシンプルですが、メッセージ本文に改行を含めたい場合はエスケープ処理や別形式が必要です。

11-2. データ長を先頭に付けて送る方法

より実用的なのは、先頭にデータ長を付ける方法です。たとえば、先頭4バイトを本文サイズとして使い、その後に本文を送信します。

C#
using System.Buffers.Binary;
using System.Text;

static async Task SendMessageAsync(NetworkStream stream, string message, CancellationToken token)
{
byte[] body = Encoding.UTF8.GetBytes(message);
byte[] header = new byte[4];

BinaryPrimitives.WriteInt32BigEndian(header, body.Length);

await stream.WriteAsync(header, token);
await stream.WriteAsync(body, token);
}

受信側では、まず4バイトのヘッダーを読み、その長さぶん本文を読みます。

C#
using System.Buffers.Binary;
using System.Text;

static async Task<string> ReceiveMessageAsync(NetworkStream stream, CancellationToken token)
{
byte[] header = new byte[4];
await ReadExactlyAsync(stream, header, token);

int length = BinaryPrimitives.ReadInt32BigEndian(header);

if (length < 0 || length > 10_000_000)
{
throw new InvalidDataException("不正なデータ長です。");
}

byte[] body = new byte[length];
await ReadExactlyAsync(stream, body, token);

return Encoding.UTF8.GetString(body);
}

static async Task ReadExactlyAsync(Stream stream, byte[] buffer, CancellationToken token)
{
int offset = 0;

while (offset < buffer.Length)
{
int read = await stream.ReadAsync(buffer.AsMemory(offset), token);

if (read == 0)
{
throw new EndOfStreamException("接続が終了しました。");
}

offset += read;
}
}

この形式にすると、1回のReadで全データが読めない問題に対応しやすくなります。

11-3. JSONをNetworkStreamで送受信する方法

JSONを送る場合も、最終的には文字列をUTF-8のバイト列に変換して送ります。実用上は、JSON本文の前にデータ長を付ける方法が扱いやすいです。

C#
using System.Text.Json;

var request = new
{
Command = "echo",
Message = "Hello JSON"
};

string json = JsonSerializer.Serialize(request);

await SendMessageAsync(stream, json, cancellationToken);

受信側では、読み取ったJSON文字列をデシリアライズします。

C#
string json = await ReceiveMessageAsync(stream, cancellationToken);

var data = JsonSerializer.Deserialize<Dictionary<string, string>>(json);

Console.WriteLine(data?["Message"]);

JSONは人間が読める形式でデバッグしやすいため、独自プロトコルの本文形式としてよく使われます。

11-4. 大きなデータを分割して送受信する方法

大きなファイルや画像を送る場合は、一度にすべてをメモリに読み込むのではなく、一定サイズのバッファで分割して送信します。

C#
static async Task SendFileAsync(NetworkStream stream, string path, CancellationToken token)
{
using FileStream file = File.OpenRead(path);

byte[] buffer = new byte[8192];
int read;

while ((read = await file.ReadAsync(buffer, token)) > 0)
{
await stream.WriteAsync(buffer.AsMemory(0, read), token);
}
}

ただし、このままだと受信側はファイルの終わりを判断できません。実用的には、先にファイルサイズを送る、またはチャンクごとにサイズを付けるなど、終了条件を明確にする必要があります。

12. NetworkStreamのFAQ

12-1. NetworkStreamとSocketの違いは何か

Socketは、より低レベルで柔軟なネットワーク通信を扱うクラスです。接続、送受信、オプション設定、非ブロッキング制御などを細かく扱えます。

一方、NetworkStreamは、接続済みソケット上のデータ送受信をStreamとして扱うためのクラスです。TcpClientと組み合わせると、TCP通信をシンプルに読み書きできます。

高度な制御や性能最適化が必要な場合はSocket、一般的なTCP通信をわかりやすく実装したい場合はTcpClientNetworkStreamが向いています。Microsoftの解説でも、TCPでは最大限の制御と性能を求める場合はSocket、簡単に扱う場合はTcpClientTcpListenerという選択肢が示されています。Microsoft Learn

12-2. TcpClient.CloseとNetworkStream.Disposeはどちらが必要か

基本的には、usingTcpClientNetworkStreamの両方を管理する書き方がおすすめです。

C#
using TcpClient client = new TcpClient();
client.Connect("127.0.0.1", 5000);

using NetworkStream stream = client.GetStream();

// 通信処理

TcpClient.Close()は、TcpClientを破棄し、関連するTCP接続を閉じるよう要求します。関連するNetworkStreamが作成されている場合、そのNetworkStreamも閉じられます。Microsoft Learn

一方で、NetworkStream.Close()はストリームのリソースを解放し、TcpClientに関連付けられたNetworkStreamの場合はTCP接続を閉じますが、TcpClient自体を破棄するわけではありません。Microsoft Learn

迷った場合は、usingで両方をスコープ管理するのが安全です。

12-3. NetworkStreamはUDP通信で使えるか

NetworkStreamはTCPのような接続型通信で使うクラスです。UDP通信では通常、UdpClientSocketを使います。

UDPは接続を確立して連続したストリームとしてデータを扱うのではなく、データグラム単位で送受信します。そのため、TCP向けのNetworkStreamとは考え方が異なります。

12-4. SSL/TLS通信をしたい場合はどうするか

NetworkStreamの通信内容を暗号化したい場合は、SslStreamを使います。SslStreamは、SSL/TLSを使ったクライアント・サーバー間通信のためのストリームを提供するクラスです。Microsoft Learn

基本的な考え方は、NetworkStreamを取得し、その上にSslStreamを重ねることです。

C#
using NetworkStream networkStream = client.GetStream();

using SslStream sslStream = new SslStream(
networkStream,
leaveInnerStreamOpen: false
);

// クライアント側
await sslStream.AuthenticateAsClientAsync("example.com");

認証後は、sslStream.ReadAsyncsslStream.WriteAsyncを使って暗号化された通信を行います。

12-5. NetworkStreamの通信内容をログに残すにはどうするか

通信内容をログに残す場合は、送信前のバイト列や文字列、受信後に復元した文字列を記録します。

C#
string message = "Hello";
byte[] data = Encoding.UTF8.GetBytes(message);

Console.WriteLine($"送信ログ: {message}");

stream.Write(data, 0, data.Length);

受信側では、bytesReadで実際に読んだ範囲だけをログに残します。

C#
int bytesRead = stream.Read(buffer, 0, buffer.Length);

if (bytesRead > 0)
{
string received = Encoding.UTF8.GetString(buffer, 0, bytesRead);
Console.WriteLine($"受信ログ: {received}");
}

バイナリデータをログに残す場合は、16進数文字列に変換すると確認しやすくなります。

C#
string hex = Convert.ToHexString(buffer, 0, bytesRead);
Console.WriteLine($"受信HEX: {hex}");

ただし、パスワード、トークン、個人情報などをログにそのまま出力しないように注意が必要です。本番環境では、必要に応じてマスク処理やログレベル制御を行います。

まとめ

C#のNetworkStreamは、TCP通信をストリームとして扱える便利なクラスです。TcpClient.GetStream()から取得し、ReadWriteを使ってバイト列を送受信できます。

一方で、TCPはメッセージ単位ではなくバイトストリームであるため、1回のReadで全データが読めるとは限りません。実用的な通信を行うには、改行区切りやデータ長付き形式など、メッセージの区切りを自分で設計する必要があります。

また、文字列を送る場合はEncoding.UTF8などでバイト列に変換し、送信側と受信側で文字コードを統一することが重要です。通信中の切断、タイムアウト、SocketExceptionIOExceptionにも備えて、例外処理とリソース解放を適切に実装しましょう。

シンプルな処理では同期版のReadWriteから始め、複数接続やUIアプリではReadAsyncWriteAsyncを使った非同期処理へ発展させるのがおすすめです。NetworkStreamの基本を理解しておくと、C#でのTCPクライアント、TCPサーバー、独自プロトコル、JSON通信、SSL/TLS通信まで幅広く応用できます。