第 45 章 / 共 58 章 卷九 网络编程

TCP 编程

HTTP 是应用层协议,它的底层是 TCP。当你需要自定义协议(游戏、IoT、RPC、数据库连接)时,就要直接操作 TCP。xlang 标准库提供了简洁的 TCP API:tcp.listen 监听、tcp.connect 连接、TcpStream 收发数据——同步和异步两套接口,外加 NoDelay、KeepAlive 等底层调优选项。本章从最简单的 ping-pong 开始,逐步深入到异步模型和高级配置。

本章导读

TCP 提供可靠、有序、面向连接的字节流传输。与 HTTP 不同,TCP 没有"请求-响应"的语义——它只是一根双向管道,你发什么对方收什么,消息边界需要你自己定义(长度前缀、分隔符、固定长度等)。xlang 的 TCP API 封装在 tcp 命名空间和 TcpServer、TcpStream 两个类型中。

本章学习目标
  • 用 tcp.listen 创建 TCP 服务器并接受连接
  • 用 tcp.connect 建立客户端连接
  • 用 send/recv 收发文本,sendBytes/recvBytes 收发二进制
  • 理解 drain 的作用和必要性
  • 用异步 API(acceptAsync/connectAsync/sendAsync/recvAsync)实现非阻塞通信
  • 配置 NoDelay、KeepAlive、ReuseAddr 等套接字选项
  • 使用 net.resolve 进行域名解析
  • 实现一个完整的 Echo 服务器

45.1 监听与接受连接

TCP 服务器的第一步是监听端口:

// 创建 TCP 服务器——端口 0 = 系统自动分配
TcpServer server = TcpServer(tcp.listen(0))
int port = int(server.port())
println("监听端口: " + port.toString())

// 接受连接(异步)
future<point> accepted = server.acceptAsync(8000)  // 超时 8 秒
point peerHandle = await accepted
TcpStream peer = TcpStream(peerHandle)
println("客户端已连接")

tcp.listen(port) 返回一个原始句柄(point),用 TcpServer() 包装后获得面向对象接口。acceptAsync(timeout) 异步等待一个客户端连接,返回 future<point>。

端口 0 的用途

传 0 让操作系统自动分配一个可用端口。这在测试和临时服务中非常有用——不需要担心端口冲突。通过 server.port() 获取实际分配的端口号。

45.2 建立连接

客户端用 tcp.connect 连接服务器:

// 同步连接——阻塞直到连接建立或超时
TcpStream client = TcpStream(tcp.connect("127.0.0.1", port, 8000))
println("已连接到服务器")

// 异步连接——不阻塞当前线程
future<point> connectFuture = tcp.connectAsync("127.0.0.1", port, 8000)
TcpStream asyncClient = TcpStream(await connectFuture)

tcp.connect(host, port, timeout) 的三个参数:目标地址、目标端口、超时毫秒。连接失败(超时、拒绝)时返回 null 句柄。

45.3 文本收发:send/recv

最简单的数据交换——字符串形式:

// 客户端发送
bool sent = client.send("ping")
bool drained = client.drain(8000)   // 等待数据实际发出

// 服务端接收
string message = peer.recv(8000)    // 阻塞等待数据,超时 8 秒
println("收到: " + message)         // "ping"

// 服务端回复
peer.send("pong")
peer.drain(3000)

// 客户端接收回复
string reply = client.recv(8000)
println("回复: " + reply)           // "pong"

关键要点:

  • send(s) 将字符串写入发送缓冲区,返回是否成功
  • recv(timeout) 阻塞等待对端数据,返回收到的字符串
  • TCP 是字节流——一次 send 的数据可能被对端分多次 recv 收到,也可能多次 send 被合并为一次 recv 收到(粘包/拆包)

45.4 二进制收发:sendBytes/recvBytes

传输非文本数据(图片、协议帧、序列化结构)时使用二进制接口:

// 构建二进制数据
Buffer data = Buffer()
data.appendByte(0)      // 消息类型
data.appendByte(1)      // 版本
data.appendByte(128)    // 载荷长度高位
data.appendByte(255)    // 载荷长度低位

// 发送二进制
bool sent = client.sendBytes(data.data(), c_size(data.len()))
client.drain(8000)

// 接收二进制
Buffer received = peer.recvBytes(8000)
println("收到 " + received.len().toString() + " 字节")
println("第一字节: " + received[0].toString())   // 0
println("最后字节: " + received[3].toString())   // 255

sendBytes(ptr, size) 接受原始指针和长度;recvBytes(timeout) 返回 Buffer 对象,可以按索引访问每个字节。

45.5 drain:确保数据发完

send 只是将数据写入操作系统的发送缓冲区,不保证数据已经到达对端。drain 等待缓冲区清空:

client.send("important data")
bool ok = client.drain(8000)   // 等待数据实际发送到网络
if not ok then
    println("发送超时或连接断开")
end

什么时候需要 drain?

  • 发送后紧跟 close()——不 drain 可能丢数据
  • 需要确认数据已发出(如协议握手的 ACK)
  • 发送大量数据后检查是否阻塞
日常简单场景

如果你 send 后不立即关闭连接,且对时序没有严格要求,可以省略 drain——TCP 栈会自动发送缓冲区中的数据。但在协议实现中,drain 是保证时序正确的重要工具。

45.6 异步 TCP 操作

同步 API 会阻塞线程。高并发场景使用异步版本:

// 异步接受连接
TcpServer server = TcpServer(tcp.listen(0))
int port = int(server.port())
future<point> acceptFuture = server.acceptAsync(8000)

// 异步建立连接
future<point> connectFuture = tcp.connectAsync("127.0.0.1", port, 8000)

// 等待双方就绪
TcpStream client = TcpStream(await connectFuture)
TcpStream peer = TcpStream(await acceptFuture)

// 异步接收(先发起)
future<string> recvFuture = peer.recvAsync(8000)

// 异步发送
bool sent = await client.sendAsync("async hello")
bool drained = await client.drainAsync(8000)

// 等待接收完成
string message = await recvFuture
println("异步收到: " + message)   // "async hello"

client.close()
peer.close()

异步 API 对照表:

同步异步返回类型
server.acceptAsync(t)(本身就是异步)future<point>
tcp.connect(h,p,t)tcp.connectAsync(h,p,t)future<point>
stream.send(s)stream.sendAsync(s)future<bool>
stream.recv(t)stream.recvAsync(t)future<string>
stream.drain(t)stream.drainAsync(t)future<bool>

45.7 连接与监听配置

高级场景需要调整套接字选项:

// 连接配置
TcpConnectConfig connectCfg = tcp.connectConfig("127.0.0.1", 65535)
connectCfg.flags(tcp.connectNoDelay + tcp.connectKeepAlive)
// NoDelay:禁用 Nagle 算法,减少小包延迟
// KeepAlive:启用 TCP 保活探测

// 监听配置
TcpListenConfig listenCfg = tcp.listenConfig(0, "127.0.0.1")
listenCfg.flags(tcp.listenReuseAddr + tcp.listenReusePort + tcp.listenNoDelay + tcp.listenKeepAlive)
// ReuseAddr:允许绑定 TIME_WAIT 状态的地址
// ReusePort:允许多个进程/线程绑定同一端口(负载均衡)
标志作用典型场景
tcp.connectNoDelay禁用 Nagle,立即发送小包游戏、实时通信
tcp.connectKeepAliveTCP 保活探测长连接、检测死连接
tcp.listenReuseAddr允许重用 TIME_WAIT 地址服务器快速重启
tcp.listenReusePort多进程共享端口多核负载均衡
tcp.listenNoDelay接受的连接自动设 NoDelay低延迟服务
tcp.listenKeepAlive接受的连接自动设 KeepAlive长连接服务

45.8 域名解析与网络工具

net 命名空间提供网络辅助功能:

// 域名解析
NetAddress addr = net.resolve("localhost", 80)
println("解析结果: " + addr.text())   // "127.0.0.1"
println("端口: " + addr.port.toString())  // 80

// 本机网络信息
string ip = net.localIP()       // 本机 IP
string mac = net.localMAC()     // 本机 MAC 地址
string name = net.localName()   // 主机名

// 代理配置
NetProxy noProxy = net.proxy(net.proxyNone, "127.0.0.1", 0)
NetProxy socks = net.proxy(net.proxySocks5, "127.0.0.1", 1080)
NetProxy httpProxy = net.proxy(net.proxyHttpConnect, "127.0.0.1", 8080)

// 关闭标志
// net.closeAbort = 1     立即关闭(发 RST)
// net.closeGraceful = 2  优雅关闭(发 FIN)
// net.closeWaitPeer = 4  等待对端关闭

45.9 网络引擎 NetEngine

NetEngine 是底层网络事件循环,通常由标准库自动管理。高级用户可以显式控制:

// 创建网络引擎:1 个 I/O 线程,0 = 自动工作线程数
NetEngine engine = net.engine(1, net.engineAutoWorkers)

// 启动
assert engine.start()

// ... 执行网络操作 ...

// 停止
engine.stop()

大多数应用不需要手动管理 NetEngine——tcp.connect、tcp.listen 等 API 内部会自动使用全局引擎。只有在需要隔离网络资源或精确控制线程数时才显式创建。

45.10 实战:Echo 服务器

把前面的知识组合成一个完整的 Echo 服务器——收到什么就回什么:

// === Echo 服务器 ===
TcpServer server = TcpServer(tcp.listen(0))
int port = int(server.port())
println("Echo 服务器运行在端口 " + port.toString())

// 接受一个连接
future<point> acceptF = server.acceptAsync(30000)

// 客户端连接
TcpStream client = TcpStream(tcp.connect("127.0.0.1", port, 5000))
TcpStream peer = TcpStream(await acceptF)

// 客户端发送多条消息
client.send("Hello")
client.drain(3000)
string echo1 = client.recv(5000)
println("Echo: " + echo1)   // "Hello"

client.send("xlang TCP")
client.drain(3000)
string echo2 = client.recv(5000)
println("Echo: " + echo2)   // "xlang TCP"

// 二进制 echo
Buffer binData = Buffer()
binData.appendByte(0xDE)
binData.appendByte(0xAD)
client.sendBytes(binData.data(), c_size(binData.len()))
client.drain(3000)
Buffer binEcho = client.recvBytes(5000)
println("二进制 Echo 长度: " + binEcho.len().toString())

// 关闭
client.close()
peer.close()
真实 Echo 服务器的循环

上面的示例只处理一次连接。真实的 Echo 服务器会在 while 循环中反复 recv → send,直到客户端关闭连接(recv 返回空字符串)。多客户端则需要为每个连接启动一个线程或使用异步模型。

深入细节

深入 1:TCP 粘包与消息边界

TCP 是字节流协议,没有消息边界。你 send("Hello") 再 send("World"),对端可能一次 recv 收到 "HelloWorld",也可能分三次收到 "Hel"、"loW"、"orld"。解决方案:① 长度前缀(先发 4 字节长度,再发载荷);② 分隔符(如 \n);③ 固定长度帧。xlang 的 recv 返回"当前缓冲区中可用的数据",不保证对应一次 send。

深入 2:close 的三种模式

net.closeAbort(发 RST)立即断开,对端可能丢失未读数据;net.closeGraceful(发 FIN)正常四次挥手;net.closeWaitPeer 发 FIN 后等待对端也关闭。TcpStream.close() 默认使用优雅关闭。如果你需要立即断开(如检测到恶意客户端),可以用 abort 模式。

深入 3:acceptAsync 的超时语义

server.acceptAsync(8000) 的超时是"等待新连接的最大时间"。如果 8 秒内没有客户端连接,await 会返回 null 句柄。这不是错误——只是没有客户端。服务器通常用循环反复 accept:while true { peer = await server.acceptAsync(30000); if peer != null then handle(peer) }。

⚠️ 常见错误

❌ 错误 1:send 后立即 close 导致丢数据

client.send("bye"); client.close() 可能丢失 "bye"——因为 send 只写入缓冲区,close 可能在数据发出前就关闭了连接。正确做法:send 后调用 drain 确认数据发出,再 close。

❌ 错误 2:假设 recv 返回完整消息

对端 send("Hello World") 不代表你 recv 一次就能收到完整的 "Hello World"。TCP 可能拆包。必须用长度前缀或分隔符协议来界定消息边界。

❌ 错误 3:不处理 recv 超时/空返回

recv 超时返回空字符串,对端关闭连接也返回空字符串。如果不检查就使用返回值,可能导致逻辑错误。空返回应视为"连接结束"或"超时无数据"。

❌ 错误 4:忘记关闭连接

TcpStream 持有操作系统文件描述符。不 close 会泄漏资源,最终耗尽系统 fd 上限。每个连接用完必须 close,建议用 try/finally 模式保证关闭。

本章小结

主题核心要点
监听TcpServer(tcp.listen(port));port() 获取实际端口;acceptAsync 等待连接
连接TcpStream(tcp.connect(host, port, timeout));connectAsync 异步版本
文本send(s) 发送字符串;recv(timeout) 接收字符串
二进制sendBytes(ptr, size);recvBytes(timeout) 返回 Buffer
drain等待发送缓冲区清空;send 后 close 前必须 drain
异步sendAsync/recvAsync/drainAsync 返回 future;await 获取结果
配置connectConfig/listenConfig + flags(NoDelay/KeepAlive/ReuseAddr/ReusePort)
网络工具net.resolve 域名解析;net.localIP/localMAC/localName 本机信息
引擎net.engine(ioThreads, workers) 显式控制事件循环(通常不需要)
关闭stream.close() 优雅关闭;net.closeAbort 强制断开

复习题

  1. tcp.listen(0) 的 0 是什么含义?如何获取实际分配的端口?
  2. send 和 drain 的区别是什么?什么情况下必须调用 drain?
  3. TCP 的"粘包"是什么现象?xlang 中如何应对?
  4. recv 返回空字符串有哪两种可能?如何区分?
  5. tcp.connectAsync 和 tcp.connect 的使用场景有什么不同?
  6. tcp.connectNoDelay 禁用了什么算法?什么场景下需要它?
  7. tcp.listenReusePort 解决什么问题?
  8. net.resolve("localhost", 80) 返回什么?如果域名无法解析会怎样?
  9. sendBytes 的参数为什么是 (ptr, size) 而不是直接传 Buffer?
  10. 为什么 acceptAsync 超时返回 null 不算错误?服务器应如何处理?

编程练习

练习 1:长度前缀协议

实现一个简单的长度前缀协议:发送方先发 4 字节大端整数表示消息长度,再发消息体;接收方先读 4 字节获取长度,再精确读取对应字节数。用 sendBytes/recvBytes 实现,验证多条不同长度的消息能正确收发。

参考思路:用 Buffer 构建 4 字节长度头 + 消息体;接收端循环 recvBytes 直到凑够指定长度。

练习 2:异步多客户端 Echo

实现一个 Echo 服务器,能同时处理 3 个客户端。用 acceptAsync 接受连接,为每个连接启动一个线程(thread),在线程中循环 recv → send 直到客户端断开。客户端发送 "quit" 时服务器关闭该连接。

参考思路:主线程循环 acceptAsync;每个连接 spawn 一个 thread 处理;线程内 while recv != "" 循环。

练习 3:简易聊天协议

在练习 2 的基础上,实现广播功能:任一客户端发送的消息,服务器转发给所有其他已连接的客户端。维护一个在线连接列表(用 list 存储 TcpStream),收到消息后遍历列表逐一 send。

参考思路:用 mutex 保护连接列表;新连接加入列表,断开时移除;广播时跳过发送者自身。