9.5 网络编程基础
网络编程是现代程序员必备的技能,几乎所有的应用都需要网络通信功能。理解网络编程的原理,能够帮助我们写出高性能、高可靠的网络应用。
Socket编程原理
Socket(套接字)是网络编程的基础抽象,是应用层和传输层之间的接口,我们通过Socket来发送和接收网络数据。
什么是Socket
Socket是操作系统提供的网络编程接口,封装了底层的网络协议细节,让应用程序不需要关心TCP/IP的实现细节,只需要调用Socket接口就可以实现网络通信。
- Socket可以看成是两个网络通信端点的抽象,每个Socket对应一个
IP:端口 - 常见的Socket类型:
- 流套接字(SOCK_STREAM):对应TCP协议,提供可靠的、面向连接的字节流服务
- 数据报套接字(SOCK_DGRAM):对应UDP协议,提供不可靠的、无连接的数据报服务
- 原始套接字(SOCK_RAW):可以直接访问IP层,用来构造自定义的IP数据包,用于ping、traceroute等工具
TCP Socket编程流程
服务器端流程
- 创建Socket:调用
socket()函数,创建一个TCP套接字 - 绑定地址:调用
bind()函数,把Socket绑定到指定的IP和端口 - 监听连接:调用
listen()函数,让套接字进入监听状态,等待客户端连接 - 接受连接:调用
accept()函数,阻塞等待客户端连接,有客户端连接时返回一个新的Socket,专门用来和这个客户端通信 - 收发数据:通过
read()/recv()和write()/send()函数和客户端通信 - 关闭连接:通信完成后,调用
close()关闭Socket
服务器:
socket() → bind() → listen() → accept() → recv()/send() → close()
客户端流程
- 创建Socket:调用
socket()函数创建TCP套接字 - 连接服务器:调用
connect()函数,连接到服务器的IP和端口 - 收发数据:连接成功后,通过
read()/recv()和write()/send()和服务器通信 - 关闭连接:通信完成后调用
close()关闭连接
客户端:
socket() → connect() → send()/recv() → close()
UDP Socket编程流程
UDP是无连接的,不需要建立连接:
服务器端
- 创建UDP Socket:
socket(AF_INET, SOCK_DGRAM, 0) - 绑定地址:
bind() - 接收数据:
recvfrom(),可以得到发送方的地址 - 发送数据:
sendto(),指定接收方地址
客户端
- 创建UDP Socket
- 发送数据:
sendto()指定服务器地址 - 接收数据:
recvfrom()
UDP不需要建立连接,每次发送数据都需要指定目标地址,适合短消息、实时性要求高的场景。
常见网络IO模型
网络IO模型决定了程序处理多个连接的能力,是高性能网络编程的核心。
1. 阻塞IO(BIO)
- 原理:所有IO操作都是阻塞的,一个线程只能处理一个连接
- 工作流程:accept()阻塞等待连接,连接建立后,recv()阻塞等待数据,处理完一个连接才能处理下一个
- 优点:编程简单,容易实现
- 缺点:并发能力低,每个连接需要一个线程,线程资源有限,而且线程切换开销大,高并发场景下性能很差
- 适用场景:连接数不多的简单场景
2. 非阻塞IO(NIO)
- 原理:把Socket设置为非阻塞模式,IO调用会立即返回,不会阻塞线程
- 工作流程:线程轮询所有Socket,看哪个Socket有数据可读可写,有事件了就处理
- 优点:一个线程可以处理多个连接,不需要为每个连接开线程
- 缺点:轮询会消耗大量CPU,大部分轮询都是空转,效率低
- 适用场景:很少单独使用,通常和IO多路复用结合
3. IO多路复用(IO Multiplexing)
- 原理:用一个线程监听多个Socket的状态,当某个Socket有事件时,才通知应用程序处理
- 核心是多路复用器:select/poll/epoll(Linux)、kqueue(BSD/macOS)、IOCP(Windows)
- 工作流程:
- 把要监听的Socket注册到多路复用器上
- 调用多路复用器的等待函数,阻塞等待事件发生
- 有事件发生时,返回所有就绪的Socket列表
- 遍历就绪的Socket列表,逐个处理
- 优点:一个线程可以处理成千上万的连接,线程开销小,并发能力强,是现在高性能网络服务器的主流模型
- 缺点:编程复杂度比BIO高,需要处理事件驱动的逻辑
- **常见实现:
- select:跨平台,最多支持1024个连接,性能随连接数增加而下降
- poll:和select类似,但没有最大连接数限制,性能还是随连接数增加而下降
- epoll:Linux下的高性能多路复用器,事件通知机制,性能不会随连接数增加而下降,是现在Linux服务器的首选
- 适用场景:高并发网络服务,比如Web服务器、网关、即时通信服务等,Nginx、Redis、Node.js等都用的是IO多路复用模型
4. 异步IO(AIO)
- 原理:用户发起IO请求后立即返回,不需要等待,内核完成IO操作后通知用户程序
- 和IO多路复用的区别:IO多路复用是通知你Socket就绪了,需要你自己去读写数据;异步IO是内核已经帮你把数据读写完成了,直接通知你结果
- 优点:性能最高,完全不阻塞线程,可以充分利用CPU
- 缺点:编程复杂,实现难度高,跨平台支持不好,Linux下的原生AIO实现不够完善,现在实际使用还不多
- 适用场景:对性能要求极高的场景,现在高并发场景下更多还是用IO多路复用 + 多线程的模式
5. 信号驱动IO
- 原理:Socket注册信号处理函数,IO就绪时内核发送SIGIO信号通知程序处理,很少使用。
高并发模型对比
| 模型 | 线程数 | 并发能力 | 编程复杂度 | 性能 | 适用场景 |
|---|---|---|---|---|---|
| BIO | 连接数 | 低(几百) | 低 | 低 | 连接少的简单场景 |
| NIO | 1 | 中等(几千) | 中 | 中 | 很少单独使用 |
| IO多路复用 | 1+ | 高(几万到几十万) | 中高 | 高 | 高并发场景,主流方案 |
| AIO | 1+ | 极高 | 高 | 很高 | 高性能场景,还未普及 |
RPC远程过程调用
RPC(Remote Procedure Call)远程过程调用,让调用远程服务就像调用本地函数一样简单,屏蔽了网络通信的细节,是现在分布式系统的核心技术。
RPC的原理
RPC封装了网络通信的细节,让用户不需要关心网络传输、序列化、错误处理等细节,就像调用本地函数一样调用远程服务:
客户端调用本地Stub函数 → Stub将参数序列化 → 发送网络请求到服务器
↓
服务器接收请求 → 反序列化参数 → 调用实际服务函数 → 结果序列化返回给客户端
↓
客户端Stub收到结果 → 反序列化返回给调用者
RPC的核心组件
- 客户端Stub:客户端的代理,封装远程调用的细节,把函数调用转换为网络请求
- 服务端Stub:服务端的代理,接收网络请求,反序列化参数,调用对应的服务函数
- 序列化/反序列化:把内存中的对象转换为字节流在网络上传输,常见的序列化协议:JSON、Protocol Buffer、Thrift、Hessian等
- 网络传输层:负责数据的传输,通常使用TCP协议,也有用HTTP、UDP的
- 服务注册与发现:分布式场景下,客户端需要知道服务端的地址,通过服务注册中心发现服务地址
常见的RPC框架
- gRPC:Google开源的RPC框架,基于HTTP/2,使用Protocol Buffer序列化,性能高,跨语言
- Thrift:Apache开源的跨语言RPC框架,性能优异
- Dubbo:阿里巴巴开源的Java RPC框架,国内使用广泛
- Spring Cloud:基于HTTP的微服务框架,使用RESTful API
网络编程常见坑点和最佳实践
常见坑点
-
TCP粘包/拆包问题:TCP是面向字节流的,没有报文边界,发送端发送的多个数据包可能会被粘在一起,或者一个包被拆成多个,接收方需要自己处理报文边界
- 解决方法:
- 固定长度报文,不够的补位
- 特殊分隔符分隔,比如换行符分隔
- 报文头加长度字段,先读取长度,再读取对应长度的内容
- 解决方法:
-
忽略错误处理:网络是不可靠的,所有的网络调用都可能失败,必须处理各种错误情况:连接中断、超时、重置等,不要假设网络调用一定会成功
-
阻塞模式下的超时问题:阻塞IO一定要设置超时时间,否则连接断了或者网络有问题时,线程会一直阻塞,无法释放资源
-
大端小端字节序问题:网络字节序是大端序,不同主机的字节序可能不同,多字节整数传输时一定要转成网络字节序,接收方再转为主机字节序
-
连接泄漏:网络连接使用完一定要关闭,特别是异常路径下也要关闭,否则会导致连接泄漏,文件描述符耗尽
-
流量控制和拥塞控制:不要发送太快,要考虑接收方的处理能力和网络拥塞情况,避免被系统丢包
最佳实践
-
优先使用成熟的网络库:不要自己从零实现Socket服务,优先使用成熟的高性能网络库:
- C++:libevent、libuv、Boost.Asio
- Java:Netty
- Go:标准库net包
- Python:asyncio、Twisted 这些库已经处理了所有底层的复杂问题,性能和稳定性都经过了验证。
-
合理设置超时时间:所有的网络操作都要设置超时时间,包括连接超时、读取超时、写入超时,避免无限等待
-
心跳机制:长连接场景下要加心跳机制,定期检测连接是否存活,及时清理死连接
-
限流和熔断:高并发场景下要加限流机制,避免请求量过大打垮服务;下游服务故障时要熔断,避免雪崩效应
-
二进制协议优先:性能要求高的场景优先使用二进制序列化协议(Protobuf、Thrift),比JSON性能高很多,体积小
-
IO密集型服务用IO多路复用模型:高并发网络服务优先使用IO多路复用 + 线程池的模型,充分利用CPU资源
-
使用长连接减少握手开销:频繁通信的场景尽量使用长连接,避免TCP三次握手和四次挥手的开销,比如连接池、HTTP keep-alive
-
合理设置缓冲区大小:Socket的发送和接收缓冲区大小要根据业务场景合理设置,太小会导致频繁IO,太大会浪费内存
思考问题
- 简述TCP服务端Socket编程的主要流程,每个系统调用的作用是什么?
- IO多路复用模型相比多线程阻塞模型有什么优势?为什么高并发场景下都用IO多路复用?
- 什么是TCP粘包问题?为什么会出现?怎么解决?
- RPC相比直接使用HTTP通信有什么优势?适合什么场景?