TCPでSendした単位ごとにReceiveできるという誤解 ── バイトストリームとして扱うための受信設計
· 小村 豪 · TCP, Socket, Network, .NET, CSharp, ProtocolDesign, 運用, 既存資産活用
1. 最初に押さえるべきこと
TCP 通信の実装で、かなりよくある誤解があります。
それは、
送信側が
Send/Writeした単位ごとに、受信側でもReceive/Readできる
というものです。
たとえば、送信側が次のように送ったとします。
Send("LOGIN\n")
Send("GET /items\n")
Send("QUIT\n")
このとき、受信側で次のように 3 回受け取れると思ってしまう。
Receive() => "LOGIN\n"
Receive() => "GET /items\n"
Receive() => "QUIT\n"
しかし、TCP ではそうとは限りません。
実際には、次のどれも起こり得ます。
Receive() => "LOGIN\nGET /items\nQUIT\n"
Receive() => "LOG"
Receive() => "IN\nGET /ite"
Receive() => "ms\nQUIT\n"
Receive() => "LOGIN\nGET /items\n"
Receive() => "QUIT"
Receive() => "\n"
どれも TCP としては正常です。
TCP が保証しているのは、ざっくり言えば「送ったバイト列が、順序を保って、重複なく、欠落なく届くこと」です。保証していないのは、「アプリケーションが Send した単位が、受信側の Receive の単位として保存されること」です。
そのため、TCP を使うアプリケーションでは、受信側で「いま受け取ったバイト列が、どこからどこまでで 1 メッセージなのか」を判断する仕組みが必要です。
これを、アプリケーションプロトコルのフレーミングと呼びます。
この記事では、TCP の Send と Receive の関係で起きやすい誤解と、.NET / C# での正しい扱い方を整理します。
2. TCPは「メッセージ」ではなく「バイト列」を運ぶ
まず、TCP をメッセージキューのように考えないことが重要です。
TCP は、アプリケーションから渡されたデータを、連続したバイト列として扱います。
たとえば送信側が次のように 3 回 Send したとしても、
Send("ABC")
Send("DEF")
Send("GHI")
TCP から見ると、これは最終的に次のような 9 バイトの流れです。
ABCDEFGHI
この中に、
ABC | DEF | GHI
というアプリケーション都合の境界が残るわけではありません。
受信側は、あるタイミングで「いま受信バッファにある分」を読みます。したがって、受信結果は次のようになり得ます。
| 送信側の呼び出し | 受信側の見え方の例 |
|---|---|
Send("ABC"), Send("DEF") |
Receive() 1 回で "ABCDEF" |
Send("ABCDEF") |
Receive() 2 回で "AB", "CDEF" |
Send("ABC"), Send("DEF"), Send("GHI") |
Receive() 3 回で "A", "BCDEFG", "HI" |
Send("\u3042") のような UTF-8 文字 |
マルチバイト文字の途中で分割されることもある |
大事なのは、ここに「異常」はないということです。
「たまに受信データが欠ける」「複数メッセージがくっつく」「文字化けする」という不具合に見えるものの多くは、TCP の異常ではなく、受信側が TCP をメッセージ単位で扱ってしまっている設計ミスです。
3. なぜSend単位で受信できるように見えるのか
この誤解がなくならない理由は、ローカル環境や小さなデータでは、たまたま期待どおりに見えることが多いからです。
開発環境では、次の条件がそろいやすくなります。
- クライアントとサーバーが同じマシン、または近いネットワーク上にある
- データ量が小さい
- 通信相手がすぐ読み取ってくれる
- CPU やネットワークに余裕がある
- テストが手動で、タイミングの揺らぎが少ない
Sendの直後にReceiveしている
このような条件では、1 回の Send に対して 1 回の Receive で読めているように見えることがあります。
しかし、本番環境では条件が変わります。
- OS の送受信バッファにたまる
- 複数回の小さな送信がまとめられる
- 大きな送信が TCP セグメントや受信バッファの都合で分割される
- 受信側スレッドのスケジューリングが遅れる
- TLS、プロキシ、ロードバランサー、VPN などの層が入る
- ネットワーク遅延や輻輳が起きる
- Nagle アルゴリズムや遅延 ACK の影響を受ける
その結果、「開発環境では動いたのに、本番でたまに壊れる」という厄介な不具合になります。
ネットワーク処理では、この「たまたま動く」が一番危険です。
4. よくある壊れやすい受信コード
たとえば、次のようなコードは危険です。
byte[] buffer = new byte[4096];
int read = await stream.ReadAsync(buffer, cancellationToken);
if (read == 0)
{
// 相手が正常に切断した
return;
}
string message = Encoding.UTF8.GetString(buffer, 0, read);
await HandleMessageAsync(message, cancellationToken);
このコードは、「1 回の ReadAsync で 1 メッセージが取れる」ことを前提にしています。
しかし、TCP ではその前提は成り立ちません。
問題は大きく 3 つあります。
1 つ目は、1 メッセージが分割されることです。
送信: {"command":"login","user":"komura"}\n
受信1: {"command":"login",
受信2: "user":"komura"}\n
この場合、受信1だけを JSON としてパースしようとすると失敗します。
2 つ目は、複数メッセージが結合されることです。
送信1: {"command":"login"}\n
送信2: {"command":"get"}\n
受信: {"command":"login"}\n{"command":"get"}\n
この場合、1 個の JSON としてパースしようとすると失敗します。
3 つ目は、文字コードの境界で分割されることです。
UTF-8 では、1 文字が複数バイトになることがあります。ReadAsync の境界が文字の境界と一致する保証はありません。
そのため、受け取ったバイト列を毎回すぐ Encoding.UTF8.GetString して文字列化すると、マルチバイト文字の途中で分割された場合に壊れる可能性があります。
「受け取ったらすぐ文字列化」ではなく、「メッセージの境界が分かるまでバイトとして蓄積し、1 メッセージ分そろってからデコードする」ことが基本です。
5. DataAvailableでメッセージ終端を判断してはいけない
次のようなコードもよく見かけます。
var ms = new MemoryStream();
byte[] buffer = new byte[4096];
while (stream.DataAvailable)
{
int read = await stream.ReadAsync(buffer, cancellationToken);
if (read == 0)
{
break;
}
ms.Write(buffer, 0, read);
}
byte[] message = ms.ToArray();
これも危険です。
DataAvailable が表すのは、「その瞬間に、読み取り可能なデータがローカルの受信バッファにあるか」です。
それは、アプリケーション上の 1 メッセージが完了したことを意味しません。
たとえば、1 メッセージが 100 バイトだったとして、最初の 40 バイトだけが到着した瞬間に DataAvailable が true になり、その 40 バイトを読んだ直後に一時的に false になることがあります。残りの 60 バイトは少し後に届くかもしれません。
このとき、DataAvailable == false を「メッセージ終端」と解釈すると、途中までのデータを 1 メッセージとして処理してしまいます。
DataAvailable は、読み取りループの最適化やノンブロッキング的な確認に使うことはあっても、プロトコルの境界判定には使わない方が安全です。
6. 正しい考え方は「受信」と「解釈」を分けること
TCP の受信処理では、次の 2 つを分けて考えると設計しやすくなります。
受信: TCPから届いたバイト列を読み、バッファに積む
解釈: バッファから、アプリケーション上の1メッセージを切り出す
Receive / Read は、あくまで「バイトを読む」処理です。
それに対して、「1 メッセージがどこで終わるか」は、アプリケーションプロトコルで決める必要があります。
代表的な方法は次の 4 つです。
| 方式 | 内容 | 向いている用途 |
|---|---|---|
| 固定長 | 常に決まったバイト数を 1 メッセージとする | レガシー機器、バイナリ電文、制御系 |
| 区切り文字 | \n など、特定のバイト列までを 1 メッセージとする |
コマンド、ログ、NDJSON、簡易プロトコル |
| 長さプレフィックス | 先頭に本文長を置き、そのバイト数だけ本文を読む | バイナリ、JSON、MessagePack、Protocol Buffers など |
| 自己記述形式 | HTTP の Content-Length や chunked のように形式内で長さや終端を表す |
既存プロトコル、拡張性が必要な通信 |
個人的には、独自プロトコルを作るなら、まずは長さプレフィックス方式を検討します。
理由は、本文に改行や任意のバイナリを含められること、受信側の実装が明確なこと、最大サイズ制限を入れやすいことです。
7. 長さプレフィックス方式の基本
長さプレフィックス方式では、メッセージを次のような形式にします。
[4バイトの本文長][本文]
たとえば、本文が UTF-8 の JSON で、本文長が 31 バイトなら、次のように送ります。
00 00 00 1F 7B 22 63 6F 6D 6D 61 6E 64 ...
^---------^ ^------------------------------^
本文長 本文
受信側は、次の順番で処理します。
- まず 4 バイトを読み切る
- その 4 バイトから本文長を取り出す
- 本文長が不正でないか検証する
- 本文長の分だけ読み切る
- 読み切った本文を 1 メッセージとして処理する
- 次のフレームを読む
ここで重要なのは、「4 バイトのヘッダーも分割され得る」ということです。
受信1: 00 00
受信2: 00 1F 7B 22 63 ...
したがって、ヘッダーだからといって 1 回の Read で 4 バイト取れるとは限りません。
本文も同じです。
Read の戻り値が要求サイズより小さいことは普通にあります。必要なバイト数が決まっているなら、必要なだけ読み切るループを書く必要があります。
8. .NETでの受信実装例:長さプレフィックス方式
次は、.NET / C# で長さプレフィックス方式のフレームを読む例です。
ここでは、先頭 4 バイトを big-endian の int として本文長にしています。
using System.Buffers.Binary;
using System.IO;
public static class LengthPrefixedProtocol
{
private const int HeaderSize = 4;
private const int MaxPayloadSize = 1024 * 1024; // 1 MiB。用途に合わせて決める
public static async ValueTask<byte[]?> ReadFrameAsync(
Stream stream,
CancellationToken cancellationToken)
{
byte[] header = new byte[HeaderSize];
int headerBytes = await ReadUntilFullOrEndAsync(
stream,
header,
cancellationToken);
if (headerBytes == 0)
{
// フレームの途中ではなく、次のフレーム開始前に相手が正常終了した
return null;
}
if (headerBytes != HeaderSize)
{
throw new EndOfStreamException("Frame header was truncated.");
}
int payloadLength = BinaryPrimitives.ReadInt32BigEndian(header);
if (payloadLength < 0 || payloadLength > MaxPayloadSize)
{
throw new InvalidDataException(
$"Invalid payload length: {payloadLength} bytes.");
}
byte[] payload = new byte[payloadLength];
int payloadBytes = await ReadUntilFullOrEndAsync(
stream,
payload,
cancellationToken);
if (payloadBytes != payloadLength)
{
throw new EndOfStreamException("Frame payload was truncated.");
}
return payload;
}
private static async ValueTask<int> ReadUntilFullOrEndAsync(
Stream stream,
Memory<byte> buffer,
CancellationToken cancellationToken)
{
int totalRead = 0;
while (totalRead < buffer.Length)
{
int read = await stream.ReadAsync(
buffer[totalRead..],
cancellationToken);
if (read == 0)
{
break;
}
totalRead += read;
}
return totalRead;
}
}
使う側は、次のようになります。
while (true)
{
byte[]? payload = await LengthPrefixedProtocol.ReadFrameAsync(
stream,
cancellationToken);
if (payload is null)
{
// 相手がフレーム境界できれいに切断した
break;
}
await HandleMessageAsync(payload, cancellationToken);
}
この実装では、ReadAsync が何バイトずつ返しても問題ありません。
1 バイトずつ返ってきても、ヘッダーと本文を読み切るまでループします。
逆に、複数メッセージ分のデータが OS の受信バッファにたまっている場合でも、先頭のフレームだけを本文長に従って切り出し、次のループで次のフレームを読みます。
なお、現行の .NET では Stream.ReadExactly / ReadExactlyAsync を使える環境もあります。その場合、必要バイト数を読み切る処理を標準 API に任せられます。ただし、接続終了時の扱い、フレーム開始前の正常終了、途中で切れた異常終了をどう区別するかは、アプリケーション側で設計しておく必要があります。
9. 送信側の実装例
送信側も、同じフレーム形式に従って送ります。
using System.Buffers.Binary;
using System.IO;
public static class LengthPrefixedProtocolWriter
{
private const int HeaderSize = 4;
private const int MaxPayloadSize = 1024 * 1024;
public static async ValueTask WriteFrameAsync(
Stream stream,
ReadOnlyMemory<byte> payload,
CancellationToken cancellationToken)
{
if (payload.Length > MaxPayloadSize)
{
throw new InvalidDataException(
$"Payload is too large: {payload.Length} bytes.");
}
byte[] header = new byte[HeaderSize];
BinaryPrimitives.WriteInt32BigEndian(header, payload.Length);
await stream.WriteAsync(header, cancellationToken);
await stream.WriteAsync(payload, cancellationToken);
}
}
このコードでは、ヘッダーと本文を別々に WriteAsync しています。
ここで、また誤解が生まれやすいです。
送信側でヘッダーと本文を 2 回に分けて WriteAsync しても、受信側で 2 回に分かれて読めるとは限りません。
受信側では、次のように見えるかもしれません。
Read() => [ヘッダー4バイト + 本文の一部]
Read() => [本文の残り]
あるいは、こうかもしれません。
Read() => [ヘッダーの前半2バイト]
Read() => [ヘッダーの後半2バイト + 本文全部 + 次フレームのヘッダー]
だからこそ、受信側は「何回 Read したか」ではなく、「フレーム形式に従って何バイト読めたか」で判断します。
10. Socket.Sendを直接使う場合は送信側も戻り値を見る
NetworkStream.Write / WriteAsync を使っている場合は、基本的には指定した範囲を書き込む API として扱えます。
一方で、Socket.Send を直接使う場合は、戻り値に注意が必要です。
Socket.Send は「送信できたバイト数」を返します。特にノンブロッキングソケットなどでは、要求したバイト数より少ないバイト数で成功することがあります。
そのため、Socket.Send を直接使うなら、送信側にも「全部送るまで繰り返す」処理が必要です。
using System.Net.Sockets;
public static async ValueTask SendAllAsync(
Socket socket,
ReadOnlyMemory<byte> buffer,
CancellationToken cancellationToken)
{
while (!buffer.IsEmpty)
{
int sent = await socket.SendAsync(
buffer,
SocketFlags.None,
cancellationToken);
if (sent == 0)
{
throw new IOException("Socket was closed while sending data.");
}
buffer = buffer[sent..];
}
}
ただし、ここでいう「送れた」は、「相手アプリケーションがそのメッセージを処理した」という意味ではありません。
送信 API の成功は、アプリケーションプロトコル上の成功応答とは別です。
たとえば業務的に「注文を受け付けた」「ファイルを保存した」「コマンドを実行した」を確認したいなら、TCP の送信成功ではなく、相手アプリケーションからの ACK や応答メッセージをプロトコルとして定義する必要があります。
11. 区切り文字方式を使う場合の注意点
テキストプロトコルでは、改行区切りを使うことがあります。
LOGIN komura secret\n
GET item-001\n
QUIT\n
この方式は分かりやすく、ログやコマンド形式と相性が良いです。
ただし、次の点に注意が必要です。
- 区切り文字が本文中に現れる場合のエスケープ規則を決める
\r\nと\nの扱いを決める- 1 行の最大長を決める
- 区切り文字が来るまで無制限にメモリをためない
- UTF-8 のマルチバイト文字が分割されても壊れないようにする
特に、次のコードは避けたいです。
int read = await stream.ReadAsync(buffer, cancellationToken);
string text = Encoding.UTF8.GetString(buffer, 0, read);
foreach (string line in text.Split('\n'))
{
await HandleLineAsync(line, cancellationToken);
}
このコードは、受信した範囲の最後が行の途中かもしれないことを考慮していません。
また、UTF-8 文字の途中で分割される可能性も考慮していません。
改行区切りを使うなら、少なくとも「バイトを蓄積して、改行バイトを探し、1 行分そろってからデコードする」か、StreamReader.ReadLineAsync のようにストリーム上で行を読む API を使います。
ただし、StreamReader.ReadLineAsync を使う場合でも、最大行長、タイムアウト、キャンセル、接続終了時の扱いは設計しておくべきです。
12. 固定長方式を使う場合の注意点
固定長電文では、「必ず 128 バイトで 1 メッセージ」のように決めます。
この方式は、古い業務システム、制御系、機器連携で見かけます。
固定長でも考え方は同じです。
1メッセージ = 128バイト
なら、受信側は 128 バイトを読み切るまでループします。
byte[] message = new byte[128];
int read = await ReadUntilFullOrEndAsync(stream, message, cancellationToken);
if (read != message.Length)
{
throw new EndOfStreamException("Fixed-length message was truncated.");
}
await HandleMessageAsync(message, cancellationToken);
ここでも、1 回の ReadAsync で 128 バイト返るとは限りません。
固定長は境界が明確なので実装しやすい一方、可変長データを扱いにくい、将来の拡張が難しい、余白の扱いが面倒、文字コード変換でバイト数が変わる、といった問題があります。
13. Nagleを無効にしてもメッセージ境界の問題は解決しない
小さなデータをすぐ送りたい場合、Socket.NoDelay = true を検討することがあります。
これは Nagle アルゴリズムを無効にする設定です。
ただし、NoDelay は「小さな送信をどのようにまとめるか」という送信遅延や効率に関する設定であって、「Send 単位を Receive 単位として保存する」設定ではありません。
つまり、NoDelay = true にしても、次の問題は解決しません。
- 1 回の
Sendが複数回のReceiveに分かれる - 複数回の
Sendが 1 回のReceiveにまとまる - 文字の途中で分割される
- 受信側がメッセージ境界を判断できない
NoDelay はレイテンシの調整としては意味があります。
しかし、フレーミングの代わりにはなりません。
14. TLSやSslStreamを使っても考え方は同じ
SslStream を使って TLS 化している場合も、アプリケーションから見た扱いは基本的に同じです。
TLS には TLS レコードという内部的な単位がありますが、それはアプリケーションメッセージの境界ではありません。
SslStream.ReadAsync でも、アプリケーションが期待する 1 メッセージ分が 1 回で返るとは限りません。
したがって、TLS の有無にかかわらず、アプリケーション層で次のどれかを設計します。
- 長さプレフィックス
- 改行などの区切り文字
- 固定長
- 既存プロトコル形式
TLS は暗号化と認証の層です。
メッセージ境界を自動で作ってくれる層ではありません。
15. 受信ループで意識したいエラー処理
TCP の受信処理では、正常系だけでなく、切断や途中終了を明確に扱うことが大事です。
Read / Receive の戻り値が 0 の場合、一般に、相手が正常に送信を終了したことを意味します。
ただし、アプリケーションプロトコル上は、次の 2 つを分ける必要があります。
| 状態 | 扱い |
|---|---|
| 次のフレームを読む前に 0 バイトで終了 | 正常終了として扱える場合がある |
| フレームのヘッダー途中、または本文途中で終了 | 中途半端な電文なので異常として扱う |
長さプレフィックス方式なら、たとえば次のように考えます。
フレーム境界で切断:
正常終了として扱ってよい
4バイトヘッダーのうち2バイトだけ受け取って切断:
プロトコルエラー
本文長100バイトなのに60バイトだけ受け取って切断:
プロトコルエラー
この区別を入れておくと、ログ調査がかなり楽になります。
「相手が切断しました」だけではなく、
Frame payload was truncated. expected=100 actual=60
のように出せると、相手側の異常終了、タイムアウト、プロトコル不一致を疑いやすくなります。
16. 最大サイズを必ず決める
長さプレフィックス方式では、先頭に本文長が入ります。
ここで危険なのは、相手から巨大な長さを指定された場合です。
FF FF FF FF
これをそのまま配列確保に使うと、メモリを大量に確保しようとしてアプリケーションが不安定になります。
そのため、受信側では必ず最大サイズを決めます。
private const int MaxPayloadSize = 1024 * 1024;
if (payloadLength < 0 || payloadLength > MaxPayloadSize)
{
throw new InvalidDataException(
$"Invalid payload length: {payloadLength} bytes.");
}
最大サイズは業務要件で決めます。
コマンドなら 64 KiB で十分かもしれません。 画像やファイルを送るなら、別の転送方式やストリーミングを考えるべきかもしれません。
重要なのは、「理論上いくらでも受け付ける」設計にしないことです。
17. 文字列プロトコルでは「文字数」ではなく「バイト数」を見る
TCP が運ぶのは文字列ではなくバイト列です。
そのため、長さプレフィックス方式で本文長を入れる場合は、通常は「文字数」ではなく「バイト数」を入れます。
たとえば、次の文字列を UTF-8 にするとします。
こんにちは
これは 5 文字ですが、UTF-8 では 15 バイトです。
プロトコル上の長さを 5 としてしまうと、受信側は本文を 5 バイトだけ読んでしまい、文字の途中で切れます。
送信側は、必ずエンコード後のバイト配列を基準に長さを計算します。
string json = "{\"message\":\"こんにちは\"}";
byte[] payload = Encoding.UTF8.GetBytes(json);
await LengthPrefixedProtocolWriter.WriteFrameAsync(
stream,
payload,
cancellationToken);
受信側は、フレーム本文をバイトとして読み切ってから文字列に戻します。
byte[]? payload = await LengthPrefixedProtocol.ReadFrameAsync(
stream,
cancellationToken);
if (payload is not null)
{
string json = Encoding.UTF8.GetString(payload);
await HandleJsonAsync(json, cancellationToken);
}
この順番にすると、UTF-8 文字の途中で Read が分割されても問題になりません。
18. 並行Writeによるアプリケーションレベルの混線にも注意する
もう 1 つ、意外と見落とされるのが並行書き込みです。
たとえば、同じ TCP 接続に対して複数のタスクが同時にフレームを書き込むとします。
_ = WriteFrameAsync(stream, messageA, cancellationToken);
_ = WriteFrameAsync(stream, messageB, cancellationToken);
これを制御しないと、アプリケーションレベルで次のような混線が起きる可能性があります。
Aのヘッダー
Bのヘッダー
Aの本文
Bの本文
受信側は、A のヘッダーを読んだあと A の本文が来る前提で処理します。
そこに B のヘッダーが割り込むと、プロトコルが壊れます。
そのため、1 つの接続に対する書き込みは直列化するのが安全です。
たとえば SemaphoreSlim や送信用キューを使って、フレーム単位の書き込みが混ざらないようにします。
private readonly SemaphoreSlim _sendLock = new(1, 1);
public async ValueTask SendFrameSafelyAsync(
Stream stream,
byte[] payload,
CancellationToken cancellationToken)
{
await _sendLock.WaitAsync(cancellationToken);
try
{
await LengthPrefixedProtocolWriter.WriteFrameAsync(
stream,
payload,
cancellationToken);
}
finally
{
_sendLock.Release();
}
}
TCP はバイトの順序を守ってくれます。
しかし、アプリケーションが複数タスクからバイト列を混ぜて書いた場合、その「混ざった順序」を正しく届けてしまいます。
19. テストではあえて分割・結合させる
TCP の受信処理は、普通にテストすると「たまたま動く」状態を見逃しがちです。
そのため、テストでは意図的に次のパターンを作ります。
| テスト観点 | 例 |
|---|---|
| 1 バイトずつ届く | ヘッダーも本文も 1 バイト単位で Read される |
| ヘッダー途中で切断 | 4 バイトヘッダーのうち 2 バイトだけ届いて終了する |
| 本文途中で切断 | 本文長 100 のうち 60 バイトだけ届いて終了する |
| 複数フレームが結合 | 2 つのフレームが 1 回の内部バッファに入っている |
| 巨大サイズ指定 | 最大サイズを超える本文長を送る |
| 0 バイト本文 | 本文長 0 を許可するか確認する |
| UTF-8 分割 | 日本語や絵文字のバイト列が途中で分割される |
ユニットテストでは、実際の TCP ソケットを使わなくても、Stream を差し替えて「指定したチャンクサイズでしか読めないストリーム」を作ると、受信処理を検証しやすくなります。
本当に TCP 越しに検証する統合テストも必要ですが、まずは受信パーサーを Stream に対する純粋な処理として切り出しておくと、テストしやすくなります。
ネットワーク処理の品質は、「普通に送ったら動く」ではなく、「分割されても、結合されても、途中で切れても、想定どおり振る舞う」で判断します。
20. 既存コードを直すときのチェックリスト
既存の TCP 通信コードを確認するときは、次の観点で見ると問題を見つけやすいです。
| 観点 | 確認すること |
|---|---|
| 受信単位 | 1 回の Read / Receive を 1 メッセージとして扱っていないか |
| 戻り値 | Read / Receive の戻り値のバイト数を必ず使っているか |
| 蓄積 | メッセージがそろうまでバイトを蓄積しているか |
| 境界 | 固定長、区切り文字、長さプレフィックスなどのルールがあるか |
| 文字コード | メッセージ完成前に文字列化していないか |
| 最大長 | 長さや行長に上限があるか |
| 切断 | フレーム境界での切断と途中切断を区別しているか |
| 送信 | Socket.Send の戻り値を無視していないか |
| 並行性 | 同じ接続への複数タスクの書き込みが混ざらないか |
| ログ | expected / actual のバイト数を出せるか |
| テスト | 分割、結合、途中切断のテストがあるか |
特に危険なコードは、次のような形です。
int read = socket.Receive(buffer);
string message = Encoding.UTF8.GetString(buffer);
Handle(message);
問題点は複数あります。
readの値を使っていない- バッファ全体を文字列化している
- 1 回の
Receiveを 1 メッセージとして扱っている - メッセージ境界がない
- 文字の途中分割を考慮していない
最低でも、次の考え方に変える必要があります。
Receiveで得た read バイトだけを受信バッファに追加する
↓
受信バッファから、プロトコルに従って1フレーム切り出せるか確認する
↓
切り出せるなら処理する
↓
余ったバイトは次のフレームの先頭として残す
↓
足りないなら次のReceiveを待つ
21. まとめ
TCP 通信では、Send した単位ごとに Receive できるとは限りません。
これは例外的な挙動ではなく、TCP を使う上での基本です。
押さえるべきポイントは次のとおりです。
- TCP はメッセージではなく、順序付きのバイトストリームを提供する
Send/Writeの呼び出し単位は、受信側のReceive/Read単位として保存されない- 1 回の送信が複数回の受信に分かれることも、複数回の送信が 1 回の受信にまとまることもある
- 受信側は、アプリケーションプロトコルとしてメッセージ境界を決める必要がある
- 独自プロトコルでは、長さプレフィックス方式が扱いやすいことが多い
- 必要バイト数を読み切るループ、最大サイズ、途中切断、文字コード、並行書き込みを設計に含める
NoDelayやDataAvailableは、メッセージ境界の代わりにはならない
ネットワーク処理は、正常なときだけを見ると簡単に見えます。
しかし、実際には「どこで区切るか」「足りないときどう待つか」「多すぎるときどう残すか」「途中で切れたときどう扱うか」を決めて初めて、安定した通信になります。
TCP を使うなら、Receive はメッセージを返すものではありません。
Receive は、バイト列の一部を返すだけです。
メッセージにする責任は、アプリケーション側のプロトコル設計にあります。
参考
- RFC 9293: Transmission Control Protocol (TCP) https://www.rfc-editor.org/rfc/rfc9293.html
- Microsoft Learn:
Socket.Receivehttps://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.socket.receive?view=net-10.0 - Microsoft Learn:
NetworkStream.Readhttps://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.networkstream.read?view=net-10.0 - Microsoft Learn:
Socket.Sendhttps://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.socket.send?view=net-10.0 - Microsoft Learn:
NetworkStream.Writehttps://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.networkstream.write?view=net-10.0 - Microsoft Learn:
Stream.ReadExactly/ReadExactlyAsynchttps://learn.microsoft.com/en-us/dotnet/api/system.io.stream.readexactly?view=net-10.0 https://learn.microsoft.com/en-us/dotnet/api/system.io.stream.readexactlyasync?view=net-10.0 - Microsoft Learn:
Socket.NoDelayhttps://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.socket.nodelay?view=net-10.0
関連する記事
同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。
Windowsの偽装トークンを正しく扱う ── スレッド単位の権限借用と安全な戻し方
Windowsの偽装トークンについて、アクセストークン、プライマリトークン、スレッドトークン、偽装レベル、RevertToSelf、.NETのWindowsIdentity.RunImpersonatedまで、実務で安全に扱うための考え方を整理します。
.NETでGC待ちとメモリリークを見分ける ── 増えるメモリを観測・比較・証明する実務手順
.NETアプリケーションで、メモリが増えている理由がガベージコレクション待ちなのか、本当にメモリリークしているのかを、dotnet-counters、dotnet-gcdump、dotnet-dumpを使って切り分ける手順を整理します。
代数的データ型を.NET Framework / .NETで使う ── 状態と結果を型で表す設計
代数的データ型、特に直和型・判別共用体を.NET Frameworkや.NETで使う方法とメリットを、F#、C#のクラス階層、record、OneOf、C# 15 union previewまで含めて整理します。
C#(CSharp)でPowerShellを実行して、オブジェクトとして受け取る方法
C#からPowerShellを起動し、文字列ではなくPSObjectとして結果を受け取る方法を、PowerShell SDK、AddCommand、AddParameter、BaseObject、Properties、エラー処理まで実務目線で整理します。
PesterによるPowerShellのテスト整備 ── 運用スクリプトを壊しにくくする実務の型
PowerShellスクリプトをPester v5でテストし、日付処理、ファイル操作、削除処理、モック、CI実行までを安全に整備する実務手順を整理します。
関連トピック
このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。
Windows技術トピック
Windows 開発、不具合調査、既存資産活用の技術トピックをまとめた入口です。
不具合調査 / 長期稼働テーマ
再現しにくい不具合、通信停止、長期稼働障害、失敗パス検証を整理するトピックです。
このテーマがつながるサービス
この記事は次のサービスページにつながります。近い入口からご覧ください。
Windowsアプリ開発
業務アプリ、装置連携、通信ツールなどの Windows ソフト開発を支援します。
既存資産活用・移行支援
COM / ActiveX / OCX、32bit / 64bit 制約を抱える既存資産の活用と移行を支援します。
著者プロフィール
記事の著者プロフィールページです。
小村 豪
合同会社小村ソフト 代表
Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。
公開リンク