1. 计算机网络编程基础
1. 七层模型
七层模型(OSI,Open System Interconnection 参考模型), 是参考是国际标准化组织制定的一个用于计算机或通信系统间互联的标准体系. 它是一个七层抽象的模型, 不仅包括一系列抽象的术语和概念, 也包括具体的协议. 经典的描述如下:
简述每一层的含义:
物理层(Physical Layer): 建立, 维护, 断开物理连接.
数据链路层 (Link): 逻辑连接, 进行硬件地址寻址, 差错校验等.
网络层 (Network): 进行逻辑寻址, 实现不同网络之间的路径选择.
传输层 (Transport): 定义传输数据的协议端口号, 及流控和差错校验.
会话层(Session Layer): 建立, 管理, 终止会话.
表示层(Presentation Layer): 数据的表示, 安全, 压缩.
应用层 (Application): 网络服务与最终用户的一个接口
每一层利用下一层提供的服务与对等层通信, 每一层使用自己的协议. 了解了这些, 然并卵. 但是, 这一模型确实是绝大多数网络编程的基础, 作为抽象类存在的, 而 TCP/IP 协议栈只是这一模型的一个具体实现.
2.TCP/IP 协议模型
IP 数据包结构:
---
TCP 数据包结构:
一个模型例子:
寻址过程: 每台机子都有个物理地址 Mac 地址和逻辑地址 IP 地址, 物理地址用于底层的硬件的通信, 逻辑地址用于上层的协议间的通信. 寻址过程会先使用 ip 地址进行路由寻址, 在不同网络中进行路由转发, 到了同一个局域网时, 再根据物理地址进行广播寻址, 数据在以太网的局域网中都是以广播方式传输的, 整个局域网中的所有节点都会收到该帧, 只有目标 Mac 地址与自己的 Mac 地址相同的帧才会被接收.
建立可靠的连接: A 向 B 传输一个文件时, 如果文件中有部分数据丢失, 就可能会造成在 B 上无法正常阅读或使用. TCP 协议就是建立了可靠的连接:
TCP 三次握手确定了双方数据包的序号, 最大接受数据的大小 (Windows) 以及 MSS(Maximum Segment Size)
会话层用来建立, 维护, 管理应用程序之间的会话, 主要功能是对话控制和同步, 编程中所涉及的 session 是会话层的具体体现. 表示层完成数据的解编码, 加解密, 压缩解压缩等.
2.Socket 编程
在 Linux 世界,"一切皆文件", 操作系统把网络读写作为 IO 操作, 就像读写文件那样, 对外提供出来的编程接口就是 Socket. 所以, socket(套接字)是通信的基石, 是支持 TCP/IP 协议网络通信的基本操作单元. socket 实质上提供了进程通信的端点. 进程通信之前, 双方首先必须各自创建一个端点, 否则是没有办法建立联系并相互通信的. 一个完整的 socket 有一个本地唯一的 socket 号, 这是由操作系统分配的.
在许多操作系统中, Socket 描述符和其他 IO 描述符是集成在一起的, 操作系统把 socket 描述符实现为一个指针数组, 这些指针指向内部数据结构. 进程进行 Socket 操作时, 也有着多种处理方式, 如阻塞式 IO, 非阻塞式 IO, 多路复用(select/poll/epoll),AIO 等等.
多路复用往往在提升性能方面有着重要的作用.
当前主流的 Server 侧 Socket 实现大都采用了 epoll 的方式, 例如 Nginx, 在配置文件可以显式地看到 use epoll.
举个栗子 Java 中 Socket 服务端的简单实现: 基本思路就是一个大循环不断监听客户端请求, 为了提高处理效率可以使用线程池多个线程进行每个连接的数据读取
- public class BIOServer {
- private ServerSocket serverSocket;
- private ExecutorService executorService = Executors.newCachedThreadPool();
- class Handler implements Runnable {
- Socket socket;
- public Handler(Socket socket) {
- this.socket = socket;
- }
- @Override
- public void run() {
- try {
- BufferedReader buf = new BufferedReader(new InputStreamReader(socket.getInputStream()));
- String readData = buf.readLine();
- while (readData != null) {
- readData = buf.readLine();
- System.out.println(readData);
- }
- } catch (Exception e) {
- e.printStackTrace();
- }
- }
- }
- public BIOServer(int port) {
- try {
- serverSocket = new ServerSocket(port);
- } catch (IOException e) {
- e.printStackTrace();
- }
- }
- public void run() {
- try {
- Socket socket = serverSocket.accept();
- executorService.submit(new Handler(socket));
- } catch (Exception e) {
- }
- }
- }
客户端: 建立 socket 连接, 发起请求, 读取响应
- public class IOClient {
- public void start(String host, int port) {
- try {
- Socket s = new Socket("127.0.0.1",8888);
- InputStream is = s.getInputStream();
- OutputStream os = s.getOutputStream();
- BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(os));
- bw.write("测试客户端和服务器通信, 服务器接收到消息返回到客户端 \ n");
- bw.flush();
- BufferedReader br = new BufferedReader(new InputStreamReader(is));
- String mess = br.readLine();
- System.out.println("服务器:"+mess);
- } catch (Exception e) {
- e.printStackTrace();
- }
- }
- }
3.IO 模型
对于一次 IO 访问(以 read 举例), 数据会先被拷贝到操作系统内核的缓冲区 page cache 中, 然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间. 所以说, 当一个 read 操作发生时, 它会经历两个阶段:
等待数据准备
将数据从内核拷贝到进程中
IO 模型的分类有下:
阻塞 I/O(blocking IO)
非阻塞 I/O(nonblocking IO)
I/O 多路复用( IO multiplexing)
异步 I/O(asynchronous IO)
BIO 阻塞 I/O
缺点: 一个请求一个线程, 浪费线程, 且上下文切换开销大;
上面写的 socket 列子就是典型的 BIO
当用户进程调用了 recvfrom 这个系统调用, kernel 就开始了 IO 的第一个阶段: 准备数据(对于网络 IO 来说, 很多时候数据在一开始还没有到达. 比如, 还没有收到一个完整的 UDP 包. 这个时候 kernel 就要等待足够的数据到来). 这个过程需要等待, 也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的. 而在用户进程这边, 整个进程会被阻塞(当然, 是进程自己选择的阻塞). 当 kernel 一直等到数据准备好了, 它就会将数据从 kernel 中拷贝到用户内存, 然后 kernel 返回结果, 用户进程才解除 block 的状态, 重新运行起来.
NIO 非阻塞 I/O
当用户进程发出 read 操作时, 如果 kernel 中的数据还没有准备好, 那么它并不会 block 用户进程, 而是立刻返回一个 error . 从用户进程角度讲 , 它发起一个 read 操作后, 并不需要等待, 而是马上就得到了一个结果. 用户进程判断结果是一个 error 时, 它就知道数据还没有准备好, 于是它可以再次发送 read 操作. 一旦 kernel 中的数据准备好了, 并且又再次收到了用户进程的 system call, 那么它马上就将数据拷贝到了用户内存, 然后返回.
nonblocking IO 的特点是用户进程需要不断的主动询问 kernel 数据好了没有.
I/O 多路复用
IO multiplexing 就是我们说的 select,poll,epoll, 有些地方也称这种 IO 方式为 event driven IO.select/epoll 的好处就在于单个 process 就可以同时处理多个网络连接的 IO. 它的基本原理就是 select,poll,epoll 这个 function 会不断的轮询所负责的所有 socket, 当某个 socket 有数据到达了, 就通知用户进程.
机制: 一个线程以阻塞的方式监听客户端请求; 另一个线程采用 NIO 的形式 select 已经接收到数据的 channel 信道, 处理请求;
select,poll,epoll 模型 - 处理更多的连接
上面所说的多路复用的 select,poll,epoll 本质上都是同步 IO, 因为他们都需要在读写事件就绪后自己负责进行读写, 也就是说这个读写过程是阻塞的, 实际上是指阻塞在 select 上面, 必须等到读就绪, 写就绪等网络事件. 异步 IO 则无需自己负责进行读写, 异步 IO 的实现会负责把数据从内核拷贝到用户空间.
I/O 多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符,
而这些文件描述符 (套接字描述符) 其中的任意一个进入读就绪状态, select()
函数就可以返回. 所以, 如果处理的连接数不是很高的话, 使用 select/epoll 的 web
server 不一定比使用 multi-threading + blocking IO 的 Web
server 性能更好, 可能延迟还更大. select/
epoll 的优势并不是对于单个连接能处理得更快, 而是在于能处理更多的连接.
一个面试问题: select,poll,epoll 的区别?
Java 中的 I/O 多路复用: Reactor 模型
(主从 Reactor 模型)netty 就是主从 Reactor 模型的实现, 相当于这个模型在
对比与传统的 I/O 多路复用, Reactor 模型增加了事件分发器, 基于事件驱动, 能够将相应的读写事件分发给不同的线程执行, 真正实现了非阻塞 I/O.
基于 Reactor Pattern 处理模式中, 定义以下三种角色
Reactor 将 I/O 事件分派给对应的 Handler
Acceptor 处理客户端新连接, 并分派请求到处理器链中
Handlers 执行非阻塞读 / 写 任务
举个栗子
回顾我们上面写的代码, 是不是每个线程处理一个连接, 显然在高并发情况下是不适用的, 应该采用 IO 多路复用 的思想, 使得一个线程能够处理多个连接, 并且不能阻塞读写操作, 添加一个 选择器在 buffer 有数据的时候就开始写入用户空间. 这里的多路是指 N 个连接, 每一个连接对应一个 channel, 或者说多路就是多个 channel. 复用, 是指多个连接复用了一个线程或者少量线程
现在我们来优化下上面的 socket IO 模型
优化后的 IO 模型:
实现一个最简单的 Reactor 模式: 注册所有感兴趣的事件处理器, 单线程轮询选择就绪事件, 执行事件处理器. 流程就是不断轮询可以进行处理的事件, 然后交给不同的 handler 进行处理.
上面提到的主要是四个网络事件: 有连接就绪, 接收就绪, 读就绪, 写就绪. I/O 复用主要是通过 Selector 复用器来实现的, 可以结合下面这个图理解上面的叙述
- public class NIOServer {
- private ServerSocketChannel serverSocket;
- private Selector selector;
- private ReadHandler readHandler;
- private WriteHandler writeHandler;
- private ExecutorService executorService = Executors.newCachedThreadPool();
- abstract class Handler {
- protected SelectionKey key;
- }
- class ReadHandler extends Handler implements Runnable {
- @Override
- public void run() {
- ///... 读操作
- }
- }
- class WriteHandler extends Handler implements Runnable {
- @Override
- public void run() {
- ///... 写操作
- }
- }
- public NIOServer(int port) {
- try {
- selector = Selector.open();
- serverSocket = ServerSocketChannel.open();
- serverSocket.bind(new InetSocketAddress(port));
- serverSocket.register(this.selector, SelectionKey.OP_ACCEPT);
- } catch (IOException e) {
- e.printStackTrace();
- }
- }
- public void run() {
- while (!Thread.interrupted()) {
- try {
- selector.select(); // 阻塞等待事件
- Iterator<SelectionKey> iterator = this.selector.keys().iterator(); // 事件列表 , key -> channel , 每个 KEY 对应了一个 channel
- while (iterator.hasNext()) {
- iterator.remove();
- dispatch(iterator.next()); // 分发事件
- }
- } catch (IOException e) {
- e.printStackTrace();
- }
- }
- }
- private void dispatch(SelectionKey key) {
- if (key.isAcceptable()) {
- register(key); // 新连接建立, 注册一个新的读写处理器
- } else if (key.isReadable()) {
- this.executorService.submit(new ReadHandler(key)); // 可以写, 执行写事件
- } else if (key.isWritable()) {
- this.executorService.submit(new WriteHandler(key)); // 可以读. 执行读事件
- }
- }
- private void register(SelectionKey key) {
- ServerSocketChannel channel = (ServerSocketChannel) key.channel(); // 通过 key 找到对应的 channel
- try {
- SocketChannel socketChannel = channel.accept();
- channel.configureBlocking(false);
- channel.register(this.selector, SelectionKey.OP_ACCEPT);
- } catch (IOException e) {
- e.printStackTrace();
- }
- }
- }
优化线程模型
上述模型还可以继续优化. 因为上述模型只是增多个客户端连接的数量, 但是在高并发的情况下,
参考资料:
老曹眼中的网络编程基础 https://mp.weixin.qq.com/s/XXMz5uAFSsPdg38bth2jAA
Linux IO 模式及 select,poll,epoll 详解 https://segmentfault.com/a/1190000003063859
来源: https://juejin.im/entry/5bfd61d3f265da616720033d