找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

6085

积分

1

好友

765

主题
发表于 前天 21:02 | 查看: 0| 回复: 0

你在浏览器里打开一个网页,在聊天软件里发送一条消息,在服务器上执行一次 API 请求。

你看到的是"连接成功""发送成功""收到响应"。

但真正发生的事情远比这些按钮复杂。

数据从一台机器离开,经过网卡、交换机、路由器、运营商网络,最终抵达另一台机器。中间可能发生丢包、乱序、重复、延迟、拥塞,甚至某个节点突然消失。

问题来了:

应用程序凭什么相信,对方收到的就是自己发送的数据?

如果一个数据包丢了怎么办?

如果第二个包比第一个包先到怎么办?

如果服务器收到的数据只是一半怎么办?

如果网络突然变慢怎么办?

如果一个客户端断线了,服务器怎么知道?

这些问题,很大一部分最终都会落到一个名字上:

TCP。

在云栈社区,我们一直习惯从工程本质出发来拆解这些基础协议。

一、真正的问题从来不是"怎么发数据"

很多初学者第一次学习网络编程时,会把网络理解成:

客户端

   ↓ 发送数据

服务器

   ↓ 返回数据

客户端

看起来非常简单。

但如果把网络放到真实环境里,事情马上变得完全不同。

假设客户端准备发送:

Hello, Server!

理论上我们希望服务器最终收到:

Hello, Server!

可是现实世界中,网络并不是一根可靠的线。

可能变成:

Hello,
Server!

也可能:

Hel
lo, Server!

甚至:

Hello,
Serv

一部分数据晚到了。

还有可能:

第 2 部分

比:

第 1 部分

先到。

甚至中间一个数据包永久丢失。

这意味着应用程序真正面对的问题并不是:

"怎样把字节放进网卡?"

而是:

怎样在一个不可靠的底层网络之上,构造一个让应用程序能够依赖的通信抽象?

这才是 TCP/IP 真正存在的原因。

RFC 9293 对 TCP 的定义,本质上就是提供一种面向连接的传输机制,使应用程序能够在 IP 网络之上获得可靠、按序的字节流通信。现代 TCP 规范由 RFC 9293 统一整理,它取代了最初的 RFC 793 及多个后续修订文档。

二、为什么不能直接使用 IP?

如果应用程序直接建立在 IP 之上,会遇到一个根本问题:

IP 本身不负责把"应用需要的可靠通信"做好。

IP 更关心:

数据包从源地址
        ↓
尽可能送到目标地址

它并不把"完整、有序、可靠的应用数据流"作为自己的职责。

例如:

发送:

Packet 1
Packet 2
Packet 3
Packet 4

到达时可能变成:

Packet 1
Packet 3
Packet 4

Packet 2 丢了。

或者:

Packet 3
Packet 1
Packet 2
Packet 4

发生乱序。

或者:

Packet 1
Packet 2
Packet 2
Packet 3

出现重复。

如果没有更高层机制,应用程序就不得不自己处理这些问题。

于是每一个应用程序都可能开始重新实现:

序列号
ACK
重传
超时
乱序重组
流量控制
拥塞控制
连接管理

这显然不是一个好的系统设计。

所以互联网协议栈把职责拆开了。

可以先粗略理解成:

应用层
HTTP
RPC
WebSocket
        ↓
传输层
TCP / UDP
        ↓
网络层
IP
        ↓
链路层
Ethernet / Wi-Fi
        ↓
物理网络

TCP 的价值,就是把:

IP 提供的数据包网络

转换成:

应用程序可以直接依赖的可靠字节流。

三、TCP 真正解决了什么问题?

可以把 TCP 的核心能力浓缩成几个工程问题。

1. 谁和谁通信?

TCP 是面向连接的。

它不是简单地:

A → B

而是先建立一个通信关系:

A ←────────→ B

建立连接

然后双方维护这个连接的状态。

2. 数据有没有丢?

TCP 使用序列号和确认机制帮助发送端判断哪些数据已经被确认。

可以简单理解:

Sender

发送 Seq=1000
        ↓
Receiver

收到后 ACK=1500
        ↓
Sender

这里的数字只是为了帮助理解。

真正重要的是:

TCP 不把"发送成功"简单等同于"网卡发出去了"。

TCP 必须维护:

哪些字节已经发送、哪些已经确认、哪些可能需要重新发送。

RFC 9293 对 TCP 的序列号、确认号、重传以及连接状态都有明确规范。

四、TCP 不是"把消息一个包一个包发送"

这是学习 TCP 时最容易犯的错误。

很多人会把 TCP 想象成:

send("Hello")
        ↓
一个 TCP 包

send("World")
        ↓
另一个 TCP 包

这是错误的抽象。

TCP 对应用程序提供的是:

字节流。

而不是消息队列。

比如:

conn.Write([]byte("Hello"))
conn.Write([]byte("World"))

接收端可能读取到:

HelloWorld

也可能第一次:

Hel

第二次:

loWorld

甚至:

HelloW

然后:

orld

TCP 并不承诺:

一次 Write()
=
一次 Read()

这就是所谓的:

TCP 没有消息边界。

这是后面设计聊天协议、RPC 协议、数据库协议时非常重要的基础。

五、TCP 连接到底是怎么建立的?

我们经常听到:

TCP 三次握手。

很多教程会直接告诉你:

客户端 SYN
服务器 SYN + ACK
客户端 ACK

然后结束。

但真正值得理解的是:

为什么通信双方需要交换这些信息?

第一次:客户端发起连接

客户端:

Client
   |
   | SYN
   ↓
Server

客户端告诉服务器:

我希望建立 TCP 连接。

同时会携带自己的初始序列号等信息。

第二次:服务器响应

服务器:

Client
   |
   | SYN
   ↓
Server
   |
   | SYN + ACK
   ↓
Client

服务器的意思是:

我收到了你的连接请求,同时我也希望建立反向通信。

第三次:客户端确认

客户端:

Client
   |
   | ACK
   ↓
Server

于是双方对连接建立所需的状态达成了同步。

可以理解成:

Client                      Server

  SYN --------------------->

      <---------------- SYN + ACK

  ACK --------------------->

        Connection Ready

这里真正重要的不是背"三次"这个数字。

而是理解:

TCP 连接建立,本质上是在两个独立系统之间同步通信状态。

TCP 规范明确规定了连接建立、状态转换以及序列号空间等机制。

六、为什么不是直接两次握手?

因为 TCP 不只是想知道:

"我能不能发给你?"

它还需要确认双方对于连接状态的认知。

假设网络中存在一个非常古老的连接请求:

旧 SYN

由于网络延迟,它过了很久以后重新抵达服务器。

如果服务器只看到 SYN 就立即认为:

连接已经建立

就可能出现状态混乱。

TCP 的握手机制让双方能够建立并确认各自的序列号空间。

所以三次握手真正解决的问题不是:

"TCP 规定就是三次。"

而是:

双方需要通过通信建立一个双方都认可的连接状态。

七、TCP 内部到底在维护什么?

TCP 连接建立以后,并不是两台机器简单地"连起来了"。

双方内部实际上在维护大量状态。

可以粗略理解:

TCP Connection

┌──────────────────────┐
│ Local Address        │
│ Remote Address       │
├──────────────────────┤
│ Sequence Number      │
│ ACK Number           │
├──────────────────────┤
│ Send Buffer          │
│ Receive Buffer       │
├──────────────────────┤
│ Receive Window       │
│ Congestion Window    │
├──────────────────────┤
│ Retransmission Timer │
├──────────────────────┤
│ Connection State     │
└──────────────────────┘

所以:

TCP连接

不是一个抽象概念。

它在操作系统内核里是真实存在的一组状态。

而 Go 的:

net.Conn

只是应用程序看到的那层接口。

下面还有操作系统。

八、数据丢了以后,TCP 怎么办?

这是 TCP 最核心的能力之一。

假设:

Sender

Packet 1
Packet 2
Packet 3
Packet 4

        ↓ 网络

Receiver

Packet 1
Packet 3
Packet 4

Packet 2 丢了。

Receiver 不会简单地告诉 Sender:

我没看到 Packet 2。

TCP 通过序列号与确认机制让发送端能够推断当前数据接收状态,并在必要时进行重传。

概念上:

Seq=1000
Seq=2000
Seq=3000
Seq=4000

如果中间的数据没有得到确认:

3000

发送端就必须判断:

这部分数据是不是丢了?

然后触发重传机制。

九、TCP 为什么需要"窗口"?

假设一个服务器发送数据非常快:

Sender
  ↓
  ↓
  ↓
  ↓
  ↓
  ↓
Receiver

而接收端处理能力很慢。

如果发送端完全不考虑接收能力,就可能把接收端缓冲区塞满。

于是 TCP 引入了:

流量控制。

接收端会告诉发送端:

我目前最多还能接收多少数据。

这就是接收窗口相关机制的重要作用。

可以简单理解成:

Receiver

Buffer
┌──────────────────┐
│ 已使用            │
│                  │
│                  │
│ 剩余空间          │
└──────────────────┘

告诉 Sender:

你最多继续发这么多。

所以:

TCP流量控制

解决的是:

发送端太快,接收端吃不下。

十、流量控制和拥塞控制不是一回事

这两个概念极其容易混淆。

流量控制

关注:

Sender
   ↓
Receiver

问题是:

接收端能不能吃下?

拥塞控制

关注:

Sender
   ↓
Internet
   ↓
Router
   ↓
Receiver

问题是:

网络本身是否已经拥堵?

这两个问题完全不同。

比如:

Receiver 很强

但是:

Internet 很堵

那么发送端也不能无限提高发送速度。

现代 TCP 的拥塞控制涉及拥塞窗口等机制,不同操作系统还可能提供不同的拥塞控制算法。Linux 例如提供多个 TCP congestion-control 相关配置与算法选择机制。

于是 TCP 实际上需要同时回答两个问题:

接收端还有多少空间?
        ↓
网络还能承受多少?

这也是为什么 TCP 比"简单的可靠发送"复杂得多。

十一、为什么 TCP 会变慢?

很多人对 TCP 有一个错误理解:

TCP 可靠,所以一定慢。

这并不准确。

真正应该理解的是:

TCP 必须为可靠性、顺序、流量控制、拥塞控制付出成本。

例如:

序列号
ACK
重传
计时器
窗口
拥塞控制
连接状态

这些机制都需要状态与计算。

更重要的是:

TCP 会主动限制发送速度。

因为它的目标不是:

疯狂发送

而是:

尽可能高效地使用网络,
同时避免把网络打崩。

Linux 的 TCP 实现甚至允许应用针对连接选择拥塞控制算法,这说明"TCP 性能"并不是一个固定数字,而是协议机制、操作系统实现和网络环境共同决定的。

十二、TCP 连接关闭时又发生了什么?

建立连接通常需要握手。

关闭连接同样不是:

close()
↓
直接消失

TCP 通常通过 FIN 和 ACK 等机制完成连接终止。

概念上:

Client                          Server

  FIN ------------------------>

      <----------------------- ACK

      <----------------------- FIN

  ACK ------------------------>

这里值得注意:

TCP 连接的两个方向可以独立关闭。

也就是说:

Client → Server

停止发送以后,

并不意味着:

Server → Client

也必须马上结束。

这就是 TCP 的全双工连接模型。

十三、为什么服务器关闭连接后还可能出现 TIME_WAIT?

这是网络编程里非常经典的问题。

很多人第一次看到:

TIME_WAIT

会觉得:

连接都关闭了,为什么还不释放?

答案是:

TCP 关闭不仅仅是在释放资源,还需要保证旧连接的延迟报文不会干扰未来的新连接。

网络中的数据包并不是瞬间消失。

例如:

旧连接数据
        ↓

网络中延迟

        ↓

几秒后才到达

如果马上创建一个具有相同四元组的新连接:

src IP
src Port
dst IP
dst Port

就可能存在旧报文污染新连接状态的问题。

所以 TCP 的连接关闭设计中包含 TIME_WAIT 等状态,用于确保旧连接数据不会轻易破坏新的连接状态。

这也是高并发服务器进行端口、连接和生命周期设计时必须理解的内容。

十四、TCP 四元组到底是什么?

一个 TCP 连接,通常可以用:

源 IP
源 Port
目标 IP
目标 Port

来区分。

例如:

192.168.1.10:50000
        ↓
10.0.0.20:443

于是:

Source IP   = 192.168.1.10
Source Port = 50000

Dest IP     = 10.0.0.20
Dest Port   = 443

四个值共同描述一条 TCP 流。

这就是所谓:

四元组。

于是同一个服务器:

10.0.0.20:443

可以同时服务:

Client A → 10.0.0.20:443

Client B → 10.0.0.20:443

Client C → 10.0.0.20:443

区别在于客户端侧端口以及客户端 IP 不同。

这也是服务器能够同时维持大量 TCP 连接的重要基础。

十五、TCP 头部里到底有什么?

一个 TCP Segment 可以粗略理解成:

┌──────────────────────────────┐
│ Source Port                  │
├──────────────────────────────┤
│ Destination Port             │
├──────────────────────────────┤
│ Sequence Number              │
├──────────────────────────────┤
│ Acknowledgment Number        │
├──────────────────────────────┤
│ Header Length / Flags        │
├──────────────────────────────┤
│ Receive Window               │
├──────────────────────────────┤
│ Checksum                     │
├──────────────────────────────┤
│ Urgent Pointer / Options     │
├──────────────────────────────┤
│                              │
│ Data                         │
│                              │
└──────────────────────────────┘

其中几个字段非常重要:

Sequence Number

用来标识字节流中的位置。

Acknowledgment Number

表示接收方期望收到的下一个序列号。

Window

参与流量控制。

Flags

包括:

SYN
ACK
FIN
RST
PSH
URG

以及现代规范中定义的其他控制位。

RFC 9293 给出了现代 TCP 的报文头部、控制位和相关选项定义。

十六、Segment、Packet、Byte Stream,不是一回事

这是进入网络编程后必须形成的概念。

应用看到:

Byte Stream

TCP 处理:

Segment

IP 处理:

Packet

链路层处理:

Frame

可以粗略理解:

Application
    │
    │ Byte Stream
    ▼
   TCP
    │
    │ Segment
    ▼
    IP
    │
    │ Packet
    ▼
Ethernet
    │
    │ Frame
    ▼
 Network

因此:

应用程序看到的"数据",和网络线上实际流动的"数据包"并不是同一个东西。

这是后面分析:

MTU
MSS
IP分片
TCP分段
Socket Buffer

等问题的基础。

十七、那么 Go 到底做了什么?

到这里,终于轮到 Go。

Go 并没有自己重新发明 TCP。

Go 的:

package net

提供了应用程序使用网络能力的接口。

例如:

net.Listen
net.Dial
net.TCPConn
net.TCPListener

Go 官方 net 包中确实包含 TCPAddrTCPConn、TCP listener 等实现;TCP 连接的具体平台实现则进一步分布在 tcpsock.gotcpsock_posix.gotcpsock_windows.go 等文件中。

也就是说:

你的 Go 程序
        ↓
      net
        ↓
   internal/poll
        ↓
    Go Runtime
        ↓
   OS Networking
        ↓
     TCP/IP
        ↓
     网卡

这才是真正的网络路径。

十八、用 Go 1.26.5 写一个最简单的 TCP 服务器

先不使用 HTTP。

直接操作 TCP。

服务器:

package main

import (
    "fmt"
    "io"
    "log"
    "net"
)

func main() {
    listener, err := net.Listen("tcp", ":8080")
    if err != nil {
        log.Fatal(err)
    }
    defer listener.Close()

    fmt.Println("TCP server listening on :8080")

    for {
        conn, err := listener.Accept()
        if err != nil {
            log.Println("accept error:", err)
            continue
        }

        go handle(conn)
    }
}

func handle(conn net.Conn) {
    defer conn.Close()

    buf := make([]byte, 4096)

    for {
        n, err := conn.Read(buf)
        if err != nil {
            if err == io.EOF {
                fmt.Println("client closed connection")
            } else {
                log.Println("read error:", err)
            }
            return
        }

        fmt.Printf("received: %q\n", buf[:n])

        _, err = conn.Write(buf[:n])
        if err != nil {
            log.Println("write error:", err)
            return
        }
    }
}

这个程序做的事情非常简单:

Listen
   ↓
Accept
   ↓
Read
   ↓
Write

也就是:

监听
↓
接受连接
↓
读取字节流
↓
返回字节流

十九、客户端

再写一个 TCP 客户端:

package main

import (
    "fmt"
    "io"
    "log"
    "net"
)

func main() {
    conn, err := net.Dial("tcp", "127.0.0.1:8080")
    if err != nil {
        log.Fatal(err)
    }
    defer conn.Close()

    message := []byte("hello tcp")

    _, err = conn.Write(message)
    if err != nil {
        log.Fatal(err)
    }

    buf := make([]byte, 4096)

    n, err := conn.Read(buf)
    if err != nil {
        if err != io.EOF {
            log.Fatal(err)
        }
        return
    }

    fmt.Printf("server response: %q\n", buf[:n])
}

运行:

Server:

TCP server listening on :8080

客户端:

server response: "hello tcp"

看起来非常简单。

但这两段 Go 代码背后实际上已经经过:

net.Dial

    ↓

Socket

    ↓

OS TCP stack

    ↓

IP

    ↓

Network

这就是为什么 Go 网络代码通常看起来非常短。

二十、一个非常重要的问题:Read 为什么不一定读满?

假设客户端:

conn.Write([]byte("Hello World"))

服务器:

buf := make([]byte, 5)

n, _ := conn.Read(buf)

你不能要求:

n == 5

也不能认为:

一次 Write
=
一次 Read

TCP 是字节流。

所以如果应用层协议需要"消息"概念,就必须自己建立消息边界。

例如:

方案一:固定长度

Header
32 bytes

方案二:长度前缀

[4 bytes length]
[message body]

例如:

00000005
Hello

方案三:分隔符

Hello\n
World\n

方案四:关闭连接代表结束

例如一些非常简单的协议:

发送所有数据
        ↓
关闭连接
        ↓
EOF

这就是为什么:

TCP 只是传输层,不负责你的应用层协议设计。

二十一、Go 为什么不用你自己处理 epoll?

这里开始进入 Go Runtime。

如果你以前使用 C 语言网络编程,可能会看到:

socket()
bind()
listen()
accept()
epoll()
read()
write()

而 Go 只需要:

listener, _ := net.Listen("tcp", ":8080")

conn, _ := listener.Accept()

难道 Go 不需要 epoll 吗?

当然需要。

只是:

Go 把这些复杂性隐藏到了运行时和标准库下面。

Go Runtime 内部存在一个集成网络轮询器。

官方 runtime/netpoll 源码明确说明:

Go Runtime 使用 integrated network poller,并由具体平台实现对应的机制;不同平台底层可以使用 epoll、kqueue、port 等机制。

二十二、Go 网络模型真正厉害的地方

可以把它想象成:

                  Go Application

                      │
                      ▼

                    net.Conn

                      │

                      ▼

                 internal/poll

                      │

                      ▼

                  Go Runtime

                      │

                ┌──────┴──────┐
                │             │
             netpoll       Scheduler
                │             │
                └──────┬──────┘
                      │
                      ▼
                Operating System

于是:

conn.Read(buf)

并不意味着:

整个 Go 程序傻等着

而是 Runtime 可以把等待网络事件的过程和 Goroutine 调度结合起来。

Go Runtime 的 netpoll 设计中,平台相关实现负责等待文件描述符就绪,然后将已经准备好的 Goroutine 返回给调度系统继续执行。

这就是为什么 Go 可以写出:

for {
    conn, _ := listener.Accept()
    go handle(conn)
}

这样的代码。

二十三、一个 Goroutine 对应一个 TCP 连接吗?

从编程模型来看:

Connection A
      ↓
Goroutine A

Connection B
      ↓
Goroutine B

Connection C
      ↓
Goroutine C

这是一个非常自然的 Go 编程方式。

但不要把它误解为:

一个 Goroutine = 一个永久占用的 OS Thread。

Goroutine 是 Go Runtime 调度的执行单元,而 Runtime 使用网络轮询机制来处理网络等待。

所以:

Goroutine
    ↓
等待网络
    ↓
不一定持续占用一个操作系统线程

这正是 Go 并发网络服务器设计的重要基础。

二十四、Go 的 TCP 连接路径

我们可以把一次:

conn, err := net.Dial("tcp", "example.com:80")

抽象成:

Go Program

    │
    ▼
net.Dial

    │
    ▼
net.Dialer

    │
    ▼
TCP connection setup

    │
    ▼
Socket

    │
    ▼
Operating System

    │
    ▼
TCP/IP Stack

    │
    ▼
Network Interface

    │
    ▼
Internet

服务端则类似:

Network

   │
   ▼

Operating System

   │
   ▼

TCP Listener

   │
   ▼

Go net package

   │
   ▼

Accept()

   │
   ▼

net.Conn

   │
   ▼

Goroutine

所以:

net.Conn 只是程序员看到的抽象。

真正负责 TCP 状态机的不是你的 Go 代码。

二十五、源码应该怎么看?

Go 网络源码非常多。

如果一上来就打开:

runtime/netpoll.go
tcpsock.go
fd_poll_runtime.go

很容易迷失。

正确的理解顺序应该是:

你的代码

    ↓

net.Listen / net.Dial

    ↓

TCPListener / TCPConn

    ↓

internal/poll

    ↓

runtime netpoll

    ↓

操作系统

    ↓

TCP/IP

Go 官方 net 源码中可以看到 tcpsock.go 定义 TCP 地址与连接相关抽象,同时还有针对不同平台的 TCP 实现文件。

而 Runtime 的 netpoll.go 则明确说明了网络轮询器与平台实现之间的关系。

二十六、为什么 TCP 服务器可以同时处理很多连接?

假设:

Client 1
Client 2
Client 3
...
Client 100000

如果每个连接都需要一个重量级 OS Thread:

100000 Connections
        ↓
100000 Threads

这是一个非常沉重的模型。

Go 的思路则是:

100000 Connections
        ↓
大量 Goroutines
        ↓
Runtime Scheduler
        ↓
Network Poller
        ↓
有限数量 OS Threads

当然,这并不意味着:

"Go 可以无条件支持百万 TCP 连接。"

真正的上限还受到:

内存
文件描述符
Socket Buffer
CPU
网络带宽
内核参数
连接生命周期
业务处理成本
GC压力

等因素影响。

所以正确的工程结论不是:

Go 天生百万连接。

而是:

Go 的 Goroutine + Runtime netpoll 模型,让高并发网络服务拥有非常好的编程模型和较低的并发管理成本。

二十七、TCP 最大的价值其实不是"可靠"

如果只把 TCP 总结成:

"TCP 很可靠。"

实际上还是没有真正理解它。

TCP 真正的工程价值在于:

不可靠 IP 网络
        ↓
TCP
        ↓
可靠、有序、面向连接的字节流
        ↓
应用程序

它把复杂的网络问题隔离在传输层。

于是应用程序可以关注:

HTTP
RPC
数据库协议
消息协议
聊天协议

而不是每天自己处理:

包丢了怎么办?
乱序怎么办?
重复怎么办?
超时怎么办?
什么时候重传?
网络堵塞怎么办?

这是一种非常典型的系统工程思想:

把复杂问题封装到正确的抽象层。

二十八、TCP 付出的代价是什么?

任何抽象都不是免费的。

TCP 带来的能力,同时意味着成本。

连接状态

每一条 TCP 连接都需要维护状态。

缓冲区

发送端和接收端都有 Buffer。

ACK

需要确认机制。

重传

丢包以后需要重传。

定时器

需要处理超时。

流量控制

需要限制发送速度。

拥塞控制

需要感知网络拥塞。

有序交付

需要处理乱序数据。

所以:

TCP

可靠性
+
顺序
+
流量控制
+
拥塞控制
+
连接管理

这些能力都不是"免费"的。

二十九、TCP 与 UDP 为什么会长期共存?

如果 TCP 什么都有,那么 UDP 为什么还存在?

因为:

TCP 解决的问题,恰恰也是它的约束来源。

TCP 强调:

可靠
有序
连接

但是某些业务希望:

延迟更低
自己控制可靠性
允许少量丢包
不需要严格顺序

这时候 TCP 就可能不是最佳选择。

例如某些实时通信场景:

实时音视频
在线游戏
实时状态同步

应用可能更希望自己定义:

哪些数据必须可靠?
哪些数据丢了无所谓?
哪些数据必须最新?

于是 UDP 提供了更轻的传输层抽象。

所以:

TCP ≠ 更高级的 UDP
UDP ≠ 更差的 TCP

它们解决的是不同的问题。

三十、TCP 与 C、C++、Rust、Go 到底有什么关系?

TCP 并不是 Go 发明的。

它是网络协议。

因此:

C
C++
Rust
Go
Java
Python

都可以使用 TCP。

区别主要在:

语言如何把操作系统 TCP 能力暴露给程序员。

C

通常更接近系统调用:

socket()
bind()
listen()
accept()
recv()
send()

优点:

控制能力强
抽象少
系统距离近

代价:

代码复杂
资源管理困难
容易出现内存和生命周期问题

Rust

可以实现非常精细的系统控制,同时利用类型系统改善内存安全。

但学习和工程模型更加复杂。

Java

可以通过 JVM 网络 API 使用 TCP。

生态强大,但需要理解:

JVM
Thread
GC
NIO
Event Loop

等更多层次。

Go

Go 的特点非常直接:

listener.Accept()

conn.Read()

conn.Write()

与此同时,Runtime 帮你处理:

Goroutine
Scheduler
Network Poller

所以它在网络服务开发中非常有吸引力。

三十一、一个真正的工程问题:TCP 可靠,不等于业务可靠

这是必须建立起来的观念。

假设:

客户端
   ↓
TCP
   ↓
服务器

TCP 保证的是:

数据流层面的可靠传输语义。

但它不知道:

"付款成功"

还是:

"付款失败"

也不知道:

"这条消息是否已经被业务系统处理"

比如:

Client

发送:
CREATE_ORDER

        ↓

TCP

        ↓

Server

收到消息
        ↓
写数据库
        ↓
服务器崩溃

此时:

TCP已经正确工作

但是业务层仍然需要解决:

订单到底创建成功没有?
客户端要不要重试?
重试会不会产生重复订单?

于是我们又进入:

幂等性
事务
ACK
重试
消息ID
去重

等问题。

这就是为什么:

TCP 可靠,不代表你的系统可靠。

协议可靠性与业务可靠性,是两层完全不同的问题。

三十二、TCP 真正改变的是软件架构

如果没有可靠的传输层抽象,那么应用程序会不断重复解决:

丢包
乱序
重复
超时
连接状态

TCP 把这些问题统一封装。

于是上层可以逐渐建立:

TCP
↓
HTTP
↓
REST
↓
RPC
↓
微服务
↓
云原生系统

而现代互联网的大量服务,本质上都建立在:

网络
+
可靠传输
+
应用层协议

之上。

所以学习 TCP,并不是为了以后手写 TCP。

而是为了理解:

现代网络服务到底建立在什么东西上。

三十三、性能分析:TCP 服务器真正慢在哪里?

一个 TCP 服务器出现性能问题时,不能简单地说:

"TCP 太慢。"

真实系统通常需要从多个层面分析。

可以粗略画成:

Client
  │
  ▼
Network
  │
  ▼
NIC
  │
  ▼
Kernel
  │
  ▼
TCP Stack
  │
  ▼
Socket Buffer
  │
  ▼
Go Runtime
  │
  ▼
Goroutine
  │
  ▼
Application

任何一层都可能成为瓶颈。

CPU

可能出现:

Context Switch
Cache Miss
Lock Contention

内存

可能出现:

Buffer过大
连接过多
大量对象分配
GC压力

网络

可能出现:

带宽不足
RTT过高
丢包
拥塞

内核

可能出现:

Socket Buffer不足
文件描述符不足
TCP backlog压力

Linux TCP 本身就暴露了大量与重传、窗口、SACK、拥塞控制、Keepalive 等相关的参数,这也说明高性能网络服务并不是简单把"连接数"拉高就结束了。

三十四、Cache Friendly 为什么也和 TCP 服务器有关?

一个网络服务器即使没有复杂算法,也可能因为数据布局不好而浪费大量 CPU 时间。

CPU 有:

L1 Cache
L2 Cache
L3 Cache
Memory

如果连接状态被频繁访问,而数据布局非常分散:

CPU
↓
Cache Miss
↓
Memory

成本就会上升。

因此高性能网络服务器最终都会进入一个更底层的问题:

数据如何布局?

这也是为什么网络编程学到后面,最终会和:

CPU Cache
Memory Allocation
Scheduler
Runtime

连接起来。

三十五、为什么 TCP 学习最终会走向操作系统?

因为 TCP 并不是一段 Go 代码。

它最终会进入:

Operating System Kernel

以典型 Linux 路径为例:

Go Application

      ↓

net.Conn

      ↓

internal/poll

      ↓

Go Runtime netpoll

      ↓

Socket

      ↓

Linux Kernel

      ↓

TCP/IP Stack

      ↓

NIC

Go Runtime 的网络轮询机制负责把"文件描述符就绪"这一层与 Goroutine 调度连接起来;在不同平台上,具体网络轮询机制不同。

所以:

真正理解 Go 网络编程,最终一定会进入操作系统

三十六、把今天的知识串成一条线

现在把整篇文章压缩成一张图:

            Application

        HTTP / RPC / WebSocket
                  │
                  ▼
            TCP Byte Stream
                  │
       ┌──────────┼───────────┐
       │          │           │
   Sequence      ACK      Window
       │          │           │
       └──────────┼───────────┘
                  │
          Retransmission
                  │
          Congestion Control
                  │
                  ▼
                 IP
                  │
                  ▼
              Network

然后再换成 Go 的视角:

Go Program
    │
    ▼
net.Listen / net.Dial
    │
    ▼
TCPConn / TCPListener
    │
    ▼
internal/poll
    │
    ▼
Runtime netpoll
    │
    ▼
Operating System
    │
    ▼
TCP/IP
    │
    ▼
Network

最终你会发现:

TCP 从来不是一个孤立的知识点。

它连接了:

应用层
传输层
操作系统
Runtime
CPU
内核
网络设备
互联网

三十七、TCP 真正值得学习的地方

如果把 TCP 只学成:

三次握手
四次挥手
TCP可靠

那么这门课基本没有真正开始。

真正值得学习的是 TCP 背后的工程思想。

第一:抽象

底层网络是不可靠的。

TCP 给应用程序一个更容易使用的抽象:

可靠字节流

第二:状态机

一个 TCP 连接不是一个布尔值:

connected = true

它拥有完整的生命周期:

LISTEN
SYN-SENT
SYN-RECEIVED
ESTABLISHED
FIN-WAIT
CLOSE-WAIT
TIME-WAIT
...

这说明:

复杂系统往往不是"有没有",而是"处于什么状态"。

第三:反馈控制

TCP 不是一味发送。

它不断观察:

ACK
RTT
Loss
Window
Congestion

然后调整自己的行为。

这实际上是一种反馈控制思想。

第四:分层

TCP 并不知道:

HTTP是什么
订单是什么
聊天消息是什么
JSON是什么

它只负责自己的层。

这就是网络协议设计里非常重要的:

职责边界。

三十八、Go 1.26.5 时代,我们应该如何使用 TCP?

现代 Go 开发里,大多数程序员不会直接自己实现 TCP 协议。

这是因为:

TCP/IP

已经由操作系统实现。

Go 要做的是:

正确使用 TCP 抽象

例如:

listener, err := net.Listen("tcp", ":8080")

客户端:

conn, err := net.Dial("tcp", "127.0.0.1:8080")

然后建立自己的应用层协议:

TCP
↓
Application Protocol

比如:

TCP
↓
HTTP

或者:

TCP
↓
自定义消息协议

这也是后面:

Day 66 HTTP
Day 69 WebSocket
Day 70 RPC
Day 71 gRPC
Day 73 高性能网络服务器
Day 74 百万级连接

这些主题成立的基础。

三十九、TCP 不是终点,而是网络编程的起点

今天我们看见的是:

TCP

但真正的学习路线应该继续向下:

TCP
↓
Socket
↓
epoll / kqueue
↓
OS Kernel
↓
Go Runtime
↓
Scheduler
↓
Goroutine

同时向上:

TCP
↓
HTTP
↓
WebSocket
↓
RPC
↓
微服务
↓
分布式系统

于是网络编程突然变成了一张巨大的知识网络。

四十、结语:当你真正理解 TCP,看到的就不再是"一个协议"

TCP 最值得学习的,也许并不是:

SYN
ACK
FIN
RST

这些名词本身。

而是它背后的一个非常经典的工程思想:

在一个充满不确定性的环境里,建立一个可以依赖的抽象。

底层网络可能:

丢包
乱序
延迟
拥塞
断开

但应用程序不应该每天重新面对这些问题。

于是 TCP 在中间建立了一层:

        不可靠网络

            ↓

            TCP

            ↓

      可靠字节流

            ↓

       应用程序

Go 又在 TCP 之上继续做了一层抽象:

       Operating System

              ↓

          TCP / Socket

              ↓

        Go net package

              ↓

         Goroutine

              ↓

       Application Logic

这其实正是系统软件最迷人的地方:

真正伟大的系统,并不是让所有人理解底层的全部复杂性。

而是把复杂性放在正确的位置。

TCP 没有消灭网络的不可靠。

它只是把这种不可靠,隔离到了应用程序不需要每天直接面对的边界之外。

而当你下一次写下:

conn, err := net.Dial("tcp", "127.0.0.1:8080")

你看到的不应该再只是:

"连接服务器。"

你应该想到:

DNS / Address
      ↓
Socket
      ↓
TCP状态机
      ↓
Sequence Number
      ↓
ACK
      ↓
Retransmission
      ↓
Flow Control
      ↓
Congestion Control
      ↓
OS Kernel
      ↓
Network

这一行代码的背后,是几十年来互联网工程不断累积出来的一套协议与系统设计。

TCP 真正伟大的地方,不是它让网络变得可靠。

而是它让上层软件能够:

在一个本质上并不可靠的世界里,建立起可靠的系统。




上一篇:浏览器到服务器全链路拆解:DNS、TCP、TLS、HTTP/3 与 Go 网络编程
下一篇:UDP 协议与 Go 实践:实时系统为什么选择丢包而不是等待?
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-9-13 18:08 , Processed in 1.669782 second(s), 45 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表