TCP(信頼できるバイトストリーム)
ネットワークの下の層は、データを小分けの包みにして宛先へ投げるだけで、届く保証も順番の保証もない。それでもアプリが書いたバイト列は、欠けず、書いた順で相手に届く。この差を埋めているのが TCP になる。この章では、包みが落ちたり追い越したりする模擬の網の上で、番号を振り、受け取りを確認し、返事が無ければ送り直して、1本のバイトの流れを組み立てる。
この章で作るもの
まず土台の話から。ネットワークでは、データはパケットという小分けの包みで運ばれる。 手紙を封筒に分けて出すのと同じで、大きなデータはそのままでは送れない。 この包みを宛先まで運ぶ層が IP で、やることは運ぶことだけになる。
運ぶだけ、というのは文字どおりの意味だ。IP は包みが届いたかを確認しない。 途中の機械が混んでいれば捨てるし、別の経路を通った包みが先に着けば順番も入れ替わる。 落ちる・遅れる・追い越す。どれも異常ではなく、IP では日常になる。
ところが、その上で動くアプリからは違う景色が見える。write で書いたバイト列は、 相手の read に欠けず、書いた順で届く。この差を埋めているのが TCP で、 この章ではそれを、包みが落ちたり追い越したりする模擬の網の上に組み立てる。
順に作る。
- 番号を振る: どの包みが何バイト目かを、送る側と受け取る側で突き合わせられるようにする
- 受け取りを確認し、無ければ送り直す: 「ここまで届いた」の返事が来ない部分を埋める
- 送りすぎない: 相手が受け取れる量を超えて送らない
① 番号を振る(シーケンス番号)
網を流れる1つの包みを Segment と呼ぶ。中身は、データ本体と、 このデータが全体の何バイト目から始まるかという番号(シーケンス番号)になる。
なぜバイト単位の番号かというと、包みは途中で落ちるからだ。 「3つめの包み」と数えると、送り直しで包みの切り方が変わった瞬間に数が合わなくなる。 バイトに番号を振っておけば、どう切り直しても「105バイト目から4バイトぶん」で通じる。
// State は接続の状態(RFC 793 の主要サブセット)。
type State int
const (
Closed State = iota
Listen // 受け身で SYN を待つ(サーバ)
SynSent // SYN を送って SYN-ACK を待つ(クライアント)
SynRcvd // SYN を受け、SYN-ACK を送って ACK を待つ
Established // 確立。データを送受信できる
CloseWait // 相手の FIN を受けた(こちらはまだ送るかも)
LastAck // こちらも FIN を送り、その ACK を待つ
FinWait // こちらから FIN を送り、相手の ACK/FIN を待つ
)
func (s State) String() string {
switch s {
case Closed:
return "CLOSED"
case Listen:
return "LISTEN"
case SynSent:
return "SYN_SENT"
case SynRcvd:
return "SYN_RCVD"
case Established:
return "ESTABLISHED"
case CloseWait:
return "CLOSE_WAIT"
case LastAck:
return "LAST_ACK"
case FinWait:
return "FIN_WAIT"
default:
return "?"
}
}
// Flag はセグメントの制御ビット。
type Flag uint8
const (
SYN Flag = 1 << iota // 接続開始・初期シーケンス番号の同期
ACK // Ack 番号が有効(確認応答)
FIN // 送信終了
)
func (f Flag) String() string {
s := ""
if f&SYN != 0 {
s += "SYN "
}
if f&ACK != 0 {
s += "ACK "
}
if f&FIN != 0 {
s += "FIN "
}
if s == "" {
return "-"
}
return s[:len(s)-1]
}
// Segment は 1 つの TCP セグメント(パケットに載る単位)。Seq はこのセグメント先頭の
// シーケンス番号、Ack は「次に期待するバイト番号」、Window は受信側の空き容量。
type Segment struct {
Seq uint32
Ack uint32
Flags Flag
Window uint32
Payload []byte
}
func (s Segment) String() string {
return fmt.Sprintf("%s seq=%d ack=%d win=%d len=%d", s.Flags, s.Seq, s.Ack, s.Window, len(s.Payload))
}接続の開始(SYN)と終了(FIN)の合図も、シーケンス番号を1つ消費する。 データと同じ番号の空間に置くことで、「開始の合図が確かに届いた」ことも、 データと同じ仕組みで確認できるようになる。通信の最初に SYN → SYN-ACK → ACK と 1往復半かける 3-way ハンドシェイクは、この番号の起点を両者で合わせる手続きになる。
② 受け取りを確認し、無ければ送り直す
番号があれば、受け取った側は「どこまで届いたか」を返事できる。この返事が ACK で、 中身は次に欲しいバイトの番号1つだけになる。105 と返せば 「104バイト目までは連続で受け取った。次は105から頼む」の意味だ。
大事なのは、この返事が連続して受け取れた先頭までしか進まないことだ(累積 ACK)。 105〜108 が抜けたまま 109〜 が届いても、返事は 105 のまま止まる。 先に届いたぶんは捨てずに脇へ置いておき(並べ替えバッファ)、 抜けが埋まった瞬間にまとめてアプリへ渡す。
// deliver は届いたセグメントを処理する。ACK でウィンドウを進め、SYN/FIN で状態を
// 遷移し、データは順序を見て受信バッファへ入れる。
func (e *Endpoint) deliver(s Segment) {
// --- ACK 処理: 未確認の先頭を進める ---
if s.Flags&ACK != 0 && s.Ack > e.sndUna && s.Ack <= e.sndMax {
e.sndUna = s.Ack
e.sinceUp = 0 // 前進した → 再送タイマをリセット
}
if s.Flags&ACK != 0 {
e.sndWnd = s.Window
}
// --- ハンドシェイク/状態遷移 ---
switch e.state {
case Listen:
if s.Flags&SYN != 0 {
e.rcvNxt = s.Seq + 1
e.dataBase = e.iss + 1
e.state = SynRcvd
e.started = false
return
}
case SynSent:
if s.Flags&SYN != 0 && s.Flags&ACK != 0 {
e.rcvNxt = s.Seq + 1
e.state = Established
e.needAck = true // 最終 ACK を返す
return
}
case SynRcvd:
if s.Flags&ACK != 0 && s.Ack >= e.iss+1 {
e.state = Established
// このセグメントにデータや FIN が載っていれば下で処理する
}
}
// --- データ受信(Established 以降) ---
if len(s.Payload) > 0 {
e.recvData(s.Seq, s.Payload)
e.needAck = true
}
// --- FIN 受信(順序どおりに来たら) ---
if s.Flags&FIN != 0 && s.Seq == e.rcvNxt {
e.gotFin = true
e.rcvNxt++ // FIN も seq を 1 消費
e.needAck = true
switch e.state {
case Established:
e.state = CloseWait
case FinWait:
e.state = Closed
}
}
if e.state == LastAck && e.sndUna > e.finSeq {
e.state = Closed // こちらの FIN が ACK された
}
}
// recvData は 1 セグメントのデータを受信バッファへ入れ、順序どおりに繋がる範囲を
// アプリへ渡す。順序が乱れていたら ooo に退避し、隙間が埋まったらまとめて渡す。
func (e *Endpoint) recvData(seq uint32, payload []byte) {
if seq < e.rcvNxt {
return // 既に受け取った(再送の重複)
}
if seq == e.rcvNxt {
e.recvd = append(e.recvd, payload...)
e.rcvNxt += uint32(len(payload))
e.drainOOO()
return
}
// 順序が先走っている → 退避(累積 ACK は rcvNxt のままなので相手は隙間を埋め直す)
if _, ok := e.ooo[seq]; !ok {
e.ooo[seq] = append([]byte(nil), payload...)
}
}
// drainOOO は退避したセグメントのうち、rcvNxt に連続する分を順に取り込む。
func (e *Endpoint) drainOOO() {
for {
p, ok := e.ooo[e.rcvNxt]
if !ok {
return
}
delete(e.ooo, e.rcvNxt)
e.recvd = append(e.recvd, p...)
e.rcvNxt += uint32(len(p))
}
}
// OOOKeys は退避中の out-of-order セグメントの seq を昇順で返す(観察用)。
func (e *Endpoint) OOOKeys() []uint32 {
ks := make([]uint32, 0, len(e.ooo))
for k := range e.ooo {
ks = append(ks, k)
}
sort.Slice(ks, func(i, j int) bool { return ks[i] < ks[j] })
return ks
}送る側は、返事が止まっていることで「105 が届いていない」と分かる。 返事が一定時間進まなければ、確認が取れていない先頭まで戻って送り直す。
// emit はこのステップで送り出すセグメントを返す。再送タイマの満了・新規データ・
// ハンドシェイク・ACK・FIN をここで一手にさばく。
func (e *Endpoint) emit() []Segment {
var out []Segment
switch e.state {
case SynSent:
if !e.started || e.timerExpired() {
if e.started {
e.Retransmits++
}
out = append(out, Segment{Seq: e.iss, Flags: SYN, Window: e.rcvWnd})
e.sndNxt, e.sndMax = e.iss+1, e.iss+1 // SYN は seq を 1 消費する
e.mark()
}
case SynRcvd:
if !e.started || e.timerExpired() {
if e.started {
e.Retransmits++
}
out = append(out, Segment{Seq: e.iss, Ack: e.rcvNxt, Flags: SYN | ACK, Window: e.rcvWnd})
e.sndNxt, e.sndMax = e.iss+1, e.iss+1 // SYN-ACK の SYN も 1 消費
e.mark()
}
case Established, CloseWait, FinWait, LastAck:
out = append(out, e.emitData()...)
}
// 送るものが無くても、応答すべき ACK があれば単独で返す。
if e.needAck && len(out) == 0 {
out = append(out, Segment{Seq: e.sndNxt, Ack: e.rcvNxt, Flags: ACK, Window: e.rcvWnd})
}
if len(out) > 0 {
e.needAck = false
}
return out
}
// emitData はデータ・FIN の(再)送を組み立てる。再送タイマが満了していたら、
// 未確認の先頭(sndUna)まで巻き戻して送り直す(go-back-N)。
func (e *Endpoint) emitData() []Segment {
if e.timerExpired() && e.sndUna < e.sndMax {
e.sndNxt = e.sndUna // 未確認の先頭から送り直す
e.Retransmits++
e.sinceUp = 0
}
var out []Segment
dataEnd := e.dataBase + uint32(len(e.toSend))
limit := e.sndUna + e.sndWnd // フロー制御: ウィンドウの右端
for e.sndNxt < dataEnd && e.sndNxt < limit {
start := int(e.sndNxt - e.dataBase)
n := e.mss
if start+n > len(e.toSend) {
n = len(e.toSend) - start
}
if e.sndNxt+uint32(n) > limit {
n = int(limit - e.sndNxt)
}
if n <= 0 {
break
}
payload := append([]byte(nil), e.toSend[start:start+n]...)
out = append(out, Segment{Seq: e.sndNxt, Ack: e.rcvNxt, Flags: ACK, Window: e.rcvWnd, Payload: payload})
e.sndNxt += uint32(n)
if e.sndNxt > e.sndMax {
e.sndMax = e.sndNxt
}
}
// 全データを送り切り、FIN を出したいなら FIN を送る(seq を 1 消費)。
if e.wantFin && !e.finDone && e.sndNxt == dataEnd && e.sndNxt < limit {
e.finSeq = dataEnd
out = append(out, Segment{Seq: e.finSeq, Ack: e.rcvNxt, Flags: FIN | ACK, Window: e.rcvWnd})
e.sndNxt++
if e.sndNxt > e.sndMax {
e.sndMax = e.sndNxt
}
e.finDone = true
if e.state == Established {
e.state = FinWait
} else if e.state == CloseWait {
e.state = LastAck
}
}
if len(out) > 0 && !e.started {
e.mark()
}
return out
}
func (e *Endpoint) mark() { e.started = true; e.sinceUp = 0 }
func (e *Endpoint) timerExpired() bool { return e.started && e.sinceUp >= e.rto }
// tick は 1 ステップぶん時間を進める(再送タイマを刻む)。
func (e *Endpoint) tick() {
if e.started {
e.sinceUp++
}
} 送る側 受け取る側
seq101 "HELL" ──────▶ ack105 (104まで受け取った)
seq105 "O TC" ──✗ 落ちた
seq109 "P" ──────▶ ack105 (109〜は脇へ置く。返事は進まない)
(返事が進まないので送り直す)
seq105 "O TC" ──────▶ ack110 (穴が埋まり、まとめて確定)
アプリが read で受け取るのは "HELLO TCP"。落ちたことは見えないこの3つ(番号・返事・送り直し)が信頼性の全部になる。 番号が無ければどこが抜けたか分からない。返事が無ければ抜けたことに気づけない。 送り直しが無ければ気づいても埋められない。どれか1つ欠けても成立しない。
③ 送りすぎない(ウィンドウ)
もう1つ、送る量の問題がある。受け取る側の処理が追いつかないのに送り続ければ、 届いても置き場が無くて捨てられる。そこで受け取る側は、返事に あとどれだけ受け取れるか(ウィンドウ)を載せる。
送る側は、確認が取れていない先頭からウィンドウのぶんまでしか先行できない。 limit = sndUna + sndWnd がその右端で、返事が進むたびに窓が右へずれて、 次のデータを送れるようになる。窓を滑らせながら送るので、スライディングウィンドウと呼ばれる。
これは相手を守る仕組みで、フロー制御と呼ばれる。途中の網が混んでいないかを見て 送る速さを変える仕組み(輻輳制御)は別にあり、輻輳制御(AIMD)の章で作る。
動かす
下のデモは、この TCP をそのままブラウザで動かしている。「セグメントを落とす」を選ぶと 特定の包みが失われ、「1手すすめる」で1ステップずつ追える。 返事が抜けの手前で止まり、送り直しで一気に進むところが見える。
接続開始: クライアントが SYN を送る(初期シーケンス番号 100 を通知)
累積 ACK は「連続して受け取った先頭」までしか進めない。だから穴が 1 つあると後続が届いても ACK は止まり、送信側が抜けを再送する。受信側は先行分を退避しておき、穴が埋まればまとめて確定する。
設計の観点
- 単位に番号を振ってから話を始める: 抜け・重複・順序は、番号が無いとそもそも定義できない
- 返事は「次に欲しいもの」1つにする: 状態が1つの数字に潰れるので、返事自体が落ちても壊れない
- 合図もデータと同じ空間に置く: 開始や終了を特別扱いせず番号を消費させると、確認の仕組みを二重に作らずに済む
- 相手を守る量と、網を守る量を分ける: フロー制御と輻輳制御は別の問題で、混ぜると片方しか守れない
- 返事の粒度には代償がある: 「ここまで連続」しか言えない返事は単純だが、抜けが1つあると後続の送り直しが無駄になりうる。抜けの位置まで伝える拡張(SACK)が実運用ではほぼ使われる
- 順序保証そのものが弱点にもなる: 1つの抜けが後続を全部止める。これを嫌う用途は TCP を選ばない
対照と実例
| プロトコル | 届く保証・順番 | 通信開始の速さ | 1本の接続で並行に運べるか | 実例 |
|---|---|---|---|---|
| TCP | 保証する | 1.5往復かかる | 1本の流れだけ | HTTP/1.1・HTTP/2、SSH、DB 接続 |
| UDP | 保証しない(必要なら自前) | 即送れる | アプリ次第 | DNS、動画・音声、ゲーム |
| QUIC | 流れごとに保証する | 速い(0往復も可) | 並行に運べる | HTTP/3、YouTube、多くの CDN |
裏どり:
- HTTP/2 が踏んだ罠: HTTP/2 は1本の TCP に複数のリクエストを相乗りさせたが、TCP が1つ抜けを検出すると相乗りしている全員が止まる。順序保証が接続全体に効いてしまうためで、これが HTTP/3(QUIC)への移行動機になった
- QUIC は UDP の上に作り直した: 保証しない UDP の上に、流れごとの番号と再送を実装し直したもの。ある流れの抜けが他の流れを止めない。この章でやったことを、単位を変えてもう一度やった形になる
- DNS が UDP を選ぶ理由: 問い合わせ1発・応答1発で終わる通信では、1.5往復のハンドシェイクが本体より高くつく。DNS の章で測った 512 バイトの制約は、この選択の代償になる
- SACK は標準装備: 現代の TCP はほぼ SACK(抜けの位置まで伝える返事)を有効にしている。RFC 2018。累積 ACK だけの送り直しの無駄を補う
- 一次資料: RFC 9293 が現行の仕様。状態機械と番号の扱いはここに全部書いてある
簡略化したこと
- 輻輳制御なし: 網の混み具合を見て速さを変える仕組みは別の章で作る。ここは相手の申告だけを見る
- 送り直しの判定は固定: 実物は往復時間を測って待ち時間を調整する(Karn/Jacobson)。ここでは固定のステップ数で判定する
- データは一方向: クライアントからサーバへの転送だけ。開始と終了の手続きは両方向にある
- 返事は累積のみ: 抜けの位置まで伝える SACK は実装していない
- チェックサム・ポート・実ヘッダなし: 壊れた包みの検出も、どのアプリ宛かの振り分けも扱わない。番号・返事・送り直し・窓の骨格だけ
参考資料
- RFC 9293(旧 793)"Transmission Control Protocol" — 状態機械とシーケンス番号の一次資料
- Kurose & Ross, Computer Networking: A Top-Down Approach — 信頼データ転送とウィンドウの教科書
- W. Richard Stevens, TCP/IP Illustrated, Vol.1 — 実挙動をパケット単位で追う定番
- 実装: network/tcp